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:
@@ -3,11 +3,14 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Trash2, AlertTriangle, Package } from "lucide-react";
|
||||
import { Plus, Trash2, AlertTriangle, Package, Pencil, ChevronDown, Merge } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatQuantity, hasQuantity } from "@/lib/fractions";
|
||||
import { PantryScanDialog } from "@/components/pantry/pantry-scan-dialog";
|
||||
import { PantryItemDialog, type PantryItem } from "@/components/pantry/pantry-item-dialog";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -20,13 +23,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
type PantryItem = {
|
||||
id: string;
|
||||
rawName: string;
|
||||
quantity: string | null;
|
||||
unit: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
const OTHER_KEY = "__other__";
|
||||
|
||||
function daysUntilExpiry(dateStr: string): number {
|
||||
const diff = new Date(dateStr).getTime() - Date.now();
|
||||
@@ -35,6 +32,7 @@ function daysUntilExpiry(dateStr: string): number {
|
||||
|
||||
export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {
|
||||
const t = useTranslations("pantry");
|
||||
const tShopping = useTranslations("shoppingLists");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState<PantryItem[]>(initialItems);
|
||||
@@ -44,6 +42,23 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
|
||||
const [expiresAt, setExpiresAt] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const [editingItem, setEditingItem] = useState<PantryItem | null>(null);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [merging, setMerging] = useState(false);
|
||||
|
||||
async function mergeDuplicates() {
|
||||
setMerging(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/pantry/merge-duplicates", { method: "POST" });
|
||||
if (!res.ok) { toast.error(t("mergeDuplicatesFailed")); return; }
|
||||
const { removed } = await res.json() as { mergedGroups: number; removed: number };
|
||||
if (removed === 0) toast.success(t("mergeDuplicatesNoneFound"));
|
||||
else toast.success(t("mergeDuplicatesSuccess", { count: removed }));
|
||||
router.refresh();
|
||||
} finally {
|
||||
setMerging(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!name.trim()) return;
|
||||
@@ -61,7 +76,10 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
|
||||
});
|
||||
if (!res.ok) { toast.error(t("addFailed")); return; }
|
||||
const { id } = await res.json() as { id: string };
|
||||
setItems((prev) => [...prev, { id, rawName: name.trim(), quantity: quantity || null, unit: unit || null, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null }]);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{ id, rawName: name.trim(), quantity: quantity || null, unit: unit || null, notes: null, aisle: null, expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null },
|
||||
]);
|
||||
setName(""); setQuantity(""); setUnit(""); setExpiresAt("");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
@@ -74,14 +92,38 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
|
||||
else toast.error(t("removeFailed"));
|
||||
}
|
||||
|
||||
function toggleCollapsed(key: string) {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const itemPendingDelete = items.find((i) => i.id === confirmId) ?? null;
|
||||
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const sortWithinGroup = (a: PantryItem, b: PantryItem) => {
|
||||
if (a.expiresAt && b.expiresAt) return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
|
||||
if (a.expiresAt) return -1;
|
||||
if (b.expiresAt) return 1;
|
||||
return a.rawName.localeCompare(b.rawName);
|
||||
});
|
||||
};
|
||||
|
||||
const grouped = new Map<string, PantryItem[]>();
|
||||
for (const item of items) {
|
||||
const key = item.aisle ?? OTHER_KEY;
|
||||
const group = grouped.get(key) ?? [];
|
||||
group.push(item);
|
||||
grouped.set(key, group);
|
||||
}
|
||||
for (const group of grouped.values()) group.sort(sortWithinGroup);
|
||||
|
||||
function categoryLabel(key: string): string {
|
||||
return key === OTHER_KEY ? tShopping("aisleOther") : tShopping(`categories.${key}`);
|
||||
}
|
||||
|
||||
const sortedGroupKeys = [...grouped.keys()].sort((a, b) => categoryLabel(a).localeCompare(categoryLabel(b)));
|
||||
const showGroupHeaders = grouped.size > 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
@@ -95,33 +137,67 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
|
||||
<Plus className="h-4 w-4" /> {t("add")}
|
||||
</Button>
|
||||
<PantryScanDialog onAdded={() => router.refresh()} />
|
||||
<Button variant="outline" size="sm" onClick={() => { void mergeDuplicates(); }} disabled={merging}>
|
||||
<Merge className="h-4 w-4" /> {t("mergeDuplicates")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
{items.length === 0 ? (
|
||||
<EmptyState icon={Package} title={t("empty")} description={t("emptyDescription")} compact />
|
||||
) : (
|
||||
<div className="rounded-xl border divide-y">
|
||||
{sorted.map((item) => {
|
||||
const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null;
|
||||
const expiring = days !== null && days <= 3;
|
||||
const expired = days !== null && days < 0;
|
||||
<div className="space-y-4">
|
||||
{sortedGroupKeys.map((key) => {
|
||||
const groupItems = grouped.get(key)!;
|
||||
const isCollapsed = collapsed.has(key);
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{item.rawName}</span>
|
||||
{item.quantity && <span className="text-xs text-muted-foreground">{item.quantity}{item.unit ? ` ${item.unit}` : ""}</span>}
|
||||
{expired && <span className="text-xs text-destructive flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expired")}</span>}
|
||||
{expiring && !expired && <span className="text-xs text-orange-500 flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expiresInDays", { days })}</span>}
|
||||
<div key={key} className="rounded-xl border overflow-hidden">
|
||||
{showGroupHeaders && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapsed(key)}
|
||||
className="w-full flex items-center justify-between gap-2 px-4 py-2 bg-muted/40 hover:bg-muted/60 transition-colors text-left"
|
||||
>
|
||||
<span className="text-sm font-medium">{categoryLabel(key)}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{groupItems.length}</span>
|
||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", isCollapsed && "-rotate-90")} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div className="divide-y">
|
||||
{groupItems.map((item) => {
|
||||
const days = item.expiresAt ? daysUntilExpiry(item.expiresAt) : null;
|
||||
const expiring = days !== null && days <= 3;
|
||||
const expired = days !== null && days < 0;
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{item.rawName}</span>
|
||||
{hasQuantity(item.quantity) && (
|
||||
<span className="text-xs text-muted-foreground">{formatQuantity(parseFloat(item.quantity!))}{item.unit ? ` ${item.unit}` : ""}</span>
|
||||
)}
|
||||
{expired && <span className="text-xs text-destructive flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expired")}</span>}
|
||||
{expiring && !expired && <span className="text-xs text-orange-500 flex items-center gap-1"><AlertTriangle className="h-3 w-3" />{t("expiresInDays", { days })}</span>}
|
||||
</div>
|
||||
{item.expiresAt && !expired && !expiring && (
|
||||
<p className="text-xs text-muted-foreground">{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}</p>
|
||||
)}
|
||||
{item.notes && <p className="text-xs text-muted-foreground italic mt-0.5">{item.notes}</p>}
|
||||
</div>
|
||||
<button onClick={() => setEditingItem(item)} aria-label={t("editItem")} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => setConfirmId(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{item.expiresAt && !expired && !expiring && (
|
||||
<p className="text-xs text-muted-foreground">{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setConfirmId(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -150,6 +226,18 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{editingItem && (
|
||||
<PantryItemDialog
|
||||
item={editingItem}
|
||||
open={!!editingItem}
|
||||
onOpenChange={(open) => !open && setEditingItem(null)}
|
||||
onSaved={(updated) => {
|
||||
setItems((prev) => prev.map((i) => (i.id === updated.id ? updated : i)));
|
||||
setEditingItem(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { hasQuantity } from "@/lib/fractions";
|
||||
import { hasQuantity, formatQuantity } from "@/lib/fractions";
|
||||
import { guessAisle, GROCERY_CATEGORIES } from "@/lib/grocery-categories";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -694,7 +694,7 @@ function ItemRow({ item, readOnly, categoryOptions, categoryLabel, tShopping, on
|
||||
)}
|
||||
{(hasQuantity(item.quantity) || item.unit) && (
|
||||
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
|
||||
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""}
|
||||
{hasQuantity(item.quantity) ? formatQuantity(parseFloat(item.quantity!)) : ""}{item.unit ? ` ${item.unit}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { GROCERY_CATEGORIES } from "@/lib/grocery-categories";
|
||||
|
||||
export type PantryItem = {
|
||||
id: string;
|
||||
rawName: string;
|
||||
quantity: string | null;
|
||||
unit: string | null;
|
||||
notes: string | null;
|
||||
aisle: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
const OTHER_VALUE = "__other__";
|
||||
|
||||
export function PantryItemDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
item: PantryItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: (updated: PantryItem) => void;
|
||||
}) {
|
||||
const t = useTranslations("pantry");
|
||||
const tShopping = useTranslations("shoppingLists");
|
||||
const tCommon = useTranslations("common");
|
||||
const [rawName, setRawName] = useState(item.rawName);
|
||||
const [quantity, setQuantity] = useState(item.quantity ?? "");
|
||||
const [unit, setUnit] = useState(item.unit ?? "");
|
||||
const [aisle, setAisle] = useState(item.aisle ?? OTHER_VALUE);
|
||||
const [notes, setNotes] = useState(item.notes ?? "");
|
||||
const [expiresAt, setExpiresAt] = useState(item.expiresAt ? item.expiresAt.slice(0, 10) : "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
if (!rawName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pantry/${item.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rawName: rawName.trim(),
|
||||
quantity: quantity.trim() || null,
|
||||
unit: unit.trim() || null,
|
||||
aisle: aisle === OTHER_VALUE ? null : aisle,
|
||||
notes: notes.trim() || null,
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) { toast.error(t("editFailed")); return; }
|
||||
toast.success(t("editSaved"));
|
||||
onSaved({
|
||||
id: item.id,
|
||||
rawName: rawName.trim(),
|
||||
quantity: quantity.trim() || null,
|
||||
unit: unit.trim() || null,
|
||||
aisle: aisle === OTHER_VALUE ? null : aisle,
|
||||
notes: notes.trim() || null,
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("editDialogTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pantry-edit-name">{t("itemNamePlaceholder")}</Label>
|
||||
<Input id="pantry-edit-name" value={rawName} onChange={(e) => setRawName(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pantry-edit-qty">{t("qtyPlaceholder")}</Label>
|
||||
<Input id="pantry-edit-qty" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pantry-edit-unit">{t("unitPlaceholder")}</Label>
|
||||
<Input id="pantry-edit-unit" value={unit} onChange={(e) => setUnit(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("categoryLabel")}</Label>
|
||||
<Select value={aisle} onValueChange={(v) => setAisle(v ?? OTHER_VALUE)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={OTHER_VALUE}>{tShopping("aisleOther")}</SelectItem>
|
||||
{GROCERY_CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>{tShopping(`categories.${c}`)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pantry-edit-expiry">{t("colExpires")}</Label>
|
||||
<Input id="pantry-edit-expiry" type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pantry-edit-notes">{t("notesLabel")}</Label>
|
||||
<Textarea id="pantry-edit-notes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} placeholder={t("notesPlaceholder")} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => { void handleSave(); }} disabled={saving || !rawName.trim()}>
|
||||
{saving ? tCommon("saving") : tCommon("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export type CookLog = {
|
||||
id: string;
|
||||
cookedAt: string;
|
||||
servings: number | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export function EditCookLogDialog({
|
||||
recipeId,
|
||||
log,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
recipeId: string;
|
||||
log: CookLog;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: (updated: CookLog) => void;
|
||||
}) {
|
||||
const t = useTranslations("recipe");
|
||||
const tCommon = useTranslations("common");
|
||||
const [date, setDate] = useState(log.cookedAt.slice(0, 10));
|
||||
const [servings, setServings] = useState(log.servings ?? "");
|
||||
const [notes, setNotes] = useState(log.notes ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked/${log.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cookedAt: date,
|
||||
servings: servings === "" ? null : Number(servings),
|
||||
notes: notes.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) { toast.error(t("editCookLogFailed")); return; }
|
||||
toast.success(t("editCookLogSaved"));
|
||||
onSaved({ id: log.id, cookedAt: new Date(date).toISOString(), servings: servings === "" ? null : Number(servings), notes: notes.trim() || null });
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("editCookLogTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cook-log-date">{t("markCookedDateLabel")}</Label>
|
||||
<Input id="cook-log-date" type="date" value={date} max={new Date().toISOString().slice(0, 10)} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cook-log-servings">{t("markCookedServingsLabel")}</Label>
|
||||
<Input
|
||||
id="cook-log-servings"
|
||||
type="number"
|
||||
min={1}
|
||||
value={servings}
|
||||
onChange={(e) => setServings(e.target.value === "" ? "" : Math.max(1, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cook-log-notes">{t("cookLogNotesLabel")}</Label>
|
||||
<Textarea id="cook-log-notes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => { void handleSave(); }} disabled={saving}>
|
||||
{saving ? tCommon("saving") : tCommon("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
export function ForkedByPopover({
|
||||
label,
|
||||
forks,
|
||||
}: {
|
||||
label: string;
|
||||
forks: { id: string; title: string }[];
|
||||
}) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger className="text-sm text-muted-foreground hover:text-foreground underline-offset-2 hover:underline text-left">
|
||||
{label}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
<ul className="space-y-0.5">
|
||||
{forks.map((f) => (
|
||||
<li key={f.id}>
|
||||
<Link
|
||||
href={`/recipes/${f.id}`}
|
||||
className="block rounded px-2 py-1.5 text-sm hover:bg-accent truncate"
|
||||
>
|
||||
{f.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ interface MarkCookedDialogProps {
|
||||
baseServings: number;
|
||||
batchDishId?: string;
|
||||
trigger: React.ReactNode;
|
||||
onLogged?: (cookedAt: string) => void;
|
||||
onLogged?: (log: { id: string; cookedAt: string; servings: number }) => void;
|
||||
}
|
||||
|
||||
/** Logs a cook event — date, servings, and whether to deduct matching
|
||||
@@ -55,9 +55,10 @@ export function MarkCookedDialog({ recipeId, baseServings, batchDishId, trigger,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const { id } = await res.json() as { id: string };
|
||||
toast.success(t("markCookedSuccess"));
|
||||
setOpen(false);
|
||||
onLogged?.(date);
|
||||
onLogged?.({ id, cookedAt: date, servings });
|
||||
} catch {
|
||||
toast.error(t("markCookedFailed"));
|
||||
} finally {
|
||||
|
||||
@@ -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