feat: add "cooked it" photo reviews
Users can rate a recipe with review text and an optional photo. Adds ratings.photo_key column, a reviews list endpoint, and a review-purpose presign path (reviewer isn't the recipe owner, so the upload authorization differs from cover-photo uploads). Also fixes CSP connect-src/img-src to allow the storage origin — direct-to-S3/MinIO presigned uploads and stored images were silently blocked by Content-Security-Policy in the browser. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onMouseEnter={() => onHover(i)}
|
||||
onMouseLeave={() => onHover(0)}
|
||||
onClick={() => onPick(i)}
|
||||
className="hover:scale-110 transition-transform"
|
||||
>
|
||||
<Star className={cn("h-6 w-6", i <= display ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground/30")} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(initialPhotoKey);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reviews, setReviews] = useState<Review[]>([]);
|
||||
const [loadingReviews, setLoadingReviews] = useState(true);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ChefHat className="h-4 w-4 text-primary" />
|
||||
{t("cookedItPrompt")}
|
||||
</div>
|
||||
<Stars value={score} hovered={hovered} onHover={setHovered} onPick={setScore} />
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={t("reviewTextPlaceholder")}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
{(preview || photoKey) && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={preview ?? getPublicUrl(photoKey!)}
|
||||
alt=""
|
||||
className="h-16 w-16 rounded-lg object-cover border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPhotoKey(null); setPreview(null); }}
|
||||
className="absolute -top-1.5 -right-1.5 rounded-full bg-background border p-0.5"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<Camera className="h-4 w-4 mr-1.5" />
|
||||
{uploading ? t("reviewPhotoUploading") : t("reviewPhotoAdd")}
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/avif"
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files?.[0] && handlePhoto(e.target.files[0])}
|
||||
/>
|
||||
<Button type="button" size="sm" className="ml-auto" disabled={score < 1 || saving} onClick={submit}>
|
||||
{saving ? t("reviewSubmitting") : t("reviewSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loadingReviews && reviews.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{t("reviewsTitle", { count: reviews.length })}</h3>
|
||||
{reviews.map((r) => (
|
||||
<div key={r.id} className="flex gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={r.user.avatarUrl ?? undefined} />
|
||||
<AvatarFallback>{r.user.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{r.user.name}</span>
|
||||
<div className="flex items-center">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Star key={i} className={cn("h-3 w-3", i <= r.score ? "fill-yellow-400 text-yellow-400" : "text-muted-foreground/30")} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(r.createdAt).toLocaleDateString(locale)}
|
||||
</span>
|
||||
</div>
|
||||
{r.reviewText && <p className="text-sm text-muted-foreground">{r.reviewText}</p>}
|
||||
{r.photoKey && (
|
||||
<img
|
||||
src={getPublicUrl(r.photoKey)}
|
||||
alt=""
|
||||
className="h-32 w-32 rounded-lg object-cover border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user