diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 7de2e19..0000000 --- a/.dockerignore +++ /dev/null @@ -1,32 +0,0 @@ -# flyctl launch added from .gitignore -**/pkg/github/tmp -**/pkg/parser/tmp -**/postgres-data -**/.DS_Store - -# flyctl launch added from ui/.gitignore -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -ui/node_modules -ui/.pnp -ui/**/.pnp.js - -# testing -ui/coverage - -# next.js -ui/.next -ui/out - -# production -ui/build - -# misc -ui/**/.DS_Store - -# debug -ui/**/npm-debug.log* -ui/**/yarn-debug.log* -ui/**/yarn-error.log* -fly.toml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8d54635..a625e36 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,7 +8,7 @@ jobs: - name: Install Go uses: actions/setup-go@v5 with: - go-version: 1.22 + go-version: 1.23 - name: Check out code into the Go module directory uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 4e06414..8f734db 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ ui/npm-debug.log* ui/yarn-debug.log* ui/yarn-error.log* .DS_Store +.env diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9274396..0000000 --- a/Dockerfile +++ /dev/null @@ -1,75 +0,0 @@ -# Build API -FROM golang:1.22-alpine AS api_builder - -RUN apk add build-base - -WORKDIR /api - -COPY --from=api go.mod . -COPY --from=api go.sum . - -RUN go mod download - -COPY --from=api ./cmd ./cmd -COPY --from=api ./pkg ./pkg -COPY --from=api ./migrations ./migrations -COPY --from=api ./surveys ./surveys-examples -RUN CGO_ENABLED=1 GOOS=linux go build -o api -tags enablecgo cmd/console-api/api.go - - -# Build UI -FROM node:20-alpine AS ui_base - -FROM ui_base AS deps -RUN apk add --no-cache libc6-compat -WORKDIR /app - -COPY --from=ui package.json package-lock.json ./ -RUN npm ci - -FROM ui_base AS ui_builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY --from=ui . . - -ENV NODE_ENV=production - -RUN npm run build - - -# Final image -FROM alpine:latest AS runner - -RUN apk --no-cache add ca-certificates tzdata nodejs - -WORKDIR /app -ENV NODE_ENV=production - -COPY --from=ui_builder /app/public ./public - -RUN mkdir .next -RUN chown 1000:1000 .next - -COPY --from=ui_builder --chown=1000:1000 /app/.next/standalone ./ -COPY --from=ui_builder --chown=1000:1000 /app/.next/static ./.next/static - -WORKDIR /api - -COPY --from=api_builder --chown=1000:1000 /api/api ./api -COPY --from=api_builder --chown=1000:1000 /api/migrations ./migrations -COPY --from=api_builder --chown=1000:1000 /api/surveys-examples ./surveys-examples - -RUN mkdir /data -RUN chown 1000:1000 /data - -COPY start.sh /start.sh -RUN chmod +x /start.sh - -USER 1000:1000 -RUN mkdir /data/surveys -RUN mkdir /data/db - -EXPOSE 3000 -EXPOSE 8080 - -CMD ["sh", "/start.sh"] diff --git a/README.md b/README.md index 46e45d3..4521bc6 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,13 @@ This approach offers a number of advantages, including: - [x] Default theme - [x] Custom themes support - [x] Personalized options: intro, outro, etc. -- [x] Cookie/IP deduplication +- [x] Cookie/IP duplicate response protection - [x] Admin user authentication - [x] Different database options: SQLite and Postgres - [x] Continue where you left off - [x] Advanced validation rules - [x] Detect survey changes in real time +- [x] Export responses in UI or via API - [ ] Advanced question types - [ ] Pipe answers into the following questions @@ -249,6 +250,17 @@ Presents a question where users can only answer "yes" or "no". label: Is Berlin the capital of Germany? ``` +## Responses + +Responses can be shown in the UI and exported as a JSON. Alternatively you can use REST API to get survey resposnes: + +```bash +curl -XGET \ +http://localhost:9900/app/surveys/{SURVEY_ID}/sessions?limit=100&offset=0&sort_by=created_at&order=desc +``` + +Where `{SURVEY_ID}` id the UUID of a given survey. + ## Screenshots

