feat: add list and compact view modes to recipes page

Grid was the only layout. Add a view-mode toggle (grid/list/compact)
persisted to localStorage:
- List: thumbnail + description + inline metadata, one per row
- Compact: dense single-line rows (thumb, title, servings/time/
  difficulty/date/visibility) for scanning large libraries
Selection mode and bulk actions work identically across all three.

Also fixes a real bug hit while wiring this up: "recipe.visibility"
was defined twice in the messages files — once as a flat string
("Visibility", used as the bulk-actions dropdown label) and once as
the {private,unlisted,public} label object. The object silently won
in JSON, so t("visibility") resolved to an object and next-intl threw
INSUFFICIENT_PATH the moment you opened that dropdown. Renamed the
flat one to visibilityMenuLabel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-09 09:47:39 +02:00
parent 1614da38cd
commit 64f45ed74d
4 changed files with 222 additions and 133 deletions
+1 -1
View File
@@ -265,7 +265,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="visibility">{t("visibility")}</Label> <Label htmlFor="visibility">{t("visibilityMenuLabel")}</Label>
<select <select
id="visibility" id="visibility"
value={visibility} value={visibility}
+195 -114
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks } from "lucide-react"; import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3 } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -10,8 +10,10 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner"; import { toast } from "sonner";
import { getPublicUrl } from "@/lib/storage"; import { getPublicUrl } from "@/lib/storage";
import { useLocale } from "@/lib/i18n/provider";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Clock, Users } from "lucide-react"; import { Clock, Users } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -31,39 +33,16 @@ type Recipe = {
photos?: Array<{ storageKey: string; isCover: boolean }>; 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 DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe }; const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
function SelectableRecipeCard({ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Recipe; selectMode: boolean; selected: boolean; className?: string }) {
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 cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); return (
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility]; <div className={cn("relative overflow-hidden bg-muted shrink-0", className)}>
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 ? ( {cover ? (
<img <img
src={getPublicUrl(cover.storageKey)} src={getPublicUrl(cover.storageKey)}
@@ -75,44 +54,47 @@ function SelectableRecipeCard({
)} )}
/> />
) : ( ) : (
<div className={cn( <div className={cn("w-full h-full flex items-center justify-center text-2xl", selectMode && !selected && "opacity-60")}>
"w-full h-full flex items-center justify-center text-4xl transition-opacity",
selectMode && !selected && "opacity-60"
)}>
🍽 🍽
</div> </div>
)} )}
{selectMode && selected && <div className="absolute inset-0 bg-primary/20" />}
{/* Selection overlay tint */} {selectMode && (
{selectMode && selected && (
<div className="absolute inset-0 bg-primary/20" />
)}
{/* Checkbox */}
{selectMode ? (
<div className={cn( <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", "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 selected ? "bg-primary border-primary" : "bg-black/30 border-white/70 group-hover:border-white"
? "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]" />} {selected && <Check className="h-3 w-3 text-primary-foreground stroke-[3]" />}
</div> </div>
) : null} )}
</div> </div>
);
}
{/* Body */} 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 (
<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 flex-col flex-1 p-3 gap-1">
<h3 className={cn( <h3 className={cn("font-semibold leading-tight line-clamp-2 text-sm transition-colors", !selectMode && "group-hover:text-primary")}>
"font-semibold leading-tight line-clamp-2 text-sm transition-colors",
!selectMode && "group-hover:text-primary"
)}>
{recipe.title} {recipe.title}
</h3> </h3>
{recipe.description && ( {recipe.description && (
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed"> <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.description}</p>
{recipe.description}
</p>
)} )}
{recipe.tags.length > 0 && ( {recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
@@ -122,46 +104,110 @@ function SelectableRecipeCard({
</span> </span>
))} ))}
{recipe.tags.length > 3 && ( {recipe.tags.length > 3 && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs text-muted-foreground"> <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs text-muted-foreground">+{recipe.tags.length - 3}</span>
+{recipe.tags.length - 3}
</span>
)} )}
</div> </div>
)} )}
</div> </div>
{/* Footer */}
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground"> <div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
<Users className="h-3 w-3" /> {totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
{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 && ( {recipe.difficulty && (
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5"> <Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>
{t(`difficulty.${recipe.difficulty}`)}
</Badge>
)} )}
</div> </div>
<VisibilityIcon className="h-3 w-3 shrink-0" /> <VisibilityIcon className="h-3 w-3 shrink-0" />
</div> </div>
</div> </div>
); );
}
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 (
<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>
<VisibilityIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground mt-0.5" />
</div>
{recipe.description && <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.description}</p>}
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mt-auto flex-wrap">
<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", !selectMode && "group-hover:text-primary")}>{recipe.title}</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>
<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) { if (selectMode) {
return ( return (
<div <div onClick={() => onToggle(recipe.id)} role="checkbox" aria-checked={selected} className="h-full">
onClick={() => onToggle(recipe.id)}
role="checkbox"
aria-checked={selected}
className="h-full"
>
{inner} {inner}
</div> </div>
); );
@@ -174,6 +220,40 @@ function SelectableRecipeCard({
); );
} }
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[] }) { export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) {
const t = useTranslations("recipe"); const t = useTranslations("recipe");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
@@ -181,6 +261,17 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
const [selectMode, setSelectMode] = useState(false); const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>("grid");
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) => { const toggleSelect = useCallback((id: string) => {
setSelected((prev) => { setSelected((prev) => {
@@ -191,9 +282,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
}, []); }, []);
const toggleAll = () => { const toggleAll = () => {
setSelected((prev) => setSelected((prev) => (prev.size === recipes.length ? new Set() : new Set(recipes.map((r) => r.id))));
prev.size === recipes.length ? new Set() : new Set(recipes.map((r) => r.id))
);
}; };
const exitSelect = () => { const exitSelect = () => {
@@ -230,9 +319,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
body: JSON.stringify({ ids: [...selected], visibility }), body: JSON.stringify({ ids: [...selected], visibility }),
}); });
if (!res.ok) throw new Error("Update failed"); if (!res.ok) throw new Error("Update failed");
setRecipes((prev) => setRecipes((prev) => prev.map((r) => (selected.has(r.id) ? { ...r, visibility } : r)));
prev.map((r) => selected.has(r.id) ? { ...r, visibility } : r)
);
toast.success(t("bulkVisibility", { count: selected.size, visibility })); toast.success(t("bulkVisibility", { count: selected.size, visibility }));
exitSelect(); exitSelect();
} catch { } catch {
@@ -249,26 +336,23 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between min-h-[36px]"> <div className="flex items-center justify-between min-h-[36px] gap-3">
{selectMode ? ( {selectMode ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button onClick={toggleAll} className="text-sm font-medium text-primary hover:underline">
onClick={toggleAll}
className="text-sm font-medium text-primary hover:underline"
>
{allSelected ? t("deselectAll") : t("selectAll")} {allSelected ? t("deselectAll") : t("selectAll")}
</button> </button>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{selected.size > 0 {selected.size > 0 ? t("selectedCount", { count: selected.size }) : t("clickToSelect")}
? t("selectedCount", { count: selected.size })
: t("clickToSelect")}
</span> </span>
</div> </div>
) : ( ) : (
<div /> <div />
)} )}
<div className="flex items-center gap-2">
<ViewToggle value={viewMode} onChange={changeViewMode} />
<Button <Button
variant={selectMode ? "ghost" : "ghost"} variant="ghost"
size="sm" size="sm"
onClick={selectMode ? exitSelect : () => setSelectMode(true)} onClick={selectMode ? exitSelect : () => setSelectMode(true)}
className={cn("gap-1.5", selectMode && "text-muted-foreground")} className={cn("gap-1.5", selectMode && "text-muted-foreground")}
@@ -280,15 +364,23 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
)} )}
</Button> </Button>
</div> </div>
</div>
{/* Grid */} {/* Recipes */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start"> <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) => ( {recipes.map((recipe) => (
<SelectableRecipeCard <SelectableRecipe
key={recipe.id} key={recipe.id}
recipe={recipe} recipe={recipe}
selected={selected.has(recipe.id)} selected={selected.has(recipe.id)}
selectMode={selectMode} selectMode={selectMode}
viewMode={viewMode}
onToggle={toggleSelect} onToggle={toggleSelect}
/> />
))} ))}
@@ -298,17 +390,12 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
{selectMode && selected.size > 0 && ( {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="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"> <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"> <span className="text-sm font-semibold tabular-nums mr-1">{t("selectedCount", { count: selected.size })}</span>
{t("selectedCount", { count: selected.size })}
</span>
<div className="w-px h-5 bg-border mx-1" /> <div className="w-px h-5 bg-border mx-1" />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger disabled={busy} className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5"}>
disabled={busy}
className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5"}
>
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
{t("visibility")} {t("visibilityMenuLabel")}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" side="top"> <DropdownMenuContent align="center" side="top">
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}> <DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
@@ -322,13 +409,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button <Button variant="destructive" size="sm" onClick={() => { void bulkDelete(); }} disabled={busy} className="gap-1.5">
variant="destructive"
size="sm"
onClick={() => { void bulkDelete(); }}
disabled={busy}
className="gap-1.5"
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
{tCommon("delete")} {tCommon("delete")}
</Button> </Button>
+5 -1
View File
@@ -117,6 +117,10 @@
"diffDescriptionLabel": "Description", "diffDescriptionLabel": "Description",
"diffIngredientsLabel": "Ingredients", "diffIngredientsLabel": "Ingredients",
"diffStepsLabel": "Steps", "diffStepsLabel": "Steps",
"viewGrid": "Grid view",
"viewList": "List view",
"viewCompact": "Compact view",
"updatedLabel": "Updated {date}",
"versionHistoryTitle": "Version History", "versionHistoryTitle": "Version History",
"versionsLoading": "Loading versions...", "versionsLoading": "Loading versions...",
"versionsEmpty": "No versions yet. Versions are saved automatically when you edit this recipe.", "versionsEmpty": "No versions yet. Versions are saved automatically when you edit this recipe.",
@@ -145,7 +149,7 @@
"clickToSelect": "Click cards to select", "clickToSelect": "Click cards to select",
"selectedCount": "{count} selected", "selectedCount": "{count} selected",
"select": "Select", "select": "Select",
"visibility": "Visibility", "visibilityMenuLabel": "Visibility",
"makePublic": "Make public", "makePublic": "Make public",
"makeUnlisted": "Make unlisted", "makeUnlisted": "Make unlisted",
"makePrivate": "Make private", "makePrivate": "Make private",
+5 -1
View File
@@ -117,6 +117,10 @@
"diffDescriptionLabel": "Description", "diffDescriptionLabel": "Description",
"diffIngredientsLabel": "Ingrédients", "diffIngredientsLabel": "Ingrédients",
"diffStepsLabel": "Étapes", "diffStepsLabel": "Étapes",
"viewGrid": "Vue grille",
"viewList": "Vue liste",
"viewCompact": "Vue compacte",
"updatedLabel": "Mis à jour le {date}",
"versionHistoryTitle": "Historique des versions", "versionHistoryTitle": "Historique des versions",
"versionsLoading": "Chargement des versions...", "versionsLoading": "Chargement des versions...",
"versionsEmpty": "Aucune version pour l'instant. Les versions sont enregistrées automatiquement lorsque vous modifiez cette recette.", "versionsEmpty": "Aucune version pour l'instant. Les versions sont enregistrées automatiquement lorsque vous modifiez cette recette.",
@@ -145,7 +149,7 @@
"clickToSelect": "Cliquez sur les cartes pour sélectionner", "clickToSelect": "Cliquez sur les cartes pour sélectionner",
"selectedCount": "{count} sélectionnée(s)", "selectedCount": "{count} sélectionnée(s)",
"select": "Sélectionner", "select": "Sélectionner",
"visibility": "Visibilité", "visibilityMenuLabel": "Visibilité",
"makePublic": "Rendre publique", "makePublic": "Rendre publique",
"makeUnlisted": "Rendre non répertoriée", "makeUnlisted": "Rendre non répertoriée",
"makePrivate": "Rendre privée", "makePrivate": "Rendre privée",