- {/* Cover image */}
-
- {cover ? (
-
})
- ) : (
-
- 🍽️
-
- )}
-
- {/* Selection overlay tint */}
- {selectMode && selected && (
-
- )}
-
- {/* Checkbox */}
- {selectMode ? (
-
- {selected && }
-
- ) : null}
-
-
- {/* Body */}
+
-
+
{recipe.title}
{recipe.description && (
-
- {recipe.description}
-
+
{recipe.description}
)}
{recipe.tags.length > 0 && (
@@ -122,46 +104,110 @@ function SelectableRecipeCard({
))}
{recipe.tags.length > 3 && (
-
- +{recipe.tags.length - 3}
-
+ +{recipe.tags.length - 3}
)}
)}
-
- {/* Footer */}
-
-
- {recipe.baseServings}
-
- {totalMins > 0 && (
-
-
- {t("total", { mins: totalMins })}
-
- )}
+ {recipe.baseServings}
+ {totalMins > 0 && {t("total", { mins: totalMins })}}
{recipe.difficulty && (
-
- {t(`difficulty.${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 (
+ onToggle(recipe.id)}
- role="checkbox"
- aria-checked={selected}
- className="h-full"
- >
+
onToggle(recipe.id)} role="checkbox" aria-checked={selected} className="h-full">
{inner}
);
@@ -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 (
+
+
+ {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");
@@ -181,6 +261,17 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState
>(new Set());
const [busy, setBusy] = useState(false);
+ const [viewMode, setViewMode] = useState("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) => {
setSelected((prev) => {
@@ -191,9 +282,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
}, []);
const toggleAll = () => {
- setSelected((prev) =>
- prev.size === recipes.length ? new Set() : new Set(recipes.map((r) => r.id))
- );
+ setSelected((prev) => (prev.size === recipes.length ? new Set() : new Set(recipes.map((r) => r.id))));
};
const exitSelect = () => {
@@ -230,9 +319,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
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)
- );
+ setRecipes((prev) => prev.map((r) => (selected.has(r.id) ? { ...r, visibility } : r)));
toast.success(t("bulkVisibility", { count: selected.size, visibility }));
exitSelect();
} catch {
@@ -249,46 +336,51 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
return (
{/* Toolbar */}
-
+
{selectMode ? (
-
) : (
)}
-
setSelectMode(true)}
- className={cn("gap-1.5", selectMode && "text-muted-foreground")}
- >
- {selectMode ? (
- <>{tCommon("cancel")}>
- ) : (
- <>{t("select")}>
- )}
-
+
+
+ setSelectMode(true)}
+ className={cn("gap-1.5", selectMode && "text-muted-foreground")}
+ >
+ {selectMode ? (
+ <>{tCommon("cancel")}>
+ ) : (
+ <>{t("select")}>
+ )}
+
+
- {/* Grid */}
-
+ {/* Recipes */}
+
{recipes.map((recipe) => (
-
))}
@@ -298,17 +390,12 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
{selectMode && selected.size > 0 && (
-
- {t("selectedCount", { count: selected.size })}
-
+
{t("selectedCount", { count: selected.size })}
-
+
- {t("visibility")}
+ {t("visibilityMenuLabel")}
{ void bulkSetVisibility("public"); }}>
@@ -322,13 +409,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
-
{ void bulkDelete(); }}
- disabled={busy}
- className="gap-1.5"
- >
+ { void bulkDelete(); }} disabled={busy} className="gap-1.5">
{tCommon("delete")}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 635efc0..f053697 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -117,6 +117,10 @@
"diffDescriptionLabel": "Description",
"diffIngredientsLabel": "Ingredients",
"diffStepsLabel": "Steps",
+ "viewGrid": "Grid view",
+ "viewList": "List view",
+ "viewCompact": "Compact view",
+ "updatedLabel": "Updated {date}",
"versionHistoryTitle": "Version History",
"versionsLoading": "Loading versions...",
"versionsEmpty": "No versions yet. Versions are saved automatically when you edit this recipe.",
@@ -145,7 +149,7 @@
"clickToSelect": "Click cards to select",
"selectedCount": "{count} selected",
"select": "Select",
- "visibility": "Visibility",
+ "visibilityMenuLabel": "Visibility",
"makePublic": "Make public",
"makeUnlisted": "Make unlisted",
"makePrivate": "Make private",
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 59bceee..9ad19fe 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -117,6 +117,10 @@
"diffDescriptionLabel": "Description",
"diffIngredientsLabel": "Ingrédients",
"diffStepsLabel": "Étapes",
+ "viewGrid": "Vue grille",
+ "viewList": "Vue liste",
+ "viewCompact": "Vue compacte",
+ "updatedLabel": "Mis à jour le {date}",
"versionHistoryTitle": "Historique des versions",
"versionsLoading": "Chargement des versions...",
"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",
"selectedCount": "{count} sélectionnée(s)",
"select": "Sélectionner",
- "visibility": "Visibilité",
+ "visibilityMenuLabel": "Visibilité",
"makePublic": "Rendre publique",
"makeUnlisted": "Rendre non répertoriée",
"makePrivate": "Rendre privée",