diff --git a/apps/web/.env.example b/apps/web/.env.example index 82ba1ce8..1c6b1473 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -12,6 +12,13 @@ DIRECT_UNPOOLED_URL=postgresql://db_user:db_user_pass@localhost:6033/app_db NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= CLERK_SECRET_KEY= +# uploadthing for file uploads +UPLOADTHING_TOKEN= + +# upstash for redis +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + # -- Optional -- # sentry for error tracking diff --git a/apps/web/package.json b/apps/web/package.json index 2e5a73ae..34ce9b7c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,23 +27,23 @@ "prisma:push": "prisma db push" }, "dependencies": { - "@clerk/nextjs": "^5.2.8", - "@clerk/themes": "^1.7.17", - "@clerk/types": "^3.65.2", + "@clerk/nextjs": "^5.6.0", + "@clerk/themes": "^1.7.18", + "@clerk/types": "^3.65.3", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@headlessui/tailwindcss": "^0.2.1", "@hookform/resolvers": "^3.9.0", - "@mantine/hooks": "^7.11.2", - "@neondatabase/serverless": "^0.9.4", + "@mantine/hooks": "^7.12.2", + "@neondatabase/serverless": "^0.9.5", "@opentelemetry/api": "1.9.0", "@pinecone-database/pinecone": "^1.1.3", - "@planetscale/database": "^1.18.0", - "@prisma/adapter-neon": "^5.17.0", - "@prisma/adapter-planetscale": "^5.17.0", - "@prisma/client": "^5.17.0", + "@planetscale/database": "^1.19.0", + "@prisma/adapter-neon": "^5.19.1", + "@prisma/adapter-planetscale": "^5.19.1", + "@prisma/client": "^5.19.1", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", @@ -65,17 +65,19 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.2", - "@sentry/nextjs": "^8.20.0", + "@sentry/nextjs": "^8.30.0", "@t3-oss/env-nextjs": "^0.6.1", - "@tailwindcss/forms": "^0.5.7", - "@tailwindcss/typography": "^0.5.13", - "@tanstack/react-query": "^5.51.15", - "@tanstack/react-table": "^8.19.3", - "@tremor/react": "^3.17.4", - "@types/node": "^20.14.13", + "@tailwindcss/forms": "^0.5.9", + "@tailwindcss/typography": "^0.5.15", + "@tanstack/react-query": "^5.56.2", + "@tanstack/react-table": "^8.20.5", + "@tremor/react": "^3.18.2", + "@types/node": "^20.16.5", "@types/react": "18.2.22", "@types/react-dom": "18.2.7", - "@uploadthing/react": "^5.7.0", + "@uploadthing/react": "^7.0.2", + "@upstash/ratelimit": "^2.0.3", + "@upstash/redis": "^1.34.0", "@vercel/analytics": "^1.3.1", "@vercel/speed-insights": "^1.0.12", "ai": "^2.2.37", @@ -91,19 +93,19 @@ "lucide-react": "^0.279.0", "next": "14.2.5", "next-contentlayer2": "^0.4.6", - "next-safe-action": "^7.4.3", + "next-safe-action": "^7.9.3", "next-themes": "^0.2.1", - "openai": "^4.53.2", + "openai": "^4.62.1", "pdf-parse": "^1.1.1", "postcss": "8.4.31", - "posthog-js": "^1.150.1", - "posthog-node": "^4.0.1", - "prisma": "^5.17.0", + "posthog-js": "^1.161.6", + "posthog-node": "^4.2.0", + "prisma": "^5.19.1", "react": "18.3.1", "react-day-picker": "^8.10.1", "react-dom": "18.3.1", "react-dropzone": "^14.2.3", - "react-hook-form": "^7.52.1", + "react-hook-form": "^7.53.0", "react-loading-skeleton": "^3.4.0", "react-markdown": "^8.0.7", "react-pdf": "^7.7.3", @@ -114,12 +116,12 @@ "sonner": "^1.5.0", "stripe": "^13.11.0", "superjson": "^2.2.1", - "tailwind-merge": "^2.4.0", + "tailwind-merge": "^2.5.2", "tailwindcss": "3.4.3", "tailwindcss-animate": "^1.0.7", - "typescript": "^5.5.4", - "uploadthing": "^5.7.4", - "vaul": "^0.9.1", + "typescript": "^5.6.2", + "uploadthing": "^7.0.2", + "vaul": "^0.9.4", "ws": "^8.18.0", "zod": "^3.23.8" }, @@ -129,7 +131,7 @@ "devDependencies": { "@faker-js/faker": "^8.4.1", "@ianvs/prettier-plugin-sort-imports": "^4.3.1", - "@types/eslint": "^8.56.11", + "@types/eslint": "^8.56.12", "@types/lodash": "^4.17.7", "@types/ws": "^8.5.12", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -140,7 +142,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-tailwindcss": "^3.17.4", "prettier": "^3.3.3", - "prettier-plugin-tailwindcss": "^0.6.5", + "prettier-plugin-tailwindcss": "^0.6.6", "rehype-pretty-code": "^0.10.2", "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.0", diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 33327e57..0229319c 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -26,6 +26,15 @@ model Recipe { ratings RecipeRating[] ingredients IngredientsOnRecipes[] steps Step[] + images StoredFile[] +} + +model StoredFile { + id String @id @default(cuid()) + name String + url String + recipe Recipe? @relation(fields: [recipeId], references: [id]) + recipeId String? } model RecipeRating { diff --git a/apps/web/src/app/api/uploadthing/core.ts b/apps/web/src/app/api/uploadthing/core.ts new file mode 100644 index 00000000..6d65c0da --- /dev/null +++ b/apps/web/src/app/api/uploadthing/core.ts @@ -0,0 +1,47 @@ +import { ratelimit } from '@/lib/rate-limit'; +import { currentUser } from '@clerk/nextjs/server'; +import { createUploadthing, type FileRouter } from 'uploadthing/next'; +import { UploadThingError } from 'uploadthing/server'; + +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 + recipeImage: f({ + image: { maxFileSize: '4MB', maxFileCount: 3 }, + }) + // Set permissions and file types for this FileRoute + .middleware(async ({ req }) => { + // This code runs on your server before upload + + // Rate limit the upload + const ip = req.ip ?? '127.0.0.1'; + + const { success } = await ratelimit.limit(ip); + + if (!success) { + throw new UploadThingError('Rate limit exceeded'); + } + + const user = await currentUser(); + + // If you throw, the user will not be able to upload + if (!user) throw new UploadThingError('Unauthorized'); + + // Whatever is returned here is accessible in onUploadComplete as `metadata` + return { userId: user.id }; + }) + // eslint-disable-next-line @typescript-eslint/require-await + .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); + + // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback + return { uploadedBy: metadata.userId }; + }), +} satisfies FileRouter; + +export type OurFileRouter = typeof ourFileRouter; diff --git a/apps/web/src/app/api/uploadthing/route.ts b/apps/web/src/app/api/uploadthing/route.ts new file mode 100644 index 00000000..21f8b1f2 --- /dev/null +++ b/apps/web/src/app/api/uploadthing/route.ts @@ -0,0 +1,10 @@ +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/apps/web/src/backend/recipe/recipeActions.ts b/apps/web/src/backend/recipe/recipeActions.ts index a6484118..9d6813f6 100644 --- a/apps/web/src/backend/recipe/recipeActions.ts +++ b/apps/web/src/backend/recipe/recipeActions.ts @@ -7,6 +7,7 @@ import { ActionError, authAction, } from '@/lib/safe-action'; +import type { StoredFile } from '@/types'; import { faker } from '@faker-js/faker'; import { z } from 'zod'; import { @@ -20,7 +21,13 @@ import { computeIngredientsToAddAndKeep } from './recipeUtil'; export const createRecipe = authAction .metadata({ actionName: 'createRecipe' }) - .schema(createRecipeInput) + .schema( + createRecipeInput.omit({ images: true }).extend({ + images: z.array( + z.object({ id: z.string(), name: z.string(), url: z.string() }) + ), + }) + ) .action(async ({ parsedInput: input, ctx }) => { const { userId } = ctx; @@ -65,6 +72,12 @@ export const createRecipe = authAction content: step.content, })), }, + images: { + create: input.images.map((image: StoredFile) => ({ + name: image.name, + url: image.url, + })), + }, }, }); @@ -121,6 +134,13 @@ export const getRecipe = authAction }, steps: true, ratings: true, + images: { + select: { + id: true, + name: true, + url: true, + }, + }, }, }); @@ -148,6 +168,13 @@ export const getPublicRecipe = action }, steps: true, ratings: true, + images: { + select: { + id: true, + name: true, + url: true, + }, + }, }, }); @@ -159,7 +186,13 @@ export const getPublicRecipe = action export const updateRecipe = authAction .metadata({ actionName: 'updateRecipe' }) - .schema(updateRecipeInput) + .schema( + updateRecipeInput.omit({ images: true }).extend({ + images: z.array( + z.object({ id: z.string(), name: z.string(), url: z.string() }) + ), + }) + ) .action(async ({ parsedInput: input, ctx }) => { const { userId } = ctx; @@ -230,6 +263,12 @@ export const updateRecipe = authAction content: step.content, })), }, + images: { + create: input.images.map((image) => ({ + name: image.name, + url: image.url, + })), + }, }, }); @@ -335,308 +374,3 @@ export const generateRecipes = authAction }); } }); - -// const recipeActions = router({ -// createRecipe: privateProcedure -// .input(createRecipeInput) -// .mutation(async ({ ctx, input }) => { -// const { userId } = ctx; -// -// const existingIngredients = await db.ingredient.findMany({ -// where: { -// userId, -// }, -// }); -// -// const ingredientsAlreadyOnRecipe = await db.ingredientsOnRecipes.findMany( -// { -// where: { -// ingredient: { -// userId, -// }, -// }, -// include: { -// ingredient: true, -// }, -// } -// ); -// -// const { ingredientsToAdd } = computeIngredientsToAddAndKeep( -// existingIngredients, -// ingredientsAlreadyOnRecipe, -// input.ingredients, -// userId -// ); -// -// const recipe = db.recipe.create({ -// data: { -// title: input.title, -// description: input.description, -// isPublic: input.isPublic, -// timeInKitchen: z.coerce.number().parse(input.timeInKitchen), -// waitingTime: z.coerce.number().parse(input.waitingTime), -// numberOfPeople: z.coerce.number().parse(input.numberOfPeople), -// userId, -// ingredients: { -// create: ingredientsToAdd, -// }, -// steps: { -// create: input.steps.map((step) => ({ -// content: step.content, -// })), -// }, -// }, -// }); -// -// return recipe; -// }), -// -// getRecipes: privateProcedure.query(async ({ ctx }) => { -// const { userId } = ctx; -// -// return await db.recipe.findMany({ -// where: { -// userId: userId, -// }, -// include: { -// ratings: true, -// }, -// }); -// }), -// -// getPublicRecipes: publicProcedure.query(async () => { -// const recipes = await db.recipe.findMany({ -// where: { -// isPublic: true, -// }, -// include: { -// ratings: true, -// }, -// }); -// -// return recipes; -// }), -// -// getRecipe: privateProcedure -// .input(z.object({ id: z.string() })) -// .query(async ({ ctx, input }) => { -// const { userId } = ctx; -// -// const recipe = await db.recipe.findFirst({ -// where: { -// id: input.id, -// userId, -// }, -// include: { -// ingredients: { -// select: { -// ingredient: true, -// quantity: true, -// }, -// }, -// steps: true, -// ratings: true, -// }, -// }); -// -// if (!recipe) throw new ActionError({ code: 'NOT_FOUND' }); -// -// return recipe; -// }), -// -// getPublicRecipe: publicProcedure -// .input(z.object({ id: z.string() })) -// .query(async ({ input }) => { -// const recipe = await db.recipe.findFirst({ -// where: { -// id: input.id, -// isPublic: true, -// }, -// include: { -// ingredients: { -// select: { -// ingredient: true, -// quantity: true, -// }, -// }, -// steps: true, -// ratings: true, -// }, -// }); -// -// if (!recipe) throw new ActionError({ code: 'NOT_FOUND' }); -// -// return recipe; -// }), -// -// updateRecipe: privateProcedure -// .input(updateRecipeInput) -// .mutation(async ({ ctx, input }) => { -// const { userId } = ctx; -// -// const recipeToUpdate = await db.recipe.findFirst({ -// where: { -// id: input.id, -// }, -// }); -// -// if (!recipeToUpdate) -// throw new ActionError({ code: 'NOT_FOUND', message: 'Recipe not found' }); -// -// if (recipeToUpdate?.userId !== userId) -// throw new ActionError({ -// code: 'UNAUTHORIZED', -// message: 'You are not authorized to update this recipe', -// }); -// -// const existingIngredients = await db.ingredient.findMany({ -// where: { -// userId, -// }, -// }); -// -// const ingredientsAlreadyOnRecipe = await db.ingredientsOnRecipes.findMany( -// { -// where: { -// recipeId: input.id, -// }, -// include: { -// ingredient: true, -// }, -// } -// ); -// -// const { ingredientsToAdd, ingredientIDsToKeep, ingredientsToUpdate } = -// computeIngredientsToAddAndKeep( -// existingIngredients, -// ingredientsAlreadyOnRecipe, -// input.ingredients, -// userId -// ); -// -// const recipe = await db.recipe.update({ -// where: { -// id: input.id, -// }, -// data: { -// title: input.title, -// description: input.description, -// timeInKitchen: input.timeInKitchen, -// waitingTime: input.waitingTime, -// numberOfPeople: input.numberOfPeople, -// isPublic: input.isPublic, -// ingredients: { -// deleteMany: { -// ingredientId: { -// notIn: ingredientIDsToKeep, -// }, -// }, -// create: ingredientsToAdd, -// updateMany: ingredientsToUpdate, -// }, -// steps: { -// deleteMany: {}, -// create: input.steps.map((step) => ({ -// content: step.content, -// })), -// }, -// }, -// }); -// -// return recipe; -// }), -// -// deleteRecipe: privateProcedure -// .input(z.object({ id: z.string() })) -// .mutation(async ({ ctx, input }) => { -// const { userId } = ctx; -// -// const recipeToDelete = await db.recipe.findFirst({ -// where: { -// id: input.id, -// }, -// }); -// -// if (!recipeToDelete) -// throw new ActionError({ code: 'NOT_FOUND', message: 'Recipe not found' }); -// -// if (recipeToDelete?.userId !== userId) -// throw new ActionError({ -// code: 'UNAUTHORIZED', -// message: 'You are not authorized to delete this recipe', -// }); -// -// await db.step.deleteMany({ -// where: { -// recipeId: input.id, -// }, -// }); -// -// await db.recipeRating.deleteMany({ -// where: { -// recipeId: input.id, -// }, -// }); -// -// await db.ingredientsOnRecipes.deleteMany({ -// where: { -// recipeId: input.id, -// }, -// }); -// -// const recipeDeleted = await db.recipe.delete({ -// where: { -// id: input.id, -// }, -// }); -// -// return recipeDeleted; -// }), -// generateRecipes: privateProcedure -// .input(generateRecipeInput) -// .mutation(async ({ ctx, input }) => { -// const { count } = input; -// const { userId } = ctx; -// -// // loop over recipes and create them one by one recipe[0], recipe[1], recipe[2]... -// for (let i = 0; i < count; i++) { -// await db.recipe.create({ -// data: { -// title: faker.commerce.productName(), -// description: faker.lorem.paragraph(), -// isPublic: faker.datatype.boolean(), -// userId, -// timeInKitchen: faker.number.int({ min: 1, max: 60 }), -// waitingTime: faker.number.int({ min: 1, max: 60 }), -// numberOfPeople: faker.number.int({ min: 1, max: 10 }), -// ingredients: { -// create: Array.from({ length: 7 }, () => ({ -// ingredient: { -// create: { -// name: faker.commerce.productName(), -// unit: faker.science.unit().symbol, -// userId, -// }, -// }, -// quantity: faker.number.int({ min: 1, max: 10 }), -// })), -// }, -// steps: { -// create: Array.from({ length: 7 }, () => ({ -// content: faker.lorem.paragraph(), -// })), -// }, -// ratings: { -// create: Array.from( -// { length: faker.number.int({ min: 1, max: 30 }) }, -// () => ({ -// rating: faker.number.int({ min: 1, max: 5 }), -// userId: faker.string.uuid(), -// }) -// ), -// }, -// }, -// }); -// } -// }), -// }); diff --git a/apps/web/src/backend/recipe/recipeDTOs.ts b/apps/web/src/backend/recipe/recipeDTOs.ts index 5e32f9f1..dba1aff4 100644 --- a/apps/web/src/backend/recipe/recipeDTOs.ts +++ b/apps/web/src/backend/recipe/recipeDTOs.ts @@ -23,6 +23,11 @@ export const createAndUpdateRecipeInput = z.object({ content: z.string().min(1), }) ), + images: z + .custom() + .optional() + .nullable() + .default(null), }); export const createRecipeInput = createAndUpdateRecipeInput; diff --git a/apps/web/src/components/FileUploader.tsx b/apps/web/src/components/FileUploader.tsx new file mode 100644 index 00000000..3ff4cd29 --- /dev/null +++ b/apps/web/src/components/FileUploader.tsx @@ -0,0 +1,314 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/Progress'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useControllableState } from '@/hooks/use-controllable-state'; +import { cn, formatBytes } from '@/lib/utils'; +import { Cross2Icon, UploadIcon } from '@radix-ui/react-icons'; +import Image from 'next/image'; +import * as React from 'react'; +import Dropzone, { + type DropzoneProps, + type FileRejection, +} from 'react-dropzone'; +import { toast } from 'sonner'; + +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/apps/web/src/components/Files.tsx b/apps/web/src/components/Files.tsx new file mode 100644 index 00000000..a8349735 --- /dev/null +++ b/apps/web/src/components/Files.tsx @@ -0,0 +1,53 @@ +import { EmptyCard } from '@/components/cards/EmptyCard'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; +import type { StoredFile } from '@/types'; +import Image from 'next/image'; + +interface FilesProps { + files: StoredFile[]; +} + +export function Files({ files }: FilesProps) { + return ( + + + Uploaded files + View the uploaded files here + + + {files.length > 0 ? ( + +
+ {files.map((file) => ( +
+ {file.name} +
+ ))} +
+ +
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/cards/EmptyCard.tsx b/apps/web/src/components/cards/EmptyCard.tsx new file mode 100644 index 00000000..0b63cd7f --- /dev/null +++ b/apps/web/src/components/cards/EmptyCard.tsx @@ -0,0 +1,39 @@ +import { Icons } from '@/components/icons'; +import { Card, CardDescription, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +interface EmptyCardProps extends React.ComponentPropsWithoutRef { + title: string; + description?: string; + icon?: keyof typeof Icons; +} + +export function EmptyCard({ + title, + description, + icon = 'placeholder', + children, + className, + ...props +}: EmptyCardProps) { + const Icon = Icons[icon]; + + return ( + +
+
+
+ {title} + {description ? {description} : null} +
+ {children} +
+ ); +} diff --git a/apps/web/src/components/forms/recipe/AddRecipeForm.tsx b/apps/web/src/components/forms/recipe/AddRecipeForm.tsx index 66fa94c4..dffaac56 100644 --- a/apps/web/src/components/forms/recipe/AddRecipeForm.tsx +++ b/apps/web/src/components/forms/recipe/AddRecipeForm.tsx @@ -34,6 +34,7 @@ export function AddRecipeForm() { waitingTime: 30, numberOfPeople: 2, isPublic: false, + images: [], }} onSubmit={handleSubmit} /> diff --git a/apps/web/src/components/forms/recipe/RecipeForm.tsx b/apps/web/src/components/forms/recipe/RecipeForm.tsx index 14b6459a..6119eed0 100644 --- a/apps/web/src/components/forms/recipe/RecipeForm.tsx +++ b/apps/web/src/components/forms/recipe/RecipeForm.tsx @@ -1,9 +1,20 @@ 'use client'; +import type { OurFileRouter } from '@/app/api/uploadthing/core'; import { createRecipeInput } from '@/backend/recipe/recipeDTOs'; +import { Files } from '@/components/Files'; +import { FileUploader } from '@/components/FileUploader'; import { Icons } from '@/components/icons'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; import { Form, FormControl, @@ -23,14 +34,22 @@ import { SortableItem, } from '@/components/ui/sortable'; import { Textarea } from '@/components/ui/textarea'; +import { useUploadFile } from '@/hooks/use-upload-file'; import { catchError } from '@/lib/utils'; +import type { StoredFile } from '@/types'; import { zodResolver } from '@hookform/resolvers/zod'; import { DragHandleDots2Icon } from '@radix-ui/react-icons'; +import { generateUploadButton } from '@uploadthing/react'; import * as React from 'react'; import { useForm } from 'react-hook-form'; import type { z } from 'zod'; -export type RecipeFormInput = z.infer; +export const UploadButton = generateUploadButton(); +export type FormRecipeFormInput = z.infer; + +export type RecipeFormInput = Omit & { + images: StoredFile[]; +}; interface RecipeFormProps { initialData: RecipeFormInput; @@ -50,10 +69,17 @@ export function RecipeForm({ mode: 'onTouched', }); - function handleSubmit(data: RecipeFormInput) { + function handleSubmit(data: FormRecipeFormInput) { startTransition(async () => { try { - await onSubmit(data); + console.log('images', data.images); + // Remove this line + // void uploadFiles(data.images ?? []).then(async () => { + await onSubmit({ + ...data, + images: JSON.stringify(uploadedFiles) as unknown as StoredFile[], + }); + // }); } catch (err) { catchError(err); } @@ -403,6 +429,63 @@ export function RecipeForm({ )} /> + ( +
+ + Images + + + + + + + + Upload files + + Drag and drop your files here or click to browse. + + + { + // Do something with the response + console.log('Files: ', res); + alert('Upload Completed'); + // Update the form field value with the uploaded files + field.onChange(res); + }} + onUploadError={(error: Error) => { + // Do something with the error. + alert(`ERROR! ${error.message}`); + }} + onBeforeUploadBegin={(files) => { + // Preprocess files before uploading (e.g. rename them) + return files.map( + (f) => + new File([f], 'renamed-' + f.name, { + type: f.type, + }) + ); + }} + onUploadBegin={(name) => { + // Do something once upload begins + console.log('Uploading: ', name); + }} + /> + + + + + + {uploadedFiles.length > 0 ? ( + + ) : null} +
+ )} + />