From 983f9ba167c11e7fcdad73c7a33b768c1feb6a16 Mon Sep 17 00:00:00 2001 From: d-ivashchuk Date: Tue, 9 Apr 2024 09:45:03 +0200 Subject: [PATCH] add upload files examples --- package.json | 8 + pnpm-lock.yaml | 204 ++++++++++- src/app/api/uploadthing/core.ts | 33 ++ src/app/api/uploadthing/route.ts | 11 + .../(examples)/examples/page.tsx | 32 ++ .../(examples)/upload/index.tsx | 31 ++ .../patterns/file-upload/file-uploader.tsx | 316 ++++++++++++++++++ .../patterns/file-upload/upload-dialog.tsx | 124 +++++++ src/components/patterns/layout.tsx | 6 + src/components/ui/form.tsx | 176 ++++++++++ src/components/ui/progress.tsx | 28 ++ src/components/ui/scroll-area.tsx | 48 +++ src/hooks/use-upload-file.ts | 61 ++++ src/lib/uploadthing.ts | 25 ++ src/lib/utils.ts | 18 + src/server/api/root.ts | 6 +- .../api/routers/{post.ts => example.ts} | 2 +- src/server/api/routers/user.ts | 20 ++ 18 files changed, 1144 insertions(+), 5 deletions(-) create mode 100644 src/app/api/uploadthing/core.ts create mode 100644 src/app/api/uploadthing/route.ts create mode 100644 src/app/app/(authenticated-routes)/(examples)/examples/page.tsx create mode 100644 src/app/app/(authenticated-routes)/(examples)/upload/index.tsx create mode 100644 src/components/patterns/file-upload/file-uploader.tsx create mode 100644 src/components/patterns/file-upload/upload-dialog.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/hooks/use-upload-file.ts create mode 100644 src/lib/uploadthing.ts rename src/server/api/routers/{post.ts => example.ts} (91%) create mode 100644 src/server/api/routers/user.ts 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) ? ( + {file.name} + ) : 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 ( + + + + + + + Upload image + + Drag and drop your image here or click browse filesystem. + + +
+ + ( +
+ + Images + + + + + +
+ )} + /> + + + +
+
+ ); +} 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 ( +