diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts new file mode 100644 index 0000000..b173a91 --- /dev/null +++ b/app/api/uploadthing/core.ts @@ -0,0 +1,14 @@ +import { createUploadthing } from 'uploadthing/next' + +const f = createUploadthing() + +// FileRouter for your app, can contain multiple FileRoutes +export const ourFileRouter = { + // * Takes a 4 2mb images and/or 1 256mb video + mediaPost: f({ + image: { maxFileSize: '32MB', maxFileCount: 10 }, + video: { maxFileSize: '256MB', maxFileCount: 5 } + }).onUploadComplete(() => {}) +} + +export type OurFileRouter = typeof ourFileRouter diff --git a/app/api/uploadthing/route.ts b/app/api/uploadthing/route.ts new file mode 100644 index 0000000..10c3d4e --- /dev/null +++ b/app/api/uploadthing/route.ts @@ -0,0 +1,8 @@ +import { createRouteHandler } from 'uploadthing/next' + +import { ourFileRouter } from './core' + +// Export routes for Next App Router +export const { GET, POST } = createRouteHandler({ + router: ourFileRouter +}) diff --git a/app/layout.tsx b/app/layout.tsx index f0808b1..38b439f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,8 +3,11 @@ import type { Metadata } from 'next' import React, { ReactNode } from 'react' import { Inter } from 'next/font/google' import { ClerkProvider } from '@clerk/nextjs' +import { extractRouterConfig } from 'uploadthing/server' +import { NextSSRPlugin } from '@uploadthing/react/next-ssr-plugin' import '~/styles/globals.css' +import { ourFileRouter } from '~/app/api/uploadthing/core' import { TRPCProvider } from '~/components/providers/trpc-provider' import { ModalProvider } from '~/components/providers/modal-providers' @@ -32,6 +35,7 @@ export default function RootLayout({ children }: RootLayoutProps): JSX.Element { + diff --git a/components/modals/upload-post-modal.tsx b/components/modals/upload-post-modal.tsx index f1fca38..7dfa242 100644 --- a/components/modals/upload-post-modal.tsx +++ b/components/modals/upload-post-modal.tsx @@ -1,11 +1,14 @@ 'use client' import Image from 'next/image' +import { toast } from 'sonner' import { isEmpty } from 'lodash' import { zodResolver } from '@hookform/resolvers/zod' import { SubmitHandler, useForm } from 'react-hook-form' +import { FileWithPath, useDropzone } from 'react-dropzone' import { ChevronDown, ChevronLeft, MapPin } from 'lucide-react' -import React, { ChangeEvent, ReactNode, useState } from 'react' +import { generateClientDropzoneAccept } from 'uploadthing/client' +import React, { ChangeEvent, ReactNode, useCallback, useState } from 'react' import { cn } from '~/lib/utils' import { trpc } from '~/trpc/client' @@ -14,6 +17,7 @@ import { useUpload } from '~/hooks/use-upload' import { Emoji } from '~/helpers/emoji-helpers' import { Textarea } from '~/components/ui/textarea' import { PostSchema, PostSchemaType } from '~/zod/schema' +import { MediaFiles, useUploadThing } from '~/helpers/uploadthing' import { Dialog, DialogContent, DialogHeader } from '~/components/ui/dialog' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/collapsible' import { @@ -37,6 +41,7 @@ export const UploadPostModal = (): JSX.Element => { const upload = useUpload() const currentUser = trpc.user.currentUser.useQuery() const hashtags = trpc.hashtag.getHashtags.useQuery() + const createPost = trpc.post.create.useMutation() const [tags, setTags] = useState([]) const [files, setFiles] = useState([]) @@ -77,6 +82,38 @@ export const UploadPostModal = (): JSX.Element => { ) } + // * UPLOAD THING HOOKS + const { startUpload } = useUploadThing('mediaPost', { + onClientUploadComplete: (res) => { + const mediaFiles = res?.map((file) => file) + setValue( + 'mediaFiles', + mediaFiles?.map((m) => ({ + key: m.key, + url: m.url + })) as MediaFiles[] + ) + }, + onUploadError: (error) => { + toast.error(error.message) + } + }) + + // * UPLOAD THING ONDROP + const onDrop = useCallback((acceptedFiles: FileWithPath[]) => { + setFiles(acceptedFiles) // * USE TO PREPARE TO UPLOAD IN `UPLOADTHING` + + // * USE TO PARTIALLY DISPLAY THE IMAGE/VIDEOS + const urls = acceptedFiles.map((file) => URL.createObjectURL(file)) + setFileUrls(urls) + }, []) + + const fileTypes = ['image', 'video'] + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + accept: !isEmpty(fileTypes) ? generateClientDropzoneAccept(fileTypes) : undefined + }) + const handleCollapsibleChange = (): void => setAdvanceSetting((prev) => !prev) const handleReset = (): void => { @@ -100,10 +137,54 @@ export const UploadPostModal = (): JSX.Element => { } const onPost: SubmitHandler = async (data): Promise => { - // console.log({ - // ...data, - // tags - // }) + const uploads = await startUpload(files) + .then( + (p) => + p?.map((d) => ({ + key: d.key, + url: d.url + })) + ) + .catch( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + (error: any) => toast.error(error?.message) + ) + + if (!isEmpty(uploads)) { + await createPost.mutateAsync( + { + title: data.captions ?? '', + mediaFiles: { + createMany: { + data: uploads as MediaFiles[] + } + }, + isHideLikeAndCount: data.isHideLikeAndCount, + isTurnOffComment: data.isTurnOffComment, + postHashtags: { + create: tags?.map((t) => ({ + hashtag: { + connectOrCreate: { + where: { tag: t.text }, + create: { tag: t.text } + } + } + })) + } + }, + { + onSuccess() { + toast.success('Created new post successfully!') + }, + onSettled() { + handleReset() + upload.onClose() + } + } + ) + } else { + toast.error('Something went wrong!') + } } return ( @@ -153,8 +234,11 @@ export const UploadPostModal = (): JSX.Element => { > {isEmpty(isFileExist) && (
-
- +
+