@@ -256,7 +268,7 @@ Presents a question where users can only answer "yes" or "no".

-## Installation: Docker +## Installation & Deployment ``` docker-compose up -d --build @@ -264,22 +276,14 @@ docker-compose up -d --build And you should be able to access the UI on http://localhost:3000 (default basic auth: `user:pass`). -## Deployment +You can deploy individual services to any cloud provider or self host them. -You can deploy individual services to any cloud provider: - -- Go backend. It's packaged as a Docker container and can be deployed to any cloud provider. -- Next.js frontend. It's also packaged as a Docker container, but also can be deployed to Vercel or Netlify. +- Go backend. It's packaged as a Docker container and can be deployed anywhere. +- Next.js frontend. It's also packaged as a Docker container, but also can be deployed to Vercel for example. - [Optional] Postgres database. You can use managed Postgres services or deploy it yourself. The demo service (links above) is deployed to Fly.io (Go, SQLite) and Vercel (Next.js) and are under the free tiers. -There is also a combined version of Go and Next.js in the same Docker container. You can run it with: - -``` -docker-compose -f compose-combined.yml up -d --build -``` - ### Environment Variables API: @@ -291,14 +295,14 @@ API: UI: -- `CONSOLE_API_ADDR_INTERNAL` - Internal address of the Go backend, e.g. `http://api:8080` (could be the same as `CONSOLE_API_ADDR` if UI amd API are not on the same network). -- `CONSOLE_API_ADDR` - Public address of the Go backend. +- `CONSOLE_API_ADDR` - Public address of the Go backend. Need to be accessible from the browser. +- `CONSOLE_API_ADDR_INTERNAL` - Internal address of the Go backend, e.g. `http://api:8080` (could be the same as `CONSOLE_API_ADDR`). - `IRON_SESSION_SECRET` - Secret for session encryption - `HTTP_BASIC_AUTH` - Format: `user:pass` for basic auth (optional) ## Tech Stack -- Backend: Go, Postgres, SQLite +- Backend: Go, (Postgres or SQLite) - UI: Next.js, Tailwind CSS ## Create new SQLite/Postgres migration diff --git a/api/Dockerfile b/api/Dockerfile index 7ed06c5..f32ca4b 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine AS builder +FROM golang:1.23-alpine AS builder RUN apk add build-base diff --git a/api/go.mod b/api/go.mod index f0b951b..cb61550 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,6 +1,6 @@ module github.com/plutov/formulosity/api -go 1.22 +go 1.23 require ( github.com/fsnotify/fsnotify v1.7.0 diff --git a/compose-combined.yml b/compose-combined.yml deleted file mode 100644 index dd0fe56..0000000 --- a/compose-combined.yml +++ /dev/null @@ -1,25 +0,0 @@ -services: - formulosity: - restart: unless-stopped - image: formulosity:latest - build: - context: . - additional_contexts: - api: ./api - ui: ./ui - ports: - - "3000:3000" - - "8080:8080" - init: true - environment: - - LOG_LEVEL=debug - - DATABASE_TYPE=sqlite # postgres|sqlite - - DATABASE_URL=/data/sqlite3/formulosity.db - - SURVEYS_DIR=/data/surveys - - CONSOLE_API_ADDR_INTERNAL=http://127.0.0.1:8080 - - CONSOLE_API_ADDR=http://127.0.0.1:8080 - - IRON_SESSION_SECRET=e75af92dffba8065f2730472f45f2046941fe35f361739d31992f42d88d6bf6c - - HTTP_BASIC_AUTH=user:pass - volumes: - - ./api/surveys:/data/surveys - - ./api/sqlite3:/data/sqlite3 diff --git a/ui/src/app/app/surveys/[survey_uuid]/responses/page.tsx b/ui/src/app/app/surveys/[survey_uuid]/responses/page.tsx index 87ec88e..828559e 100644 --- a/ui/src/app/app/surveys/[survey_uuid]/responses/page.tsx +++ b/ui/src/app/app/surveys/[survey_uuid]/responses/page.tsx @@ -33,7 +33,8 @@ export default async function ResponsesPage({ const surveySessionsResp = await getSurveySessions( currentSurvey.uuid, - `limit=${SurveySessionsLimit}&offset=0&sort_by=created_at&order=desc` + `limit=${SurveySessionsLimit}&offset=0&sort_by=created_at&order=desc`, + '' ) if (surveySessionsResp.error) { errMsg = 'Unable to fetch survey sessions' @@ -53,9 +54,11 @@ export default async function ResponsesPage({ ) } + const apiURL = process.env.CONSOLE_API_ADDR || '' + return ( - + ) } diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 1723e74..448e819 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -2,7 +2,6 @@ import 'styles/global.css' import { ReactNode } from 'react' import { Metadata } from 'next' import { siteConfig } from 'lib/siteConfig' -import { dm_sans, inter } from 'lib/fonts' export const metadata: Metadata = { title: { @@ -31,13 +30,9 @@ type LayoutProps = { children?: ReactNode } export default async function RootLayout({ children }: LayoutProps) { return ( - + - {children} + {children} ) } diff --git a/ui/src/components/app/AppLayout.tsx b/ui/src/components/app/AppLayout.tsx index 564aaf5..42fb75e 100644 --- a/ui/src/components/app/AppLayout.tsx +++ b/ui/src/components/app/AppLayout.tsx @@ -1,11 +1,10 @@ 'use client' import { ReactNode, useState } from 'react' -import { usePathname } from 'next/navigation' import { Badge, Navbar, Sidebar } from 'flowbite-react' import { LogoIcon } from 'components/ui/LogoIcon' import { siteConfig } from 'lib/siteConfig' -import { HiViewGrid, HiMenu, HiOutlineBookOpen } from 'react-icons/hi' +import { HiMenu, HiOutlineBookOpen } from 'react-icons/hi' import 'styles/app.css' type AppLayoutProps = { @@ -14,25 +13,16 @@ type AppLayoutProps = { export default function AppLayout({ children }: AppLayoutProps) { const [isSidebarOpen, setIsSidebarOpen] = useState(false) - const pathname = usePathname() - - const navigation = [ - { - name: 'Surveys', - href: `/app`, - icon: HiViewGrid, - }, - ] return (
- +
{cols.map((col) => ( {sessions.map((session) => ( - + {session.uuid} {session.status === SurveySessionStatus.Completed && ( @@ -180,7 +184,7 @@ export function SurveyResponsesPage({
+
Question ID Question @@ -254,7 +258,7 @@ export function SurveyResponsesPage({ } return ( {answer.question_id} diff --git a/ui/src/components/app/SurveyRow.tsx b/ui/src/components/app/SurveyRow.tsx index 0da68a2..c978ed6 100644 --- a/ui/src/components/app/SurveyRow.tsx +++ b/ui/src/components/app/SurveyRow.tsx @@ -47,16 +47,14 @@ export function SurveyRow({ survey, apiURL }: SurveyCardProps) { survey.parse_status === SurveyParseStatus.Success && !isLaunched return ( - - + +
{survey.name}
{survey.config && ( -
- {survey.config.title} -
+
{survey.config.title}
)} -
+
Created on: {moment(survey.created_at).format('MMM D, YYYY')}
@@ -93,7 +91,7 @@ export function SurveyRow({ survey, apiURL }: SurveyCardProps) { {(isLaunched || canSartSurvey) && (
- - Name/Title - Build - Delivery - Share - Responses - Completion - - - {surveys.map((survey) => { - return ( - - ) - })} - -
- - - - +
+
+ + + Name/Title + Build + Delivery + Share + Responses + Completion + + + {surveys.map((survey) => { + return ( + + ) + })} + +
+
+
) diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 1bd5017..888db18 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -160,8 +160,12 @@ export async function getSurveySession( ) } -export async function getSurveySessions(surveyUUID: string, filter: string) { - return await get(`/app/surveys/${surveyUUID}/sessions?${filter}`) +export async function getSurveySessions( + surveyUUID: string, + filter: string, + apiURL: string +) { + return await get(`/app/surveys/${surveyUUID}/sessions?${filter}`, apiURL) } export async function updateSurvey( diff --git a/ui/src/lib/fonts.ts b/ui/src/lib/fonts.ts deleted file mode 100644 index ef59a5f..0000000 --- a/ui/src/lib/fonts.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DM_Sans, Inter } from 'next/font/google' - -export const inter = Inter({ - subsets: ['latin'], - display: 'swap', - variable: '--font-inter', -}) - -export const dm_sans = DM_Sans({ - weight: ['400', '500', '700'], - subsets: ['latin'], - display: 'swap', - variable: '--font-dm-sans', -}) diff --git a/ui/src/styles/app.css b/ui/src/styles/app.css index ba64879..d91bd24 100644 --- a/ui/src/styles/app.css +++ b/ui/src/styles/app.css @@ -4,7 +4,7 @@ @layer components { .sidebar > div { - @apply rounded-none mt-16; + @apply rounded-none mt-16 bg-gray-800; } .sidebar .org-name span { @@ -16,14 +16,14 @@ } .dropdown button { - @apply justify-start w-full dark:bg-gray-700 dark:enabled:hover:bg-gray-800; + @apply justify-start w-full bg-gray-700 enabled:hover:bg-gray-800; } .tabs button.active { - @apply dark:text-crimson-9 dark:border-crimson-9; + @apply text-crimson-9 border-crimson-9; } .tabs button { @apply focus:ring-0; } -} \ No newline at end of file +} diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css index c8f3ee3..940265c 100644 --- a/ui/src/styles/global.css +++ b/ui/src/styles/global.css @@ -2,6 +2,14 @@ @tailwind components; @tailwind utilities; +body { + font-family: monospace; +} + +table { + @apply text-gray-100; +} + @layer components { .display { @apply font-display text-[80px] font-semibold leading-[88px] tracking-[-2%]; @@ -19,48 +27,48 @@ @apply font-display text-[32px] font-semibold leading-[40px] tracking-[-0.2%]; } .h5 { - @apply font-sans text-[16px] font-bold uppercase leading-[20px] tracking-[5%]; + @apply text-[16px] font-bold uppercase leading-[20px] tracking-[5%]; } .h6 { - @apply font-sans text-[14px] font-semibold uppercase leading-[16px] tracking-[5%]; + @apply text-[14px] font-semibold uppercase leading-[16px] tracking-[5%]; } .body-xl { - @apply font-sans text-[24px] font-normal leading-[36px] tracking-[0%]; + @apply text-[24px] font-normal leading-[36px] tracking-[0%]; } .body-l { - @apply font-sans text-[20px] font-normal leading-[32px] tracking-[-0.1%]; + @apply text-[20px] font-normal leading-[32px] tracking-[-0.1%]; } .body-l-medium { - @apply font-sans text-[20px] font-medium leading-[32px] tracking-[-0.1%]; + @apply text-[20px] font-medium leading-[32px] tracking-[-0.1%]; } .body-l-semibold { - @apply font-sans text-[20px] font-semibold leading-[32px] tracking-[-0.1%]; + @apply text-[20px] font-semibold leading-[32px] tracking-[-0.1%]; } .body { - @apply font-sans text-[16px] font-normal leading-[24px] tracking-[0%]; + @apply text-[16px] font-normal leading-[24px] tracking-[0%]; } .body-medium { - @apply font-sans text-[16px] font-medium leading-[24px] tracking-[0%]; + @apply text-[16px] font-medium leading-[24px] tracking-[0%]; } .body-semibold { - @apply font-sans text-[16px] font-semibold leading-[24px] tracking-[0%]; + @apply text-[16px] font-semibold leading-[24px] tracking-[0%]; } .caption { - @apply font-sans text-[14px] font-normal leading-[20px] tracking-[0%]; + @apply text-[14px] font-normal leading-[20px] tracking-[0%]; } .caption-medium { - @apply font-sans text-[14px] font-medium leading-[20px] tracking-[0%]; + @apply text-[14px] font-medium leading-[20px] tracking-[0%]; } .caption-semibold { - @apply font-sans text-[14px] font-semibold leading-[20px] tracking-[0%]; + @apply text-[14px] font-semibold leading-[20px] tracking-[0%]; } .caption-s { - @apply font-sans text-[12px] font-normal leading-[16px] tracking-[0%]; + @apply text-[12px] font-normal leading-[16px] tracking-[0%]; } .caption-s-medium { - @apply font-sans text-[12px] font-medium leading-[16px] tracking-[0%]; + @apply text-[12px] font-medium leading-[16px] tracking-[0%]; } .caption-s-semibold { - @apply font-sans text-[12px] font-semibold leading-[16px] tracking-[0%]; + @apply text-[12px] font-semibold leading-[16px] tracking-[0%]; } -} \ No newline at end of file +} diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index fd620a6..fb1d88c 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -1,64 +1,69 @@ -const { fontFamily } = require("tailwindcss/defaultTheme"); +const { fontFamily } = require('tailwindcss/defaultTheme') /** @type {import('tailwindcss').Config} */ module.exports = { + darkMode: 'selector', content: [ - "./src/**/*.{js,ts,jsx,tsx,mdx}", - "./node_modules/flowbite-react/**/*.js" + './src/**/*.{js,ts,jsx,tsx,mdx}', + './node_modules/flowbite-react/**/*.js', ], theme: { fontSize: { - "2xs": ["0.75rem", { lineHeight: "1.25rem" }], - xs: ["0.8125rem", { lineHeight: "1.5rem" }], - sm: ["0.875rem", { lineHeight: "1.5rem" }], - base: ["1rem", { lineHeight: "1.75rem" }], - lg: ["1.125rem", { lineHeight: "1.75rem" }], - xl: ["1.25rem", { lineHeight: "1.75rem" }], - "2xl": ["1.5rem", { lineHeight: "2rem" }], - "3xl": ["1.875rem", { lineHeight: "2.25rem" }], - "4xl": ["2.25rem", { lineHeight: "2.5rem" }], - "5xl": ["3rem", { lineHeight: "1" }], - "6xl": ["3.75rem", { lineHeight: "1" }], - "7xl": ["4.5rem", { lineHeight: "1" }], - "8xl": ["6rem", { lineHeight: "1" }], - "9xl": ["8rem", { lineHeight: "1" }], + '2xs': ['0.75rem', { lineHeight: '1.25rem' }], + xs: ['0.8125rem', { lineHeight: '1.5rem' }], + sm: ['0.875rem', { lineHeight: '1.5rem' }], + base: ['1rem', { lineHeight: '1.75rem' }], + lg: ['1.125rem', { lineHeight: '1.75rem' }], + xl: ['1.25rem', { lineHeight: '1.75rem' }], + '2xl': ['1.5rem', { lineHeight: '2rem' }], + '3xl': ['1.875rem', { lineHeight: '2.25rem' }], + '4xl': ['2.25rem', { lineHeight: '2.5rem' }], + '5xl': ['3rem', { lineHeight: '1' }], + '6xl': ['3.75rem', { lineHeight: '1' }], + '7xl': ['4.5rem', { lineHeight: '1' }], + '8xl': ['6rem', { lineHeight: '1' }], + '9xl': ['8rem', { lineHeight: '1' }], }, extend: { fontFamily: { - sans: ["var(--font-inter)", ...fontFamily.sans], - display: ["var(--font-dm-sans)", ...fontFamily.sans], + sans: ['var(--font-inter)', ...fontFamily.sans], + display: ['var(--font-dm-sans)', ...fontFamily.sans], }, colors: { slate: { - 1: "hsl(200, 7.0%, 8.8%)", - 2: "hsl(195, 7.1%, 11.0%)", - 3: "hsl(197, 6.8%, 13.6%)", - 4: "hsl(198, 6.6%, 15.8%)", - 5: "hsl(199, 6.4%, 17.9%)", - 6: "hsl(201, 6.2%, 20.5%)", - 7: "hsl(203, 6.0%, 24.3%)", - 8: "hsl(207, 5.6%, 31.6%)", - 9: "hsl(206, 6.0%, 43.9%)", - 10: "hsl(206, 5.2%, 49.5%)", - 11: "hsl(206, 6.0%, 63.0%)", - 12: "hsl(210, 6.0%, 93.0%)", + 1: 'hsl(200, 7.0%, 8.8%)', + 2: 'hsl(195, 7.1%, 11.0%)', + 3: 'hsl(197, 6.8%, 13.6%)', + 4: 'hsl(198, 6.6%, 15.8%)', + 5: 'hsl(199, 6.4%, 17.9%)', + 6: 'hsl(201, 6.2%, 20.5%)', + 7: 'hsl(203, 6.0%, 24.3%)', + 8: 'hsl(207, 5.6%, 31.6%)', + 9: 'hsl(206, 6.0%, 43.9%)', + 10: 'hsl(206, 5.2%, 49.5%)', + 11: 'hsl(206, 6.0%, 63.0%)', + 12: 'hsl(210, 6.0%, 93.0%)', }, crimson: { - 1: "hsl(335, 20.0%, 9.6%)", - 2: "hsl(335, 32.2%, 11.6%)", - 3: "hsl(335, 42.5%, 16.5%)", - 4: "hsl(335, 47.2%, 19.3%)", - 5: "hsl(335, 50.9%, 21.8%)", - 6: "hsl(335, 55.7%, 25.3%)", - 7: "hsl(336, 62.9%, 30.8%)", - 8: "hsl(336, 74.9%, 39.0%)", - 9: "hsl(336, 80.0%, 57.8%)", - 10: "hsl(339, 84.1%, 62.6%)", - 11: "hsl(341, 90.0%, 67.3%)", - 12: "hsl(332, 87.0%, 96.0%)", + 1: 'hsl(335, 20.0%, 9.6%)', + 2: 'hsl(335, 32.2%, 11.6%)', + 3: 'hsl(335, 42.5%, 16.5%)', + 4: 'hsl(335, 47.2%, 19.3%)', + 5: 'hsl(335, 50.9%, 21.8%)', + 6: 'hsl(335, 55.7%, 25.3%)', + 7: 'hsl(336, 62.9%, 30.8%)', + 8: 'hsl(336, 74.9%, 39.0%)', + 9: 'hsl(336, 80.0%, 57.8%)', + 10: 'hsl(339, 84.1%, 62.6%)', + 11: 'hsl(341, 90.0%, 67.3%)', + 12: 'hsl(332, 87.0%, 96.0%)', }, }, }, }, - plugins: [require("tailwindcss-animate"), require("@tailwindcss/forms"), require("flowbite/plugin")], -}; \ No newline at end of file + plugins: [ + require('tailwindcss-animate'), + require('@tailwindcss/forms'), + require('flowbite/plugin'), + ], +}