2154512e54
Recipe cards, the recipe detail page, and meal planner still rendered raw
"{n} servings"/"{n}m cook"/"{n} srv" text and untranslated difficulty labels
outside the earlier i18n pass. Also wires the user's locale into AI features
that previously always answered in English regardless of app language:
recipe chat, ingredient substitution, recipe ideas, and generate-from-idea.
The recipe-language picker in the AI generate dialog now defaults to the
user's locale instead of always defaulting to English.
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks } from "lucide-react";
|
|
import { Button, buttonVariants } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { toast } from "sonner";
|
|
import { getPublicUrl } from "@/lib/storage";
|
|
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 }>;
|
|
};
|
|
|
|
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
|
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
|
|
|
|
function SelectableRecipeCard({
|
|
recipe,
|
|
selected,
|
|
selectMode,
|
|
onToggle,
|
|
}: {
|
|
recipe: Recipe;
|
|
selected: boolean;
|
|
selectMode: boolean;
|
|
onToggle: (id: string) => void;
|
|
}) {
|
|
const t = useTranslations("recipe");
|
|
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
|
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
|
|
|
const inner = (
|
|
<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"
|
|
)}
|
|
>
|
|
{/* Cover image */}
|
|
<div className="relative aspect-video overflow-hidden bg-muted shrink-0">
|
|
{cover ? (
|
|
<img
|
|
src={getPublicUrl(cover.storageKey)}
|
|
alt={recipe.title}
|
|
className={cn(
|
|
"w-full h-full 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-4xl transition-opacity",
|
|
selectMode && !selected && "opacity-60"
|
|
)}>
|
|
🍽️
|
|
</div>
|
|
)}
|
|
|
|
{/* Selection overlay tint */}
|
|
{selectMode && selected && (
|
|
<div className="absolute inset-0 bg-primary/20" />
|
|
)}
|
|
|
|
{/* Checkbox */}
|
|
{selectMode ? (
|
|
<div className={cn(
|
|
"absolute top-2.5 left-2.5 h-6 w-6 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.5 w-3.5 text-primary-foreground stroke-[3]" />}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<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.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>
|
|
|
|
{/* Footer */}
|
|
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-2.5">
|
|
<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>
|
|
<VisibilityIcon className="h-3 w-3 shrink-0" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) {
|
|
const t = useTranslations("recipe");
|
|
const [recipes, setRecipes] = useState(initialRecipes);
|
|
const [selectMode, setSelectMode] = useState(false);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
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(`Delete ${selected.size} recipe${selected.size !== 1 ? "s" : ""}? This cannot be undone.`)) 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 (
|
|
<div className="space-y-4">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between min-h-[36px]">
|
|
{selectMode ? (
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={toggleAll}
|
|
className="text-sm font-medium text-primary hover:underline"
|
|
>
|
|
{allSelected ? "Deselect all" : "Select all"}
|
|
</button>
|
|
<span className="text-sm text-muted-foreground">
|
|
{selected.size > 0
|
|
? `${selected.size} selected`
|
|
: "Click cards to select"}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<Button
|
|
variant={selectMode ? "ghost" : "ghost"}
|
|
size="sm"
|
|
onClick={selectMode ? exitSelect : () => setSelectMode(true)}
|
|
className={cn("gap-1.5", selectMode && "text-muted-foreground")}
|
|
>
|
|
{selectMode ? (
|
|
<><X className="h-4 w-4" />Cancel</>
|
|
) : (
|
|
<><ListChecks className="h-4 w-4" />Select</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Grid */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start">
|
|
{recipes.map((recipe) => (
|
|
<SelectableRecipeCard
|
|
key={recipe.id}
|
|
recipe={recipe}
|
|
selected={selected.has(recipe.id)}
|
|
selectMode={selectMode}
|
|
onToggle={toggleSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Floating bulk action bar */}
|
|
{selectMode && selected.size > 0 && (
|
|
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 duration-200">
|
|
<div className="flex items-center gap-2 bg-popover border shadow-2xl rounded-2xl px-5 py-3">
|
|
<span className="text-sm font-semibold tabular-nums mr-1">
|
|
{selected.size} selected
|
|
</span>
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
disabled={busy}
|
|
className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5"}
|
|
>
|
|
<Globe className="h-4 w-4" />
|
|
Visibility
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="center" side="top">
|
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
|
|
<Globe className="h-4 w-4 mr-2 text-green-500" /> Make public
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
|
|
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> Make unlisted
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("private"); }}>
|
|
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> Make private
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => { void bulkDelete(); }}
|
|
disabled={busy}
|
|
className="gap-1.5"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|