├── .commitlintrc.json ├── .env.example ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ └── checks.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierignore ├── LICENSE ├── README.md ├── drizzle.config.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── og.jpg └── site.webmanifest ├── src ├── actions │ ├── feedback.ts │ ├── fields.ts │ ├── forms.ts │ ├── submissions.ts │ └── webhooks.ts ├── app │ ├── (auth) │ │ ├── layout.tsx │ │ ├── login │ │ │ └── page.tsx │ │ └── register │ │ │ └── page.tsx │ ├── (dashboard) │ │ ├── dashboard │ │ │ ├── columns.tsx │ │ │ ├── page.tsx │ │ │ └── settings │ │ │ │ └── page.tsx │ │ ├── forms │ │ │ ├── [id] │ │ │ │ ├── _components │ │ │ │ │ ├── export-button.tsx │ │ │ │ │ ├── form-nav.tsx │ │ │ │ │ └── submissions-table.tsx │ │ │ │ ├── columns.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── settings │ │ │ │ │ └── page.tsx │ │ │ │ └── webhooks │ │ │ │ │ ├── [webhookId] │ │ │ │ │ ├── columns.tsx │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── _components │ │ │ │ │ └── create-webhook-button.tsx │ │ │ │ │ ├── columns.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── create │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── (editor) │ │ └── forms │ │ │ └── [id] │ │ │ └── edit │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ ├── (form) │ │ └── f │ │ │ └── [id] │ │ │ ├── layout.tsx │ │ │ ├── not-found.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ ├── (marketing) │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── privacy │ │ │ └── page.tsx │ │ └── terms │ │ │ └── page.tsx │ ├── api │ │ ├── cron │ │ │ └── events │ │ │ │ └── retry │ │ │ │ └── route.ts │ │ ├── forms │ │ │ └── [id] │ │ │ │ └── submissions │ │ │ │ └── export │ │ │ │ └── route.ts │ │ └── og │ │ │ └── route.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── opengraph-image.jpg │ └── robots.tsx ├── assets │ └── fonts │ │ ├── CalSans-SemiBold.ttf │ │ ├── CalSans-SemiBold.woff │ │ └── CalSans-SemiBold.woff2 ├── components │ ├── analytics.tsx │ ├── card-skeleton.tsx │ ├── create-form-form.tsx │ ├── edit-field-card.tsx │ ├── edit-field-form.tsx │ ├── edit-field-sheet.tsx │ ├── editor.tsx │ ├── empty-placeholder.tsx │ ├── feedback-button.tsx │ ├── form-renderer.tsx │ ├── header.tsx │ ├── icons.tsx │ ├── logo.tsx │ ├── main-nav.tsx │ ├── mobile-nav.tsx │ ├── mode-toggle.tsx │ ├── nav.tsx │ ├── secret-input.tsx │ ├── shell.tsx │ ├── site-footer.tsx │ ├── tailwind-indicator.tsx │ ├── theme-provider.tsx │ ├── typography │ │ └── index.tsx │ ├── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── context-menu.tsx │ │ ├── data-column-header.tsx │ │ ├── data-table-pagination.tsx │ │ ├── data-table.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input-required-hint.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── status-badge.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts │ ├── user-account-nav.tsx │ ├── user-auth-form.tsx │ ├── user-avatar.tsx │ └── user-nav.tsx ├── config │ ├── dashboard.ts │ ├── marketing.ts │ └── site.ts ├── env.mjs ├── hooks │ └── use-lock-body.ts ├── lib │ ├── auth.ts │ ├── db │ │ ├── index.ts │ │ ├── lib │ │ │ └── drizzle-adapter.ts │ │ └── schema.ts │ ├── events │ │ ├── index.ts │ │ └── types.ts │ ├── id.ts │ ├── ratelimiter.ts │ ├── session.ts │ ├── utils.ts │ └── validations │ │ ├── auth.ts │ │ └── og.ts ├── middleware.ts ├── pages │ └── api │ │ └── auth │ │ └── [...nextauth].ts └── types │ ├── index.d.ts │ └── next-auth.d.ts ├── tailwind.config.ts ├── tsconfig.json └── vercel.json /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # App 3 | # ----------------------------------------------------------------------------- 4 | NEXT_PUBLIC_APP_URL=http://localhost:3000 5 | 6 | # ----------------------------------------------------------------------------- 7 | # Authentication (NextAuth.js) 8 | # ----------------------------------------------------------------------------- 9 | NEXTAUTH_URL=http://localhost:3000 10 | NEXTAUTH_SECRET= 11 | 12 | GITHUB_CLIENT_ID= 13 | GITHUB_CLIENT_SECRET= 14 | 15 | # ----------------------------------------------------------------------------- 16 | # Database (MySQL - PlanetScale) 17 | # ----------------------------------------------------------------------------- 18 | DATABASE_URL= 19 | 20 | # ----------------------------------------------------------------------------- 21 | # Email (Postmark) (optional - for email sign in) 22 | # ----------------------------------------------------------------------------- 23 | SMTP_FROM= 24 | POSTMARK_API_TOKEN= 25 | POSTMARK_SIGN_IN_TEMPLATE= 26 | POSTMARK_ACTIVATION_TEMPLATE= 27 | 28 | # ----------------------------------------------------------------------------- 29 | # Redis (Vercel KV) (optional) 30 | # ----------------------------------------------------------------------------- 31 | KV_URL= 32 | KV_REST_API_URL= 33 | KV_REST_API_TOKEN= 34 | KV_REST_API_READ_ONLY_TOKEN= 35 | 36 | # ----------------------------------------------------------------------------- 37 | # Upstash (optional) 38 | # ----------------------------------------------------------------------------- 39 | QSTASH_CURRENT_SIGNING_KEY= 40 | QSTASH_NEXT_SIGNING_KEY= 41 | 42 | # ----------------------------------------------------------------------------- 43 | # umami Analytics (optional) 44 | # ----------------------------------------------------------------------------- 45 | NEXT_PUBLIC_UMAMI_DOMAIN= 46 | NEXT_PUBLIC_UMAMI_SITE_ID= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "react/jsx-key": "off", 13 | "tailwindcss/no-custom-classname": "off", 14 | "tailwindcss/classnames-order": "error" 15 | }, 16 | "settings": { 17 | "tailwindcss": { 18 | "callees": ["cn"], 19 | "config": "tailwind.config.js" 20 | }, 21 | "next": { 22 | "rootDir": true 23 | } 24 | }, 25 | "overrides": [ 26 | { 27 | "files": ["*.ts", "*.tsx"], 28 | "parser": "@typescript-eslint/parser" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: Create a bug report to help us improve 3 | title: "bug: " 4 | labels: ["🐞❔ unconfirmed bug"] 5 | body: 6 | - type: textarea 7 | attributes: 8 | label: Provide environment information 9 | description: | 10 | Run this command in your project root and paste the results in a code block: 11 | ```bash 12 | npx envinfo --system --binaries 13 | ``` 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Describe the bug 19 | description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. 20 | validations: 21 | required: true 22 | - type: input 23 | attributes: 24 | label: Link to reproduction 25 | description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored. 26 | - type: textarea 27 | attributes: 28 | label: To reproduce 29 | description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Additional information 35 | description: Add any other information related to the bug here, screenshots if applicable. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | # This template is heavily inspired by the Next.js's template: 2 | # See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml 3 | 4 | name: 🛠 Feature Request 5 | description: Create a feature request for the core packages 6 | title: "feat: " 7 | labels: ["✨ enhancement"] 8 | body: 9 | - type: markdown 10 | attributes: 11 | value: | 12 | Thank you for taking the time to file a feature request. Please fill out this form as completely as possible. 13 | - type: textarea 14 | attributes: 15 | label: Describe the feature you'd like to request 16 | description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed. 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: Describe the solution you'd like to see 22 | description: Please describe the solution you would like to see. Adding example usage is a good way to provide context. 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: Additional information 28 | description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. 29 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout repo 10 | uses: actions/checkout@v2 11 | 12 | - name: Setup Node.js 18.x 13 | uses: actions/setup-node@v2 14 | with: 15 | node-version: 18.x 16 | 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v2.2.4 19 | 20 | - name: Install dependencies 21 | run: pnpm install --config.platform=linux --config.architecture=x64 22 | 23 | - name: Lint 24 | run: pnpm lint 25 | env: 26 | SKIP_ENV_VALIDATION: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.18.0 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .next 4 | build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Dorf - An open source visual form builder for everyone who wants to gather feedback, leads and opinions 3 |

