"use client";
import { useState, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus } 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { getPublicUrl } from "@/lib/storage";
import { useLocale } from "@/lib/i18n/provider";
import { Badge } from "@/components/ui/badge";
import { Clock, Users } from "lucide-react";
import Link from "next/link";
import { cn } 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 }>;
};
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 ? (
})
) : (
🍽️
)}
{selectMode && selected &&
}
{selectMode && (
{selected && }
)}
);
}
function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
const t = useTranslations("recipe");
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
{recipe.title}
{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.baseServings}
{totalMins > 0 && {t("total", { mins: totalMins })}}
{recipe.difficulty && (
{t(`difficulty.${recipe.difficulty}`)}
)}
);
}
function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
const t = useTranslations("recipe");
const { locale } = useLocale();
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
{recipe.title}
{recipe.description &&
{recipe.description}
}
{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 { locale } = useLocale();
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
return (
{recipe.title}
{recipe.baseServings}
{totalMins > 0 && (
{t("total", { mins: totalMins })}
)}
{recipe.difficulty && (
{t(`difficulty.${recipe.difficulty}`)}
)}
{recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })}
);
}
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);
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() {
if (!confirm(t("bulkDeleteConfirm", { count: selected.size }))) return;
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 && (
{t("selectedCount", { count: selected.size })}
{t("visibilityMenuLabel")}
{ void bulkSetVisibility("public"); }}>
{t("makePublic")}
{ void bulkSetVisibility("unlisted"); }}>
{t("makeUnlisted")}
{ void bulkSetVisibility("private"); }}>
{t("makePrivate")}
)}
);
}