002f14ced0
Shopping list add already worked generically for batch-cook recipes — no code needed there. New: mark a specific batch-cook dish as "cooked today", track its fridge expiry (cookingHistory.batchDishId), surface a "Leftovers expiring soon" widget on the pantry page, and send a daily push+email reminder via a new /api/internal/cron/leftover-reminders endpoint (mirrors the weekly-digest cron pattern; doesn't use the social notifications table, which requires a non-null actor and isn't built for self-reminders). Also fixes, from user-reported bugs: - Recipe cards showed no batch-cook badge/dish-count/prep-time in some views — added dishCount + prepMins/cookMins (now generated by the AI and persisted) to the card component and /recipes query. - Batch-cook descriptions occasionally contained raw markdown (**bold**) — added explicit "plain prose only" prompt instructions and a stripMarkdown() defensive fallback at render time. - Truncated/cut-off descriptions — the generateObject call had no maxOutputTokens set, so long structured responses could get cut off mid-field; now capped explicitly at 8000. - Generate dialogs (batch-cook + the main AI dialog) could show buttons unreachable once the progress bar appeared mid-generation — restructured so the action row is pinned outside the scrollable content area, not affected by content height changes. - /api/internal/* routes were being redirected to /login by middleware before their own CRON_SECRET check ever ran (pre-existing bug, affected the weekly-digest cron too) — added to PUBLIC_PATHS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
518 lines
22 KiB
TypeScript
518 lines
22 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)}
|
|
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">
|
|
<div className="flex items-center gap-1.5">
|
|
<h3 className={cn("font-semibold leading-tight line-clamp-2 text-sm transition-colors", !selectMode && "group-hover:text-primary")}>
|
|
{recipe.title}
|
|
</h3>
|
|
{recipe.isBatchCook && <ChefHat className="h-3.5 w-3.5 text-primary shrink-0" />}
|
|
</div>
|
|
{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">
|
|
{!selectMode && (
|
|
<StopPropagation>
|
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
|
|
</StopPropagation>
|
|
)}
|
|
<VisibilityIcon className="h-3 w-3" />
|
|
</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 flex items-center gap-1.5", !selectMode && "group-hover:text-primary")}>
|
|
{recipe.title}
|
|
{recipe.isBatchCook && <ChefHat className="h-3.5 w-3.5 text-primary shrink-0" />}
|
|
</h3>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{!selectMode && (
|
|
<StopPropagation>
|
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
|
|
</StopPropagation>
|
|
)}
|
|
<VisibilityIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</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 { 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 flex items-center gap-1.5", !selectMode && "group-hover:text-primary")}>
|
|
<span className="truncate">{recipe.title}</span>
|
|
{recipe.isBatchCook && <ChefHat className="h-3.5 w-3.5 text-primary shrink-0" />}
|
|
</h3>
|
|
<span className="hidden sm:flex items-center gap-1 text-xs text-muted-foreground shrink-0"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
|
{totalMins > 0 && (
|
|
<span className="hidden sm:flex items-center gap-1 text-xs text-muted-foreground shrink-0"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>
|
|
)}
|
|
{recipe.difficulty && (
|
|
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="hidden sm:inline-flex text-xs h-4 px-1.5 shrink-0">{t(`difficulty.${recipe.difficulty}`)}</Badge>
|
|
)}
|
|
<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>
|
|
{!selectMode && (
|
|
<StopPropagation>
|
|
<FavoriteButton recipeId={recipe.id} initialFavorited={recipe.isFavorited} />
|
|
</StopPropagation>
|
|
)}
|
|
<VisibilityIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
</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)] max-w-md sm:w-auto 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 [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>
|
|
);
|
|
}
|