diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 3f0a4e7..2b89ef1 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -26,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { ServingScaler } from "@/components/recipe/serving-scaler"; import { FavoriteButton } from "@/components/social/favorite-button"; import { RatingStars } from "@/components/social/rating-stars"; +import { CookedItReview } from "@/components/social/cooked-it-review"; import { CommentsSection } from "@/components/social/comments-section"; import { getPublicUrl } from "@/lib/storage"; import { cn } from "@/lib/utils"; @@ -386,7 +387,16 @@ export default async function RecipePage({ params }: Params) { <>
- + {isOwner ? ( + + ) : ( + + )}
diff --git a/apps/web/app/api/v1/recipes/[id]/rate/route.ts b/apps/web/app/api/v1/recipes/[id]/rate/route.ts index 3238c9b..f256877 100644 --- a/apps/web/app/api/v1/recipes/[id]/rate/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/rate/route.ts @@ -7,6 +7,7 @@ import { createNotification } from "@/lib/notifications"; const Schema = z.object({ score: z.number().int().min(1).max(5), reviewText: z.string().max(2000).optional(), + photoKey: z.string().max(500).optional(), }); type Params = { params: Promise<{ id: string }> }; @@ -34,7 +35,12 @@ export async function POST(req: NextRequest, { params }: Params) { if (existing) { await db.update(ratings) - .set({ score: parsed.data.score, reviewText: parsed.data.reviewText, updatedAt: new Date() }) + .set({ + score: parsed.data.score, + reviewText: parsed.data.reviewText, + photoKey: parsed.data.photoKey, + updatedAt: new Date(), + }) .where(eq(ratings.id, existing.id)); return NextResponse.json({ updated: true }); } @@ -45,6 +51,7 @@ export async function POST(req: NextRequest, { params }: Params) { userId: session!.user.id, score: parsed.data.score, reviewText: parsed.data.reviewText, + photoKey: parsed.data.photoKey, }); void createNotification({ userId: recipe.authorId, type: "rating", actorId: session!.user.id, recipeId: id }); return NextResponse.json({ created: true }, { status: 201 }); diff --git a/apps/web/app/api/v1/recipes/[id]/reviews/route.ts b/apps/web/app/api/v1/recipes/[id]/reviews/route.ts new file mode 100644 index 0000000..b740f03 --- /dev/null +++ b/apps/web/app/api/v1/recipes/[id]/reviews/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, recipes, ratings, eq, and, or, inArray, desc } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const recipe = await db.query.recipes.findFirst({ + where: and( + eq(recipes.id, id), + or(eq(recipes.authorId, session!.user.id), inArray(recipes.visibility, ["public", "unlisted"])) + ), + columns: { id: true }, + }); + if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const rows = await db.query.ratings.findMany({ + where: eq(ratings.recipeId, id), + orderBy: desc(ratings.createdAt), + limit: 50, + with: { + user: { columns: { id: true, name: true, username: true, avatarUrl: true } }, + }, + }); + + const reviews = rows + .filter((r) => r.reviewText || r.photoKey) + .map((r) => ({ + id: r.id, + score: r.score, + reviewText: r.reviewText, + photoKey: r.photoKey, + createdAt: r.createdAt, + user: r.user, + })); + + return NextResponse.json({ data: reviews }); +} diff --git a/apps/web/app/api/v1/upload/presign/route.ts b/apps/web/app/api/v1/upload/presign/route.ts index 18a20fa..c9c5e3e 100644 --- a/apps/web/app/api/v1/upload/presign/route.ts +++ b/apps/web/app/api/v1/upload/presign/route.ts @@ -12,6 +12,7 @@ const Schema = z.object({ message: "Content type must be jpeg, png, webp, or avif", }), recipeId: z.string().uuid(), + purpose: z.enum(["recipe", "review"]).default("recipe"), }); export async function POST(req: NextRequest) { @@ -24,15 +25,22 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); } - const owned = await db.query.recipes.findFirst({ - where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)), - columns: { id: true }, + const { recipeId, contentType, purpose } = parsed.data; + const recipe = await db.query.recipes.findFirst({ + where: purpose === "recipe" + ? and(eq(recipes.id, recipeId), eq(recipes.authorId, session!.user.id)) + : eq(recipes.id, recipeId), + columns: { id: true, visibility: true, authorId: true }, }); - if (!owned) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (purpose === "review" && recipe.visibility === "private" && recipe.authorId !== session!.user.id) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } - const ext = parsed.data.contentType.split("/")[1] ?? "jpg"; - const key = `recipes/${parsed.data.recipeId}/photos/${session!.user.id}-${crypto.randomUUID()}.${ext}`; - const url = await createPresignedUploadUrl(key, parsed.data.contentType); + const ext = contentType.split("/")[1] ?? "jpg"; + const folder = purpose === "review" ? "reviews" : "photos"; + const key = `recipes/${recipeId}/${folder}/${session!.user.id}-${crypto.randomUUID()}.${ext}`; + const url = await createPresignedUploadUrl(key, contentType); return NextResponse.json({ url, key }); } diff --git a/apps/web/components/social/cooked-it-review.tsx b/apps/web/components/social/cooked-it-review.tsx new file mode 100644 index 0000000..801329e --- /dev/null +++ b/apps/web/components/social/cooked-it-review.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useTranslations, useLocale } from "next-intl"; +import { Star, Camera, X, ChefHat } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { getPublicUrl } from "@/lib/storage"; +import { cn } from "@/lib/utils"; + +type Review = { + id: string; + score: number; + reviewText: string | null; + photoKey: string | null; + createdAt: string; + user: { id: string; name: string; username: string | null; avatarUrl: string | null }; +}; + +function Stars({ + value, + hovered, + onHover, + onPick, +}: { + value: number; + hovered: number; + onHover: (v: number) => void; + onPick: (v: number) => void; +}) { + const display = hovered || value; + return ( +
+ {[1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ); +} + +export function CookedItReview({ + recipeId, + initialScore = 0, + initialText = "", + initialPhotoKey = null, +}: { + recipeId: string; + initialScore?: number; + initialText?: string; + initialPhotoKey?: string | null; +}) { + const t = useTranslations("social"); + const locale = useLocale(); + const [score, setScore] = useState(initialScore); + const [hovered, setHovered] = useState(0); + const [text, setText] = useState(initialText); + const [photoKey, setPhotoKey] = useState(initialPhotoKey); + const [preview, setPreview] = useState(null); + const [uploading, setUploading] = useState(false); + const [saving, setSaving] = useState(false); + const [reviews, setReviews] = useState([]); + const [loadingReviews, setLoadingReviews] = useState(true); + const inputRef = useRef(null); + + useEffect(() => { + fetch(`/api/v1/recipes/${recipeId}/reviews`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { data: Review[] } | null) => setReviews(data?.data ?? [])) + .finally(() => setLoadingReviews(false)); + }, [recipeId]); + + async function handlePhoto(file: File) { + setUploading(true); + try { + const res = await fetch("/api/v1/upload/presign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recipeId, contentType: file.type, purpose: "review" }), + }); + if (!res.ok) { + toast.error(t("reviewPhotoFailed")); + return; + } + const { url, key } = (await res.json()) as { url: string; key: string }; + await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); + setPhotoKey(key); + setPreview(URL.createObjectURL(file)); + } finally { + setUploading(false); + } + } + + async function submit() { + if (score < 1) return; + setSaving(true); + try { + const res = await fetch(`/api/v1/recipes/${recipeId}/rate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ score, reviewText: text.trim() || undefined, photoKey: photoKey ?? undefined }), + }); + if (!res.ok) { + const err = (await res.json()) as { error?: string }; + toast.error(err.error ?? t("ratingFailed")); + return; + } + toast.success(t("ratingSaved")); + const listRes = await fetch(`/api/v1/recipes/${recipeId}/reviews`); + if (listRes.ok) { + const data = (await listRes.json()) as { data: Review[] }; + setReviews(data.data); + } + } finally { + setSaving(false); + } + } + + return ( +
+
+
+ + {t("cookedItPrompt")} +
+ +