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