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
@@ -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>
);
}