diff --git a/package.json b/package.json
index 21790e6..d12b2ce 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"dependencies": {
"@auth/prisma-adapter": "^1.4.0",
"@cloudflare/stream-react": "^1.9.1",
+ "@hookform/resolvers": "^3.3.4",
"@icons-pack/react-simple-icons": "^9.4.0",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"@prisma/client": "^5.10.2",
@@ -25,11 +26,14 @@
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
+ "@radix-ui/react-progress": "^1.0.3",
+ "@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
+ "@radix-ui/react-use-controllable-state": "^1.0.1",
"@sentry/nextjs": "^7.105.0",
"@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "^5.25.0",
@@ -42,6 +46,7 @@
"@trpc/next": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
+ "@uploadthing/react": "^6.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
@@ -59,6 +64,8 @@
"react": "18.2.0",
"react-day-picker": "^8.10.0",
"react-dom": "18.2.0",
+ "react-dropzone": "^14.2.3",
+ "react-hook-form": "^7.51.2",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.13.1",
"rehype-slug": "^6.0.0",
@@ -68,6 +75,7 @@
"superjson": "^2.2.1",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
+ "uploadthing": "^6.7.0",
"usehooks-ts": "^3.1.0",
"uvcanvas": "^0.2.1",
"zod": "^3.22.4"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc516d1..52a5561 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ dependencies:
'@cloudflare/stream-react':
specifier: ^1.9.1
version: 1.9.1(react@18.2.0)
+ '@hookform/resolvers':
+ specifier: ^3.3.4
+ version: 3.3.4(react-hook-form@7.51.2)
'@icons-pack/react-simple-icons':
specifier: ^9.4.0
version: 9.4.0(react@18.2.0)
@@ -38,6 +41,12 @@ dependencies:
'@radix-ui/react-popover':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-progress':
+ specifier: ^1.0.3
+ version: 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-scroll-area':
+ specifier: ^1.0.5
+ version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-select':
specifier: ^2.0.0
version: 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0)
@@ -53,6 +62,9 @@ dependencies:
'@radix-ui/react-tooltip':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-use-controllable-state':
+ specifier: ^1.0.1
+ version: 1.0.1(@types/react@18.2.62)(react@18.2.0)
'@sentry/nextjs':
specifier: ^7.105.0
version: 7.108.0(next@14.1.4)(react@18.2.0)(webpack@5.91.0)
@@ -89,6 +101,9 @@ dependencies:
'@trpc/server':
specifier: next
version: 11.0.0-rc.332
+ '@uploadthing/react':
+ specifier: ^6.4.1
+ version: 6.4.1(next@14.1.4)(react@18.2.0)(uploadthing@6.7.0)
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@@ -140,6 +155,12 @@ dependencies:
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
+ react-dropzone:
+ specifier: ^14.2.3
+ version: 14.2.3(react@18.2.0)
+ react-hook-form:
+ specifier: ^7.51.2
+ version: 7.51.2(react@18.2.0)
rehype-autolink-headings:
specifier: ^7.1.0
version: 7.1.0
@@ -167,6 +188,9 @@ dependencies:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.1)
+ uploadthing:
+ specifier: ^6.7.0
+ version: 6.7.0(next@14.1.4)(tailwindcss@3.4.1)
usehooks-ts:
specifier: ^3.1.0
version: 3.1.0(react@18.2.0)
@@ -2350,6 +2374,14 @@ packages:
yargs: 17.7.2
dev: false
+ /@hookform/resolvers@3.3.4(react-hook-form@7.51.2):
+ resolution: {integrity: sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==}
+ peerDependencies:
+ react-hook-form: ^7.0.0
+ dependencies:
+ react-hook-form: 7.51.2(react@18.2.0)
+ dev: false
+
/@humanwhocodes/config-array@0.11.14:
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@@ -3422,6 +3454,28 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.1
+ '@radix-ui/react-context': 1.0.1(@types/react@18.2.62)(react@18.2.0)
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0)
+ '@types/react': 18.2.62
+ '@types/react-dom': 18.2.22
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
peerDependencies:
@@ -3451,6 +3505,35 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /@radix-ui/react-scroll-area@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.1
+ '@radix-ui/number': 1.0.1
+ '@radix-ui/primitive': 1.0.1
+ '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.62)(react@18.2.0)
+ '@radix-ui/react-context': 1.0.1(@types/react@18.2.62)(react@18.2.0)
+ '@radix-ui/react-direction': 1.0.1(@types/react@18.2.62)(react@18.2.0)
+ '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.62)(react@18.2.0)
+ '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.62)(react@18.2.0)
+ '@types/react': 18.2.62
+ '@types/react-dom': 18.2.22
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/@radix-ui/react-select@2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.62)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==}
peerDependencies:
@@ -5699,6 +5782,59 @@ packages:
/@ungap/structured-clone@1.2.0:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+ /@uploadthing/dropzone@0.2.1(react@18.2.0):
+ resolution: {integrity: sha512-OK4rSFnQ2woJ07t78hTfxjMxXedLoj+Jp34kIEtYPYBTPOnNwoKhDXfRojSu8cBilkQROzIe67i0p6F4B6LQhQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0
+ solid-js: ^1.7.11
+ peerDependenciesMeta:
+ react:
+ optional: true
+ solid-js:
+ optional: true
+ dependencies:
+ file-selector: 0.6.0
+ react: 18.2.0
+ dev: false
+
+ /@uploadthing/mime-types@0.2.6:
+ resolution: {integrity: sha512-mB7XAKy5ARltUWZxb2oE0OwUW5Wplxi7Z3cqCvf/ZFvQ1E6lcGVNNKbmJI8c4GaONyEYnalo0Yl228zzHzyZnQ==}
+ dev: false
+
+ /@uploadthing/react@6.4.1(next@14.1.4)(react@18.2.0)(uploadthing@6.7.0):
+ resolution: {integrity: sha512-e6/2U97B+rAiCjEfAEf5RfFy6sxXrhsOHHkT4ssMbbpGihbAVn5fFvEYT4q3GulQNAEfZE7JudEapbHKyADWrw==}
+ peerDependencies:
+ next: '*'
+ react: ^17.0.2 || ^18.0.0
+ uploadthing: 6.7.0
+ peerDependenciesMeta:
+ next:
+ optional: true
+ dependencies:
+ '@uploadthing/dropzone': 0.2.1(react@18.2.0)
+ '@uploadthing/shared': 6.4.0(@uploadthing/mime-types@0.2.6)
+ file-selector: 0.6.0
+ next: 14.1.4(@babel/core@7.24.3)(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
+ react: 18.2.0
+ tailwind-merge: 2.2.2
+ uploadthing: 6.7.0(next@14.1.4)(tailwindcss@3.4.1)
+ transitivePeerDependencies:
+ - '@uploadthing/mime-types'
+ - solid-js
+ dev: false
+
+ /@uploadthing/shared@6.4.0(@uploadthing/mime-types@0.2.6):
+ resolution: {integrity: sha512-N2fYHl5pfOjrRyEXFNyKf8gLqYiSSsPE71Vky7Efbze/tgCcX9J4H4UXPSHS4Ty2w6A2xeZgGtJRjCXv9lXjaQ==}
+ peerDependencies:
+ '@uploadthing/mime-types': 0.2.6
+ peerDependenciesMeta:
+ '@uploadthing/mime-types':
+ optional: true
+ dependencies:
+ '@uploadthing/mime-types': 0.2.6
+ std-env: 3.7.0
+ dev: false
+
/@vitest/expect@1.3.1:
resolution: {integrity: sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==}
dependencies:
@@ -6236,6 +6372,11 @@ packages:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: false
+ /attr-accept@2.2.2:
+ resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==}
+ engines: {node: '>=4'}
+ dev: false
+
/available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -6948,7 +7089,6 @@ packages:
/consola@3.2.3:
resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==}
engines: {node: ^14.18.0 || >=16.10.0}
- dev: true
/console-browserify@1.2.0:
resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==}
@@ -8463,6 +8603,13 @@ packages:
flat-cache: 3.2.0
dev: true
+ /file-selector@0.6.0:
+ resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==}
+ engines: {node: '>= 12'}
+ dependencies:
+ tslib: 2.6.2
+ dev: false
+
/file-system-cache@2.3.0:
resolution: {integrity: sha512-l4DMNdsIPsVnKrgEXbJwDJsA5mB8rGwHYERMgqQx/xAUtChPJMre1bXBzDEqqVbWv9AIbFezXMxeEkZDSrXUOQ==}
dependencies:
@@ -12368,7 +12515,6 @@ packages:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
- dev: true
/property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
@@ -12589,6 +12735,18 @@ packages:
react: 18.2.0
scheduler: 0.23.0
+ /react-dropzone@14.2.3(react@18.2.0):
+ resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==}
+ engines: {node: '>= 10.13'}
+ peerDependencies:
+ react: '>= 16.8 || 18.0.0'
+ dependencies:
+ attr-accept: 2.2.2
+ file-selector: 0.6.0
+ prop-types: 15.8.1
+ react: 18.2.0
+ dev: false
+
/react-element-to-jsx-string@15.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==}
peerDependencies:
@@ -12602,6 +12760,15 @@ packages:
react-is: 18.1.0
dev: true
+ /react-hook-form@7.51.2(react@18.2.0):
+ resolution: {integrity: sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==}
+ engines: {node: '>=12.22.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18
+ dependencies:
+ react: 18.2.0
+ dev: false
+
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -13487,6 +13654,10 @@ packages:
engines: {node: '>= 0.8'}
dev: true
+ /std-env@3.7.0:
+ resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==}
+ dev: false
+
/stop-iteration-iterator@1.0.0:
resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==}
engines: {node: '>= 0.4'}
@@ -14451,6 +14622,35 @@ packages:
escalade: 3.1.2
picocolors: 1.0.0
+ /uploadthing@6.7.0(next@14.1.4)(tailwindcss@3.4.1):
+ resolution: {integrity: sha512-meWUtFcESRTnmn0vvis6GH07tuQ73sZVYwlQBzspPy2XJq+4fJQ6S9j3sXbp/ocrpc6UyeiMLfM+QURzAtv5lA==}
+ engines: {node: '>=18.13.0'}
+ peerDependencies:
+ express: '*'
+ fastify: '*'
+ h3: '*'
+ next: '*'
+ tailwindcss: '*'
+ peerDependenciesMeta:
+ express:
+ optional: true
+ fastify:
+ optional: true
+ h3:
+ optional: true
+ next:
+ optional: true
+ tailwindcss:
+ optional: true
+ dependencies:
+ '@uploadthing/mime-types': 0.2.6
+ '@uploadthing/shared': 6.4.0(@uploadthing/mime-types@0.2.6)
+ consola: 3.2.3
+ next: 14.1.4(@babel/core@7.24.3)(@opentelemetry/api@1.8.0)(react-dom@18.2.0)(react@18.2.0)
+ std-env: 3.7.0
+ tailwindcss: 3.4.1(ts-node@10.9.2)
+ dev: false
+
/uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
dependencies:
diff --git a/src/app/api/uploadthing/core.ts b/src/app/api/uploadthing/core.ts
new file mode 100644
index 0000000..4a25257
--- /dev/null
+++ b/src/app/api/uploadthing/core.ts
@@ -0,0 +1,33 @@
+import { getServerSession } from "next-auth";
+import { createUploadthing, type FileRouter } from "uploadthing/next";
+import { UploadThingError } from "uploadthing/server";
+import { authOptions } from "~/server/auth";
+
+const f = createUploadthing();
+
+// FileRouter for your app, can contain multiple FileRoutes
+export const ourFileRouter = {
+ // Define as many FileRoutes as you like, each with a unique routeSlug
+ imageUploader: f({ image: { maxFileSize: "4MB", maxFileCount: 8 } })
+ // Set permissions and file types for this FileRoute
+ .middleware(async () => {
+ const session = await getServerSession(authOptions);
+ // This code runs on your server before upload
+
+ // If you throw, the user will not be able to upload
+ if (!session) throw new UploadThingError("Unauthorized");
+
+ // Whatever is returned here is accessible in onUploadComplete as `metadata`
+ return { userId: session?.user.id };
+ })
+ .onUploadComplete(async ({ metadata, file }) => {
+ // This code RUNS ON YOUR SERVER after upload
+ console.log("Upload complete for userId:", metadata.userId);
+
+ console.log("file url", file.url);
+
+ return { uploadedBy: metadata.userId };
+ }),
+} satisfies FileRouter;
+
+export type OurFileRouter = typeof ourFileRouter;
diff --git a/src/app/api/uploadthing/route.ts b/src/app/api/uploadthing/route.ts
new file mode 100644
index 0000000..81af864
--- /dev/null
+++ b/src/app/api/uploadthing/route.ts
@@ -0,0 +1,11 @@
+import { createRouteHandler } from "uploadthing/next";
+
+import { ourFileRouter } from "./core";
+
+// Export routes for Next App Router
+export const { GET, POST } = createRouteHandler({
+ router: ourFileRouter,
+
+ // Apply an (optional) custom config:
+ // config: { ... },
+});
diff --git a/src/app/app/(authenticated-routes)/(examples)/examples/page.tsx b/src/app/app/(authenticated-routes)/(examples)/examples/page.tsx
new file mode 100644
index 0000000..a88cc45
--- /dev/null
+++ b/src/app/app/(authenticated-routes)/(examples)/examples/page.tsx
@@ -0,0 +1,32 @@
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
+import React from "react";
+import { Separator } from "~/components/ui/separator";
+import UploadFile from "../upload";
+
+const Examples = () => {
+ //this component will display some tabs with different examples
+ return (
+
+
+
Examples
+
+ On this screen you see some examples of popular flows in SaaS
+ applications
+
+
+
+
+
+ Upload
+ AI
+
+
+
+
+ Under construction
+
+
+ );
+};
+
+export default Examples;
diff --git a/src/app/app/(authenticated-routes)/(examples)/upload/index.tsx b/src/app/app/(authenticated-routes)/(examples)/upload/index.tsx
new file mode 100644
index 0000000..8a19da7
--- /dev/null
+++ b/src/app/app/(authenticated-routes)/(examples)/upload/index.tsx
@@ -0,0 +1,31 @@
+"use client";
+import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
+import { useSession } from "next-auth/react";
+import React from "react";
+import { UploadWithDialog } from "~/components/patterns/file-upload/upload-dialog";
+
+const UploadFile = () => {
+ const session = useSession();
+ return (
+
+
Change user profile picture
+ {session.status === "authenticated" && (
+
+
+
+
+ {session.data.user.name?.charAt(0) ??
+ session.data.user.email?.charAt(0)}
+
+
+
+ )}
+
+
+ );
+};
+
+export default UploadFile;
diff --git a/src/components/patterns/file-upload/file-uploader.tsx b/src/components/patterns/file-upload/file-uploader.tsx
new file mode 100644
index 0000000..9785f63
--- /dev/null
+++ b/src/components/patterns/file-upload/file-uploader.tsx
@@ -0,0 +1,316 @@
+"use client";
+
+import * as React from "react";
+import Image from "next/image";
+
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
+import Dropzone, {
+ type DropzoneProps,
+ type FileRejection,
+} from "react-dropzone";
+import { toast } from "sonner";
+
+import { cn, formatBytes } from "~/lib/utils";
+import { Button } from "~/components/ui/button";
+import { Progress } from "~/components/ui/progress";
+import { ScrollArea } from "~/components/ui/scroll-area";
+import { Upload, X } from "lucide-react";
+
+interface FileUploaderProps extends React.HTMLAttributes {
+ /**
+ * Value of the uploader.
+ * @type File[]
+ * @default undefined
+ * @example value={files}
+ */
+ value?: File[];
+
+ /**
+ * Function to be called when the value changes.
+ * @type React.Dispatch>
+ * @default undefined
+ * @example onValueChange={(files) => setFiles(files)}
+ */
+ onValueChange?: React.Dispatch>;
+
+ /**
+ * Function to be called when files are uploaded.
+ * @type (files: File[]) => Promise
+ * @default undefined
+ * @example onUpload={(files) => uploadFiles(files)}
+ */
+ onUpload?: (files: File[]) => Promise;
+
+ /**
+ * Progress of the uploaded files.
+ * @type Record | undefined
+ * @default undefined
+ * @example progresses={{ "file1.png": 50 }}
+ */
+ progresses?: Record;
+
+ /**
+ * Accepted file types for the uploader.
+ * @type { [key: string]: string[]}
+ * @default
+ * ```ts
+ * { "image/*": [] }
+ * ```
+ * @example accept={["image/png", "image/jpeg"]}
+ */
+ accept?: DropzoneProps["accept"];
+
+ /**
+ * Maximum file size for the uploader.
+ * @type number | undefined
+ * @default 1024 * 1024 * 2 // 2MB
+ * @example maxSize={1024 * 1024 * 2} // 2MB
+ */
+ maxSize?: DropzoneProps["maxSize"];
+
+ /**
+ * Maximum number of files for the uploader.
+ * @type number | undefined
+ * @default 1
+ * @example maxFiles={5}
+ */
+ maxFiles?: DropzoneProps["maxFiles"];
+
+ /**
+ * Whether the uploader should accept multiple files.
+ * @type boolean
+ * @default false
+ * @example multiple
+ */
+ multiple?: boolean;
+
+ /**
+ * Whether the uploader is disabled.
+ * @type boolean
+ * @default false
+ * @example disabled
+ */
+ disabled?: boolean;
+}
+
+export function FileUploader(props: FileUploaderProps) {
+ const {
+ value: valueProp,
+ onValueChange,
+ onUpload,
+ progresses,
+ accept = { "image/*": [] },
+ maxSize = 1024 * 1024 * 2,
+ maxFiles = 1,
+ multiple = false,
+ disabled = false,
+ className,
+ ...dropzoneProps
+ } = props;
+
+ const [files, setFiles] = useControllableState({
+ prop: valueProp,
+ onChange: onValueChange,
+ });
+
+ const onDrop = React.useCallback(
+ (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
+ if (!multiple && maxFiles === 1 && acceptedFiles.length > 1) {
+ toast.error("Cannot upload more than 1 file at a time");
+ return;
+ }
+
+ if ((files?.length ?? 0) + acceptedFiles.length > maxFiles) {
+ toast.error(`Cannot upload more than ${maxFiles} files`);
+ return;
+ }
+
+ const newFiles = acceptedFiles.map((file) =>
+ Object.assign(file, {
+ preview: URL.createObjectURL(file),
+ }),
+ );
+
+ const updatedFiles = files ? [...files, ...newFiles] : newFiles;
+
+ setFiles(updatedFiles);
+
+ if (rejectedFiles.length > 0) {
+ rejectedFiles.forEach(({ file }) => {
+ toast.error(`File ${file.name} was rejected`);
+ });
+ }
+
+ if (
+ onUpload &&
+ updatedFiles.length > 0 &&
+ updatedFiles.length <= maxFiles
+ ) {
+ const target =
+ updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
+
+ toast.promise(onUpload(updatedFiles), {
+ loading: `Uploading ${target}...`,
+ success: () => {
+ setFiles([]);
+ return `${target} uploaded`;
+ },
+ error: `Failed to upload ${target}`,
+ });
+ }
+ },
+
+ [files, maxFiles, multiple, onUpload, setFiles],
+ );
+
+ function onRemove(index: number) {
+ if (!files) return;
+ const newFiles = files.filter((_, i) => i !== index);
+ setFiles(newFiles);
+ onValueChange?.(newFiles);
+ }
+
+ // Revoke preview url when component unmounts
+ React.useEffect(() => {
+ return () => {
+ if (!files) return;
+ files.forEach((file) => {
+ if (isFileWithPreview(file)) {
+ URL.revokeObjectURL(file.preview);
+ }
+ });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const isDisabled = disabled || (files?.length ?? 0) >= maxFiles;
+
+ return (
+
+
1 || multiple}
+ disabled={isDisabled}
+ >
+ {({ getRootProps, getInputProps, isDragActive }) => (
+
+
+ {isDragActive ? (
+
+
+
+
+
+ Drop the files here
+
+
+ ) : (
+
+
+
+
+
+
+ Drag {`'n'`} drop files here, or click to select files
+
+
+ You can upload
+ {maxFiles > 1
+ ? ` ${maxFiles === Infinity ? "multiple" : maxFiles}
+ files (up to ${formatBytes(maxSize)} each)`
+ : ` a file with ${formatBytes(maxSize)}`}
+
+
+
+ )}
+
+ )}
+
+ {files?.length ? (
+
+
+ {files?.map((file, index) => (
+ onRemove(index)}
+ progress={progresses?.[file.name]}
+ />
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+interface FileCardProps {
+ file: File;
+ onRemove: () => void;
+ progress?: number;
+}
+
+function FileCard({ file, progress, onRemove }: FileCardProps) {
+ return (
+
+
+ {isFileWithPreview(file) ? (
+
+ ) : null}
+
+
+
+ {file.name}
+
+
+ {formatBytes(file.size)}
+
+
+ {progress ?
: null}
+
+
+
+
+
+
+ );
+}
+
+function isFileWithPreview(file: File): file is File & { preview: string } {
+ return "preview" in file && typeof file.preview === "string";
+}
diff --git a/src/components/patterns/file-upload/upload-dialog.tsx b/src/components/patterns/file-upload/upload-dialog.tsx
new file mode 100644
index 0000000..44e99dd
--- /dev/null
+++ b/src/components/patterns/file-upload/upload-dialog.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import * as React from "react";
+
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import { FileUploader } from "~/components/patterns/file-upload/file-uploader";
+import { useUploadFile } from "~/hooks/use-upload-file";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { getErrorMessage } from "~/lib/uploadthing";
+import {
+ Form,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormMessage,
+} from "~/components/ui/form";
+import { api } from "~/trpc/react";
+import { useSession } from "next-auth/react";
+
+const schema = z.object({
+ images: z.array(z.instanceof(File)),
+});
+
+type Schema = z.infer;
+
+export function UploadWithDialog() {
+ const { update } = useSession();
+ const [loading, setLoading] = React.useState(false);
+ const updateUserMutation = api.user.updateUser.useMutation({
+ onSuccess: async () => {
+ await update();
+ },
+ });
+ const { uploadFiles, progresses, isUploading } = useUploadFile(
+ "imageUploader",
+ { defaultUploadedFiles: [] },
+ );
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ images: [],
+ },
+ });
+
+ async function onSubmit(input: Schema) {
+ setLoading(true);
+ const toastId = toast.loading("Uploading files...");
+ const res = await uploadFiles(input.images);
+ toast.dismiss(toastId);
+
+ if (res && res?.length > 0 && res[0]?.url) {
+ toast.success("File uploaded successfully");
+ updateUserMutation.mutate({
+ image: res[0]?.url,
+ });
+ form.reset();
+ }
+ if (res?.length === 0) {
+ toast.error("Something went wrong during upload");
+ }
+
+ setLoading(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/patterns/layout.tsx b/src/components/patterns/layout.tsx
index 07791a9..0d00e80 100644
--- a/src/components/patterns/layout.tsx
+++ b/src/components/patterns/layout.tsx
@@ -56,6 +56,12 @@ export async function Layout({ children }: { children: React.ReactNode }) {
>
Usage
+
+ Examples
+
= FieldPath
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message) : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..1d2a151
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+
+import { cn } from "~/lib/utils"
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+))
+Progress.displayName = ProgressPrimitive.Root.displayName
+
+export { Progress }
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..f66ab4f
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "~/lib/utils"
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+))
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+))
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+
+export { ScrollArea, ScrollBar }
diff --git a/src/hooks/use-upload-file.ts b/src/hooks/use-upload-file.ts
new file mode 100644
index 0000000..732b330
--- /dev/null
+++ b/src/hooks/use-upload-file.ts
@@ -0,0 +1,61 @@
+import * as React from "react";
+import { type ClientUploadedFileData } from "uploadthing/types";
+import { toast } from "sonner";
+import type { UploadFilesOptions } from "uploadthing/types";
+type UploadedFile = ClientUploadedFileData;
+
+import { uploadFiles, getErrorMessage } from "~/lib/uploadthing";
+import { type OurFileRouter } from "~/app/api/uploadthing/core";
+
+interface UseUploadFileProps
+ extends Pick<
+ UploadFilesOptions,
+ "headers" | "onUploadBegin" | "onUploadProgress" | "skipPolling"
+ > {
+ defaultUploadedFiles?: UploadedFile[];
+}
+
+export function useUploadFile(
+ endpoint: keyof OurFileRouter,
+ { defaultUploadedFiles = [], ...props }: UseUploadFileProps = {},
+) {
+ const [uploadedFiles, setUploadedFiles] =
+ React.useState(defaultUploadedFiles);
+ const [progresses, setProgresses] = React.useState>(
+ {},
+ );
+ const [isUploading, setIsUploading] = React.useState(false);
+
+ async function uploadThings(files: File[]) {
+ setIsUploading(true);
+ try {
+ const res = await uploadFiles(endpoint, {
+ ...props,
+ files,
+ onUploadProgress: ({ file, progress }) => {
+ setProgresses((prev) => {
+ return {
+ ...prev,
+ [file]: progress,
+ };
+ });
+ },
+ });
+
+ setUploadedFiles((prev) => (prev ? [...prev, ...res] : res));
+ return res;
+ } catch (err) {
+ toast.error(getErrorMessage(err));
+ } finally {
+ setProgresses({});
+ setIsUploading(false);
+ }
+ }
+
+ return {
+ uploadedFiles,
+ progresses,
+ uploadFiles: uploadThings,
+ isUploading,
+ };
+}
diff --git a/src/lib/uploadthing.ts b/src/lib/uploadthing.ts
new file mode 100644
index 0000000..42f3eda
--- /dev/null
+++ b/src/lib/uploadthing.ts
@@ -0,0 +1,25 @@
+import { generateReactHelpers } from "@uploadthing/react";
+import { isRedirectError } from "next/dist/client/components/redirect";
+import { z } from "zod";
+
+import type { OurFileRouter } from "~/app/api/uploadthing/core";
+
+export const { useUploadThing, uploadFiles } =
+ generateReactHelpers();
+
+export function getErrorMessage(err: unknown) {
+ const unknownError = "Something went wrong, please try again later.";
+
+ if (err instanceof z.ZodError) {
+ const errors = err.issues.map((issue) => {
+ return issue.message;
+ });
+ return errors.join("\n");
+ } else if (err instanceof Error) {
+ return err.message;
+ } else if (isRedirectError(err)) {
+ throw err;
+ } else {
+ return unknownError;
+ }
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index cf361b6..657cb9b 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -45,3 +45,21 @@ export const currency = "€";
export function absoluteUrl(path: string) {
return `${env.NEXT_PUBLIC_DEPLOYMENT_URL}${path}`;
}
+
+export function formatBytes(
+ bytes: number,
+ opts: {
+ decimals?: number;
+ sizeType?: "accurate" | "normal";
+ } = {},
+) {
+ const { decimals = 0, sizeType = "normal" } = opts;
+
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ const accurateSizes = ["Bytes", "KiB", "MiB", "GiB", "TiB"];
+ if (bytes === 0) return "0 Byte";
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
+ sizeType === "accurate" ? accurateSizes[i] ?? "Bytest" : sizes[i] ?? "Bytes"
+ }`;
+}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 32439a3..26fc0e9 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -1,8 +1,9 @@
-import { postRouter } from "~/server/api/routers/post";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
import { paymentManagementRouter } from "./routers/payment-management";
import { superAdminRouter } from "./routers/super-admin";
import { marketingRouter } from "./routers/marketing";
+import { exampleRouter } from "./routers/example";
+import { userRouter } from "./routers/user";
/**
* This is the primary router for your server.
@@ -10,10 +11,11 @@ import { marketingRouter } from "./routers/marketing";
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
- post: postRouter,
+ example: exampleRouter,
paymentManagement: paymentManagementRouter,
superAdmin: superAdminRouter,
marketing: marketingRouter,
+ user: userRouter,
});
// export type definition of API
diff --git a/src/server/api/routers/post.ts b/src/server/api/routers/example.ts
similarity index 91%
rename from src/server/api/routers/post.ts
rename to src/server/api/routers/example.ts
index f1b068e..76934d0 100644
--- a/src/server/api/routers/post.ts
+++ b/src/server/api/routers/example.ts
@@ -6,7 +6,7 @@ import {
publicProcedure,
} from "~/server/api/trpc";
-export const postRouter = createTRPCRouter({
+export const exampleRouter = createTRPCRouter({
hello: publicProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts
new file mode 100644
index 0000000..4475672
--- /dev/null
+++ b/src/server/api/routers/user.ts
@@ -0,0 +1,20 @@
+import { z } from "zod";
+
+import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
+
+export const userRouter = createTRPCRouter({
+ updateUser: protectedProcedure
+ .input(z.object({ image: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ const user = ctx.session?.user;
+
+ await ctx.db.user.update({
+ where: {
+ id: user?.id,
+ },
+ data: {
+ ...input,
+ },
+ });
+ }),
+});