├── .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 |
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 |
12 |
13 |
14 |
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 |
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 |
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 |
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 |

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 |
90 | )}
91 |
92 | )
93 | }
94 |
--------------------------------------------------------------------------------
/src/app/opengraph-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/src/app/opengraph-image.jpg
--------------------------------------------------------------------------------
/src/app/robots.tsx:
--------------------------------------------------------------------------------
1 | import { MetadataRoute } from "next"
2 |
3 | export default function robots(): MetadataRoute.Robots {
4 | return {
5 | rules: {
6 | userAgent: "*",
7 | allow: "/",
8 | },
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/assets/fonts/CalSans-SemiBold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/src/assets/fonts/CalSans-SemiBold.ttf
--------------------------------------------------------------------------------
/src/assets/fonts/CalSans-SemiBold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/src/assets/fonts/CalSans-SemiBold.woff
--------------------------------------------------------------------------------
/src/assets/fonts/CalSans-SemiBold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/matheins/Dorf/851aa97fa2886e5e507d5f53a8471a93f823682b/src/assets/fonts/CalSans-SemiBold.woff2
--------------------------------------------------------------------------------
/src/components/analytics.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Analytics as VercelAnalytics } from "@vercel/analytics/react"
4 |
5 | export function Analytics() {
6 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/card-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
2 | import { Skeleton } from "@/components/ui/skeleton"
3 |
4 | export function CardSkeleton() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/create-form-form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useRouter } from "next/navigation"
4 | import { createForm } from "@/actions/forms"
5 | import { zodResolver } from "@hookform/resolvers/zod"
6 | import { User } from "next-auth/core/types"
7 | import { useForm } from "react-hook-form"
8 | import * as z from "zod"
9 |
10 | import { Button } from "./ui/button"
11 | import {
12 | Card,
13 | CardContent,
14 | CardDescription,
15 | CardHeader,
16 | CardTitle,
17 | } from "./ui/card"
18 | import {
19 | Form,
20 | FormControl,
21 | FormDescription,
22 | FormField,
23 | FormItem,
24 | FormLabel,
25 | FormMessage,
26 | } from "./ui/form"
27 | import { Input } from "./ui/input"
28 | import { Textarea } from "./ui/textarea"
29 | import { useToast } from "./ui/use-toast"
30 |
31 | const formSchema = z.object({
32 | title: z.string().min(2).max(50),
33 | description: z.string().max(512).optional(),
34 | submitText: z.string().min(2).max(50),
35 | })
36 |
37 | export const CreateFormForm = ({
38 | user,
39 | }: {
40 | user: User & {
41 | id: string
42 | }
43 | }) => {
44 | const router = useRouter()
45 | const { toast } = useToast()
46 | const form = useForm>({
47 | resolver: zodResolver(formSchema),
48 | defaultValues: {
49 | title: "",
50 | description: "",
51 | submitText: "Submit",
52 | },
53 | })
54 |
55 | async function onSubmit(values: z.infer) {
56 | const newForm = await createForm({ ...values, userId: user.id })
57 | toast({
58 | title: "Form created",
59 | description: "Your form has been created.",
60 | })
61 | router.push(`/forms/${newForm?.id}/edit`)
62 | }
63 |
64 | return (
65 |
130 |
131 | )
132 | }
133 |
--------------------------------------------------------------------------------
/src/components/edit-field-card.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState, useTransition } from "react"
4 | import { deleteField } from "@/actions/fields"
5 | import { DialogClose } from "@radix-ui/react-dialog"
6 | import { InferModel } from "drizzle-orm"
7 | import { MoreVerticalIcon, Trash2 } from "lucide-react"
8 |
9 | import { fields } from "@/lib/db/schema"
10 | import { Card } from "@/components/ui/card"
11 |
12 | import { EditFieldSheet } from "./edit-field-sheet"
13 | import { TypographyMuted, TypographySmall } from "./typography"
14 | import { Button } from "./ui/button"
15 | import {
16 | Dialog,
17 | DialogContent,
18 | DialogDescription,
19 | DialogFooter,
20 | DialogHeader,
21 | DialogTitle,
22 | DialogTrigger,
23 | } from "./ui/dialog"
24 | import {
25 | DropdownMenu,
26 | DropdownMenuContent,
27 | DropdownMenuGroup,
28 | DropdownMenuItem,
29 | DropdownMenuTrigger,
30 | } from "./ui/dropdown-menu"
31 | import { useToast } from "./ui/use-toast"
32 |
33 | type Field = InferModel
34 |
35 | export const EditFieldCard = ({ field }: { field: Field }) => {
36 | const [dialogOpen, setDialogOpen] = useState(false)
37 | let [isPending, startTransition] = useTransition()
38 | const toast = useToast()
39 |
40 | const deleteClicked = () => {
41 | startTransition(() => deleteField(field.id))
42 | setDialogOpen(false)
43 |
44 | toast.toast({
45 | title: "Field deleted",
46 | description: `Field ${field.label} was deleted.`,
47 | })
48 | }
49 |
50 | return (
51 |
52 |
106 |
107 | )
108 | }
109 |
--------------------------------------------------------------------------------
/src/components/edit-field-sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState } from "react"
4 | import { InferModel } from "drizzle-orm"
5 |
6 | import { fields } from "@/lib/db/schema"
7 | import {
8 | Sheet,
9 | SheetContent,
10 | SheetDescription,
11 | SheetHeader,
12 | SheetTitle,
13 | SheetTrigger,
14 | } from "@/components/ui/sheet"
15 |
16 | import { EditFieldForm } from "./edit-field-form"
17 |
18 | type Field = InferModel
19 |
20 | export const EditFieldSheet = ({
21 | children,
22 | formId,
23 | field,
24 | }: {
25 | children: React.ReactNode
26 | formId: string
27 | field?: Field
28 | }) => {
29 | const [open, setOpen] = useState(false)
30 |
31 | return (
32 |
33 | {children}
34 |
35 |
36 | Edit field
37 |
38 | Create or edit a field for your form.
39 |
40 |
41 | setOpen(false)}
44 | field={field}
45 | />
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/empty-placeholder.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 | import { Icons } from "@/components/icons"
5 |
6 | interface EmptyPlaceholderProps extends React.HTMLAttributes {}
7 |
8 | export function EmptyPlaceholder({
9 | className,
10 | children,
11 | ...props
12 | }: EmptyPlaceholderProps) {
13 | return (
14 |
21 |
22 | {children}
23 |
24 |
25 | )
26 | }
27 |
28 | interface EmptyPlaceholderIconProps
29 | extends Partial> {
30 | name: keyof typeof Icons
31 | }
32 |
33 | EmptyPlaceholder.Icon = function EmptyPlaceHolderIcon({
34 | name,
35 | className,
36 | ...props
37 | }: EmptyPlaceholderIconProps) {
38 | const Icon = Icons[name]
39 |
40 | if (!Icon) {
41 | return null
42 | }
43 |
44 | return (
45 |
46 |
47 |
48 | )
49 | }
50 |
51 | interface EmptyPlacholderTitleProps
52 | extends React.HTMLAttributes {}
53 |
54 | EmptyPlaceholder.Title = function EmptyPlaceholderTitle({
55 | className,
56 | ...props
57 | }: EmptyPlacholderTitleProps) {
58 | return (
59 |
60 | )
61 | }
62 |
63 | interface EmptyPlacholderDescriptionProps
64 | extends React.HTMLAttributes {}
65 |
66 | EmptyPlaceholder.Description = function EmptyPlaceholderDescription({
67 | className,
68 | ...props
69 | }: EmptyPlacholderDescriptionProps) {
70 | return (
71 |
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/feedback-button.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React from "react"
4 | import { createFeedback } from "@/actions/feedback"
5 | import { zodResolver } from "@hookform/resolvers/zod"
6 | import { VariantProps } from "class-variance-authority"
7 | import { useForm } from "react-hook-form"
8 | import { z } from "zod"
9 |
10 | import { Icons } from "./icons"
11 | import { Button, buttonVariants } from "./ui/button"
12 | import {
13 | Form,
14 | FormControl,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage,
19 | } from "./ui/form"
20 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"
21 | import { Textarea } from "./ui/textarea"
22 | import { toast } from "./ui/use-toast"
23 |
24 | const formSchema = z.object({
25 | text: z.string().min(1).max(512),
26 | })
27 |
28 | type FormSchema = z.infer
29 |
30 | export interface ButtonProps
31 | extends React.ButtonHTMLAttributes,
32 | VariantProps {
33 | asChild?: boolean
34 | userId?: string
35 | }
36 |
37 | export function FeedbackButton(props: ButtonProps) {
38 | // const session = useSession()
39 | const [isOpen, setIsOpen] = React.useState(false)
40 | const [isLoading, setIsLoading] = React.useState(false)
41 | const { userId, ...rest } = props
42 |
43 | const form = useForm({
44 | resolver: zodResolver(formSchema),
45 | defaultValues: {
46 | text: "",
47 | },
48 | })
49 |
50 | async function onSubmit(data: FormSchema) {
51 | const ua = navigator.userAgent
52 | const url = window.location.href
53 | setIsLoading(true)
54 | await createFeedback({
55 | ...data,
56 | ua,
57 | url,
58 | userId,
59 | })
60 |
61 | setIsOpen(false)
62 | setIsLoading(false)
63 |
64 | form.reset()
65 |
66 | toast({
67 | title: "Feedback submitted",
68 | description: "Thanks for your feedback 🫶",
69 | })
70 | }
71 |
72 | return (
73 |
74 |
75 |
78 |
79 |
80 |
109 |
110 |
111 |
112 | )
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | interface DashboardHeaderProps {
2 | heading: string | React.ReactNode
3 | text?: string
4 | children?: React.ReactNode
5 | }
6 |
7 | export function DashboardHeader({
8 | heading,
9 | text,
10 | children,
11 | }: DashboardHeaderProps) {
12 | return (
13 |
14 |
15 |
16 | {heading}
17 |
18 | {text &&
{text}
}
19 |
20 | {children}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/logo.tsx:
--------------------------------------------------------------------------------
1 | import { TypographyH3 } from "./typography"
2 |
3 | export const Logo = () => {
4 | return Dorf.build
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/main-nav.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import Link from "next/link"
5 | import { useSelectedLayoutSegment } from "next/navigation"
6 | import { MainNavItem } from "@/types"
7 |
8 | import { siteConfig } from "@/config/site"
9 | import { cn } from "@/lib/utils"
10 | import { Icons } from "@/components/icons"
11 | import { MobileNav } from "@/components/mobile-nav"
12 |
13 | interface MainNavProps {
14 | items?: MainNavItem[]
15 | children?: React.ReactNode
16 | }
17 |
18 | export function MainNav({ items, children }: MainNavProps) {
19 | const segment = useSelectedLayoutSegment()
20 | const [showMobileMenu, setShowMobileMenu] = React.useState(false)
21 |
22 | return (
23 |
24 |
25 |
26 |
27 | {siteConfig.name}
28 |
29 |
30 | {items?.length ? (
31 |
48 | ) : null}
49 | {items?.length ? (
50 |
56 | ) : null}
57 | {showMobileMenu && items && (
58 | {children}
59 | )}
60 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/mobile-nav.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import Link from "next/link"
3 | import { MainNavItem } from "@/types"
4 |
5 | import { siteConfig } from "@/config/site"
6 | import { cn } from "@/lib/utils"
7 | import { useLockBody } from "@/hooks/use-lock-body"
8 | import { Icons } from "@/components/icons"
9 |
10 | interface MobileNavProps {
11 | items: MainNavItem[]
12 | children?: React.ReactNode
13 | }
14 |
15 | export function MobileNav({ items, children }: MobileNavProps) {
16 | useLockBody()
17 |
18 | return (
19 |
24 |
25 |
26 |
27 | {siteConfig.name}
28 |
29 |
43 | {children}
44 |
45 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { useTheme } from "next-themes"
5 |
6 | import { Button } from "@/components/ui/button"
7 | import {
8 | DropdownMenu,
9 | DropdownMenuContent,
10 | DropdownMenuItem,
11 | DropdownMenuTrigger,
12 | } from "@/components/ui/dropdown-menu"
13 | import { Icons } from "@/components/icons"
14 |
15 | export function ModeToggle() {
16 | const { setTheme } = useTheme()
17 |
18 | return (
19 |
20 |
21 |
26 |
27 |
28 | setTheme("light")}>
29 |
30 | Light
31 |
32 | setTheme("dark")}>
33 |
34 | Dark
35 |
36 | setTheme("system")}>
37 |
38 | System
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/src/components/nav.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { usePathname } from "next/navigation"
5 | import { SidebarNavItem } from "@/types"
6 |
7 | import { cn } from "@/lib/utils"
8 | import { Icons } from "@/components/icons"
9 |
10 | interface DashboardNavProps {
11 | items: SidebarNavItem[]
12 | }
13 |
14 | export function DashboardNav({ items }: DashboardNavProps) {
15 | const path = usePathname()
16 |
17 | if (!items?.length) {
18 | return null
19 | }
20 |
21 | return (
22 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/secret-input.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState } from "react"
4 | import { CopyIcon, EyeIcon } from "lucide-react"
5 |
6 | import { Icons } from "./icons"
7 | import { Button } from "./ui/button"
8 | import { Input } from "./ui/input"
9 | import { toast } from "./ui/use-toast"
10 |
11 | export function SecretInput({ value }: { value: string }) {
12 | const [showSecret, setShowSecret] = useState(false)
13 |
14 | function copySecret() {
15 | navigator.clipboard.writeText(value)
16 | toast({
17 | title: "Copied to clipboard",
18 | description: "Webhook secret has been copied to clipboard",
19 | })
20 | }
21 |
22 | return (
23 |
24 |
30 |
31 |
39 |
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/shell.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | interface DashboardShellProps extends React.HTMLAttributes {}
6 |
7 | export function DashboardShell({
8 | children,
9 | className,
10 | ...props
11 | }: DashboardShellProps) {
12 | return (
13 |
14 | {children}
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/site-footer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { siteConfig } from "@/config/site"
4 | import { cn } from "@/lib/utils"
5 | import { Icons } from "@/components/icons"
6 | import { ModeToggle } from "@/components/mode-toggle"
7 |
8 | export function SiteFooter({ className }: React.HTMLAttributes) {
9 | return (
10 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/tailwind-indicator.tsx:
--------------------------------------------------------------------------------
1 | export function TailwindIndicator() {
2 | if (process.env.NODE_ENV === "production") return null
3 |
4 | return (
5 |
6 |
xs
7 |
8 | sm
9 |
10 |
md
11 |
lg
12 |
xl
13 |
2xl
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ThemeProvider as NextThemesProvider } from "next-themes"
5 | import { ThemeProviderProps } from "next-themes/dist/types"
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children}
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/typography/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const TypographyH1 = React.forwardRef<
6 | HTMLHeadingElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | TypographyH1.displayName = "TypographyH1"
19 |
20 | const TypographyH2 = React.forwardRef<
21 | HTMLHeadingElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
32 | ))
33 | TypographyH2.displayName = "TypographyH2"
34 |
35 | const TypographyH3 = React.forwardRef<
36 | HTMLHeadingElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | TypographyH3.displayName = "TypographyH3"
49 |
50 | const TypographyH4 = React.forwardRef<
51 | HTMLHeadingElement,
52 | React.HTMLAttributes
53 | >(({ className, ...props }, ref) => (
54 |
62 | ))
63 | TypographyH4.displayName = "TypographyH4"
64 |
65 | const TypographyP = React.forwardRef<
66 | HTMLParagraphElement,
67 | React.HTMLAttributes
68 | >(({ className, ...props }, ref) => (
69 |
74 | ))
75 | TypographyP.displayName = "TypographyP"
76 |
77 | const TypographyBlockquote = React.forwardRef<
78 | HTMLQuoteElement,
79 | React.HTMLAttributes
80 | >(({ className, ...props }, ref) => (
81 |
86 | ))
87 | TypographyBlockquote.displayName = "TypographyBlockquote"
88 |
89 | const TypographyUl = React.forwardRef<
90 | HTMLUListElement,
91 | React.HTMLAttributes
92 | >(({ className, ...props }, ref) => (
93 | li]:mt-2", className)}
96 | {...props}
97 | />
98 | ))
99 | TypographyUl.displayName = "TypographyUl"
100 |
101 | const TypographyInlineCode = React.forwardRef<
102 | HTMLUListElement,
103 | React.HTMLAttributes
104 | >(({ className, ...props }, ref) => (
105 |
112 | ))
113 | TypographyInlineCode.displayName = "TypographyInlineCode"
114 |
115 | const TypographyLead = React.forwardRef<
116 | HTMLParagraphElement,
117 | React.HTMLAttributes
118 | >(({ className, ...props }, ref) => (
119 |
124 | ))
125 | TypographyLead.displayName = "TypographyLead"
126 |
127 | const TypographyLarge = React.forwardRef<
128 | HTMLDivElement,
129 | React.HTMLAttributes
130 | >(({ className, ...props }, ref) => (
131 |
136 | ))
137 | TypographyLarge.displayName = "TypographyLarge"
138 |
139 | const TypographySmall = React.forwardRef<
140 | HTMLElement,
141 | React.HTMLAttributes
142 | >(({ className, ...props }, ref) => (
143 |
148 | ))
149 | TypographySmall.displayName = "TypographySmall"
150 |
151 | const TypographyMuted = React.forwardRef<
152 | HTMLParagraphElement,
153 | React.HTMLAttributes
154 | >(({ className, ...props }, ref) => (
155 |
156 | ))
157 | TypographyMuted.displayName = "TypographyMuted"
158 |
159 | export {
160 | TypographyH1,
161 | TypographyH2,
162 | TypographyH3,
163 | TypographyH4,
164 | TypographyP,
165 | TypographyBlockquote,
166 | TypographyUl,
167 | TypographyInlineCode,
168 | TypographyLead,
169 | TypographyLarge,
170 | TypographySmall,
171 | TypographyMuted,
172 | }
173 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | AccordionItem.displayName = "AccordionItem"
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180",
32 | className
33 | )}
34 | {...props}
35 | >
36 | {children}
37 |
38 |
39 |
40 | ))
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
55 | {children}
56 |
57 | ))
58 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
59 |
60 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
61 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = ({
14 | className,
15 | children,
16 | ...props
17 | }: AlertDialogPrimitive.AlertDialogPortalProps) => (
18 |
19 |
20 | {children}
21 |
22 |
23 | )
24 | AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName
25 |
26 | const AlertDialogOverlay = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, children, ...props }, ref) => (
30 |
38 | ))
39 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
40 |
41 | const AlertDialogContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, ...props }, ref) => (
45 |
46 |
47 |
55 |
56 | ))
57 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
58 |
59 | const AlertDialogHeader = ({
60 | className,
61 | ...props
62 | }: React.HTMLAttributes) => (
63 |
70 | )
71 | AlertDialogHeader.displayName = "AlertDialogHeader"
72 |
73 | const AlertDialogFooter = ({
74 | className,
75 | ...props
76 | }: React.HTMLAttributes) => (
77 |
84 | )
85 | AlertDialogFooter.displayName = "AlertDialogFooter"
86 |
87 | const AlertDialogTitle = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => (
91 |
96 | ))
97 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
98 |
99 | const AlertDialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | AlertDialogDescription.displayName =
110 | AlertDialogPrimitive.Description.displayName
111 |
112 | const AlertDialogAction = React.forwardRef<
113 | React.ElementRef,
114 | React.ComponentPropsWithoutRef
115 | >(({ className, ...props }, ref) => (
116 |
121 | ))
122 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
123 |
124 | const AlertDialogCancel = React.forwardRef<
125 | React.ElementRef,
126 | React.ComponentPropsWithoutRef
127 | >(({ className, ...props }, ref) => (
128 |
137 | ))
138 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
139 |
140 | export {
141 | AlertDialog,
142 | AlertDialogTrigger,
143 | AlertDialogContent,
144 | AlertDialogHeader,
145 | AlertDialogFooter,
146 | AlertDialogTitle,
147 | AlertDialogDescription,
148 | AlertDialogAction,
149 | AlertDialogCancel,
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "bg-primary hover:bg-primary/80 border-transparent text-primary-foreground",
13 | secondary:
14 | "bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground",
15 | destructive:
16 | "bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | import { Icons } from "../icons"
8 |
9 | const buttonVariants = cva(
10 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
11 | {
12 | variants: {
13 | variant: {
14 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
15 | destructive:
16 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
17 | outline:
18 | "border border-input hover:bg-accent hover:text-accent-foreground",
19 | secondary:
20 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
21 | ghost: "hover:bg-accent hover:text-accent-foreground",
22 | link: "underline-offset-4 hover:underline text-primary",
23 | },
24 | size: {
25 | default: "h-10 py-2 px-4",
26 | sm: "h-9 px-3 rounded-md",
27 | lg: "h-11 px-8 rounded-md",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ChevronLeft, ChevronRight } from "lucide-react"
5 | import { DayPicker } from "react-day-picker"
6 |
7 | import { cn } from "@/lib/utils"
8 | import { buttonVariants } from "@/components/ui/button"
9 |
10 | export type CalendarProps = React.ComponentProps
11 |
12 | function Calendar({
13 | className,
14 | classNames,
15 | showOutsideDays = true,
16 | ...props
17 | }: CalendarProps) {
18 | return (
19 | ,
56 | IconRight: ({ ...props }) => ,
57 | }}
58 | {...props}
59 | />
60 | )
61 | }
62 | Calendar.displayName = "Calendar"
63 |
64 | export { Calendar }
65 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { Check } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/src/components/ui/data-column-header.tsx:
--------------------------------------------------------------------------------
1 | import { Column } from "@tanstack/react-table"
2 | import { ChevronsUpDown, EyeOff, SortAsc, SortDesc } from "lucide-react"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { Button } from "@/components/ui/button"
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuSeparator,
11 | DropdownMenuTrigger,
12 | } from "@/components/ui/dropdown-menu"
13 |
14 | interface DataTableColumnHeaderProps
15 | extends React.HTMLAttributes {
16 | column: Column
17 | title: string
18 | }
19 |
20 | export function DataTableColumnHeader({
21 | column,
22 | title,
23 | className,
24 | }: DataTableColumnHeaderProps) {
25 | if (!column.getCanSort()) {
26 | return {title}
27 | }
28 |
29 | return (
30 |
31 |
32 |
33 |
47 |
48 |
49 | column.toggleSorting(false)}>
50 |
51 | Asc
52 |
53 | column.toggleSorting(true)}>
54 |
55 | Desc
56 |
57 |
58 |
59 |
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/ui/data-table-pagination.tsx:
--------------------------------------------------------------------------------
1 | import { Table } from "@tanstack/react-table"
2 | import {
3 | ChevronLeft,
4 | ChevronRight,
5 | ChevronsLeft,
6 | ChevronsRight,
7 | } from "lucide-react"
8 |
9 | import { Button } from "@/components/ui/button"
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectItem,
14 | SelectTrigger,
15 | SelectValue,
16 | } from "@/components/ui/select"
17 |
18 | interface DataTablePaginationProps {
19 | table: Table
20 | }
21 |
22 | export function DataTablePagination({
23 | table,
24 | }: DataTablePaginationProps) {
25 | return (
26 |
27 |
28 | {table.getCoreRowModel().rows.length} row(s)
29 |
30 |
31 |
32 |
Rows per page
33 |
50 |
51 |
52 | Page {table.getState().pagination.pageIndex + 1} of{" "}
53 | {table.getPageCount()}
54 |
55 |
56 |
65 |
74 |
83 |
92 |
93 |
94 |
95 | )
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/ui/data-table.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useState } from "react"
4 | import {
5 | ColumnDef,
6 | ColumnFiltersState,
7 | flexRender,
8 | getCoreRowModel,
9 | getFilteredRowModel,
10 | getPaginationRowModel,
11 | getSortedRowModel,
12 | SortingState,
13 | useReactTable,
14 | } from "@tanstack/react-table"
15 |
16 | import { DataTablePagination } from "@/components/ui/data-table-pagination"
17 | import { Input } from "@/components/ui/input"
18 | import {
19 | Table,
20 | TableBody,
21 | TableCell,
22 | TableHead,
23 | TableHeader,
24 | TableRow,
25 | } from "@/components/ui/table"
26 |
27 | interface DataTableProps {
28 | columns: ColumnDef[]
29 | data: TData[]
30 | }
31 |
32 | export function DataTable({
33 | columns,
34 | data,
35 | searchColumn,
36 | actions,
37 | }: DataTableProps & {
38 | searchColumn?: string
39 | actions?: React.ReactNode
40 | }) {
41 | const [sorting, setSorting] = useState([])
42 | const [columnFilters, setColumnFilters] = useState([])
43 |
44 | const table = useReactTable({
45 | data,
46 | columns,
47 | getCoreRowModel: getCoreRowModel(),
48 | getPaginationRowModel: getPaginationRowModel(),
49 | onSortingChange: setSorting,
50 | getSortedRowModel: getSortedRowModel(),
51 | onColumnFiltersChange: setColumnFilters,
52 | getFilteredRowModel: getFilteredRowModel(),
53 | state: {
54 | sorting,
55 | columnFilters,
56 | },
57 | })
58 |
59 | return (
60 |
61 |
62 |
63 | {searchColumn && (
64 |
71 | table
72 | .getColumn(searchColumn)
73 | ?.setFilterValue(event.target.value)
74 | }
75 | className="max-w-sm"
76 | />
77 | )}
78 |
79 |
80 | {actions && (
81 |
{actions}
82 | )}
83 |
84 |
85 |
86 |
87 | {table.getHeaderGroups().map((headerGroup) => (
88 |
89 | {headerGroup.headers.map((header) => {
90 | return (
91 |
92 | {header.isPlaceholder
93 | ? null
94 | : flexRender(
95 | header.column.columnDef.header,
96 | header.getContext()
97 | )}
98 |
99 | )
100 | })}
101 |
102 | ))}
103 |
104 |
105 | {table.getRowModel().rows?.length ? (
106 | table.getRowModel().rows.map((row) => (
107 |
111 | {row.getVisibleCells().map((cell) => (
112 |
113 | {flexRender(
114 | cell.column.columnDef.cell,
115 | cell.getContext()
116 | )}
117 |
118 | ))}
119 |
120 | ))
121 | ) : (
122 |
123 |
127 | No results.
128 |
129 |
130 | )}
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 | )
139 | }
140 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = ({
14 | className,
15 | children,
16 | ...props
17 | }: DialogPrimitive.DialogPortalProps) => (
18 |
19 |
20 | {children}
21 |
22 |
23 | )
24 | DialogPortal.displayName = DialogPrimitive.Portal.displayName
25 |
26 | const DialogOverlay = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, ...props }, ref) => (
30 |
38 | ))
39 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
40 |
41 | const DialogContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, children, ...props }, ref) => (
45 |
46 |
47 |
55 | {children}
56 |
57 |
58 | Close
59 |
60 |
61 |
62 | ))
63 | DialogContent.displayName = DialogPrimitive.Content.displayName
64 |
65 | const DialogHeader = ({
66 | className,
67 | ...props
68 | }: React.HTMLAttributes) => (
69 |
76 | )
77 | DialogHeader.displayName = "DialogHeader"
78 |
79 | const DialogFooter = ({
80 | className,
81 | ...props
82 | }: React.HTMLAttributes) => (
83 |
90 | )
91 | DialogFooter.displayName = "DialogFooter"
92 |
93 | const DialogTitle = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, ...props }, ref) => (
97 |
105 | ))
106 | DialogTitle.displayName = DialogPrimitive.Title.displayName
107 |
108 | const DialogDescription = React.forwardRef<
109 | React.ElementRef,
110 | React.ComponentPropsWithoutRef
111 | >(({ className, ...props }, ref) => (
112 |
117 | ))
118 | DialogDescription.displayName = DialogPrimitive.Description.displayName
119 |
120 | export {
121 | Dialog,
122 | DialogTrigger,
123 | DialogContent,
124 | DialogHeader,
125 | DialogFooter,
126 | DialogTitle,
127 | DialogDescription,
128 | }
129 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/src/components/ui/input-required-hint.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Tooltip,
3 | TooltipContent,
4 | TooltipProvider,
5 | TooltipTrigger,
6 | } from "./tooltip"
7 |
8 | interface InputRequiredHintProps {
9 | children: React.ReactNode
10 | required?: boolean
11 | }
12 |
13 | export const InputRequiredHint = ({
14 | children,
15 | required,
16 | }: InputRequiredHintProps) => {
17 | return (
18 |
19 | {children}
20 | {required && (
21 |
22 |
23 |
24 | *
25 |
26 | Required
27 |
28 |
29 | )}
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { AtSignIcon } from "lucide-react"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | import { Icons } from "../icons"
7 | import { InputRequiredHint } from "./input-required-hint"
8 |
9 | export interface InputProps
10 | extends React.InputHTMLAttributes {
11 | icon?: keyof typeof Icons
12 | }
13 |
14 | const Input = React.forwardRef(
15 | ({ className, type, required, icon, ...props }, ref) => {
16 | const Icon = icon ? Icons[icon] : undefined
17 |
18 | return (
19 |
20 | {Icon && (
21 |
22 |
23 |
24 | )}
25 |
35 |
36 | )
37 | }
38 | )
39 | Input.displayName = "Input"
40 |
41 | export { Input }
42 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ProgressPrimitive from "@radix-ui/react-progress"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Progress = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, value, ...props }, ref) => (
12 |
20 |
24 |
25 | ))
26 | Progress.displayName = ProgressPrimitive.Root.displayName
27 |
28 | export { Progress }
29 |
--------------------------------------------------------------------------------
/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
5 | import { Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | )
20 | })
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | )
41 | })
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
43 |
44 | export { RadioGroup, RadioGroupItem }
45 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | import { InputRequiredHint } from "./input-required-hint"
10 |
11 | const Select = SelectPrimitive.Root
12 |
13 | const SelectGroup = SelectPrimitive.Group
14 |
15 | const SelectValue = SelectPrimitive.Value
16 |
17 | interface SelectTriggerProps
18 | extends React.ComponentPropsWithoutRef {
19 | required?: boolean
20 | }
21 |
22 | const SelectTrigger = React.forwardRef<
23 | React.ElementRef,
24 | SelectTriggerProps
25 | >(({ className, children, required, ...props }, ref) => (
26 |
27 |
35 | {children}
36 |
37 |
38 |
39 |
40 |
41 | ))
42 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
43 |
44 | const SelectContent = React.forwardRef<
45 | React.ElementRef,
46 | React.ComponentPropsWithoutRef
47 | >(({ className, children, position = "popper", ...props }, ref) => (
48 |
49 |
59 |
66 | {children}
67 |
68 |
69 |
70 | ))
71 | SelectContent.displayName = SelectPrimitive.Content.displayName
72 |
73 | const SelectLabel = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, ...props }, ref) => (
77 |
82 | ))
83 | SelectLabel.displayName = SelectPrimitive.Label.displayName
84 |
85 | const SelectItem = React.forwardRef<
86 | React.ElementRef,
87 | React.ComponentPropsWithoutRef
88 | >(({ className, children, ...props }, ref) => (
89 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | {children}
104 |
105 | ))
106 | SelectItem.displayName = SelectPrimitive.Item.displayName
107 |
108 | const SelectSeparator = React.forwardRef<
109 | React.ElementRef,
110 | React.ComponentPropsWithoutRef
111 | >(({ className, ...props }, ref) => (
112 |
117 | ))
118 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
119 |
120 | export {
121 | Select,
122 | SelectGroup,
123 | SelectValue,
124 | SelectTrigger,
125 | SelectContent,
126 | SelectLabel,
127 | SelectItem,
128 | SelectSeparator,
129 | }
130 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/ui/status-badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const statusBadgeVariants = cva(
7 | "inline-flex items-center rounded-md text-xs font-medium ring-1 ring-inset px-2 py-1 transition-colors",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "ring-blue-600/20 text-blue-800 bg-blue-50 dark:bg-blue-400/10 dark:text-blue-500 dark:ring-blue-400/20",
13 | success:
14 | "ring-green-600/20 text-green-800 bg-green-50 dark:bg-green-400/10 dark:text-green-500 dark:ring-green-400/20",
15 | error:
16 | "ring-red-600/20 text-red-800 bg-red-50 dark:bg-red-400/10 dark:text-red-500 dark:ring-red-400/20",
17 | warning:
18 | "ring-yellow-600/20 text-yellow-800 bg-yellow-50 dark:bg-yellow-400/10 dark:text-yellow-500 dark:ring-yellow-400/20",
19 | },
20 | },
21 | defaultVariants: {
22 | variant: "default",
23 | },
24 | }
25 | )
26 |
27 | export interface BadgeProps
28 | extends React.HTMLAttributes,
29 | VariantProps {}
30 |
31 | function StatusBadge({ className, variant, ...props }: BadgeProps) {
32 | return (
33 |
37 | )
38 | }
39 |
40 | export { StatusBadge, statusBadgeVariants }
41 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 |
48 | ))
49 | TableFooter.displayName = "TableFooter"
50 |
51 | const TableRow = React.forwardRef<
52 | HTMLTableRowElement,
53 | React.HTMLAttributes
54 | >(({ className, ...props }, ref) => (
55 |
63 | ))
64 | TableRow.displayName = "TableRow"
65 |
66 | const TableHead = React.forwardRef<
67 | HTMLTableCellElement,
68 | React.ThHTMLAttributes
69 | >(({ className, ...props }, ref) => (
70 | |
78 | ))
79 | TableHead.displayName = "TableHead"
80 |
81 | const TableCell = React.forwardRef<
82 | HTMLTableCellElement,
83 | React.TdHTMLAttributes
84 | >(({ className, ...props }, ref) => (
85 | |
90 | ))
91 | TableCell.displayName = "TableCell"
92 |
93 | const TableCaption = React.forwardRef<
94 | HTMLTableCaptionElement,
95 | React.HTMLAttributes
96 | >(({ className, ...props }, ref) => (
97 |
102 | ))
103 | TableCaption.displayName = "TableCaption"
104 |
105 | export {
106 | Table,
107 | TableHeader,
108 | TableBody,
109 | TableFooter,
110 | TableHead,
111 | TableRow,
112 | TableCell,
113 | TableCaption,
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | import { InputRequiredHint } from "./input-required-hint"
6 |
7 | export interface TextareaProps
8 | extends React.TextareaHTMLAttributes {}
9 |
10 | const Textarea = React.forwardRef(
11 | ({ className, required, ...props }, ref) => {
12 | return (
13 |
14 |
22 |
23 | )
24 | }
25 | )
26 | Textarea.displayName = "Textarea"
27 |
28 | export { Textarea }
29 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from "@/components/ui/toast"
11 | import { useToast } from "@/components/ui/use-toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
5 |
6 | const TOAST_LIMIT = 1
7 | const TOAST_REMOVE_DELAY = 1000000
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string
11 | title?: React.ReactNode
12 | description?: React.ReactNode
13 | action?: ToastActionElement
14 | }
15 |
16 | const actionTypes = {
17 | ADD_TOAST: "ADD_TOAST",
18 | UPDATE_TOAST: "UPDATE_TOAST",
19 | DISMISS_TOAST: "DISMISS_TOAST",
20 | REMOVE_TOAST: "REMOVE_TOAST",
21 | } as const
22 |
23 | let count = 0
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_VALUE
27 | return count.toString()
28 | }
29 |
30 | type ActionType = typeof actionTypes
31 |
32 | type Action =
33 | | {
34 | type: ActionType["ADD_TOAST"]
35 | toast: ToasterToast
36 | }
37 | | {
38 | type: ActionType["UPDATE_TOAST"]
39 | toast: Partial
40 | }
41 | | {
42 | type: ActionType["DISMISS_TOAST"]
43 | toastId?: ToasterToast["id"]
44 | }
45 | | {
46 | type: ActionType["REMOVE_TOAST"]
47 | toastId?: ToasterToast["id"]
48 | }
49 |
50 | interface State {
51 | toasts: ToasterToast[]
52 | }
53 |
54 | const toastTimeouts = new Map>()
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId)
63 | dispatch({
64 | type: "REMOVE_TOAST",
65 | toastId: toastId,
66 | })
67 | }, TOAST_REMOVE_DELAY)
68 |
69 | toastTimeouts.set(toastId, timeout)
70 | }
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case "ADD_TOAST":
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | }
79 |
80 | case "UPDATE_TOAST":
81 | return {
82 | ...state,
83 | toasts: state.toasts.map((t) =>
84 | t.id === action.toast.id ? { ...t, ...action.toast } : t
85 | ),
86 | }
87 |
88 | case "DISMISS_TOAST": {
89 | const { toastId } = action
90 |
91 | // ! Side effects ! - This could be extracted into a dismissToast() action,
92 | // but I'll keep it here for simplicity
93 | if (toastId) {
94 | addToRemoveQueue(toastId)
95 | } else {
96 | state.toasts.forEach((toast) => {
97 | addToRemoveQueue(toast.id)
98 | })
99 | }
100 |
101 | return {
102 | ...state,
103 | toasts: state.toasts.map((t) =>
104 | t.id === toastId || toastId === undefined
105 | ? {
106 | ...t,
107 | open: false,
108 | }
109 | : t
110 | ),
111 | }
112 | }
113 | case "REMOVE_TOAST":
114 | if (action.toastId === undefined) {
115 | return {
116 | ...state,
117 | toasts: [],
118 | }
119 | }
120 | return {
121 | ...state,
122 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
123 | }
124 | }
125 | }
126 |
127 | const listeners: Array<(state: State) => void> = []
128 |
129 | let memoryState: State = { toasts: [] }
130 |
131 | function dispatch(action: Action) {
132 | memoryState = reducer(memoryState, action)
133 | listeners.forEach((listener) => {
134 | listener(memoryState)
135 | })
136 | }
137 |
138 | type Toast = Omit
139 |
140 | function toast({ ...props }: Toast) {
141 | const id = genId()
142 |
143 | const update = (props: ToasterToast) =>
144 | dispatch({
145 | type: "UPDATE_TOAST",
146 | toast: { ...props, id },
147 | })
148 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
149 |
150 | dispatch({
151 | type: "ADD_TOAST",
152 | toast: {
153 | ...props,
154 | id,
155 | open: true,
156 | onOpenChange: (open) => {
157 | if (!open) dismiss()
158 | },
159 | },
160 | })
161 |
162 | return {
163 | id: id,
164 | dismiss,
165 | update,
166 | }
167 | }
168 |
169 | function useToast() {
170 | const [state, setState] = React.useState(memoryState)
171 |
172 | React.useEffect(() => {
173 | listeners.push(setState)
174 | return () => {
175 | const index = listeners.indexOf(setState)
176 | if (index > -1) {
177 | listeners.splice(index, 1)
178 | }
179 | }
180 | }, [state])
181 |
182 | return {
183 | ...state,
184 | toast,
185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
186 | }
187 | }
188 |
189 | export { useToast, toast }
190 |
--------------------------------------------------------------------------------
/src/components/user-account-nav.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { User } from "next-auth"
5 | import { signOut } from "next-auth/react"
6 |
7 | import { dashboardConfig } from "@/config/dashboard"
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuSeparator,
13 | DropdownMenuTrigger,
14 | } from "@/components/ui/dropdown-menu"
15 | import { UserAvatar } from "@/components/user-avatar"
16 |
17 | interface UserAccountNavProps extends React.HTMLAttributes {
18 | user: Pick
19 | }
20 |
21 | export function UserAccountNav({ user }: UserAccountNavProps) {
22 | return (
23 |
24 |
25 |
29 |
30 |
31 |
32 |
33 | {user.name &&
{user.name}
}
34 | {user.email && (
35 |
36 | {user.email}
37 |
38 | )}
39 |
40 |
41 |
42 | {dashboardConfig.userNav.length > 0 && (
43 | <>
44 | {dashboardConfig.userNav.map((navItem) => (
45 |
46 | {navItem.title}
47 |
48 | ))}
49 |
50 | >
51 | )}
52 | {
55 | event.preventDefault()
56 | signOut({
57 | callbackUrl: `${window.location.origin}/login`,
58 | })
59 | }}
60 | >
61 | Sign out
62 |
63 |
64 |
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/user-avatar.tsx:
--------------------------------------------------------------------------------
1 | import { AvatarProps } from "@radix-ui/react-avatar"
2 | import { User } from "next-auth"
3 |
4 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
5 | import { Icons } from "@/components/icons"
6 |
7 | interface UserAvatarProps extends AvatarProps {
8 | user: Pick
9 | }
10 |
11 | export function UserAvatar({ user, ...props }: UserAvatarProps) {
12 | return (
13 |
14 | {user.image ? (
15 |
16 | ) : (
17 |
18 | {user.name}
19 |
20 |
21 | )}
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/user-nav.tsx:
--------------------------------------------------------------------------------
1 | import { LogOut } from "lucide-react"
2 |
3 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
4 | import { Button } from "@/components/ui/button"
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuLabel,
10 | DropdownMenuSeparator,
11 | DropdownMenuTrigger,
12 | } from "@/components/ui/dropdown-menu"
13 |
14 | export function UserNav() {
15 | return (
16 |
17 |
18 |
24 |
25 |
26 |
27 |
28 |
shadcn
29 |
30 | m@example.com
31 |
32 |
33 |
34 |
35 |
36 |
37 | Log out
38 |
39 |
40 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/src/config/dashboard.ts:
--------------------------------------------------------------------------------
1 | import { DashboardConfig } from "@/types"
2 |
3 | export const dashboardConfig: DashboardConfig = {
4 | mainNav: [],
5 | sidebarNav: [
6 | {
7 | title: "Forms",
8 | href: "/dashboard",
9 | icon: "post",
10 | },
11 | {
12 | title: "Settings",
13 | href: "/dashboard/settings",
14 | icon: "settings",
15 | },
16 | ],
17 | userNav: [],
18 | }
19 |
--------------------------------------------------------------------------------
/src/config/marketing.ts:
--------------------------------------------------------------------------------
1 | import { MarketingConfig } from "@/types"
2 |
3 | export const marketingConfig: MarketingConfig = {
4 | mainNav: [],
5 | demoLink: "https://dorf.vercel.app/f/uaTlmck",
6 | }
7 |
--------------------------------------------------------------------------------
/src/config/site.ts:
--------------------------------------------------------------------------------
1 | import { SiteConfig } from "@/types"
2 |
3 | export const siteConfig: SiteConfig = {
4 | name: "Dorf",
5 | description:
6 | "Dorf is a free, open source visual form builder for capturing feedback, leads, and opinions.",
7 | url: "https://dorf.vercel.app",
8 | ogImage: "https://dorf.vercel.app/og.jpg",
9 | links: {
10 | twitter: "https://twitter.com/mat_heins",
11 | github: "https://github.com/matheins/dorf",
12 | },
13 | }
14 |
--------------------------------------------------------------------------------
/src/env.mjs:
--------------------------------------------------------------------------------
1 | import { createEnv } from "@t3-oss/env-nextjs"
2 | import { z } from "zod"
3 |
4 | export const env = createEnv({
5 | server: {
6 | // This is optional because it's only used in development.
7 | // See https://next-auth.js.org/deployment.
8 | NEXTAUTH_URL: z.string().url().optional(),
9 | NEXTAUTH_SECRET: z.string().min(1),
10 | GITHUB_CLIENT_ID: z.string().min(1),
11 | GITHUB_CLIENT_SECRET: z.string().min(1),
12 | // GITHUB_ACCESS_TOKEN: z.string().min(1),
13 | DATABASE_URL: z.string().min(1),
14 | SMTP_FROM: z.string().min(1),
15 | POSTMARK_API_TOKEN: z.string().min(1),
16 | POSTMARK_SIGN_IN_TEMPLATE: z.string().min(1),
17 | POSTMARK_ACTIVATION_TEMPLATE: z.string().min(1),
18 | KV_URL: z.string().min(1),
19 | KV_REST_API_URL: z.string().min(1),
20 | KV_REST_API_TOKEN: z.string().min(1),
21 | KV_REST_API_READ_ONLY_TOKEN: z.string().min(1),
22 | QSTASH_CURRENT_SIGNING_KEY: z.string().min(1).optional(),
23 | QSTASH_NEXT_SIGNING_KEY: z.string().min(1).optional(),
24 | },
25 | client: {
26 | NEXT_PUBLIC_APP_URL: z.string().min(1),
27 | NEXT_PUBLIC_UMAMI_DOMAIN: z.string().min(1).optional(),
28 | NEXT_PUBLIC_UMAMI_SITE_ID: z.string().min(1).optional(),
29 | },
30 | runtimeEnv: {
31 | NEXTAUTH_URL: process.env.NEXTAUTH_URL,
32 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
33 | GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
34 | GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
35 | DATABASE_URL: process.env.DATABASE_URL,
36 | SMTP_FROM: process.env.SMTP_FROM,
37 | POSTMARK_API_TOKEN: process.env.POSTMARK_API_TOKEN,
38 | POSTMARK_SIGN_IN_TEMPLATE: process.env.POSTMARK_SIGN_IN_TEMPLATE,
39 | POSTMARK_ACTIVATION_TEMPLATE: process.env.POSTMARK_ACTIVATION_TEMPLATE,
40 | NEXT_PUBLIC_APP_URL: `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` || process.env.NEXT_PUBLIC_APP_URL,
41 | KV_URL: process.env.KV_URL,
42 | KV_REST_API_URL: process.env.KV_REST_API_URL,
43 | KV_REST_API_TOKEN: process.env.KV_REST_API_TOKEN,
44 | KV_REST_API_READ_ONLY_TOKEN: process.env.KV_REST_API_READ_ONLY_TOKEN,
45 | QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY,
46 | QSTASH_NEXT_SIGNING_KEY: process.env.QSTASH_NEXT_SIGNING_KEY,
47 | NEXT_PUBLIC_UMAMI_DOMAIN: process.env.NEXT_PUBLIC_UMAMI_DOMAIN,
48 | NEXT_PUBLIC_UMAMI_SITE_ID: process.env.NEXT_PUBLIC_UMAMI_SITE_ID,
49 | },
50 | skipValidation: process.env.SKIP_ENV_VALIDATION
51 | })
52 |
--------------------------------------------------------------------------------
/src/hooks/use-lock-body.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | // @see https://usehooks.com/useLockBodyScroll.
4 | export function useLockBody() {
5 | React.useLayoutEffect((): (() => void) => {
6 | const originalStyle: string = window.getComputedStyle(
7 | document.body
8 | ).overflow
9 | document.body.style.overflow = "hidden"
10 | return () => (document.body.style.overflow = originalStyle)
11 | }, [])
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GetServerSidePropsContext,
3 | NextApiRequest,
4 | NextApiResponse,
5 | } from "next"
6 | import { eq } from "drizzle-orm"
7 | import { getServerSession, NextAuthOptions } from "next-auth"
8 | import EmailProvider from "next-auth/providers/email"
9 | import GitHubProvider from "next-auth/providers/github"
10 | import { Client } from "postmark"
11 |
12 | import { env } from "@/env.mjs"
13 | import { siteConfig } from "@/config/site"
14 | import { db } from "@/lib/db"
15 |
16 | import { PlanetScaleAdapter } from "./db/lib/drizzle-adapter"
17 | import { users } from "./db/schema"
18 |
19 | const postmarkClient = new Client(env.POSTMARK_API_TOKEN)
20 |
21 | export const authOptions: NextAuthOptions = {
22 | adapter: PlanetScaleAdapter(db),
23 | session: {
24 | strategy: "jwt",
25 | },
26 | pages: {
27 | signIn: "/login",
28 | },
29 | providers: [
30 | GitHubProvider({
31 | clientId: env.GITHUB_CLIENT_ID,
32 | clientSecret: env.GITHUB_CLIENT_SECRET,
33 | }),
34 | EmailProvider({
35 | from: env.SMTP_FROM,
36 | sendVerificationRequest: async ({ identifier, url, provider }) => {
37 | const user = await db.query.users.findFirst({
38 | where: eq(users.email, identifier),
39 | columns: {
40 | emailVerified: true,
41 | },
42 | })
43 |
44 | const templateId = user?.emailVerified
45 | ? env.POSTMARK_SIGN_IN_TEMPLATE
46 | : env.POSTMARK_ACTIVATION_TEMPLATE
47 | if (!templateId) {
48 | throw new Error("Missing template id")
49 | }
50 |
51 | const result = await postmarkClient.sendEmailWithTemplate({
52 | TemplateId: parseInt(templateId),
53 | To: identifier,
54 | From: provider.from as string,
55 | TemplateModel: {
56 | action_url: url,
57 | product_name: siteConfig.name,
58 | },
59 | Headers: [
60 | {
61 | // Set this to prevent Gmail from threading emails.
62 | // See https://stackoverflow.com/questions/23434110/force-emails-not-to-be-grouped-into-conversations/25435722.
63 | Name: "X-Entity-Ref-ID",
64 | Value: new Date().getTime() + "",
65 | },
66 | ],
67 | })
68 |
69 | if (result.ErrorCode) {
70 | throw new Error(result.Message)
71 | }
72 | },
73 | }),
74 | ],
75 | callbacks: {
76 | async session({ token, session }) {
77 | if (token) {
78 | session.user.id = token.id
79 | session.user.name = token.name
80 | session.user.email = token.email
81 | session.user.image = token.picture
82 | }
83 |
84 | return session
85 | },
86 | async jwt({ token, user }) {
87 | // const dbUser = await db.user.findFirst({
88 | // where: {
89 | // email: token.email,
90 | // },
91 | // })
92 |
93 | let dbUser
94 | if (token.email) {
95 | dbUser = await db.query.users.findFirst({
96 | where: eq(users.email, token.email),
97 | })
98 | }
99 |
100 | if (!dbUser) {
101 | if (user) {
102 | token.id = user?.id
103 | }
104 | return token
105 | }
106 |
107 | return {
108 | id: dbUser.id,
109 | name: dbUser.name,
110 | email: dbUser.email,
111 | picture: dbUser.image,
112 | }
113 | },
114 | },
115 | }
116 |
117 | export function auth(
118 | ...args:
119 | | [GetServerSidePropsContext["req"], GetServerSidePropsContext["res"]]
120 | | [NextApiRequest, NextApiResponse]
121 | | []
122 | ) {
123 | return getServerSession(...args, authOptions)
124 | }
125 |
--------------------------------------------------------------------------------
/src/lib/db/index.ts:
--------------------------------------------------------------------------------
1 | import { connect } from "@planetscale/database"
2 | import { drizzle } from "drizzle-orm/planetscale-serverless"
3 |
4 | import { env } from "@/env.mjs"
5 |
6 | import * as schema from "./schema"
7 |
8 | // create the connection
9 | const connection = connect({
10 | url: env.DATABASE_URL,
11 | })
12 |
13 | export const db = drizzle(connection, {
14 | schema,
15 | })
16 |
--------------------------------------------------------------------------------
/src/lib/events/index.ts:
--------------------------------------------------------------------------------
1 | import { and, eq } from "drizzle-orm"
2 |
3 | import { db } from "../db"
4 | import { webhookEvents, webhooks } from "../db/schema"
5 | import { generateId } from "../id"
6 | import { eventArraySchema, EventType } from "./types"
7 |
8 | export class Event {
9 | event: EventType
10 | constructor(event: EventType) {
11 | this.event = event
12 | }
13 |
14 | async emit({
15 | formId,
16 | data,
17 | submissionId,
18 | }: {
19 | formId: string
20 | data: string
21 | submissionId: string
22 | }) {
23 | console.log(`Emitting event ${this.event} for formId ${formId}`)
24 | await triggerWebhooks({
25 | formId,
26 | event: this.event,
27 | data,
28 | submissionId,
29 | })
30 | }
31 | }
32 |
33 | async function triggerWebhooks({
34 | formId,
35 | event,
36 | data,
37 | submissionId,
38 | }: {
39 | formId: string
40 | event: EventType
41 | data: string
42 | submissionId: string
43 | }) {
44 | try {
45 | const matchingWebhooks = await db.query.webhooks.findMany({
46 | where: and(
47 | eq(webhooks.formId, formId),
48 | eq(webhooks.deleted, false),
49 | eq(webhooks.enabled, true)
50 | ),
51 | })
52 |
53 | console.log(`Found ${matchingWebhooks.length} webhooks.`)
54 | const now = new Date()
55 |
56 | await Promise.all(
57 | matchingWebhooks.map(async (webhook) => {
58 | const events = eventArraySchema.parse(
59 | JSON.parse(webhook.events as string)
60 | )
61 | if (events.includes(event)) {
62 | const id = generateId()
63 | await db.insert(webhookEvents).values({
64 | id: id,
65 | event: event,
66 | webhookId: webhook.id,
67 | submissionId,
68 | })
69 | const postRes = await postToEnpoint({
70 | endpoint: webhook.endpoint,
71 | event,
72 | data,
73 | submissionId,
74 | formId,
75 | webhookSecret: webhook.secretKey,
76 | })
77 |
78 | // update status in db
79 | await db
80 | .update(webhookEvents)
81 | .set({
82 | statusCode: postRes?.status,
83 | status: postRes?.status === 200 ? "success" : "attempting",
84 | lastAttempt: now,
85 | nextAttempt:
86 | postRes?.status === 200
87 | ? undefined
88 | : new Date(now.setMinutes(now.getMinutes() + 5)),
89 | attemptCount: 1,
90 | })
91 | .where(eq(webhookEvents.id, id))
92 | }
93 | })
94 | )
95 | } catch (err) {
96 | console.error(err)
97 | }
98 | }
99 |
100 | export async function postToEnpoint({
101 | endpoint,
102 | event,
103 | data,
104 | formId,
105 | submissionId,
106 | webhookSecret,
107 | }: {
108 | formId: string
109 | endpoint: string
110 | event: EventType
111 | data: string
112 | submissionId: string
113 | webhookSecret: string
114 | }) {
115 | try {
116 | console.log(`Post to endpoint ${endpoint} for event ${event}`)
117 |
118 | // make post request to webhook endpoint
119 | const res = await fetch(endpoint, {
120 | method: "POST",
121 | body: JSON.stringify({ data, event, formId, submissionId }),
122 | headers: {
123 | "Content-Type": "application/json",
124 | "x-dorf-secret": webhookSecret,
125 | },
126 | })
127 |
128 | return {
129 | status: res.status,
130 | }
131 | } catch (err) {
132 | console.error(err)
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/lib/events/types.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod"
2 |
3 | export const eventSchema = z.enum(["submission.created"])
4 | export const eventArraySchema = eventSchema.array()
5 | export type EventType = z.infer
6 |
--------------------------------------------------------------------------------
/src/lib/id.ts:
--------------------------------------------------------------------------------
1 | import { customAlphabet } from "nanoid"
2 |
3 | const nanoId = customAlphabet(
4 | "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
5 | 7
6 | )
7 |
8 | export const generateId = () => nanoId()
9 |
--------------------------------------------------------------------------------
/src/lib/ratelimiter.ts:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from "@upstash/ratelimit" // for deno: see above
2 | import { kv } from "@vercel/kv"
3 |
4 | // Create a new ratelimiter, that allows 10 requests per 10 seconds
5 | export const ratelimit = new Ratelimit({
6 | redis: kv,
7 | limiter: Ratelimit.slidingWindow(10, "10 s"),
8 | })
9 |
--------------------------------------------------------------------------------
/src/lib/session.ts:
--------------------------------------------------------------------------------
1 | import { getServerSession } from "next-auth/next"
2 |
3 | import { authOptions } from "@/lib/auth"
4 |
5 | export async function getCurrentUser() {
6 | const session = await getServerSession(authOptions)
7 |
8 | return session?.user
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { ClassValue, clsx } from "clsx"
2 | import { InferModel } from "drizzle-orm"
3 | import saveAs from "file-saver"
4 | import { parse } from "json2csv"
5 | import { twMerge } from "tailwind-merge"
6 |
7 | import { env } from "@/env.mjs"
8 |
9 | import { submissions } from "./db/schema"
10 |
11 | export function cn(...inputs: ClassValue[]) {
12 | return twMerge(clsx(inputs))
13 | }
14 |
15 | export function absoluteUrl(path: string) {
16 | return `${env.NEXT_PUBLIC_APP_URL}${path}`
17 | }
18 |
19 | type Submission = InferModel
20 | export const convertSubmissionsToCsv = (submissions: Submission[]): string => {
21 | const flattenedData = submissions.map((submission) => {
22 | const jsonData = JSON.parse(JSON.stringify(submission.data))
23 |
24 | jsonData.createdAt = submission.createdAt
25 |
26 | return jsonData
27 | })
28 |
29 | const headerSet = new Set()
30 | const records: any[][] = []
31 |
32 | for (const submission of flattenedData) {
33 | const fields = Object.keys(submission)
34 | fields.forEach((field) => headerSet.add(field))
35 |
36 | const record = fields.map((field) => submission[field])
37 | records.push(record)
38 | }
39 |
40 | const opts = { fields: Array.from(headerSet) }
41 |
42 | try {
43 | const csvData = parse(flattenedData, opts)
44 | return csvData
45 | } catch (error) {
46 | console.error("Error converting JSON to CSV:", error)
47 | throw error
48 | }
49 | }
50 |
51 | export const downloadFile = async (url: string, filename: string) => {
52 | const data = await fetch(url)
53 | const blob = await data.blob()
54 | saveAs(blob, filename)
55 | }
56 |
--------------------------------------------------------------------------------
/src/lib/validations/auth.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | export const userAuthSchema = z.object({
4 | email: z.string().email(),
5 | })
6 |
--------------------------------------------------------------------------------
/src/lib/validations/og.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod"
2 |
3 | export const ogImageSchema = z.object({
4 | heading: z.string(),
5 | type: z.string(),
6 | })
7 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server"
2 | import { getToken } from "next-auth/jwt"
3 | import { withAuth } from "next-auth/middleware"
4 |
5 | export default withAuth(
6 | async function middleware(req) {
7 | const token = await getToken({ req })
8 | const isAuth = !!token
9 | const isAuthPage =
10 | req.nextUrl.pathname.startsWith("/login") ||
11 | req.nextUrl.pathname.startsWith("/register")
12 |
13 | if (isAuthPage) {
14 | if (isAuth) {
15 | return NextResponse.redirect(new URL("/dashboard", req.url))
16 | }
17 |
18 | return null
19 | }
20 |
21 | if (!isAuth) {
22 | let from = req.nextUrl.pathname
23 | if (req.nextUrl.search) {
24 | from += req.nextUrl.search
25 | }
26 |
27 | return NextResponse.redirect(
28 | new URL(`/login?from=${encodeURIComponent(from)}`, req.url)
29 | )
30 | }
31 | },
32 | {
33 | callbacks: {
34 | async authorized() {
35 | // This is a work-around for handling redirect on auth pages.
36 | // We return true here so that the middleware function above
37 | // is always called.
38 | return true
39 | },
40 | },
41 | }
42 | )
43 |
44 | export const config = {
45 | matcher: ["/dashboard/:path*", "/forms/:path*", "/login", "/register"],
46 | }
47 |
--------------------------------------------------------------------------------
/src/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth"
2 |
3 | import { authOptions } from "@/lib/auth"
4 |
5 | // @see ./lib/auth
6 | export default NextAuth(authOptions)
7 |
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { Icons } from "@/components/icons"
2 |
3 | export type NavItem = {
4 | title: string
5 | href: string
6 | disabled?: boolean
7 | }
8 |
9 | export type MainNavItem = NavItem
10 | export type UserNavItem = NavItem
11 |
12 | export type SidebarNavItem = {
13 | title: string
14 | disabled?: boolean
15 | external?: boolean
16 | icon?: keyof typeof Icons
17 | } & (
18 | | {
19 | href: string
20 | items?: never
21 | }
22 | | {
23 | href?: string
24 | items: NavLink[]
25 | }
26 | )
27 |
28 | export type SiteConfig = {
29 | name: string
30 | description: string
31 | url: string
32 | ogImage: string
33 | links: {
34 | twitter: string
35 | github: string
36 | }
37 | }
38 |
39 | export type DashboardConfig = {
40 | mainNav: MainNavItem[]
41 | sidebarNav: SidebarNavItem[]
42 | userNav: UserNavItem[]
43 | }
44 |
45 | export type MarketingConfig = {
46 | mainNav: MainNavItem[]
47 | demoLink: string
48 | }
49 |
--------------------------------------------------------------------------------
/src/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import { User } from "next-auth"
2 | import { JWT } from "next-auth/jwt"
3 |
4 | type UserId = string
5 |
6 | declare module "next-auth/jwt" {
7 | interface JWT {
8 | id: UserId
9 | }
10 | }
11 |
12 | declare module "next-auth" {
13 | interface Session {
14 | user: User & {
15 | id: UserId
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 | import { fontFamily } from "tailwindcss/defaultTheme"
3 |
4 | module.exports = {
5 | darkMode: ["class"],
6 | content: [
7 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
8 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
9 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
10 | ],
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | fontFamily: {
61 | sans: ["var(--font-sans)", ...fontFamily.sans],
62 | heading: ["var(--font-heading)", ...fontFamily.sans],
63 | },
64 | keyframes: {
65 | "accordion-down": {
66 | from: { height: "0" },
67 | to: { height: "var(--radix-accordion-content-height)" },
68 | },
69 | "accordion-up": {
70 | from: { height: "var(--radix-accordion-content-height)" },
71 | to: { height: "0" },
72 | },
73 | },
74 | animation: {
75 | "accordion-down": "accordion-down 0.2s ease-out",
76 | "accordion-up": "accordion-up 0.2s ease-out",
77 | },
78 | },
79 | },
80 | plugins: [require("tailwindcss-animate")],
81 | } satisfies Config
82 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "plugins": [
19 | {
20 | "name": "next"
21 | }
22 | ],
23 | "paths": {
24 | "@/*": ["./src/*"]
25 | }
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | }
5 | }
--------------------------------------------------------------------------------