feat(recipes): full recipe CRUD with photos, print, version history
List, detail, new, edit pages. Server-side pagination, dietary tags, difficulty. Photo upload to S3-compatible storage. Version history. Multi-select grid with bulk delete/visibility. Print view. Delete confirmation dialog.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { Upload, X, Star } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
|
||||
export type PhotoEntry = {
|
||||
key: string;
|
||||
isCover: boolean;
|
||||
preview: string;
|
||||
};
|
||||
|
||||
export function PhotoUploader({
|
||||
recipeId,
|
||||
photos,
|
||||
onChange,
|
||||
}: {
|
||||
recipeId: string;
|
||||
photos: PhotoEntry[];
|
||||
onChange: (photos: PhotoEntry[]) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const res = await fetch("/api/v1/upload/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ recipeId, contentType: file.type }),
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const { url, key } = await res.json() as { url: string; key: string };
|
||||
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
|
||||
const isFirst = photos.length === 0;
|
||||
onChange([...photos, { key, isCover: isFirst, preview: URL.createObjectURL(file) }]);
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setCover(key: string) {
|
||||
onChange(photos.map((p) => ({ ...p, isCover: p.key === key })));
|
||||
}
|
||||
|
||||
function remove(key: string) {
|
||||
const filtered = photos.filter((p) => p.key !== key);
|
||||
if (filtered.length > 0 && !filtered.some((p) => p.isCover)) {
|
||||
filtered[0]!.isCover = true;
|
||||
}
|
||||
onChange(filtered);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.key} className="relative group">
|
||||
<img
|
||||
src={photo.preview || getPublicUrl(photo.key)}
|
||||
alt=""
|
||||
className={`h-24 w-24 rounded-lg object-cover border-2 ${
|
||||
photo.isCover ? "border-primary" : "border-transparent"
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg transition-opacity flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCover(photo.key)}
|
||||
className="rounded-full bg-white/20 p-1 hover:bg-white/40 transition-colors"
|
||||
title="Set as cover"
|
||||
>
|
||||
<Star className={`h-3 w-3 ${photo.isCover ? "text-yellow-400" : "text-white"}`} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(photo.key)}
|
||||
className="rounded-full bg-white/20 p-1 hover:bg-red-500/80 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="h-3 w-3 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
{photo.isCover && (
|
||||
<span className="absolute bottom-1 left-1 text-[10px] bg-primary text-primary-foreground rounded px-1">
|
||||
cover
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="h-24 w-24 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-1 text-muted-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="text-xs">{uploading ? "Uploading…" : "Add photo"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/avif"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user