Dorf

4 |
5 | 6 |

7 | An open source visual form builder for everyone who wants to gather feedback, leads and opinions. 8 |

9 |

10 | 11 | Twitter 12 | 13 | 14 | License 15 | 16 |

17 | 18 |

19 | Introduction · 20 | Tech Stack · 21 | Contributing 22 |

23 |
24 | 25 | ## Introduction 26 | 27 | Dorf is an open-source visual form builder to easily gather feedback, leads and opinions. Built with [Vercel KV](https://vercel.com/storage/kv), and [PlanetScale MySQL](https://planetscale.com/). 28 | 29 | ## Tech Stack 30 | 31 | - [Next.js](https://nextjs.org/) – framework 32 | - [Typescript](https://www.typescriptlang.org/) – language 33 | - [Tailwind](https://tailwindcss.com/) – CSS 34 | - [Vercel KV](https://vercel.com/storage/kv) – redis 35 | - [PlanetScale](https://planetscale.com/) – database 36 | - [Drizzle](https://orm.drizzle.team/) - ORM 37 | - [NextAuth.js](https://next-auth.js.org/) – auth 38 | - [Vercel](https://vercel.com/) – hosting 39 | - [Postmark](https://postmarkapp.com/) - emails 40 | 41 | ## Development 42 | 43 | 1. Clone this repo 44 | 2. cp .env.example .env.local 45 | 3. Set NEXTAUTH_SECRET in `.env.local` with value returned when running `openssl rand -base64 32` in your terminal 46 | 4. Configure Github auth provider by following [this guide](https://authjs.dev/getting-started/oauth-tutorial#2-configuring-oauth-provider) (callback url ${NEXT_PUBLIC_APP_URL}/api/auth/callback/github) and add email to permissions 47 | 5. Set DATABASE_URL in `.env.local` to the connection string of your mysql database (either locally running or a [Planetscale](https://planetscale.com/) development branch). Format should be `mysql://USER:PASSWORD@HOST/DATABASE?ssl={"rejectUnauthorized":true}` 48 | 6. Set these .env vars to `.optional()` in `src/env.mjs` if you dont want to use Email authentication: 49 | ``` 50 | SMTP_FROM: z.string().min(1).optional(), 51 | POSTMARK_API_TOKEN: z.string().min(1).optional(), 52 | POSTMARK_SIGN_IN_TEMPLATE: z.string().min(1).optional(), 53 | POSTMARK_ACTIVATION_TEMPLATE: z.string().min(1).optional() 54 | ``` 55 | 7. Create Vercel KV Database in [your dashboard](https://vercel.com/dashboard/stores) and connect it to a (new) project. 56 | 8. Set the provided secrets in `.env.local` for following values: 57 | 58 | ``` 59 | KV_URL= 60 | KV_REST_API_URL= 61 | KV_REST_API_TOKEN= 62 | KV_REST_API_READ_ONLY_TOKEN= 63 | ``` 64 | 65 | 9. Run `pnpm i` to install dependencies (if you haven't installed pnpm yet follow [this guide](https://pnpm.io/installation)) 66 | 10. Run `pnpm run db:push` to push the database schema to your database 67 | 11. Run `pnpm dev` to start the development server 68 | 12. Happy coding 🎉 69 | 70 | ## Contributing 71 | 72 | We love our contributors! Here's how you can contribute: 73 | 74 | - [Open an issue](https://github.com/matheins/dorf/issues) if you believe you've encountered a bug. 75 | - Make a [pull request](https://github.com/matheins/dorf/pull) to add new features/make quality-of-life improvements/fix bugs. 76 | 77 | ## Author 78 | 79 | - Matyas Heins ([@matheins](https://twitter.com/matheins)) 80 | 81 | ## License 82 | 83 | Inspired by [Plausible](https://plausible.io/) and [Dub](https://dub.sh), Dorf is open-source under the GNU Affero General Public License Version 3 (AGPLv3) or any later version. You can [find it here](https://github.com/matheins/dorf/blob/main/LICENSE). 84 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit" 2 | 3 | export default { 4 | schema: "./src/lib/db/schema.ts", 5 | out: "./drizzle", 6 | connectionString: process.env.DATABASE_URL, 7 | } satisfies Config 8 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import "./src/env.mjs" 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | images: { 6 | domains: ["avatars.githubusercontent.com"], 7 | }, 8 | experimental: { 9 | serverActions: true, 10 | }, 11 | redirects: async () => { 12 | return [ 13 | { 14 | source: "/forms", 15 | destination: "/dashboard", 16 | permanent: true, 17 | }, 18 | ] 19 | }, 20 | } 21 | 22 | export default nextConfig 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dorf-forms", 3 | "version": "0.1.0", 4 | "private": true, 5 | "license": "AGPL-3.0-or-later", 6 | "author": { 7 | "name": "matheins", 8 | "url": "https://twitter.com/matheins" 9 | }, 10 | "scripts": { 11 | "dev": "next dev", 12 | "turbo": "next dev --turbo", 13 | "build": "next build", 14 | "start": "next start", 15 | "lint": "next lint", 16 | "db:push": "pnpm with-env drizzle-kit push:mysql", 17 | "with-env": "dotenv -e .env.local --" 18 | }, 19 | "dependencies": { 20 | "@hookform/resolvers": "^3.1.0", 21 | "@planetscale/database": "^1.7.0", 22 | "@radix-ui/react-accordion": "^1.1.2", 23 | "@radix-ui/react-alert-dialog": "^1.0.4", 24 | "@radix-ui/react-avatar": "^1.0.3", 25 | "@radix-ui/react-checkbox": "^1.0.4", 26 | "@radix-ui/react-context-menu": "^2.1.4", 27 | "@radix-ui/react-dialog": "^1.0.4", 28 | "@radix-ui/react-dropdown-menu": "^2.0.5", 29 | "@radix-ui/react-label": "^2.0.2", 30 | "@radix-ui/react-popover": "^1.0.6", 31 | "@radix-ui/react-progress": "^1.0.3", 32 | "@radix-ui/react-radio-group": "^1.1.3", 33 | "@radix-ui/react-scroll-area": "^1.0.4", 34 | "@radix-ui/react-select": "^1.2.2", 35 | "@radix-ui/react-separator": "^1.0.3", 36 | "@radix-ui/react-slot": "^1.0.2", 37 | "@radix-ui/react-switch": "^1.0.3", 38 | "@radix-ui/react-tabs": "^1.0.4", 39 | "@radix-ui/react-toast": "^1.1.4", 40 | "@radix-ui/react-tooltip": "^1.0.6", 41 | "@t3-oss/env-nextjs": "^0.3.1", 42 | "@tanstack/react-table": "^8.9.1", 43 | "@types/node": "20.2.5", 44 | "@types/react": "18.2.14", 45 | "@types/react-dom": "18.2.6", 46 | "@upstash/qstash": "^0.3.6", 47 | "@upstash/ratelimit": "^0.4.3", 48 | "@upstash/redis": "^1.20.6", 49 | "@vercel/analytics": "^1.0.1", 50 | "@vercel/edge": "^0.3.4", 51 | "@vercel/kv": "^0.2.1", 52 | "@vercel/og": "^0.5.7", 53 | "autoprefixer": "10.4.14", 54 | "class-variance-authority": "^0.6.0", 55 | "clsx": "^1.2.1", 56 | "date-fns": "^2.30.0", 57 | "dayjs": "^1.11.7", 58 | "drizzle-orm": "^0.26.1", 59 | "drizzle-zod": "^0.4.2", 60 | "file-saver": "^2.0.5", 61 | "json2csv": "6.0.0-alpha.2", 62 | "lucide-react": "^0.224.0", 63 | "nanoid": "^4.0.2", 64 | "next": "13.4.5", 65 | "next-auth": "^4.22.1", 66 | "next-themes": "^0.2.1", 67 | "nodemailer": "^6.9.3", 68 | "postcss": "8.4.24", 69 | "postmark": "^3.0.18", 70 | "react": "18.2.0", 71 | "react-day-picker": "^8.7.1", 72 | "react-dom": "18.2.0", 73 | "react-hook-form": "^7.44.2", 74 | "react-hotkeys-hook": "^4.4.0", 75 | "tailwind-merge": "^1.12.0", 76 | "tailwindcss": "3.3.2", 77 | "tailwindcss-animate": "^1.0.5", 78 | "typescript": "5.1.3", 79 | "validator": "^13.9.0", 80 | "zod": "^3.21.4" 81 | }, 82 | "devDependencies": { 83 | "@commitlint/cli": "^17.6.3", 84 | "@commitlint/config-conventional": "^17.6.3", 85 | "@ianvs/prettier-plugin-sort-imports": "^4.0.0", 86 | "@types/file-saver": "^2.0.5", 87 | "@types/json2csv": "^5.0.3", 88 | "@types/validator": "^13.7.17", 89 | "dotenv-cli": "^7.2.1", 90 | "drizzle-kit": "^0.18.1", 91 | "eslint": "8.41.0", 92 | "eslint-config-next": "13.4.4", 93 | "eslint-config-prettier": "^8.8.0", 94 | "eslint-plugin-react": "^7.32.2", 95 | "eslint-plugin-tailwindcss": "^3.12.1", 96 | "husky": "^8.0.3", 97 | "prettier": "^2.8.8", 98 | "prettier-plugin-tailwindcss": "^0.3.0", 99 | "pretty-quick": "^3.1.3" 100 | }, 101 | "engines": { 102 | "node": ">=v18.16.0" 103 | }, 104 | "packageManager": "pnpm@8.6.0" 105 | } 106 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | endOfLine: "lf", 4 | semi: false, 5 | singleQuote: false, 6 | tabWidth: 2, 7 | trailingComma: "es5", 8 | importOrder: [ 9 | "^(react/(.*)$)|^(react$)", 10 | "^(next/(.*)$)|^(next$)", 11 | "", 12 | "", 13 | "^types$", 14 | "^@/env(.*)$", 15 | "^@/types/(.*)$", 16 | "^@/config/(.*)$", 17 | "^@/lib/(.*)$", 18 | "^@/hooks/(.*)$", 19 | "^@/components/ui/(.*)$", 20 | "^@/components/(.*)$", 21 | "^@/styles/(.*)$", 22 | "^@/app/(.*)$", 23 | "", 24 | "^[./]", 25 | ], 26 | importOrderSeparation: false, 27 | importOrderSortSpecifiers: true, 28 | importOrderBuiltinModulesToTop: true, 29 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 30 | importOrderMergeDuplicateImports: true, 31 | importOrderCombineTypeAndValueImports: true, 32 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 33 | } 34 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/public/favicon.ico -------------------------------------------------------------------------------- /public/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/public/og.jpg -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/actions/feedback.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { createInsertSchema } from "drizzle-zod" 4 | import { z } from "zod" 5 | 6 | import { db } from "@/lib/db" 7 | import { feedbacks } from "@/lib/db/schema" 8 | import { generateId } from "@/lib/id" 9 | 10 | const insertFeedbackSchema = createInsertSchema(feedbacks).omit({ 11 | id: true, 12 | createdAt: true, 13 | updatedAt: true, 14 | }) 15 | type InsertFeedback = z.infer 16 | 17 | export async function createFeedback(values: InsertFeedback) { 18 | const feedback = insertFeedbackSchema.parse(values) 19 | 20 | await db.insert(feedbacks).values({ ...feedback, id: generateId() }) 21 | } 22 | -------------------------------------------------------------------------------- /src/actions/fields.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { revalidatePath } from "next/cache" 4 | import { eq } from "drizzle-orm" 5 | import { createInsertSchema } from "drizzle-zod" 6 | import { z } from "zod" 7 | 8 | import { db } from "@/lib/db" 9 | import { fields } from "@/lib/db/schema" 10 | import { generateId } from "@/lib/id" 11 | 12 | const insertFieldSchema = createInsertSchema(fields).omit({ 13 | id: true, 14 | createdAt: true, 15 | updatedAt: true, 16 | }) 17 | type InsertField = z.infer 18 | 19 | export async function addField(values: InsertField) { 20 | const field = insertFieldSchema.parse(values) 21 | 22 | await db.insert(fields).values({ 23 | ...field, 24 | id: generateId(), 25 | }) 26 | 27 | revalidatePath(`/forms/${field.formId}/edit`) 28 | } 29 | 30 | const updateFieldSchema = createInsertSchema(fields).omit({ 31 | createdAt: true, 32 | updatedAt: true, 33 | }) 34 | type UpdateField = z.infer 35 | 36 | export async function updateField(values: UpdateField) { 37 | const field = updateFieldSchema.parse(values) 38 | 39 | if (!field.id) { 40 | throw new Error("Field id is required") 41 | } 42 | 43 | await db.update(fields).set(field).where(eq(fields.id, field.id)) 44 | 45 | revalidatePath(`/forms/${field.formId}/edit`) 46 | } 47 | 48 | export async function deleteField(id: string) { 49 | const field = await db.query.fields.findFirst({ where: eq(fields.id, id) }) 50 | 51 | if (!field) { 52 | throw new Error("Field not found") 53 | } 54 | 55 | await db.delete(fields).where(eq(fields.id, id)) 56 | 57 | revalidatePath(`/forms/${field.formId}/edit`) 58 | } 59 | -------------------------------------------------------------------------------- /src/actions/forms.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { revalidatePath } from "next/cache" 4 | import { eq } from "drizzle-orm" 5 | import { createInsertSchema } from "drizzle-zod" 6 | import { z } from "zod" 7 | 8 | import { db } from "@/lib/db" 9 | import { forms } from "@/lib/db/schema" 10 | import { generateId } from "@/lib/id" 11 | 12 | const insertFormSchema = createInsertSchema(forms).pick({ 13 | title: true, 14 | description: true, 15 | submitText: true, 16 | userId: true, 17 | }) 18 | type InsertForm = z.infer 19 | 20 | export async function createForm(values: InsertForm) { 21 | const form = insertFormSchema.parse(values) 22 | const id = generateId() 23 | await db.insert(forms).values({ 24 | ...form, 25 | id, 26 | }) 27 | 28 | const createdForm = await db.query.forms.findFirst({ 29 | where: eq(forms.id, id), 30 | }) 31 | 32 | revalidatePath("/forms") 33 | 34 | return createdForm 35 | } 36 | 37 | const publishFormSchema = createInsertSchema(forms).pick({ 38 | id: true, 39 | published: true, 40 | }) 41 | type PublishForm = z.infer 42 | 43 | export async function setFormPublished(values: PublishForm) { 44 | const form = publishFormSchema.parse(values) 45 | 46 | if (!form.id) { 47 | throw new Error("Form id is required") 48 | } 49 | 50 | await db.update(forms).set(form).where(eq(forms.id, form.id)) 51 | 52 | const updatedForm = await db.query.forms.findFirst({ 53 | where: eq(forms.id, form.id), 54 | }) 55 | 56 | revalidatePath("/forms") 57 | 58 | return updatedForm 59 | } 60 | 61 | const archiveFormSchema = createInsertSchema(forms).pick({ 62 | id: true, 63 | archived: true, 64 | }) 65 | type ArchiveForm = z.infer 66 | 67 | export async function setFormArchived(values: ArchiveForm) { 68 | const form = archiveFormSchema.parse(values) 69 | await db.update(forms).set(form).where(eq(forms.id, form.id)) 70 | 71 | const updatedForm = await db.query.forms.findFirst({ 72 | where: eq(forms.id, form.id), 73 | }) 74 | 75 | revalidatePath("/forms") 76 | 77 | return updatedForm 78 | } 79 | 80 | export async function deleteForm(id: string) { 81 | await db.delete(forms).where(eq(forms.id, id)) 82 | revalidatePath("/forms") 83 | } 84 | -------------------------------------------------------------------------------- /src/actions/submissions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { headers } from "next/headers" 4 | import { createInsertSchema } from "drizzle-zod" 5 | import { z } from "zod" 6 | 7 | import { db } from "@/lib/db" 8 | import { submissions } from "@/lib/db/schema" 9 | import { Event } from "@/lib/events" 10 | import { generateId } from "@/lib/id" 11 | import { ratelimit } from "@/lib/ratelimiter" 12 | 13 | const createSubmissionSchema = createInsertSchema(submissions).pick({ 14 | formId: true, 15 | data: true, 16 | }) 17 | type CreateSubmission = z.infer 18 | 19 | export const createSubmission = async (data: CreateSubmission) => { 20 | const submission = createSubmissionSchema.parse(data) 21 | const ip = headers().get("x-forwarded-for") 22 | 23 | const { success } = await ratelimit.limit(ip ?? "anonymous") 24 | 25 | if (!success) { 26 | throw new Error("Too many requests") 27 | } 28 | 29 | const id = generateId() 30 | await db.insert(submissions).values({ ...submission, id }) 31 | 32 | const event = new Event("submission.created") 33 | 34 | await event.emit({ 35 | formId: submission.formId, 36 | data: JSON.stringify(submission?.data), 37 | submissionId: id, 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/actions/webhooks.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { revalidatePath } from "next/cache" 4 | import { eq } from "drizzle-orm" 5 | import { createInsertSchema } from "drizzle-zod" 6 | import { z } from "zod" 7 | 8 | import { db } from "@/lib/db" 9 | import { webhooks } from "@/lib/db/schema" 10 | import { generateId } from "@/lib/id" 11 | 12 | const enableWebhookSchema = createInsertSchema(webhooks).pick({ 13 | id: true, 14 | enabled: true, 15 | }) 16 | type EnableForm = z.infer 17 | export async function setWebhookEnabled(values: EnableForm) { 18 | const webhook = enableWebhookSchema.parse(values) 19 | 20 | if (!webhook.id) { 21 | throw new Error("Form id is required") 22 | } 23 | 24 | await db.update(webhooks).set(webhook).where(eq(webhooks.id, webhook.id)) 25 | 26 | const updatedWebhook = await db.query.webhooks.findFirst({ 27 | where: eq(webhooks.id, webhook.id), 28 | }) 29 | 30 | revalidatePath(`/forms/${updatedWebhook?.formId}/webhooks`) 31 | 32 | return updatedWebhook 33 | } 34 | 35 | const deleteWebhook = createInsertSchema(webhooks).pick({ 36 | id: true, 37 | deleted: true, 38 | }) 39 | type DeleteForm = z.infer 40 | 41 | export async function setWebhookDeleted(values: DeleteForm) { 42 | const webhook = deleteWebhook.parse(values) 43 | await db.update(webhooks).set(webhook).where(eq(webhooks.id, webhook.id)) 44 | 45 | const updatedWebhook = await db.query.webhooks.findFirst({ 46 | where: eq(webhooks.id, webhook.id), 47 | }) 48 | 49 | revalidatePath(`/forms/${updatedWebhook?.formId}/webhooks`) 50 | 51 | return updatedWebhook 52 | } 53 | 54 | const insertWebhook = createInsertSchema(webhooks).pick({ 55 | formId: true, 56 | endpoint: true, 57 | }) 58 | type CreateWebhook = z.infer 59 | export async function createWebhook(values: CreateWebhook) { 60 | const webhook = insertWebhook.parse(values) 61 | 62 | const whsec = "whsec_" + generateId() 63 | const id = generateId() 64 | 65 | await db.insert(webhooks).values({ 66 | ...webhook, 67 | id: id, 68 | secretKey: whsec, 69 | events: JSON.stringify(["submission.created"]), 70 | }) 71 | 72 | const createdWebhook = await db.query.webhooks.findFirst({ 73 | where: eq(webhooks.id, id), 74 | }) 75 | 76 | revalidatePath(`/forms/${createdWebhook?.formId}/webhooks`) 77 | 78 | return createdWebhook 79 | } 80 | 81 | const rotateSecretKey = createInsertSchema(webhooks).pick({ 82 | id: true, 83 | }) 84 | type RotateKey = z.infer 85 | export async function rotateWebhookSecretKey(values: RotateKey) { 86 | const webhook = rotateSecretKey.parse(values) 87 | 88 | const whsec = "whsec_" + generateId() 89 | 90 | await db 91 | .update(webhooks) 92 | .set({ secretKey: whsec }) 93 | .where(eq(webhooks.id, webhook.id)) 94 | 95 | const updatedWebhook = await db.query.webhooks.findFirst({ 96 | where: eq(webhooks.id, webhook.id), 97 | }) 98 | 99 | revalidatePath( 100 | `/forms/${updatedWebhook?.formId}/webhooks/${updatedWebhook?.id}` 101 | ) 102 | 103 | return updatedWebhook 104 | } 105 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | interface AuthLayoutProps { 2 | children: React.ReactNode 3 | } 4 | 5 | export default function AuthLayout({ children }: AuthLayoutProps) { 6 | return
{children}
7 | } 8 | -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | import Link from "next/link" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { buttonVariants } from "@/components/ui/button" 6 | import { Icons } from "@/components/icons" 7 | import { UserAuthForm } from "@/components/user-auth-form" 8 | 9 | export const metadata: Metadata = { 10 | title: "Login", 11 | description: "Login to your account", 12 | } 13 | 14 | export default function LoginPage() { 15 | return ( 16 |
17 | 24 | <> 25 | 26 | Back 27 | 28 | 29 |
30 |
31 | 32 |

33 | Welcome back 34 |

35 |

36 | Enter your email to sign in to your account 37 |

38 |
39 | 40 |

41 | 45 | Don't have an account? Sign Up 46 | 47 |

48 |
49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { Icons } from "@/components/icons" 6 | import { UserAuthForm } from "@/components/user-auth-form" 7 | 8 | export const metadata = { 9 | title: "Create an account", 10 | description: "Create an account to get started.", 11 | } 12 | 13 | export default function RegisterPage() { 14 | return ( 15 |
16 | 23 | Login 24 | 25 |
26 |
27 |
28 |
29 | 30 |

31 | Create an account 32 |

33 |

34 | Enter your email below to create your account 35 |

36 |
37 | 38 |

39 | By clicking continue, you agree to our{" "} 40 | 44 | Terms of Service 45 | {" "} 46 | and{" "} 47 | 51 | Privacy Policy 52 | 53 | . 54 |

55 |
56 |
57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { and, eq } from "drizzle-orm" 3 | 4 | import { db } from "@/lib/db" 5 | import { forms } from "@/lib/db/schema" 6 | import { getCurrentUser } from "@/lib/session" 7 | import { buttonVariants } from "@/components/ui/button" 8 | import { DataTable } from "@/components/ui/data-table" 9 | import { EmptyPlaceholder } from "@/components/empty-placeholder" 10 | import { DashboardHeader } from "@/components/header" 11 | import { DashboardShell } from "@/components/shell" 12 | 13 | import { columns } from "./columns" 14 | 15 | const getForms = async ({ userId }: { userId: string }) => { 16 | const data = await db.query.forms.findMany({ 17 | with: { 18 | fields: true, 19 | submissions: true, 20 | }, 21 | where: and(eq(forms.archived, false), eq(forms.userId, userId)), 22 | }) 23 | 24 | return data 25 | } 26 | 27 | const Forms = async () => { 28 | const user = await getCurrentUser() 29 | if (!user) { 30 | return null 31 | } 32 | const forms = await getForms({ userId: user.id }) 33 | return ( 34 | 35 | 39 | 40 | Create form 41 | 42 | 43 |
44 | {forms?.length ? ( 45 | 46 | ) : ( 47 | 48 | 49 | No form created 50 | 51 | You don't have any forms yet. Create your first one. 52 | 53 | 54 | Create form 55 | 56 | 57 | )} 58 |
59 |
60 | ) 61 | } 62 | 63 | export default Forms 64 | -------------------------------------------------------------------------------- /src/app/(dashboard)/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from "@/components/header" 2 | import { DashboardShell } from "@/components/shell" 3 | 4 | const Settings = async () => { 5 | return ( 6 | 7 | 11 |

placeholder

12 |
13 | ) 14 | } 15 | 16 | export default Settings 17 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/_components/export-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React from "react" 4 | import { DownloadIcon, Loader2Icon } from "lucide-react" 5 | 6 | import { downloadFile } from "@/lib/utils" 7 | import { Button } from "@/components/ui/button" 8 | 9 | export default function ExportButton({ formId }: { formId: string }) { 10 | const [isLoading, setLoading] = React.useState(false) 11 | const handleSubmit = async (e: React.FormEvent) => { 12 | e.preventDefault() 13 | setLoading(true) 14 | 15 | await downloadFile( 16 | `/api/forms/${formId}/submissions/export?format=csv`, 17 | "submissions.csv" 18 | ) 19 | setLoading(false) 20 | } 21 | return ( 22 |
23 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/_components/form-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import { usePathname } from "next/navigation" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" 8 | 9 | interface FormNavProps extends React.HTMLAttributes { 10 | formId: string 11 | } 12 | 13 | export function FormNav({ className, formId, ...props }: FormNavProps) { 14 | const pathname = usePathname() 15 | 16 | const nav = [ 17 | { 18 | title: "Submissions", 19 | href: `/forms/${formId}`, 20 | }, 21 | { 22 | title: "Webhooks", 23 | href: `/forms/${formId}/webhooks`, 24 | label: "beta", 25 | }, 26 | ] 27 | 28 | return ( 29 |
30 | 31 |
32 | {nav.map((item) => ( 33 | 43 | {item.title}{" "} 44 | {item.label && ( 45 | 46 | {item.label} 47 | 48 | )} 49 | 50 | ))} 51 |
52 | 53 |
54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/_components/submissions-table.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ColumnDef } from "@tanstack/react-table" 3 | import { desc, eq, InferModel } from "drizzle-orm" 4 | 5 | import { db } from "@/lib/db" 6 | import { submissions } from "@/lib/db/schema" 7 | import { DataTable } from "@/components/ui/data-table" 8 | 9 | import ExportButton from "./export-button" 10 | 11 | const getSubmissions = async (formId: string) => { 12 | const data = await db.query.submissions.findMany({ 13 | where: eq(submissions.formId, formId), 14 | orderBy: desc(submissions.createdAt), 15 | }) 16 | 17 | return data 18 | } 19 | type Submission = InferModel 20 | 21 | export const SubmissionsTable = async ({ formId }: { formId: string }) => { 22 | const submissions = await getSubmissions(formId) 23 | 24 | const columns = () => { 25 | const allKeys = submissions.reduce((keys, submission) => { 26 | const submissionKeys = Object.keys( 27 | JSON.parse(JSON.stringify(submission.data)) 28 | ) 29 | return keys.concat(submissionKeys) 30 | }, []) 31 | 32 | // Create columns for each unique key in the data objects 33 | const uniqueKeys = [...new Set(allKeys)] 34 | const dynamicColumns: ColumnDef[] = uniqueKeys.map((key) => ({ 35 | header: key, 36 | accessorKey: `data.${key}`, 37 | })) 38 | 39 | // Add the title column 40 | const staticColumns: ColumnDef[] = [ 41 | { 42 | accessorKey: "createdAt", 43 | header: "Created at", 44 | // cell: async ({ row }) => { 45 | // const form = row.original 46 | 47 | // return
{dayjs(form.createdAt).format("MMM D, YYYY")}
48 | // }, 49 | }, 50 | ] 51 | 52 | return staticColumns.concat(dynamicColumns) 53 | } 54 | 55 | return ( 56 |
57 | } 61 | /> 62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | 5 | import { buttonVariants } from "@/components/ui/button" 6 | 7 | const Error = () => { 8 | return ( 9 |
10 |

Error

11 | 12 | Home 13 | 14 |
15 | ) 16 | } 17 | 18 | export default Error 19 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { buttonVariants } from "@/components/ui/button" 4 | import { EmptyPlaceholder } from "@/components/empty-placeholder" 5 | 6 | export default function NotFound() { 7 | return ( 8 | 9 | 10 | Uh oh! Not Found 11 | 12 | This form could not be found. Please try again. 13 | 14 | 15 | Go to Dashboard 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { notFound } from "next/navigation" 3 | import { and, eq } from "drizzle-orm" 4 | import { ExternalLinkIcon } from "lucide-react" 5 | 6 | import { auth } from "@/lib/auth" 7 | import { db } from "@/lib/db" 8 | import { forms } from "@/lib/db/schema" 9 | import { cn } from "@/lib/utils" 10 | import { buttonVariants } from "@/components/ui/button" 11 | import { DashboardHeader } from "@/components/header" 12 | import { Icons } from "@/components/icons" 13 | import { DashboardShell } from "@/components/shell" 14 | import { TypographyH2 } from "@/components/typography" 15 | 16 | import { FormNav } from "./_components/form-nav" 17 | import { SubmissionsTable } from "./_components/submissions-table" 18 | 19 | const getForm = async ({ id }: { id: string }) => { 20 | const session = await auth() 21 | if (!session) return undefined 22 | 23 | const form = await db.query.forms.findFirst({ 24 | where: and(eq(forms.id, id), eq(forms.userId, session.user.id)), 25 | with: { 26 | submissions: true, 27 | }, 28 | }) 29 | 30 | if (!form) return undefined 31 | 32 | return form 33 | } 34 | 35 | const Form = async ({ params: { id } }: { params: { id: string } }) => { 36 | const form = await getForm({ id }) 37 | 38 | if (!form) notFound() 39 | 40 | return ( 41 | 42 |
43 | 47 | 48 | All forms 49 | 50 |
51 | 52 |
53 | 58 | Preview 59 | 60 | 61 | 62 | Edit 63 | 64 |
65 |
66 | 67 | 68 |
69 | ) 70 | } 71 | 72 | export default Form 73 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm" 2 | 3 | import { db } from "@/lib/db" 4 | import { forms } from "@/lib/db/schema" 5 | 6 | const getForm = async ({ id }: { id: string }) => { 7 | const form = await db.query.forms.findFirst({ 8 | where: eq(forms.id, id), 9 | with: { 10 | submissions: true, 11 | }, 12 | }) 13 | 14 | if (!form) { 15 | throw new Error("Form not found") 16 | } 17 | 18 | return form 19 | } 20 | 21 | const FormSettings = async ({ params: { id } }: { params: { id: string } }) => { 22 | const form = await getForm({ id }) 23 | 24 | return
Settings
25 | } 26 | 27 | export default FormSettings 28 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/webhooks/[webhookId]/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ColumnDef } from "@tanstack/react-table" 4 | import dayjs from "dayjs" 5 | import { InferModel } from "drizzle-orm" 6 | 7 | import { webhookEvents } from "@/lib/db/schema" 8 | import { DataTableColumnHeader } from "@/components/ui/data-column-header" 9 | import { StatusBadge } from "@/components/ui/status-badge" 10 | 11 | type WebhookEvent = InferModel 12 | 13 | export const columns: ColumnDef[] = [ 14 | { 15 | accessorKey: "status", 16 | header: "Status", 17 | cell: ({ row }) => { 18 | const event = row.original 19 | 20 | return ( 21 | 31 | {event.status} 32 | 33 | ) 34 | }, 35 | }, 36 | { 37 | accessorKey: "event", 38 | header: ({ column }) => ( 39 | 40 | ), 41 | cell: ({ row }) => { 42 | const event = row.original 43 | 44 | return
{event.event}
45 | }, 46 | }, 47 | { 48 | accessorKey: "submissionId", 49 | header: ({ column }) => ( 50 | 51 | ), 52 | cell: ({ row }) => { 53 | const event = row.original 54 | 55 | return
{event.submissionId}
56 | }, 57 | }, 58 | { 59 | accessorKey: "createdAt", 60 | header: ({ column }) => ( 61 | 62 | ), 63 | cell: ({ row }) => { 64 | const form = row.original 65 | 66 | return
{dayjs(form.createdAt).format("MMM D, YYYY - hh:mm")}
67 | }, 68 | }, 69 | { 70 | accessorKey: "statusCode", 71 | header: "Status Code", 72 | cell: ({ row }) => { 73 | const event = row.original 74 | 75 | return ( 76 | 80 | {event.statusCode} 81 | 82 | ) 83 | }, 84 | }, 85 | { 86 | accessorKey: "attemptCount", 87 | header: "Attempts", 88 | }, 89 | ] 90 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/webhooks/[webhookId]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { notFound } from "next/navigation" 3 | import dayjs from "dayjs" 4 | import { and, eq } from "drizzle-orm" 5 | 6 | import { db } from "@/lib/db" 7 | import { webhooks } from "@/lib/db/schema" 8 | import { cn } from "@/lib/utils" 9 | import { Badge } from "@/components/ui/badge" 10 | import { buttonVariants } from "@/components/ui/button" 11 | import { DataTable } from "@/components/ui/data-table" 12 | import { 13 | Popover, 14 | PopoverContent, 15 | PopoverTrigger, 16 | } from "@/components/ui/popover" 17 | import { StatusBadge } from "@/components/ui/status-badge" 18 | import { EmptyPlaceholder } from "@/components/empty-placeholder" 19 | import { DashboardHeader } from "@/components/header" 20 | import { Icons } from "@/components/icons" 21 | import { SecretInput } from "@/components/secret-input" 22 | import { DashboardShell } from "@/components/shell" 23 | import { TypographyH4, TypographyInlineCode } from "@/components/typography" 24 | 25 | import { columns } from "./columns" 26 | 27 | const getWebhook = async ({ id }: { id: string }) => { 28 | const res = await db.query.webhooks.findFirst({ 29 | where: and(eq(webhooks.id, id), eq(webhooks.deleted, false)), 30 | with: { 31 | form: true, 32 | webhookEvents: { 33 | orderBy: (webhookEvents, { desc }) => [desc(webhookEvents.createdAt)], 34 | }, 35 | }, 36 | }) 37 | 38 | if (!res) { 39 | notFound() 40 | } 41 | 42 | return res 43 | } 44 | 45 | const Webhook = async ({ 46 | params: { webhookId }, 47 | }: { 48 | params: { webhookId: string } 49 | }) => { 50 | const webhook = await getWebhook({ id: webhookId }) 51 | 52 | return ( 53 | 54 |
55 | 59 | Webhooks 60 | 61 |
62 | 66 |
67 |
68 | Created 69 |
{dayjs(webhook.createdAt).format("MMM D, YYYY")}
70 |
71 |
72 | Status 73 | 74 | {webhook.enabled ? "Enabled" : "Disabled"} 75 | 76 |
77 |
78 | Listening for 79 | submission.created 80 |
81 |
82 | 83 | Signing secret 84 | 85 | 86 | 87 | 88 | 89 |
90 | The secret is attached as header param{" "} 91 | x-dorf-secret 92 |
93 |
94 |
95 |
96 | 97 |
98 |
99 |
100 | {webhook.webhookEvents?.length ? ( 101 | 102 | ) : ( 103 | 104 | 105 | No activity 106 | 107 | You don't have any webhook events yet. 108 | 109 | 110 | )} 111 |
112 |
113 | ) 114 | } 115 | 116 | export default Webhook 117 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/webhooks/_components/create-webhook-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { useRouter } from "next/navigation" 5 | import { createWebhook } from "@/actions/webhooks" 6 | import { zodResolver } from "@hookform/resolvers/zod" 7 | import { DialogTrigger } from "@radix-ui/react-dialog" 8 | import { Loader2 } from "lucide-react" 9 | import { useForm } from "react-hook-form" 10 | import { z } from "zod" 11 | 12 | import { Button } from "@/components/ui/button" 13 | import { 14 | Dialog, 15 | DialogContent, 16 | DialogFooter, 17 | DialogHeader, 18 | DialogTitle, 19 | } from "@/components/ui/dialog" 20 | import { 21 | Form, 22 | FormControl, 23 | FormDescription, 24 | FormField, 25 | FormItem, 26 | FormLabel, 27 | FormMessage, 28 | } from "@/components/ui/form" 29 | import { Input } from "@/components/ui/input" 30 | import { toast } from "@/components/ui/use-toast" 31 | 32 | const formSchema = z.object({ 33 | endpoint: z.string().url(), 34 | }) 35 | 36 | export function CreateWebhookButton({ formId }: { formId: string }) { 37 | const router = useRouter() 38 | const form = useForm>({ 39 | resolver: zodResolver(formSchema), 40 | defaultValues: { 41 | endpoint: "", 42 | }, 43 | }) 44 | const [isLoading, setIsLoading] = useState(false) 45 | 46 | async function onSubmit(values: z.infer) { 47 | setIsLoading(true) 48 | const newWebhook = await createWebhook({ ...values, formId }) 49 | toast({ 50 | title: "Webhook created", 51 | description: "Your webhook has been created.", 52 | }) 53 | router.push(`/forms/${newWebhook?.formId}/webhooks/${newWebhook?.id}`) 54 | setIsLoading(false) 55 | } 56 | return ( 57 | 58 | 59 | 60 | 61 | 62 |
63 | 64 | 65 | Create Webhook 66 | 67 |
68 | ( 72 | 73 | Endpoint 74 | 75 | 76 | 77 | 78 | Enter the endpoint where you want to receive webhook 79 | updates. 80 | 81 | 82 | 83 | )} 84 | /> 85 |
86 | 87 | 94 | 95 |
96 | 97 |
98 |
99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/[id]/webhooks/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { and, eq } from "drizzle-orm" 3 | 4 | import { db } from "@/lib/db" 5 | import { forms, webhooks } from "@/lib/db/schema" 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | import { DataTable } from "@/components/ui/data-table" 9 | import { EmptyPlaceholder } from "@/components/empty-placeholder" 10 | import { DashboardHeader } from "@/components/header" 11 | import { Icons } from "@/components/icons" 12 | import { DashboardShell } from "@/components/shell" 13 | 14 | import { FormNav } from "../_components/form-nav" 15 | import { CreateWebhookButton } from "./_components/create-webhook-button" 16 | import { columns } from "./columns" 17 | 18 | const getWebhooks = async ({ id }: { id: string }) => { 19 | const res = await db.query.webhooks.findMany({ 20 | where: and(eq(webhooks.formId, id), eq(webhooks.deleted, false)), 21 | }) 22 | 23 | return res 24 | } 25 | 26 | const getForm = async ({ id }: { id: string }) => { 27 | const form = await db.query.forms.findFirst({ 28 | where: eq(forms.id, id), 29 | columns: { 30 | title: true, 31 | }, 32 | }) 33 | 34 | if (!form) { 35 | throw new Error("Form not found") 36 | } 37 | 38 | return form 39 | } 40 | 41 | const Webhooks = async ({ params: { id } }: { params: { id: string } }) => { 42 | const webhooks = await getWebhooks({ id }) 43 | const { title } = await getForm({ id }) 44 | 45 | return ( 46 | 47 |
48 | 52 | 53 | All forms 54 | 55 |
56 | 60 | 61 | 62 | 63 |
64 | {webhooks?.length ? ( 65 | 66 | ) : ( 67 | 68 | 69 | No webhook created 70 | 71 | You don't have any webhooks yet. Create your first one. 72 | 73 | 74 | 75 | )} 76 |
77 |
78 | ) 79 | } 80 | 81 | export default Webhooks 82 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/create/loading.tsx: -------------------------------------------------------------------------------- 1 | import { CardSkeleton } from "@/components/card-skeleton" 2 | import { DashboardHeader } from "@/components/header" 3 | import { DashboardShell } from "@/components/shell" 4 | 5 | export default function DashboardSettingsLoading() { 6 | return ( 7 | 8 | 9 |
10 | 11 |
12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/create/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation" 2 | 3 | import { getCurrentUser } from "@/lib/session" 4 | import { CreateFormForm } from "@/components/create-form-form" 5 | import { DashboardHeader } from "@/components/header" 6 | import { DashboardShell } from "@/components/shell" 7 | 8 | const CreateForm = async () => { 9 | const user = await getCurrentUser() 10 | 11 | if (!user) return notFound() 12 | 13 | return ( 14 | 15 | 16 |
17 | 18 |
19 |
20 | ) 21 | } 22 | 23 | export default CreateForm 24 | -------------------------------------------------------------------------------- /src/app/(dashboard)/forms/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | 3 | export default function Forms() { 4 | redirect("/dashboard") 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { notFound } from "next/navigation" 3 | 4 | import { getCurrentUser } from "@/lib/session" 5 | import { FeedbackButton } from "@/components/feedback-button" 6 | import { Icons } from "@/components/icons" 7 | import { SiteFooter } from "@/components/site-footer" 8 | import { UserAccountNav } from "@/components/user-account-nav" 9 | 10 | interface DashboardLayoutProps { 11 | children?: React.ReactNode 12 | } 13 | 14 | export default async function DashboardLayout({ 15 | children, 16 | }: DashboardLayoutProps) { 17 | const user = await getCurrentUser() 18 | 19 | if (!user) { 20 | return notFound() 21 | } 22 | 23 | return ( 24 |
25 |
26 |
27 | 28 | 29 | 30 |
31 | 32 | 39 |
40 |
41 |
42 |
43 |
{children}
44 |
45 | 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/app/(editor)/forms/[id]/edit/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SiteFooter } from "@/components/site-footer" 2 | 3 | interface EditorProps { 4 | children?: React.ReactNode 5 | } 6 | 7 | export default function EditorLayout({ children }: EditorProps) { 8 | return ( 9 |
10 |
11 | {children} 12 |
13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(editor)/forms/[id]/edit/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton" 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/app/(editor)/forms/[id]/edit/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { buttonVariants } from "@/components/ui/button" 4 | import { EmptyPlaceholder } from "@/components/empty-placeholder" 5 | 6 | export default function NotFound() { 7 | return ( 8 | 9 | 10 | Uh oh! Not Found 11 | 12 | This form could not be found. Please try again. 13 | 14 | 15 | Go to Dashboard 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(editor)/forms/[id]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation" 2 | import { and, eq } from "drizzle-orm" 3 | 4 | import { auth } from "@/lib/auth" 5 | import { db } from "@/lib/db" 6 | import { forms } from "@/lib/db/schema" 7 | import { getCurrentUser } from "@/lib/session" 8 | import { Editor } from "@/components/editor" 9 | 10 | const getForm = async ({ id }: { id: string }) => { 11 | const session = await auth() 12 | if (!session) return undefined 13 | 14 | const form = await db.query.forms.findFirst({ 15 | where: and(eq(forms.id, id), eq(forms.userId, session.user.id)), 16 | with: { 17 | fields: { 18 | orderBy: (fields, { asc }) => [asc(fields.createdAt)], 19 | }, 20 | }, 21 | }) 22 | 23 | if (!form) return undefined 24 | 25 | return form 26 | } 27 | 28 | const EditForm = async ({ params: { id } }: { params: { id: string } }) => { 29 | const form = await getForm({ id }) 30 | const user = await getCurrentUser() 31 | 32 | if (!form) notFound() 33 | if (!user) return null 34 | 35 | return 36 | } 37 | 38 | export default EditForm 39 | -------------------------------------------------------------------------------- /src/app/(form)/f/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { TypographyMuted } from "@/components/typography" 4 | 5 | export default function FormLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode 9 | }) { 10 | return ( 11 |
12 | {children} 13 |
14 | 15 | 16 | built with{" "} 17 | 18 | Dorf.build 19 | 20 | 21 | 22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(form)/f/[id]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { buttonVariants } from "@/components/ui/button" 4 | import { EmptyPlaceholder } from "@/components/empty-placeholder" 5 | 6 | export default function NotFound() { 7 | return ( 8 | 9 | 10 | Uh oh! Not Found 11 | 12 | This form does not exist or is not public. Please try again. 13 | 14 | 15 | Go to Homepage 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/app/(form)/f/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | import { notFound } from "next/navigation" 3 | import { eq } from "drizzle-orm" 4 | 5 | import { env } from "@/env.mjs" 6 | import { db } from "@/lib/db" 7 | import { forms } from "@/lib/db/schema" 8 | import { absoluteUrl } from "@/lib/utils" 9 | import { Separator } from "@/components/ui/separator" 10 | import { FormRenderer } from "@/components/form-renderer" 11 | import { TypographyH1, TypographyLead } from "@/components/typography" 12 | 13 | interface FormPageProperties { 14 | params: { 15 | id: string 16 | } 17 | } 18 | 19 | const getForm = async ({ id }: { id: string }) => { 20 | const form = await db.query.forms.findFirst({ 21 | where: eq(forms.id, id), 22 | with: { 23 | fields: { 24 | orderBy: (fields, { asc }) => [asc(fields.createdAt)], 25 | }, 26 | }, 27 | }) 28 | 29 | return form 30 | } 31 | 32 | export async function generateMetadata({ 33 | params, 34 | }: FormPageProperties): Promise { 35 | const form = await getForm({ id: params.id }) 36 | 37 | if (!form) { 38 | return {} 39 | } 40 | 41 | const url = env.NEXT_PUBLIC_APP_URL 42 | 43 | const ogUrl = new URL(`${url}/api/og`) 44 | ogUrl.searchParams.set("heading", form.title) 45 | ogUrl.searchParams.set("type", "Form") 46 | 47 | return { 48 | title: form.title, 49 | description: form.description, 50 | openGraph: { 51 | title: form.title, 52 | description: form.description || "", 53 | type: "article", 54 | url: absoluteUrl(`f/${form.id}`), 55 | images: [ 56 | { 57 | url: ogUrl.toString(), 58 | width: 1200, 59 | height: 630, 60 | alt: form.title, 61 | }, 62 | ], 63 | }, 64 | twitter: { 65 | card: "summary_large_image", 66 | title: form.title, 67 | description: form.description || "", 68 | images: [ogUrl.toString()], 69 | }, 70 | } 71 | } 72 | 73 | const Form = async ({ params }: FormPageProperties) => { 74 | const { id } = params 75 | 76 | const form = await getForm({ id }) 77 | 78 | if (!form?.published || form.archived) { 79 | notFound() 80 | } 81 | 82 | return ( 83 |
84 |
85 | {form.title} 86 | {form.description} 87 |
88 | 89 | 90 |
91 | ) 92 | } 93 | 94 | export default Form 95 | -------------------------------------------------------------------------------- /src/app/(form)/f/[id]/success/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { ArrowRight } from "lucide-react" 3 | 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { TypographyH1, TypographyLead } from "@/components/typography" 6 | 7 | const FormSuccess = async ({ params }: { params: { id: string } }) => { 8 | return ( 9 |
10 |
11 | Success 12 | Form submitted successfully 13 | 14 | Start building your own 15 | 16 |
17 |
18 | ) 19 | } 20 | 21 | export default FormSuccess 22 | -------------------------------------------------------------------------------- /src/app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { Icons } from "@/components/icons" 6 | import { SiteFooter } from "@/components/site-footer" 7 | 8 | interface MarketingLayoutProps { 9 | children: React.ReactNode 10 | } 11 | 12 | export default async function MarketingLayout({ 13 | children, 14 | }: MarketingLayoutProps) { 15 | return ( 16 |
17 |
18 |
19 | 20 | 21 | 22 | 33 |
34 |
35 |
{children}
36 | 37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /src/app/(marketing)/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { marketingConfig } from "@/config/marketing" 4 | import { siteConfig } from "@/config/site" 5 | import { cn } from "@/lib/utils" 6 | import { buttonVariants } from "@/components/ui/button" 7 | import { Icons } from "@/components/icons" 8 | 9 | export const metadata = { 10 | title: "Form Building Made Simple", 11 | } 12 | 13 | export default async function IndexPage() { 14 | return ( 15 | <> 16 |
17 |
18 | 23 | Follow along on Twitter 24 | 25 |

26 | Form Building 27 |
28 | Made Simple 29 |

30 |

31 | Dorf is a free, open source visual form builder for capturing 32 | feedback, leads, and opinions. 33 |

34 |
35 | 39 | Start building 40 | 41 | 47 | Try a demo 48 | 49 |
50 |
51 |
52 |
53 |
54 |

55 | Proudly Open Source 56 |

57 |

58 | Dorf is open source and powered by open source software.
The 59 | code is available on{" "} 60 | 66 | GitHub 67 | 68 | .{" "} 69 |

70 | 71 | 77 | 78 | Star on GitHub 79 | 80 |
81 |
82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/app/(marketing)/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | const PrivacyPage = () => { 2 | return ( 3 |
4 |

Privacy

5 |
6 | ) 7 | } 8 | 9 | export default PrivacyPage 10 | -------------------------------------------------------------------------------- /src/app/(marketing)/terms/page.tsx: -------------------------------------------------------------------------------- 1 | const TermsPage = () => { 2 | return ( 3 |
4 |

Terms

5 |
6 | ) 7 | } 8 | 9 | export default TermsPage 10 | -------------------------------------------------------------------------------- /src/app/api/forms/[id]/submissions/export/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server" 2 | import { eq } from "drizzle-orm" 3 | import { getToken } from "next-auth/jwt" 4 | 5 | import { db } from "@/lib/db" 6 | import { forms, submissions, users } from "@/lib/db/schema" 7 | import { convertSubmissionsToCsv } from "@/lib/utils" 8 | 9 | export async function GET( 10 | request: NextRequest, 11 | { params }: { params: { id: string } } 12 | ) { 13 | const { searchParams } = new URL(request.url) 14 | 15 | const { id } = params 16 | const format = searchParams.get("format") 17 | const token = await getToken({ req: request }) 18 | 19 | // check if user is logged in 20 | if (!token) { 21 | return NextResponse.json({ error: "Not authorized" }, { status: 401 }) 22 | } 23 | 24 | // query form 25 | const form = await db.query.forms.findFirst({ 26 | where: eq(forms.id, id), 27 | }) 28 | 29 | if (!form) { 30 | return NextResponse.json({ error: "Form not found" }, { status: 404 }) 31 | } 32 | 33 | // check if user is form owner 34 | const isFormOwner = token.id === form.userId 35 | 36 | if (!isFormOwner) { 37 | return NextResponse.json({ error: "Not authorized" }, { status: 401 }) 38 | } 39 | 40 | // query submissions 41 | const data = await db.query.submissions.findMany({ 42 | where: eq(submissions.formId, form.id), 43 | }) 44 | 45 | if (!data) { 46 | return NextResponse.json({ error: "Submission not found" }, { status: 404 }) 47 | } 48 | 49 | if (format === "csv") { 50 | const csv = convertSubmissionsToCsv(data) 51 | 52 | return new NextResponse(csv, { 53 | headers: { 54 | "Content-Type": "text/csv", 55 | "Content-Disposition": `attachment; filename="${id}.csv"`, 56 | }, 57 | }) 58 | } 59 | 60 | return NextResponse.json( 61 | { error: "Invalid or missing value for param: format" }, 62 | { status: 400 } 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/app/api/og/route.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import { ImageResponse } from "@vercel/og" 3 | 4 | import { ogImageSchema } from "@/lib/validations/og" 5 | 6 | export const runtime = "edge" 7 | 8 | const calSans = fetch( 9 | new URL("../../../assets/fonts/CalSans-SemiBold.ttf", import.meta.url) 10 | ).then((res) => res.arrayBuffer()) 11 | 12 | const logo = fetch( 13 | new URL("../../../../public/apple-touch-icon.png", import.meta.url) 14 | ).then((res) => res.arrayBuffer()) 15 | 16 | export async function GET(req: Request) { 17 | try { 18 | const font = await calSans 19 | const logoData = (await logo) as unknown as string 20 | 21 | const url = new URL(req.url) 22 | const values = ogImageSchema.parse(Object.fromEntries(url.searchParams)) 23 | const heading = 24 | values.heading.length > 80 25 | ? `${values.heading.substring(0, 80)}...` 26 | : values.heading 27 | 28 | const fontSize = heading.length > 36 ? "70px" : "100px" 29 | 30 | return new ImageResponse( 31 | ( 32 |
39 |
40 | {`${heading} 45 | 46 | {values.type} 47 | 48 |
58 | {heading} 59 |
60 |
61 |
62 |
66 | dorf.vercel.app 67 |
68 |
69 |
70 | ), 71 | { 72 | width: 1200, 73 | height: 630, 74 | fonts: [ 75 | { 76 | name: "Cal Sans", 77 | data: font, 78 | weight: 700, 79 | style: "normal", 80 | }, 81 | ], 82 | } 83 | ) 84 | } catch (error) { 85 | return new Response(`Failed to generate image`, { 86 | status: 500, 87 | }) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 47.4% 11.2%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 47.4% 11.2%; 18 | 19 | --border: 214.3 31.8% 91.4%; 20 | --input: 214.3 31.8% 91.4%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 100% 50%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 224 71% 4%; 41 | --foreground: 213 31% 91%; 42 | 43 | --muted: 223 47% 11%; 44 | --muted-foreground: 215.4 16.3% 56.9%; 45 | 46 | --popover: 224 71% 4%; 47 | --popover-foreground: 215 20.2% 65.1%; 48 | 49 | --card: 224 71% 4%; 50 | --card-foreground: 213 31% 91%; 51 | 52 | --border: 216 34% 17%; 53 | --input: 216 34% 17%; 54 | 55 | --primary: 210 40% 98%; 56 | --primary-foreground: 222.2 47.4% 1.2%; 57 | 58 | --secondary: 222.2 47.4% 11.2%; 59 | --secondary-foreground: 210 40% 98%; 60 | 61 | --accent: 216 34% 17%; 62 | --accent-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 63% 31%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 216 34% 17%; 68 | 69 | --radius: 0.5rem; 70 | } 71 | } 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | body { 78 | @apply bg-background text-foreground; 79 | font-feature-settings: "rlig" 1, "calt" 1; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css" 2 | 3 | import { Inter as FontSans } from "next/font/google" 4 | import localFont from "next/font/local" 5 | import Script from "next/script" 6 | import { Analytics } from "@vercel/analytics/react" 7 | 8 | import { env } from "@/env.mjs" 9 | import { siteConfig } from "@/config/site" 10 | import { cn } from "@/lib/utils" 11 | import { Toaster } from "@/components/ui/toaster" 12 | import { TailwindIndicator } from "@/components/tailwind-indicator" 13 | import { ThemeProvider } from "@/components/theme-provider" 14 | 15 | const fontSans = FontSans({ subsets: ["latin"], variable: "--font-sans" }) 16 | 17 | const fontHeading = localFont({ 18 | src: "../assets/fonts/CalSans-SemiBold.woff2", 19 | variable: "--font-heading", 20 | }) 21 | 22 | export const metadata = { 23 | title: { 24 | default: siteConfig.name, 25 | template: `%s | ${siteConfig.name}`, 26 | }, 27 | description: siteConfig.description, 28 | keywords: ["Dorf forms", "Form builder"], 29 | authors: [ 30 | { 31 | name: "matheins", 32 | url: "https://github.com/matheins", 33 | }, 34 | ], 35 | creator: "matheins", 36 | themeColor: [ 37 | { media: "(prefers-color-scheme: light)", color: "white" }, 38 | { media: "(prefers-color-scheme: dark)", color: "black" }, 39 | ], 40 | openGraph: { 41 | type: "website", 42 | locale: "en_US", 43 | url: siteConfig.url, 44 | title: siteConfig.name, 45 | description: siteConfig.description, 46 | siteName: siteConfig.name, 47 | }, 48 | twitter: { 49 | card: "summary_large_image", 50 | title: siteConfig.name, 51 | description: siteConfig.description, 52 | images: [`${siteConfig.url}/og.jpg`], 53 | creator: "@matheins", 54 | }, 55 | icons: { 56 | icon: "/favicon.ico", 57 | shortcut: "/favicon-16x16.png", 58 | apple: "/apple-touch-icon.png", 59 | }, 60 | manifest: `${siteConfig.url}/site.webmanifest`, 61 | } 62 | 63 | export default function RootLayout({ 64 | children, 65 | }: { 66 | children: React.ReactNode 67 | }) { 68 | return ( 69 | 70 | 71 | 78 | 79 | {children} 80 | 81 | 82 | 83 | 84 | 85 | {process.env.NODE_ENV === "production" && ( 86 |