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:
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user