Drag photos and videos here diff --git a/helpers/uploadthing.ts b/helpers/uploadthing.ts new file mode 100644 index 0000000..11fd9ca --- /dev/null +++ b/helpers/uploadthing.ts @@ -0,0 +1,12 @@ +import { generateComponents } from '@uploadthing/react' +import { generateReactHelpers } from '@uploadthing/react/hooks' + +import { OurFileRouter } from '~/app/api/uploadthing/core' + +export const { UploadButton, UploadDropzone, Uploader } = generateComponents() +export const { useUploadThing, uploadFiles } = generateReactHelpers() + +export type MediaFiles = { + key: string + url: string +} diff --git a/package-lock.json b/package-lock.json index edd2876..ca8bc41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@trpc/next": "^10.44.1", "@trpc/react-query": "^10.44.1", "@trpc/server": "^10.44.1", + "@uploadthing/react": "^6.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "emoji-mart": "^5.5.2", @@ -38,6 +39,7 @@ "prisma": "^5.7.1", "react": "^18", "react-dom": "^18", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.49.3", "react-nice-avatar": "^1.5.0", "react-responsive-carousel": "^3.2.23", @@ -48,6 +50,7 @@ "svix": "^1.15.0", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "uploadthing": "^6.3.1", "usehooks-ts": "^2.9.1", "zod": "^3.22.4", "zustand": "^4.4.7" @@ -2643,6 +2646,67 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@uploadthing/dropzone": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@uploadthing/dropzone/-/dropzone-0.1.1.tgz", + "integrity": "sha512-ohlnFQfNr/UmImfipVo4uiBj1HQL7dp/sWBD96iAajc6RaQuYnHATGaSaISpCjZTeoLkksuu4/LR8vU9nSzpvQ==", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0" + } + }, + "node_modules/@uploadthing/mime-types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@uploadthing/mime-types/-/mime-types-0.2.2.tgz", + "integrity": "sha512-ZUo1JHOPPMZDsUw1mOhhVDIvJGlsjj6T0xJ/YJtulyJwL43S9B5pxg1cHcRuTEgjaxj7B55jiqQ6r9mDrrjH9A==" + }, + "node_modules/@uploadthing/react": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@uploadthing/react/-/react-6.2.2.tgz", + "integrity": "sha512-fDfVooLTbC5/FM1o/chPDf5i8DRxCOeRSuRl0R6yh/jIHTdDO2kwXeM5DTaBazJmaEabh5GB+I9Rwg8nU0QdVA==", + "dependencies": { + "@uploadthing/dropzone": "^0.1.1", + "@uploadthing/shared": "^6.2.0", + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0", + "tailwind-merge": "^1.13.2" + }, + "peerDependencies": { + "next": "*", + "react": "^17.0.2 || ^18.0.0", + "uploadthing": "^6.0.0" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + } + } + }, + "node_modules/@uploadthing/react/node_modules/tailwind-merge": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", + "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/@uploadthing/shared": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@uploadthing/shared/-/shared-6.2.0.tgz", + "integrity": "sha512-GMC1gwZa9X48RZwuY7gGse7E8TXYMAMUNaYWEQL2ctVhMZv7viCPyMyZ0yMqBsQmOeGfbmycPnUeNE9IuesJGg==", + "dependencies": { + "std-env": "^3.7.0" + }, + "peerDependencies": { + "@uploadthing/mime-types": "^0.2.2" + }, + "peerDependenciesMeta": { + "@uploadthing/mime-types": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", @@ -2926,6 +2990,14 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.16", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", @@ -3312,6 +3384,14 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/consola": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.2.3.tgz", + "integrity": "sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -4295,6 +4375,17 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-selector": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", + "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -6212,6 +6303,22 @@ "react": "^18.2.0" } }, + "node_modules/react-dropzone": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", + "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-easy-swipe": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/react-easy-swipe/-/react-easy-swipe-0.0.21.tgz", @@ -6741,6 +6848,11 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -7300,6 +7412,20 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uploadthing": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/uploadthing/-/uploadthing-6.3.1.tgz", + "integrity": "sha512-eqbUpxSYtofT/aqqXHlaNJhpK94st6rGeEr8d2QmL0gHWKauS9w7J4jQ5Q7Jfmb2kMJj+pXW3duydkev/5G6Mw==", + "dependencies": { + "@uploadthing/mime-types": "^0.2.2", + "@uploadthing/shared": "^6.2.0", + "consola": "^3.2.3", + "std-env": "^3.7.0" + }, + "engines": { + "node": ">=18.13.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 0690cf1..e98035f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@trpc/next": "^10.44.1", "@trpc/react-query": "^10.44.1", "@trpc/server": "^10.44.1", + "@uploadthing/react": "^6.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "emoji-mart": "^5.5.2", @@ -44,6 +45,7 @@ "prisma": "^5.7.1", "react": "^18", "react-dom": "^18", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.49.3", "react-nice-avatar": "^1.5.0", "react-responsive-carousel": "^3.2.23", @@ -54,6 +56,7 @@ "svix": "^1.15.0", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "uploadthing": "^6.3.1", "usehooks-ts": "^2.9.1", "zod": "^3.22.4", "zustand": "^4.4.7" diff --git a/server/api/routers/post.ts b/server/api/routers/post.ts index 11cd521..8cc3535 100644 --- a/server/api/routers/post.ts +++ b/server/api/routers/post.ts @@ -1,22 +1,19 @@ -import { PostSchema } from '~/zod/schema' +import { PostInput } from '~/zod/input' import { protectedProcedure, createTRPCRouter } from './../trpc' export const postRouter = createTRPCRouter({ - create: protectedProcedure.input(PostSchema).mutation(async ({ input, ctx }) => { + create: protectedProcedure.input(PostInput).mutation(async ({ input, ctx }) => { return await ctx.db.post.create({ data: { - mediaFiles: { - createMany: { - data: input.mediaFiles - } - }, + title: input.title, + mediaFiles: input.mediaFiles, isHideLikeAndCount: input.isHideLikeAndCount, isTurnOffComment: input.isTurnOffComment, - title: input.captions, + postHashtags: input.postHashtags, user: { connect: { - id: parseInt(ctx.auth.userId) + externalId: ctx.auth.userId } } }, diff --git a/tailwind.config.ts b/tailwind.config.ts index b4ec137..04d4406 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,8 +1,9 @@ // eslint-disable-next-line @typescript-eslint/no-var-requires -const { fontFamily } = require('tailwindcss/defaultTheme') +import { withUt } from 'uploadthing/tw' +import { fontFamily } from 'tailwindcss/defaultTheme' /** @type {import('tailwindcss').Config} */ -module.exports = { +export default withUt({ darkMode: ['class'], content: [ './pages/**/*.{ts,tsx}', @@ -97,12 +98,12 @@ module.exports = { }, keyframes: { 'accordion-down': { - from: { height: 0 }, + from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } }, 'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, - to: { height: 0 } + to: { height: '0' } } }, animation: { @@ -116,4 +117,4 @@ module.exports = { // eslint-disable-next-line @typescript-eslint/no-var-requires require('tailwind-scrollbar')({ nocompatible: true }) ] -} +}) diff --git a/zod/input.ts b/zod/input.ts new file mode 100644 index 0000000..8924524 --- /dev/null +++ b/zod/input.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +export const PostInput = z.object({ + title: z.string(), + mediaFiles: z.object({ + createMany: z.object({ + data: z.array( + z.object({ + key: z.string().refine((value) => value.trim() !== '', { + message: 'Media key is required' + }), + url: z.string().refine((value) => value.trim() !== '', { + message: 'Media URL is required' + }) + }) + ) + }) + }), + location: z.string().optional(), + isHideLikeAndCount: z.boolean(), + isTurnOffComment: z.boolean(), + postHashtags: z.object({ + create: z.array( + z.object({ + hashtag: z.object({ + connectOrCreate: z.object({ + where: z.object({ + tag: z.string() + }), + create: z.object({ + tag: z.string() + }) + }) + }) + }) + ) + }) +}) diff --git a/zod/schema.ts b/zod/schema.ts index 6a72462..90c6e66 100644 --- a/zod/schema.ts +++ b/zod/schema.ts @@ -13,8 +13,8 @@ export const PostSchema = z.object({ ), captions: z.string().max(200).optional(), location: z.string().optional(), - isHideLikeAndCount: z.boolean().optional().default(false), - isTurnOffComment: z.boolean().optional().default(false) + isHideLikeAndCount: z.boolean().default(false), + isTurnOffComment: z.boolean().default(false) }) export type PostSchemaType = z.infer