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,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { History, RotateCcw, ChevronDown } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
type VersionSummary = {
|
||||
id: string;
|
||||
version: number;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type SnapshotData = {
|
||||
ingredients: unknown[];
|
||||
steps: unknown[];
|
||||
};
|
||||
|
||||
type VersionDetail = {
|
||||
id: string;
|
||||
version: number;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
snapshotData: SnapshotData;
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [versions, setVersions] = useState<VersionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [expandedData, setExpandedData] = useState<Record<string, VersionDetail>>({});
|
||||
const [restoringId, setRestoringId] = useState<string | null>(null);
|
||||
|
||||
async function handleOpen(isOpen: boolean) {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/recipes/${recipeId}/versions`);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as VersionSummary[];
|
||||
setVersions(data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExpand(version: VersionSummary) {
|
||||
if (expandedId === version.id) {
|
||||
setExpandedId(null);
|
||||
return;
|
||||
}
|
||||
setExpandedId(version.id);
|
||||
if (!expandedData[version.id]) {
|
||||
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${version.id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as VersionDetail;
|
||||
setExpandedData((prev) => ({ ...prev, [version.id]: data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore(version: VersionSummary) {
|
||||
setRestoringId(version.id);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${version.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restore" }),
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success(`Recipe restored to version ${version.version}`);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error("Failed to restore version");
|
||||
}
|
||||
} finally {
|
||||
setRestoringId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpen}>
|
||||
<SheetTrigger render={
|
||||
<Button variant="ghost" size="sm">
|
||||
<History className="h-4 w-4" />
|
||||
History
|
||||
</Button>
|
||||
} />
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Version History</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-2">
|
||||
{loading && (
|
||||
<p className="text-sm text-muted-foreground">Loading versions...</p>
|
||||
)}
|
||||
|
||||
{!loading && versions.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No versions yet. Versions are saved automatically when you edit this recipe.</p>
|
||||
)}
|
||||
|
||||
{versions.map((v) => {
|
||||
const isExpanded = expandedId === v.id;
|
||||
const detail = expandedData[v.id];
|
||||
|
||||
return (
|
||||
<div key={v.id} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<button
|
||||
className="flex-1 text-left text-sm"
|
||||
onClick={() => void handleExpand(v)}
|
||||
>
|
||||
<span className="font-medium">v{v.version}</span>
|
||||
<span className="text-muted-foreground"> — {v.title} — {formatDate(v.createdAt)}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
title="Expand"
|
||||
onClick={() => void handleExpand(v)}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={restoringId === v.id}
|
||||
onClick={() => void handleRestore(v)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 text-sm text-muted-foreground border-t pt-2">
|
||||
{!detail ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<p>
|
||||
{(detail.snapshotData.ingredients as unknown[]).length} ingredient{(detail.snapshotData.ingredients as unknown[]).length !== 1 ? "s" : ""},{" "}
|
||||
{(detail.snapshotData.steps as unknown[]).length} step{(detail.snapshotData.steps as unknown[]).length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user