feat: pantry notes/categories, ingredient-alias matching, cook-log edit/delete, fork-list popover (v0.83.0)
Pantry: notes + category fields (collapsible grouping like the shopping list), a "Merge duplicates" cleanup action, and fixed quantity display precision (was showing raw decimal(10,4) strings like "0.3333 kg" everywhere — pantry, shopping list, print views, Markdown exports). Ingredient-alias matching: the ingredients table (canonical name + aliases) existed but was never populated or used. Seeded ~10 bilingual EN/FR staples and wired resolution into pantry add/edit, can-cook scoring, auto-deduct-on-cook, and shopping-list pantry-awareness, so "sel"/"sel fin"/"table salt" are recognized as the same ingredient. Cook log: entries from "Mark cooked" can now be edited and deleted (previously log-only, no fix-a-mistake path). The "Cooked N times" text is a hover tooltip listing every date and opens a full manage sheet on click. Also: the "Forked by N others" backlink is now a click-to-open popover instead of an always-inline list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,33 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChefHat } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ChefHat, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useLocale } from "@/lib/i18n/provider";
|
||||
import { MarkCookedDialog } from "./mark-cooked-dialog";
|
||||
import { EditCookLogDialog, type CookLog } from "./edit-cook-log-dialog";
|
||||
|
||||
const TOOLTIP_DATE_LIMIT = 8;
|
||||
|
||||
export function MarkCookedSection({
|
||||
recipeId,
|
||||
baseServings,
|
||||
cookCount,
|
||||
lastCookedAt,
|
||||
initialLogs,
|
||||
}: {
|
||||
recipeId: string;
|
||||
baseServings: number;
|
||||
cookCount: number;
|
||||
lastCookedAt: string | null;
|
||||
initialLogs: CookLog[];
|
||||
}) {
|
||||
const t = useTranslations("recipe");
|
||||
const router = useRouter();
|
||||
const tCommon = useTranslations("common");
|
||||
const { locale } = useLocale();
|
||||
const [logs, setLogs] = useState<CookLog[]>(initialLogs);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editingLog, setEditingLog] = useState<CookLog | null>(null);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString(locale, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked/${id}`, { method: "DELETE" });
|
||||
if (res.ok) {
|
||||
setLogs((prev) => prev.filter((l) => l.id !== id));
|
||||
toast.success(t("deleteCookLogSuccess"));
|
||||
} else {
|
||||
toast.error(t("deleteCookLogFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const cookCount = logs.length;
|
||||
const lastCookedAt = logs[0]?.cookedAt ?? null;
|
||||
const logPendingDelete = logs.find((l) => l.id === confirmId) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<MarkCookedDialog
|
||||
recipeId={recipeId}
|
||||
baseServings={baseServings}
|
||||
onLogged={() => router.refresh()}
|
||||
onLogged={(log) => {
|
||||
const entry = { id: log.id, cookedAt: new Date(log.cookedAt).toISOString(), servings: log.servings, notes: null };
|
||||
setLogs((prev) => [...prev, entry].sort((a, b) => new Date(b.cookedAt).getTime() - new Date(a.cookedAt).getTime()));
|
||||
}}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<ChefHat className="h-3.5 w-3.5" />
|
||||
@@ -36,11 +75,95 @@ export function MarkCookedSection({
|
||||
}
|
||||
/>
|
||||
{cookCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cookCount === 1 ? t("markCookedCountSingular") : t("markCookedCountPlural", { count: cookCount })}
|
||||
{lastCookedAt && t("markCookedLast", { date: new Date(lastCookedAt).toLocaleDateString(locale, { month: "short", day: "numeric" }) })}
|
||||
</p>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline text-left"
|
||||
>
|
||||
{cookCount === 1 ? t("markCookedCountSingular") : t("markCookedCountPlural", { count: cookCount })}
|
||||
{lastCookedAt && t("markCookedLast", { date: formatDate(lastCookedAt) })}
|
||||
</button>
|
||||
} />
|
||||
<TooltipContent>
|
||||
<ul className="space-y-0.5">
|
||||
{logs.slice(0, TOOLTIP_DATE_LIMIT).map((l) => (
|
||||
<li key={l.id}>{formatDate(l.cookedAt)}</li>
|
||||
))}
|
||||
</ul>
|
||||
{logs.length > TOOLTIP_DATE_LIMIT && (
|
||||
<p className="text-muted-foreground mt-1">{t("cookLogMore", { count: logs.length - TOOLTIP_DATE_LIMIT })}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("cookLogSheetTitle")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="p-2 mt-6 space-y-2">
|
||||
{logs.length === 0 && <p className="text-sm text-muted-foreground">{t("cookLogEmpty")}</p>}
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="flex items-center justify-between gap-2 border rounded-lg p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{formatDate(log.cookedAt)}</p>
|
||||
{log.servings && <p className="text-xs text-muted-foreground">{t("markCookedServingsLabel")}: {log.servings}</p>}
|
||||
{log.notes && <p className="text-xs text-muted-foreground italic">{log.notes}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => setEditingLog(log)} aria-label={tCommon("edit")} className="text-muted-foreground hover:text-foreground p-1.5">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setConfirmId(log.id)} aria-label={tCommon("delete")} className="text-muted-foreground hover:text-destructive p-1.5">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{editingLog && (
|
||||
<EditCookLogDialog
|
||||
recipeId={recipeId}
|
||||
log={editingLog}
|
||||
open={!!editingLog}
|
||||
onOpenChange={(open) => !open && setEditingLog(null)}
|
||||
onSaved={(updated) => {
|
||||
setLogs((prev) => [...prev.filter((l) => l.id !== updated.id), updated].sort((a, b) => new Date(b.cookedAt).getTime() - new Date(a.cookedAt).getTime()));
|
||||
setEditingLog(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={confirmId !== null} onOpenChange={(open) => !open && setConfirmId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteCookLogConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{logPendingDelete ? t("deleteCookLogConfirmDescription", { date: formatDate(logPendingDelete.cookedAt) }) : ""}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
if (confirmId) void handleDelete(confirmId);
|
||||
setConfirmId(null);
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user