feat: private accounts, explore/people merge, meal-plan fixes, i18n and theme cleanup

- Private accounts: users.isPrivate hides a user from search and their
  recipes from search/trending/for-you discovery surfaces (follow-aware
  where the route already has session context, blanket exclusion where it
  doesn't); existing followers and direct links are unaffected, no
  follow-request approval flow was built (explicit scope limit)
- Merged /people into the Explore tab (tab=people query param); the old
  standalone route now redirects there
- "Get Ideas" vs "Surprise Me" were doing the same empty-prompt call;
  Surprise Me now injects a real random constraint (5-ingredient, one-pot,
  etc.), matching the existing pattern in the AI recipe-generate dialog
- Meal-plan day cells now link to their recipe (was dead text) and gained
  a one-click "mark as cooked" action
- Theme toggle is now a real three-way light/dark/system control instead
  of a binary flip
- Nutrition goals form had zero i18n wiring; fully localized now

New migration 0027 (users.is_private) generated, left unapplied like the
others. Verified with typecheck, lint, and a clean --no-cache docker build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-10 09:26:03 +02:00
parent 36e7698096
commit 9c545a5bb3
20 changed files with 4804 additions and 82 deletions
+58 -8
View File
@@ -1,7 +1,8 @@
"use client";
import { useState, useCallback } from "react";
import { Plus, Trash2, Sparkles } from "lucide-react";
import Link from "next/link";
import { Plus, Trash2, Sparkles, ChefHat, Check } from "lucide-react";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
@@ -125,6 +126,8 @@ export function MealPlanner({
hasNutritionGoals?: boolean;
}) {
const [entries, setEntries] = useState<Entry[]>(initialEntries);
const [cookedIds, setCookedIds] = useState<Set<string>>(new Set());
const [markingCookedIds, setMarkingCookedIds] = useState<Set<string>>(new Set());
const [adding, setAdding] = useState<{ day: Day; mealType: MealType } | null>(null);
const [showAiModal, setShowAiModal] = useState(false);
const [aiGenerating, setAiGenerating] = useState(false);
@@ -236,6 +239,32 @@ export function MealPlanner({
}
}
async function markCooked(entry: Entry) {
if (!entry.recipe || markingCookedIds.has(entry.id)) return;
setMarkingCookedIds((prev) => new Set(prev).add(entry.id));
try {
const res = await fetch(`/api/v1/recipes/${entry.recipe.id}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ servings: entry.servings, deductFromPantry: false }),
});
if (!res.ok) {
toast.error(t("markCookedFailed"));
return;
}
setCookedIds((prev) => new Set(prev).add(entry.id));
toast.success(t("markCookedSuccess"));
} catch {
toast.error(t("markCookedFailed"));
} finally {
setMarkingCookedIds((prev) => {
const next = new Set(prev);
next.delete(entry.id);
return next;
});
}
}
const addingDay = adding ? DAYS.find((d) => d.key === adding.day) : null;
const addingMeal = adding ? MEAL_TYPES.find((m) => m.key === adding.mealType) : null;
@@ -277,14 +306,35 @@ export function MealPlanner({
<td key={day} className="py-2 px-1 align-top">
{entry?.recipe ? (
<div className="group relative rounded-lg bg-primary/10 border border-primary/20 p-2 text-xs min-h-[52px]">
<div className="font-medium line-clamp-2 pr-4">{entry.recipe.title}</div>
<div className="text-muted-foreground mt-0.5">{t("servingsAbbrev", { count: entry.servings })}</div>
<button
onClick={() => removeEntry(entry)}
className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
<Link
href={`/recipes/${entry.recipe.id}`}
className="block pr-8 hover:underline"
>
<Trash2 className="h-3 w-3" />
</button>
<div className="font-medium line-clamp-2">{entry.recipe.title}</div>
<div className="text-muted-foreground mt-0.5 flex items-center gap-1">
{t("servingsAbbrev", { count: entry.servings })}
{cookedIds.has(entry.id) && (
<Check className="h-3 w-3 text-green-600 shrink-0" />
)}
</div>
</Link>
<div className="absolute top-1 right-1 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => markCooked(entry)}
disabled={markingCookedIds.has(entry.id)}
title={t("markCooked")}
className="text-muted-foreground hover:text-primary disabled:opacity-50"
>
<ChefHat className="h-3 w-3" />
</button>
<button
onClick={() => removeEntry(entry)}
title={t("removeEntry")}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
) : (
<button