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
+12 -5
View File
@@ -27,9 +27,9 @@ export type RecipeResult = {
export default async function ExplorePage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
searchParams: Promise<{ q?: string; tab?: string }>;
}) {
const { q } = await searchParams;
const { q, tab } = await searchParams;
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
// Trending: public recipes ordered by favorite count in last 7 days
@@ -52,7 +52,7 @@ export default async function ExplorePage({
gte(favorites.createdAt, sevenDaysAgo)
)
)
.where(eq(recipes.visibility, "public"))
.where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
.groupBy(recipes.id, users.id)
.orderBy(desc(sql`count(${favorites.recipeId})`))
.limit(12);
@@ -69,12 +69,19 @@ export default async function ExplorePage({
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(eq(recipes.visibility, "public"))
.where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
.orderBy(desc(recipes.createdAt))
.limit(12);
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
const recent: RecipeResult[] = recentRows;
return <ExplorePageContent trending={trending} recent={recent} initialQuery={q ?? ""} />;
return (
<ExplorePageContent
trending={trending}
recent={recent}
initialQuery={q ?? ""}
initialTab={tab === "people" ? "people" : "recipes"}
/>
);
}
+3 -20
View File
@@ -1,22 +1,5 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { PeopleSearch } from "@/components/social/people-search";
import { getMessages } from "@/lib/i18n/server";
import { redirect } from "next/navigation";
export const metadata: Metadata = {};
export default async function PeoplePage() {
const session = await auth.api.getSession({ headers: await headers() });
const m = getMessages((session?.user as { locale?: string } | undefined)?.locale);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{m.people.title}</h1>
<p className="text-muted-foreground text-sm mt-1">{m.people.subtitle}</p>
</div>
<PeopleSearch />
</div>
);
export default function PeoplePage() {
redirect("/explore?tab=people");
}
+2 -1
View File
@@ -12,7 +12,7 @@ export default async function SettingsPage() {
const dbUser = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { bio: true, privateBio: true },
columns: { bio: true, privateBio: true, isPrivate: true },
});
return (
@@ -24,6 +24,7 @@ export default async function SettingsPage() {
locale: (session.user as { locale?: string }).locale ?? "en",
bio: dbUser?.bio ?? null,
privateBio: dbUser?.privateBio ?? null,
isPrivate: dbUser?.isPrivate ?? false,
}}
/>
);
+25 -4
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import Image from "next/image";
import { Lock } from "lucide-react";
import { auth } from "@/lib/auth/server";
import {
db,
@@ -120,6 +121,12 @@ export default async function UserProfilePage({ params, searchParams }: Params)
isBlocked = !!blockRow;
}
// Private accounts hide their recipe grid from anyone who isn't the owner or an
// existing follower. This only affects what's rendered on this direct-link profile
// page — it does not change access to a specific recipe's own direct URL, and it
// does not gate new follow requests (following stays immediate, no approval step).
const isPrivateHidden = user.isPrivate && !isOwnProfile && !isFollowing;
// Cooking history and "cooked it" photo gallery are only meaningful (and visible) to the
// profile owner: history reveals private behavioral/timing patterns, and photos may be
// attached to reviews of recipes that aren't visible to other viewers (private recipes),
@@ -210,7 +217,13 @@ export default async function UserProfilePage({ params, searchParams }: Params)
.join("")
.toUpperCase();
const recipesSection =
const recipesSection = isPrivateHidden ? (
<div className="text-center py-16 text-muted-foreground space-y-2">
<Lock className="mx-auto h-8 w-8" />
<p className="text-lg font-medium">This account is private</p>
<p className="text-sm">Follow @{user.username} to see their recipes.</p>
</div>
) : (
publicRecipes.length > 0 ? (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Recipes</h2>
@@ -274,7 +287,8 @@ export default async function UserProfilePage({ params, searchParams }: Params)
<div className="text-center py-16 text-muted-foreground">
<p className="text-lg">No public recipes yet.</p>
</div>
);
)
);
return (
<div className="max-w-4xl mx-auto space-y-10">
@@ -288,7 +302,14 @@ export default async function UserProfilePage({ params, searchParams }: Params)
<div className="flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<div>
<h1 className="text-2xl font-bold leading-tight">{user.name}</h1>
<h1 className="text-2xl font-bold leading-tight flex items-center gap-2">
{user.name}
{isOwnProfile && user.isPrivate && (
<Badge variant="outline" className="gap-1 text-xs font-normal">
<Lock className="h-3 w-3" /> Private
</Badge>
)}
</h1>
<p className="text-muted-foreground text-sm">@{user.username}</p>
</div>
{!isOwnProfile && session && (
@@ -304,7 +325,7 @@ export default async function UserProfilePage({ params, searchParams }: Params)
)}
</div>
{user.bio && (
{user.bio && !isPrivateHidden && (
<p className="text-sm leading-relaxed max-w-prose">{user.bio}</p>
)}
+8 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, users, favorites, ratings, eq, and, ne, gte, notInArray, inArray, desc } from "@epicure/db";
import { db, recipes, users, favorites, ratings, userFollows, eq, and, or, ne, gte, notInArray, inArray, desc, isNotNull } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { buildPreferenceMap, rankForYou } from "@/lib/for-you-ranking";
@@ -50,10 +50,16 @@ export async function GET(req: NextRequest) {
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin(
userFollows,
and(eq(userFollows.followingId, recipes.authorId), eq(userFollows.followerId, userId))
)
.where(and(
eq(recipes.visibility, "public"),
ne(recipes.authorId, userId),
notInArray(recipes.id, excludeIds)
notInArray(recipes.id, excludeIds),
// Private authors are excluded from discovery unless the viewer already follows them.
or(eq(users.isPrivate, false), isNotNull(userFollows.followerId))
))
.orderBy(desc(recipes.createdAt))
.limit(200); // score a bounded recent window rather than the whole table
+3 -2
View File
@@ -35,7 +35,7 @@ export async function GET(req: NextRequest) {
favorites,
and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, sevenDaysAgo))
)
.where(eq(recipes.visibility, "public"))
.where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false)))
.groupBy(recipes.id, users.id)
.orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt))
.limit(limit)
@@ -43,7 +43,8 @@ export async function GET(req: NextRequest) {
db
.select({ total: sql<number>`count(*)::int` })
.from(recipes)
.where(eq(recipes.visibility, "public")),
.innerJoin(users, eq(recipes.authorId, users.id))
.where(and(eq(recipes.visibility, "public"), eq(users.isPrivate, false))),
]);
const total = totalRow[0]?.total ?? 0;
+1
View File
@@ -71,6 +71,7 @@ export async function GET(req: NextRequest) {
const conditions = [
eq(recipes.visibility, "public"),
eq(users.isPrivate, false),
or(
ilike(recipes.title, `%${escapedQ}%`),
ilike(recipes.description, `%${escapedQ}%`)
+1
View File
@@ -9,6 +9,7 @@ const PatchSchema = z.object({
locale: z.string().max(10).optional(),
bio: z.string().max(500).optional().nullable(),
privateBio: z.string().max(2000).optional().nullable(),
isPrivate: z.boolean().optional(),
});
export async function PATCH(req: Request) {
@@ -34,6 +34,7 @@ export async function GET(req: NextRequest) {
and(
isNotNull(users.username),
ne(users.id, session!.user.id),
eq(users.isPrivate, false),
or(ilike(users.name, `%${q}%`), ilike(users.username, `%${q}%`))
)
)
+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>
+24 -4
View File
@@ -19,7 +19,8 @@
"notifications": "Notifications",
"viewProfile": "View profile",
"lightMode": "Light mode",
"darkMode": "Dark mode"
"darkMode": "Dark mode",
"systemMode": "System"
},
"notifications": {
"title": "Notifications",
@@ -305,7 +306,19 @@
"nutritionGoals": {
"title": "Daily Nutrition Goals",
"description": "Set your daily targets. These are shown as progress bars on your meal plan.",
"viewDiaryCta": "View nutrition diary"
"viewDiaryCta": "View nutrition diary",
"caloriesLabel": "Calories (kcal/day)",
"proteinLabel": "Protein (g/day)",
"carbsLabel": "Carbs (g/day)",
"fatLabel": "Fat (g/day)",
"caloriesPlaceholder": "e.g. 2000",
"proteinPlaceholder": "e.g. 50",
"carbsPlaceholder": "e.g. 250",
"fatPlaceholder": "e.g. 70",
"saveButton": "Save goals",
"saving": "Saving...",
"saveSuccess": "Nutrition goals saved",
"saveError": "Failed to save nutrition goals"
},
"webhooksPage": {
"title": "Webhooks",
@@ -423,6 +436,8 @@
},
"explore": {
"title": "Explore",
"tabRecipes": "Recipes",
"tabPeople": "People",
"searchPlaceholder": "Search public recipes…",
"maxMinutes": "Max minutes",
"aiSearchPlaceholder": "e.g. quick weeknight dinners, Italian comfort food…",
@@ -689,7 +704,10 @@
"listCreate": "Create",
"pickRecipe": "Pick recipe",
"addEntry": "+ Add",
"removeEntry": "Remove meal"
"removeEntry": "Remove meal",
"markCooked": "Mark as cooked",
"markCookedSuccess": "Marked as cooked",
"markCookedFailed": "Failed to mark as cooked"
},
"pantry": {
"title": "Pantry",
@@ -1026,7 +1044,9 @@
"publicBioPlaceholder": "Tell other cooks about yourself…",
"privateBio": "AI context (private)",
"privateBioDescription": "Never shown publicly. Injected into AI prompts to personalise suggestions — add your dietary preferences, kitchen equipment, cooking skill level, allergies, etc.",
"privateBioPlaceholder": "e.g. I'm vegetarian, have a stand mixer and an air fryer, intermediate cook, allergic to tree nuts, prefer Mediterranean flavours…"
"privateBioPlaceholder": "e.g. I'm vegetarian, have a stand mixer and an air fryer, intermediate cook, allergic to tree nuts, prefer Mediterranean flavours…",
"privateAccount": "Private account",
"privateAccountDescription": "When on, your profile and recipes won't appear in search or discovery (Explore, Trending, For You). People who already follow you keep seeing your recipes."
},
"profilePage": {
"tabRecipes": "Recipes",
+24 -4
View File
@@ -19,7 +19,8 @@
"notifications": "Notifications",
"viewProfile": "Voir le profil",
"lightMode": "Mode clair",
"darkMode": "Mode sombre"
"darkMode": "Mode sombre",
"systemMode": "Système"
},
"notifications": {
"title": "Notifications",
@@ -305,7 +306,19 @@
"nutritionGoals": {
"title": "Objectifs nutritionnels quotidiens",
"description": "Définissez vos objectifs quotidiens. Ils apparaissent sous forme de barres de progression dans votre planning repas.",
"viewDiaryCta": "Voir le journal nutritionnel"
"viewDiaryCta": "Voir le journal nutritionnel",
"caloriesLabel": "Calories (kcal/jour)",
"proteinLabel": "Protéines (g/jour)",
"carbsLabel": "Glucides (g/jour)",
"fatLabel": "Lipides (g/jour)",
"caloriesPlaceholder": "ex. 2000",
"proteinPlaceholder": "ex. 50",
"carbsPlaceholder": "ex. 250",
"fatPlaceholder": "ex. 70",
"saveButton": "Enregistrer les objectifs",
"saving": "Enregistrement...",
"saveSuccess": "Objectifs nutritionnels enregistrés",
"saveError": "Impossible d'enregistrer les objectifs nutritionnels"
},
"webhooksPage": {
"title": "Webhooks",
@@ -423,6 +436,8 @@
},
"explore": {
"title": "Explorer",
"tabRecipes": "Recettes",
"tabPeople": "Personnes",
"searchPlaceholder": "Rechercher des recettes publiques…",
"maxMinutes": "Minutes max",
"aiSearchPlaceholder": "ex. dîners rapides en semaine, cuisine réconfortante italienne…",
@@ -677,7 +692,10 @@
"listCreate": "Créer",
"pickRecipe": "Choisir une recette",
"addEntry": "+ Ajouter",
"removeEntry": "Retirer le repas"
"removeEntry": "Retirer le repas",
"markCooked": "Marquer comme cuisiné",
"markCookedSuccess": "Marqué comme cuisiné",
"markCookedFailed": "Échec du marquage comme cuisiné"
},
"pantry": {
"title": "Garde-manger",
@@ -1014,7 +1032,9 @@
"publicBioPlaceholder": "Parlez de vous aux autres cuisiniers…",
"privateBio": "Contexte IA (privé)",
"privateBioDescription": "Jamais visible publiquement. Injecté dans les prompts IA pour personnaliser les suggestions — ajoutez vos préférences alimentaires, équipements, niveau de cuisine, allergies, etc.",
"privateBioPlaceholder": "ex. Je suis végétarien, j'ai un robot pâtissier et une friteuse à air, niveau intermédiaire, allergique aux fruits à coque, je préfère les saveurs méditerranéennes…"
"privateBioPlaceholder": "ex. Je suis végétarien, j'ai un robot pâtissier et une friteuse à air, niveau intermédiaire, allergique aux fruits à coque, je préfère les saveurs méditerranéennes…",
"privateAccount": "Compte privé",
"privateAccountDescription": "Une fois activé, votre profil et vos recettes n'apparaîtront plus dans la recherche ni dans les découvertes (Explorer, Tendances, Pour vous). Les personnes qui vous suivent déjà continueront de voir vos recettes."
},
"profilePage": {
"tabRecipes": "Recettes",
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "is_private" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -190,6 +190,13 @@
"when": 1783661460616,
"tag": "0026_sharp_rictor",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1783667700330,
"tag": "0027_special_siren",
"breakpoints": true
}
]
}
+1
View File
@@ -21,6 +21,7 @@ export const users = pgTable("users", {
avatarUrl: text("avatar_url"),
bio: text("bio"),
privateBio: text("private_bio"),
isPrivate: boolean("is_private").notNull().default(false),
username: text("username").unique(),
role: userRoleEnum("role").notNull().default("user"),
tier: tierEnum("tier").notNull().default("free"),