"use client"; import { useState, useCallback, useEffect } from "react"; import Image from "next/image"; import { useTranslations } from "next-intl"; import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus, ChefHat } from "lucide-react"; import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { FavoriteButton } from "@/components/social/favorite-button"; import { toast } from "sonner"; import { getPublicUrl } from "@/lib/storage"; import { useLocale } from "@/lib/i18n/provider"; import { Badge } from "@/components/ui/badge"; import { Clock, Users, Utensils } from "lucide-react"; import Link from "next/link"; import { cn, stripMarkdown } from "@/lib/utils"; type Recipe = { id: string; title: string; description: string | null; baseServings: number; prepMins: number | null; cookMins: number | null; difficulty: "easy" | "medium" | "hard" | null; visibility: "private" | "unlisted" | "public"; tags: string[]; updatedAt: Date; photos?: Array<{ storageKey: string; isCover: boolean }>; isFavorited?: boolean; isBatchCook?: boolean; dishCount?: number; }; /** Stops the click from bubbling to the card's own Link/select handler. */ function StopPropagation({ children }: { children: React.ReactNode }) { // preventDefault is required too — stopPropagation alone doesn't stop the ancestor // Link's native anchor navigation, since that's the browser's default action for the // click event reaching the , not a bubbling JS handler stopPropagation can block. return
{ e.preventDefault(); e.stopPropagation(); }}>{children}
; } type ViewMode = "grid" | "list" | "compact"; const VIEW_STORAGE_KEY = "epicure-recipes-view"; const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const; const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe }; function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Recipe; selectMode: boolean; selected: boolean; className?: string }) { const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0]; return (
{cover ? ( {recipe.title} ) : (
🍽️
)} {selectMode && selected &&
} {selectMode && (
{selected && }
)}
); } function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) { const t = useTranslations("recipe"); const tBatch = useTranslations("batchCooking"); const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); const VisibilityIcon = VISIBILITY_ICON[recipe.visibility]; return (

{recipe.title}

{recipe.description && (

{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}

)} {recipe.tags.length > 0 && (
{recipe.tags.slice(0, 3).map((tag) => ( {tag} ))} {recipe.tags.length > 3 && ( +{recipe.tags.length - 3} )}
)}
{recipe.isBatchCook && recipe.dishCount ? ( {tBatch("dishCount", { count: recipe.dishCount })} ) : ( {recipe.baseServings} )} {totalMins > 0 && {t("total", { mins: totalMins })}} {recipe.difficulty && ( {t(`difficulty.${recipe.difficulty}`)} )}
{recipe.isBatchCook && ( } /> {t("batchCookBadge")} )} {!selectMode && ( )} } /> {t(`visibility.${recipe.visibility}`)}
); } function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) { const t = useTranslations("recipe"); const tBatch = useTranslations("batchCooking"); const { locale } = useLocale(); const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); const VisibilityIcon = VISIBILITY_ICON[recipe.visibility]; return (

{recipe.title}

{recipe.isBatchCook && ( } /> {t("batchCookBadge")} )} {!selectMode && ( )} } /> {t(`visibility.${recipe.visibility}`)}
{recipe.description &&

{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}

}
{recipe.isBatchCook && recipe.dishCount ? ( {tBatch("dishCount", { count: recipe.dishCount })} ) : ( {recipe.baseServings} )} {totalMins > 0 && {t("total", { mins: totalMins })}} {recipe.difficulty && {t(`difficulty.${recipe.difficulty}`)}} {recipe.tags.slice(0, 2).map((tag) => ( {tag} ))} {t("updatedLabel", { date: recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" }) })}
); } function CompactRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) { const t = useTranslations("recipe"); const tBatch = useTranslations("batchCooking"); const { locale } = useLocale(); const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); const VisibilityIcon = VISIBILITY_ICON[recipe.visibility]; return (

{recipe.title}

{recipe.isBatchCook && recipe.dishCount ? ( <>{tBatch("dishCount", { count: recipe.dishCount })} ) : ( <>{recipe.baseServings} )} {totalMins > 0 && (<>{t("total", { mins: totalMins })})} {recipe.difficulty && ( {t(`difficulty.${recipe.difficulty}`)} )} {recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })} {recipe.isBatchCook && ( } /> {t("batchCookBadge")} )} {!selectMode && ( )} } /> {t(`visibility.${recipe.visibility}`)}
); } function SelectableRecipe({ recipe, selected, selectMode, viewMode, onToggle, }: { recipe: Recipe; selected: boolean; selectMode: boolean; viewMode: ViewMode; onToggle: (id: string) => void; }) { const Tile = viewMode === "grid" ? GridCard : viewMode === "list" ? ListRow : CompactRow; const inner = ; if (selectMode) { return (
onToggle(recipe.id)} role="checkbox" aria-checked={selected} className="h-full"> {inner}
); } return ( {inner} ); } function ViewToggle({ value, onChange }: { value: ViewMode; onChange: (v: ViewMode) => void }) { const t = useTranslations("recipe"); const options: { mode: ViewMode; icon: typeof LayoutGrid; label: string }[] = [ { mode: "grid", icon: LayoutGrid, label: t("viewGrid") }, { mode: "list", icon: List, label: t("viewList") }, { mode: "compact", icon: Rows3, label: t("viewCompact") }, ]; return (
{options.map(({ mode, icon: Icon, label }) => ( onChange(mode)} aria-label={label} aria-pressed={value === mode} className={cn( "flex items-center justify-center h-7 w-7 rounded-md transition-colors", value === mode ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent/50" )} /> }> {label} ))}
); } export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) { const t = useTranslations("recipe"); const tCommon = useTranslations("common"); const [recipes, setRecipes] = useState(initialRecipes); const [selectMode, setSelectMode] = useState(false); const [selected, setSelected] = useState>(new Set()); const [busy, setBusy] = useState(false); const [viewMode, setViewMode] = useState("grid"); const [addToCollectionOpen, setAddToCollectionOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); useEffect(() => { const stored = localStorage.getItem(VIEW_STORAGE_KEY) as ViewMode | null; if (stored === "grid" || stored === "list" || stored === "compact") setViewMode(stored); }, []); function changeViewMode(mode: ViewMode) { setViewMode(mode); localStorage.setItem(VIEW_STORAGE_KEY, mode); } const toggleSelect = useCallback((id: string) => { setSelected((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }, []); const toggleAll = () => { setSelected((prev) => (prev.size === recipes.length ? new Set() : new Set(recipes.map((r) => r.id)))); }; const exitSelect = () => { setSelectMode(false); setSelected(new Set()); }; async function bulkDelete() { setBusy(true); try { const res = await fetch("/api/v1/recipes/bulk", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: [...selected] }), }); if (!res.ok) throw new Error("Delete failed"); setRecipes((prev) => prev.filter((r) => !selected.has(r.id))); toast.success(t("bulkDeleted", { count: selected.size })); exitSelect(); } catch { toast.error(t("bulkDeleteFailed")); } finally { setBusy(false); } } async function bulkSetVisibility(visibility: "private" | "unlisted" | "public") { setBusy(true); try { const res = await fetch("/api/v1/recipes/bulk", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids: [...selected], visibility }), }); if (!res.ok) throw new Error("Update failed"); setRecipes((prev) => prev.map((r) => (selected.has(r.id) ? { ...r, visibility } : r))); toast.success(t("bulkVisibility", { count: selected.size, visibility })); exitSelect(); } catch { toast.error(t("bulkUpdateFailed")); } finally { setBusy(false); } } if (recipes.length === 0) return null; const allSelected = selected.size === recipes.length; return (
{/* Toolbar */}
{selectMode ? (
{selected.size > 0 ? t("selectedCount", { count: selected.size }) : t("clickToSelect")}
) : (
)}
{/* Recipes */}
{recipes.map((recipe) => ( ))}
{/* Floating bulk action bar */} {selectMode && selected.size > 0 && (
{selected.size}
{t("visibilityMenuLabel")} { void bulkSetVisibility("public"); }}> {t("makePublic")} { void bulkSetVisibility("unlisted"); }}> {t("makeUnlisted")} { void bulkSetVisibility("private"); }}> {t("makePrivate")}
)} {t("bulkDeleteConfirmTitle", { count: selected.size })} {t("bulkDeleteConfirmDescription")} {tCommon("cancel")} { setDeleteConfirmOpen(false); void bulkDelete(); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {tCommon("delete")}
); }