b627dda5bf
The dish-count/servings and time spans had no fixed width (unlike the difficulty badge slot, which reserves w-16 even when empty) — a batch-cook recipe's longer "N dishes"/"Nm total" text, or a missing time value, changed that column's width row-to-row, shifting the date and icon cluster after it. Gave both spans fixed widths so every compact row has identical column layout regardless of content.
562 lines
24 KiB
TypeScript
562 lines
24 KiB
TypeScript
"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 <a>, not a bubbling JS handler stopPropagation can block.
|
|
return <div onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>{children}</div>;
|
|
}
|
|
|
|
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 (
|
|
<div className={cn("relative overflow-hidden bg-muted shrink-0", className)}>
|
|
{cover ? (
|
|
<Image
|
|
src={getPublicUrl(cover.storageKey)}
|
|
unoptimized
|
|
alt={recipe.title}
|
|
fill
|
|
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
|
|
className={cn(
|
|
"object-cover transition-transform duration-300",
|
|
!selectMode && "group-hover:scale-105",
|
|
selectMode && selected && "brightness-75"
|
|
)}
|
|
/>
|
|
) : (
|
|
<div className={cn("w-full h-full flex items-center justify-center text-2xl", selectMode && !selected && "opacity-60")}>
|
|
🍽️
|
|
</div>
|
|
)}
|
|
{selectMode && selected && <div className="absolute inset-0 bg-primary/20" />}
|
|
{selectMode && (
|
|
<div className={cn(
|
|
"absolute top-1.5 left-1.5 h-5 w-5 rounded-full border-2 flex items-center justify-center transition-all duration-150 shadow-sm",
|
|
selected ? "bg-primary border-primary" : "bg-black/30 border-white/70 group-hover:border-white"
|
|
)}>
|
|
{selected && <Check className="h-3 w-3 text-primary-foreground stroke-[3]" />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={cn(
|
|
"group relative overflow-hidden rounded-xl border bg-card flex flex-col h-full transition-all duration-200",
|
|
selectMode && "cursor-pointer select-none",
|
|
selected
|
|
? "ring-2 ring-primary border-primary shadow-lg shadow-primary/10"
|
|
: selectMode
|
|
? "hover:border-muted-foreground/40"
|
|
: "hover:shadow-md hover:border-muted-foreground/30"
|
|
)}
|
|
>
|
|
<RecipeThumb recipe={recipe} selectMode={selectMode} selected={selected} className="aspect-video" />
|
|
<div className="flex flex-col flex-1 p-3 gap-1">
|
|
<h3 className={cn("font-semibold leading-tight line-clamp-2 text-sm transition-colors", !selectMode && "group-hover:text-primary")}>
|
|
{recipe.title}
|
|
</h3>
|
|
{recipe.description && (
|
|
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
|
{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}
|
|
</p>
|
|
)}
|
|
{recipe.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{recipe.tags.slice(0, 3).map((tag) => (
|
|
<span key={tag} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-muted text-muted-foreground">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
{recipe.tags.length > 3 && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs text-muted-foreground">+{recipe.tags.length - 3}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-2.5">
|
|
{recipe.isBatchCook && recipe.dishCount ? (
|
|
<span className="flex items-center gap-1"><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</span>
|
|
) : (
|
|
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
|
)}
|
|
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
|
|
{recipe.difficulty && (
|
|
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{recipe.isBatchCook && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger render={<span className="p-1.5 inline-flex"><ChefHat className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
|
|
<TooltipContent>{t("batchCookBadge")}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{!selectMode && (
|
|
<StopPropagation>
|
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
|
|
</StopPropagation>
|
|
)}
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger render={<span className="p-1.5 inline-flex"><VisibilityIcon className="h-3 w-3" /></span>} />
|
|
<TooltipContent>{t(`visibility.${recipe.visibility}`)}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={cn(
|
|
"group flex gap-4 p-3 rounded-xl border bg-card transition-all duration-200",
|
|
selectMode && "cursor-pointer select-none",
|
|
selected ? "ring-2 ring-primary border-primary shadow-sm shadow-primary/10" : selectMode ? "hover:border-muted-foreground/40" : "hover:shadow-md hover:border-muted-foreground/30"
|
|
)}
|
|
>
|
|
<RecipeThumb recipe={recipe} selectMode={selectMode} selected={selected} className="w-28 h-20 rounded-lg" />
|
|
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<h3 className={cn("font-semibold leading-tight line-clamp-1 text-sm", !selectMode && "group-hover:text-primary")}>
|
|
{recipe.title}
|
|
</h3>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{recipe.isBatchCook && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger render={<span className="p-1.5 inline-flex"><ChefHat className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
|
|
<TooltipContent>{t("batchCookBadge")}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{!selectMode && (
|
|
<StopPropagation>
|
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
|
|
</StopPropagation>
|
|
)}
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger render={<span className="p-1.5 inline-flex"><VisibilityIcon className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
|
|
<TooltipContent>{t(`visibility.${recipe.visibility}`)}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
</div>
|
|
{recipe.description && <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}</p>}
|
|
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mt-auto flex-wrap">
|
|
{recipe.isBatchCook && recipe.dishCount ? (
|
|
<span className="flex items-center gap-1"><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</span>
|
|
) : (
|
|
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
|
)}
|
|
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
|
|
{recipe.difficulty && <Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>}
|
|
{recipe.tags.slice(0, 2).map((tag) => (
|
|
<span key={tag} className="px-1.5 py-0.5 rounded bg-muted">{tag}</span>
|
|
))}
|
|
<span className="ml-auto shrink-0">{t("updatedLabel", { date: recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" }) })}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
className={cn(
|
|
"group flex items-center gap-3 px-3 py-2 rounded-lg border bg-card transition-all duration-150",
|
|
selectMode && "cursor-pointer select-none",
|
|
selected ? "ring-2 ring-primary border-primary" : selectMode ? "hover:border-muted-foreground/40" : "hover:bg-muted/40"
|
|
)}
|
|
>
|
|
<RecipeThumb recipe={recipe} selectMode={selectMode} selected={selected} className="w-10 h-10 rounded-md shrink-0" />
|
|
<h3 className={cn("font-medium text-sm truncate flex-1 min-w-0", !selectMode && "group-hover:text-primary")}>
|
|
{recipe.title}
|
|
</h3>
|
|
<span className="hidden sm:flex items-center gap-1 text-xs text-muted-foreground shrink-0 w-16">
|
|
{recipe.isBatchCook && recipe.dishCount ? (
|
|
<><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</>
|
|
) : (
|
|
<><Users className="h-3 w-3" />{recipe.baseServings}</>
|
|
)}
|
|
</span>
|
|
<span className="hidden sm:flex items-center gap-1 text-xs text-muted-foreground shrink-0 w-20">
|
|
{totalMins > 0 && (<><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</>)}
|
|
</span>
|
|
<span className="hidden sm:inline-flex w-16 shrink-0">
|
|
{recipe.difficulty && (
|
|
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>
|
|
)}
|
|
</span>
|
|
<span className="hidden md:inline text-xs text-muted-foreground shrink-0 w-16 text-right">
|
|
{recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })}
|
|
</span>
|
|
{recipe.isBatchCook && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger render={<span className="p-1.5 inline-flex shrink-0"><ChefHat className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
|
|
<TooltipContent>{t("batchCookBadge")}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{!selectMode && (
|
|
<StopPropagation>
|
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
|
|
</StopPropagation>
|
|
)}
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger render={<span className="p-1.5 inline-flex shrink-0"><VisibilityIcon className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
|
|
<TooltipContent>{t(`visibility.${recipe.visibility}`)}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 = <Tile recipe={recipe} selected={selected} selectMode={selectMode} />;
|
|
|
|
if (selectMode) {
|
|
return (
|
|
<div onClick={() => onToggle(recipe.id)} role="checkbox" aria-checked={selected} className="h-full">
|
|
{inner}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link href={`/recipes/${recipe.id}`} className="h-full block">
|
|
{inner}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<TooltipProvider>
|
|
<div className="inline-flex items-center rounded-lg border p-0.5 gap-0.5">
|
|
{options.map(({ mode, icon: Icon, label }) => (
|
|
<Tooltip key={mode}>
|
|
<TooltipTrigger render={
|
|
<button
|
|
onClick={() => 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"
|
|
)}
|
|
/>
|
|
}>
|
|
<Icon className="h-4 w-4" />
|
|
</TooltipTrigger>
|
|
<TooltipContent>{label}</TooltipContent>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
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<Set<string>>(new Set());
|
|
const [busy, setBusy] = useState(false);
|
|
const [viewMode, setViewMode] = useState<ViewMode>("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 (
|
|
<div className="space-y-4">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between min-h-[36px] gap-3">
|
|
{selectMode ? (
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={toggleAll} className="text-sm font-medium text-primary hover:underline">
|
|
{allSelected ? t("deselectAll") : t("selectAll")}
|
|
</button>
|
|
<span className="text-sm text-muted-foreground">
|
|
{selected.size > 0 ? t("selectedCount", { count: selected.size }) : t("clickToSelect")}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<ViewToggle value={viewMode} onChange={changeViewMode} />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={selectMode ? exitSelect : () => setSelectMode(true)}
|
|
className={cn("gap-1.5", selectMode && "text-muted-foreground")}
|
|
>
|
|
{selectMode ? (
|
|
<><X className="h-4 w-4" />{tCommon("cancel")}</>
|
|
) : (
|
|
<><ListChecks className="h-4 w-4" />{t("select")}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recipes */}
|
|
<div
|
|
className={cn(
|
|
viewMode === "grid" && "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start",
|
|
viewMode === "list" && "flex flex-col gap-3",
|
|
viewMode === "compact" && "flex flex-col gap-1.5"
|
|
)}
|
|
>
|
|
{recipes.map((recipe) => (
|
|
<SelectableRecipe
|
|
key={recipe.id}
|
|
recipe={recipe}
|
|
selected={selected.has(recipe.id)}
|
|
selectMode={selectMode}
|
|
viewMode={viewMode}
|
|
onToggle={toggleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Floating bulk action bar */}
|
|
{selectMode && selected.size > 0 && (
|
|
<div className="fixed bottom-4 sm:bottom-8 left-1/2 -translate-x-1/2 z-50 w-[calc(100vw-2rem)] sm:w-auto sm:max-w-none animate-in slide-in-from-bottom-4 duration-200">
|
|
<div className="flex items-center gap-1 sm:gap-2 bg-popover border shadow-2xl rounded-2xl px-2 sm:px-5 py-2 sm:py-3 overflow-x-auto sm:overflow-visible [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
|
<span className="text-sm font-semibold tabular-nums mr-1 shrink-0">{selected.size}</span>
|
|
<div className="w-px h-5 bg-border mx-1 shrink-0" />
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger disabled={busy} className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5 shrink-0"} aria-label={t("visibilityMenuLabel")}>
|
|
<Globe className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{t("visibilityMenuLabel")}</span>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="center" side="top">
|
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
|
|
<Globe className="h-4 w-4 mr-2 text-green-500" /> {t("makePublic")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
|
|
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> {t("makeUnlisted")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("private"); }}>
|
|
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> {t("makePrivate")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button variant="ghost" size="sm" onClick={() => setAddToCollectionOpen(true)} disabled={busy} className="gap-1.5 shrink-0" aria-label={t("addToCollection")}>
|
|
<FolderPlus className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{t("addToCollection")}</span>
|
|
</Button>
|
|
<Button variant="destructive" size="sm" onClick={() => setDeleteConfirmOpen(true)} disabled={busy} className="gap-1.5 shrink-0" aria-label={tCommon("delete")}>
|
|
<Trash2 className="h-4 w-4" />
|
|
<span className="hidden sm:inline">{tCommon("delete")}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<AddToCollectionDialog
|
|
open={addToCollectionOpen}
|
|
onOpenChange={setAddToCollectionOpen}
|
|
recipeIds={[...selected]}
|
|
onDone={exitSelect}
|
|
/>
|
|
|
|
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("bulkDeleteConfirmTitle", { count: selected.size })}</AlertDialogTitle>
|
|
<AlertDialogDescription>{t("bulkDeleteConfirmDescription")}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => { setDeleteConfirmOpen(false); void bulkDelete(); }}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{tCommon("delete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|