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
+32 -8
View File
@@ -3,7 +3,7 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Apple } from "lucide-react";
import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon, Monitor, Apple } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
import {
@@ -44,8 +44,13 @@ export function Nav() {
const { data: session } = authClient.useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
const username = (session?.user as { username?: string } | undefined)?.username;
const { resolvedTheme, setTheme } = useTheme();
const { theme, setTheme } = useTheme();
const t = useTranslations("nav");
const THEME_OPTIONS = [
{ value: "light", icon: Sun, label: t("lightMode") },
{ value: "dark", icon: Moon, label: t("darkMode") },
{ value: "system", icon: Monitor, label: t("systemMode") },
] as const;
return (
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex h-14 items-center gap-6 px-4">
@@ -126,13 +131,32 @@ export function Nav() {
<DropdownMenuItem>
<Link href="/settings" className="w-full">{t("settings")}</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
className="flex items-center gap-2"
<div
role="group"
aria-label={t("systemMode")}
className="mx-1.5 my-1 flex items-center gap-0.5 rounded-md border bg-muted/50 p-0.5"
>
{resolvedTheme === "dark" ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
{resolvedTheme === "dark" ? t("lightMode") : t("darkMode")}
</DropdownMenuItem>
{THEME_OPTIONS.map(({ value, icon: Icon, label }) => (
<button
key={value}
type="button"
title={label}
aria-label={label}
aria-pressed={theme === value}
onClick={(e) => {
e.preventDefault();
setTheme(value);
}}
className={cn(
"flex flex-1 items-center justify-center rounded-sm p-1.5 text-muted-foreground transition-colors hover:text-foreground",
theme === value && "bg-background text-foreground shadow-sm"
)}
>
<Icon className="h-3.5 w-3.5" />
<span className="sr-only">{label}</span>
</button>
))}
</div>
{isAdmin && (
<>
<DropdownMenuSeparator />
+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
@@ -2,6 +2,7 @@
import { useState } from "react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -18,6 +19,7 @@ interface NutritionGoalsFormProps {
}
export function NutritionGoalsForm({ initialGoals }: NutritionGoalsFormProps) {
const t = useTranslations("settings.nutritionGoals");
const [caloriesKcal, setCaloriesKcal] = useState<string>(
initialGoals?.caloriesKcal != null ? String(initialGoals.caloriesKcal) : ""
);
@@ -50,13 +52,13 @@ export function NutritionGoalsForm({ initialGoals }: NutritionGoalsFormProps) {
});
if (!res.ok) {
toast.error("Failed to save nutrition goals");
toast.error(t("saveError"));
return;
}
toast.success("Nutrition goals saved");
toast.success(t("saveSuccess"));
} catch {
toast.error("Failed to save nutrition goals");
toast.error(t("saveError"));
} finally {
setSaving(false);
}
@@ -66,52 +68,52 @@ export function NutritionGoalsForm({ initialGoals }: NutritionGoalsFormProps) {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="caloriesKcal">Calories (kcal/day)</Label>
<Label htmlFor="caloriesKcal">{t("caloriesLabel")}</Label>
<Input
id="caloriesKcal"
type="number"
min={0}
value={caloriesKcal}
onChange={(e) => setCaloriesKcal(e.target.value)}
placeholder="e.g. 2000"
placeholder={t("caloriesPlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="proteinG">Protein (g/day)</Label>
<Label htmlFor="proteinG">{t("proteinLabel")}</Label>
<Input
id="proteinG"
type="number"
min={0}
value={proteinG}
onChange={(e) => setProteinG(e.target.value)}
placeholder="e.g. 50"
placeholder={t("proteinPlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="carbsG">Carbs (g/day)</Label>
<Label htmlFor="carbsG">{t("carbsLabel")}</Label>
<Input
id="carbsG"
type="number"
min={0}
value={carbsG}
onChange={(e) => setCarbsG(e.target.value)}
placeholder="e.g. 250"
placeholder={t("carbsPlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fatG">Fat (g/day)</Label>
<Label htmlFor="fatG">{t("fatLabel")}</Label>
<Input
id="fatG"
type="number"
min={0}
value={fatG}
onChange={(e) => setFatG(e.target.value)}
placeholder="e.g. 70"
placeholder={t("fatPlaceholder")}
/>
</div>
</div>
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save goals"}
{saving ? t("saving") : t("saveButton")}
</Button>
</form>
);
@@ -2,15 +2,16 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight, Shuffle } from "lucide-react";
import { useTranslations } from "next-intl";
import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page";
import { SearchResultCard } from "@/components/recipe/search-result-card";
import { PeopleSearch } from "@/components/social/people-search";
const DIFFICULTY_COLORS: Record<string, string> = {
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
@@ -43,6 +44,19 @@ type RecipeIdea = {
totalMins?: number;
};
const SURPRISE_IDEA_PROMPTS = [
"use only 5 ingredients",
"no oven required",
"one-pot meal",
"ready in 20 minutes or less",
"budget-friendly student meal",
"impressive but secretly easy dinner party dish",
"leftover-friendly comfort food",
"kid-friendly weeknight dinner",
"high-protein post-workout meal",
"cozy soup for a rainy day",
];
function HorizontalScroll({ children }: { children: React.ReactNode }) {
return (
@@ -56,15 +70,18 @@ type Props = {
trending: ExploreRecipeResult[];
recent: ExploreRecipeResult[];
initialQuery: string;
initialTab: "recipes" | "people";
};
export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
export function ExplorePageContent({ trending, recent, initialQuery, initialTab }: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations("explore");
const tCommon = useTranslations("common");
const tRecipe = useTranslations("recipe");
const [activeTab, setActiveTab] = useState<"recipes" | "people">(initialTab);
const [inputValue, setInputValue] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);
const [difficulty, setDifficulty] = useState("any");
@@ -127,6 +144,18 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
[router, searchParams]
);
const handleTabChange = useCallback(
(value: string | null) => {
const next = value === "people" ? "people" : "recipes";
setActiveTab(next);
const params = new URLSearchParams(searchParams.toString());
if (next === "people") params.set("tab", "people");
else params.delete("tab");
router.replace(`/explore?${params}`, { scroll: false });
},
[router, searchParams]
);
const fetchIdeas = useCallback(async (prompt: string) => {
setIdeasLoading(true);
setIdeas([]);
@@ -197,15 +226,21 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
const hasMore = results.length < total;
return (
<div className="max-w-5xl mx-auto space-y-10">
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">{t("title")}</h1>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="gap-6">
<TabsList>
<TabsTrigger value="recipes">{t("tabRecipes")}</TabsTrigger>
<TabsTrigger value="people">{t("tabPeople")}</TabsTrigger>
</TabsList>
<TabsContent value="recipes">
<div className="space-y-10">
{/* Search bar */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">{t("title")}</h1>
<Link href="/people" className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4">
Find people
</Link>
</div>
<form onSubmit={handleSubmit} className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
<Input
@@ -325,10 +360,15 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
type="button"
variant="ghost"
disabled={ideasLoading}
onClick={() => { setIdeasPrompt(""); fetchIdeas(""); }}
onClick={() => {
const idx = Math.floor(Math.random() * SURPRISE_IDEA_PROMPTS.length);
const surprisePrompt = SURPRISE_IDEA_PROMPTS[idx]!;
setIdeasPrompt(surprisePrompt);
fetchIdeas(surprisePrompt);
}}
className="shrink-0"
>
{t("surpriseMe")}
<span className="flex items-center gap-2"><Shuffle className="h-4 w-4" /> {t("surpriseMe")}</span>
</Button>
</form>
@@ -425,6 +465,13 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
</section>
</>
)}
</div>
</TabsContent>
<TabsContent value="people">
<PeopleSearch />
</TabsContent>
</Tabs>
</div>
);
}
@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "next-intl";
import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider";
@@ -17,6 +18,7 @@ type UserProps = {
locale: string;
bio: string | null;
privateBio: string | null;
isPrivate: boolean;
};
export function SettingsForm({ user }: { user: UserProps }) {
@@ -29,6 +31,8 @@ export function SettingsForm({ user }: { user: UserProps }) {
const [privateBio, setPrivateBio] = useState(user.privateBio ?? "");
const [saving, setSaving] = useState(false);
const [savingBio, setSavingBio] = useState(false);
const [isPrivate, setIsPrivate] = useState(user.isPrivate);
const [savingPrivacy, setSavingPrivacy] = useState(false);
async function saveProfile() {
setSaving(true);
@@ -65,6 +69,29 @@ export function SettingsForm({ user }: { user: UserProps }) {
const bioUnchanged = bio === (user.bio ?? "") && privateBio === (user.privateBio ?? "");
async function savePrivacy(checked: boolean) {
setSavingPrivacy(true);
const previous = isPrivate;
setIsPrivate(checked);
try {
const res = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isPrivate: checked }),
});
if (res.ok) toast.success(t_common("saved"));
else {
setIsPrivate(previous);
toast.error(t_common("saveFailed"));
}
} catch {
setIsPrivate(previous);
toast.error(t_common("saveFailed"));
} finally {
setSavingPrivacy(false);
}
}
return (
<div className="space-y-6">
<section className="rounded-xl border p-6 space-y-4">
@@ -115,6 +142,23 @@ export function SettingsForm({ user }: { user: UserProps }) {
</Button>
</section>
<section className="rounded-xl border p-6 space-y-1">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-lg">{t("privateAccount")}</h2>
<p className="text-sm text-muted-foreground mt-1 max-w-prose">
{t("privateAccountDescription")}
</p>
</div>
<Switch
id="private-account"
checked={isPrivate}
disabled={savingPrivacy}
onCheckedChange={(checked) => { void savePrivacy(checked); }}
/>
</div>
</section>
<section className="rounded-xl border p-6 space-y-4">
<h2 className="font-semibold text-lg">{t("language")}</h2>
<p className="text-sm text-muted-foreground">{t("languageDescription")}</p>