fix: mobile layout fixes, i18n coverage, and recipe share link

Mobile:
- Recipes search bar full-width on mobile instead of capped narrow
- Cook mode ingredients panel stacks above the step instead of
  squeezing it into a narrow column
- Version history Compare/Restore buttons wrap onto their own row
- Recipe edit ingredient fields wrap instead of forcing horizontal
  scroll on narrow viewports

i18n: translates remaining hardcoded strings across recipes
filter/sort, adapt-recipe and AI variations dialogs, the full
settings section (sidebar + 6 sub-pages + BYOK/model-prefs/
API-keys/webhooks managers), explore tab, collections (new/fork/
share dialogs), meal planning (planner, AI generation phases, new
shopping list, shared-plan view), photo import, recipe bulk-select
toolbar, and recipe action-button tooltips. Also fixes the recipes
page subtitle, which wasn't just unworded but missing its {count}
interpolation entirely — it always rendered as the bare word
"results" regardless of how many recipes existed.

Feature: adds a ShareRecipeButton that copies the public /r/{id}
link to the clipboard, with a notice when the recipe isn't Public
yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-02 15:13:51 +02:00
parent b07bada291
commit eb424d8c04
44 changed files with 932 additions and 376 deletions
@@ -1,12 +1,15 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { GitFork } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
export function ForkCollectionButton({ collectionId }: { collectionId: string }) {
const t = useTranslations("collections");
const tSocial = useTranslations("social");
const [forking, setForking] = useState(false);
const router = useRouter();
@@ -16,13 +19,13 @@ export function ForkCollectionButton({ collectionId }: { collectionId: string })
const res = await fetch(`/api/v1/collections/${collectionId}/fork`, { method: "POST" });
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Fork failed");
throw new Error(data.error ?? t("forkFailed"));
}
const { id } = await res.json() as { id: string };
toast.success("Collection forked to your library");
toast.success(tSocial("collectionForked"));
router.push(`/collections/${id}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Fork failed");
toast.error(err instanceof Error ? err.message : t("forkFailed"));
setForking(false);
}
}
@@ -30,7 +33,7 @@ export function ForkCollectionButton({ collectionId }: { collectionId: string })
return (
<Button variant="outline" size="sm" onClick={() => { void handleFork(); }} disabled={forking} className="gap-2 shrink-0">
<GitFork className="h-4 w-4" />
{forking ? "Forking" : "Fork collection"}
{forking ? t("forking") : t("forkCollection")}
</Button>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { UserPlus, X } from "lucide-react";
import { toast } from "sonner";
import {
@@ -40,6 +41,9 @@ interface Props {
}
export function ShareCollectionButton({ collectionId }: Props) {
const t = useTranslations("collections");
const ts = useTranslations("shareDialog");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [role, setRole] = useState<Role>("viewer");
@@ -55,7 +59,7 @@ export function ShareCollectionButton({ collectionId }: Props) {
const data = await res.json() as Member[];
setMembers(data);
} catch {
toast.error("Could not load members");
toast.error(ts("loadMembersFailed"));
} finally {
setLoading(false);
}
@@ -73,7 +77,7 @@ export function ShareCollectionButton({ collectionId }: Props) {
async function handleInvite() {
if (!email.trim()) {
toast.error("Enter an email address");
toast.error(ts("enterEmail"));
return;
}
setInviting(true);
@@ -83,14 +87,14 @@ export function ShareCollectionButton({ collectionId }: Props) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), role }),
});
if (res.status === 409) { toast.error("Already a member"); return; }
if (res.status === 404) { toast.error("User not found"); return; }
if (!res.ok) { toast.error("Could not invite user"); return; }
toast.success("Invitation sent");
if (res.status === 409) { toast.error(ts("alreadyMember")); return; }
if (res.status === 404) { toast.error(ts("userNotFound")); return; }
if (!res.ok) { toast.error(ts("inviteFailed")); return; }
toast.success(ts("invitationSent"));
setEmail("");
await fetchMembers();
} catch {
toast.error("Could not invite user");
toast.error(ts("inviteFailed"));
} finally {
setInviting(false);
}
@@ -102,11 +106,11 @@ export function ShareCollectionButton({ collectionId }: Props) {
`/api/v1/collections/${collectionId}/members?memberId=${memberId}`,
{ method: "DELETE" },
);
if (!res.ok) { toast.error("Could not remove member"); return; }
if (!res.ok) { toast.error(ts("removeMemberFailed")); return; }
setMembers((prev) => prev.filter((m) => m.id !== memberId));
toast.success("Member removed");
toast.success(ts("memberRemoved"));
} catch {
toast.error("Could not remove member");
toast.error(ts("removeMemberFailed"));
}
}
@@ -114,15 +118,15 @@ export function ShareCollectionButton({ collectionId }: Props) {
<>
<Button variant="outline" size="sm" onClick={() => handleOpenChange(true)}>
<UserPlus className="h-4 w-4 mr-2" />
Share
{tCommon("share")}
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Share collection</DialogTitle>
<DialogTitle>{t("shareTitle")}</DialogTitle>
<DialogDescription>
Invite people to view or edit this collection.
{t("shareDescription")}
</DialogDescription>
</DialogHeader>
@@ -130,7 +134,7 @@ export function ShareCollectionButton({ collectionId }: Props) {
<div className="flex gap-2 mt-2">
<Input
type="email"
placeholder="Email address"
placeholder={ts("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }}
@@ -141,22 +145,22 @@ export function ShareCollectionButton({ collectionId }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="viewer">{ts("viewer")}</SelectItem>
<SelectItem value="editor">{ts("editor")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => void handleInvite()} disabled={inviting}>
Invite
{ts("invite")}
</Button>
</div>
{/* Members list */}
<div className="mt-4 space-y-2">
{loading && (
<p className="text-sm text-muted-foreground">Loading members</p>
<p className="text-sm text-muted-foreground">{ts("loadingMembers")}</p>
)}
{!loading && members.length === 0 && (
<p className="text-sm text-muted-foreground">No members yet.</p>
<p className="text-sm text-muted-foreground">{ts("noMembers")}</p>
)}
{members.map((m) => (
<div
@@ -172,7 +176,7 @@ export function ShareCollectionButton({ collectionId }: Props) {
)}
</div>
<Badge variant={m.role === "editor" ? "default" : "secondary"}>
{m.role}
{ts(m.role)}
</Badge>
<Button
variant="ghost"
@@ -281,9 +281,9 @@ export function CookingMode({
</div>
{/* Main area */}
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-col sm:flex-row flex-1 overflow-hidden">
{/* Step content */}
<div className="flex-1 flex flex-col items-center justify-center px-8 py-12 gap-8">
<div className="flex-1 flex flex-col items-center justify-center px-4 sm:px-8 py-8 sm:py-12 gap-8 overflow-y-auto">
{/* Step number */}
<div className="text-sm font-medium text-muted-foreground">
{t("stepOf", { current: current + 1, total: steps.length })}
@@ -338,9 +338,9 @@ export function CookingMode({
)}
</div>
{/* Ingredients drawer */}
{/* Ingredients drawer — stacks above the step on mobile instead of squeezing it horizontally */}
{showIngredients && (
<aside className="w-72 border-l bg-muted/20 overflow-y-auto py-6 px-5 shrink-0">
<aside className="order-first sm:order-none w-full sm:w-72 max-h-40 sm:max-h-none border-b sm:border-b-0 sm:border-l bg-muted/20 overflow-y-auto py-4 sm:py-6 px-5 shrink-0">
<h2 className="font-semibold mb-4 text-sm uppercase tracking-wider text-muted-foreground">{t("ingredients")}</h2>
<ul className="space-y-2">
{ingredients.map((ing, i) => (
+16 -15
View File
@@ -61,7 +61,7 @@ function AddEntryModal({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ day, mealType, recipeId: recipe.id, servings: parseInt(servings) || 2 }),
});
if (!res.ok) { toast.error("Failed to add"); return; }
if (!res.ok) { toast.error(t("addFailed")); return; }
const { id } = await res.json() as { id: string };
onAdded({ id, day, mealType, servings: parseInt(servings) || 2, recipe, note: null });
onClose();
@@ -133,15 +133,16 @@ export function MealPlanner({
const [pantryMode, setPantryMode] = useState(false);
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
const t = useTranslations("mealPlan");
const tRecipe = useTranslations("recipe");
async function generateWithAi() {
setAiGenerating(true);
setAiPhase("Analyzing your preferences…");
setAiPhase(t("aiPhaseAnalyzing"));
const phases = [
[1500, "Planning breakfast, lunch & dinner…"],
[5000, "Selecting recipes for each day…"],
[10000, "Balancing nutrition across the week…"],
[16000, "Finalizing your meal plan…"],
[1500, t("aiPhasePlanning")],
[5000, t("aiPhaseSelecting")],
[10000, t("aiPhaseBalancing")],
[16000, t("aiPhaseFinalizing")],
] as const;
const timers = phases.map(([delay, label]) =>
setTimeout(() => setAiPhase(label), delay)
@@ -161,7 +162,7 @@ export function MealPlanner({
});
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? "Generation failed");
throw new Error(data.error ?? t("generationFailed"));
}
const data = await res.json() as {
entries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }>;
@@ -187,7 +188,7 @@ export function MealPlanner({
setShowAiModal(false);
toast.success(t("aiGenerated"));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Generation failed");
toast.error(err instanceof Error ? err.message : t("generationFailed"));
} finally {
timers.forEach(clearTimeout);
setAiGenerating(false);
@@ -227,7 +228,7 @@ export function MealPlanner({
if (res.ok) {
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
} else {
toast.error("Failed to remove");
toast.error(t("removeFailed"));
}
}
@@ -345,16 +346,16 @@ export function MealPlanner({
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Difficulty</label>
<label className="text-sm font-medium">{t("difficulty")}</label>
<Select value={aiDifficulty} onValueChange={(v) => setAiDifficulty(v as typeof aiDifficulty)} disabled={aiGenerating}>
<SelectTrigger>
<SelectValue placeholder="Any" />
<SelectValue placeholder={t("anyDifficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any</SelectItem>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Plus, ShoppingCart } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
@@ -10,6 +11,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function NewShoppingListButton() {
const t = useTranslations("mealPlan");
const tCommon = useTranslations("common");
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
@@ -28,9 +31,9 @@ export function NewShoppingListButton() {
fromMealPlanWeek: weekStart || undefined,
}),
});
if (!res.ok) { toast.error("Failed to create"); return; }
if (!res.ok) { toast.error(t("listCreateFailed")); return; }
const { id } = await res.json() as { id: string };
toast.success("List created");
toast.success(t("listCreated"));
setOpen(false);
setName(""); setWeekStart("");
router.push(`/shopping-lists/${id}`);
@@ -42,24 +45,24 @@ export function NewShoppingListButton() {
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" /> New list
<Plus className="h-4 w-4" /> {t("newList")}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>New shopping list</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{t("newListTitle")}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Weekly groceries" />
<Label>{t("listNameLabel")}</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("listNamePlaceholder")} />
</div>
<div className="space-y-2">
<Label>Generate from meal plan week (optional)</Label>
<Label>{t("generateFromWeek")}</Label>
<Input type="date" value={weekStart} onChange={(e) => setWeekStart(e.target.value)} />
<p className="text-xs text-muted-foreground">Picks Monday of the selected week</p>
<p className="text-xs text-muted-foreground">{t("generateFromWeekHint")}</p>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? "Creating" : "Create"}</Button>
<Button variant="outline" onClick={() => setOpen(false)}>{tCommon("cancel")}</Button>
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? t("listCreating") : t("listCreate")}</Button>
</div>
</div>
</DialogContent>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { UserPlus, X } from "lucide-react";
import { toast } from "sonner";
import {
@@ -40,6 +41,9 @@ interface Props {
}
export function ShareMealPlanButton({ weekStart }: Props) {
const t = useTranslations("mealPlan");
const ts = useTranslations("shareDialog");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [role, setRole] = useState<Role>("viewer");
@@ -55,7 +59,7 @@ export function ShareMealPlanButton({ weekStart }: Props) {
const data = await res.json() as Member[];
setMembers(data);
} catch {
toast.error("Could not load members");
toast.error(ts("loadMembersFailed"));
} finally {
setLoading(false);
}
@@ -73,7 +77,7 @@ export function ShareMealPlanButton({ weekStart }: Props) {
async function handleInvite() {
if (!email.trim()) {
toast.error("Enter an email address");
toast.error(ts("enterEmail"));
return;
}
setInviting(true);
@@ -83,14 +87,14 @@ export function ShareMealPlanButton({ weekStart }: Props) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), role }),
});
if (res.status === 409) { toast.error("Already a member"); return; }
if (res.status === 404) { toast.error("User not found"); return; }
if (!res.ok) { toast.error("Could not invite user"); return; }
toast.success("Invitation sent");
if (res.status === 409) { toast.error(ts("alreadyMember")); return; }
if (res.status === 404) { toast.error(ts("userNotFound")); return; }
if (!res.ok) { toast.error(ts("inviteFailed")); return; }
toast.success(ts("invitationSent"));
setEmail("");
await fetchMembers();
} catch {
toast.error("Could not invite user");
toast.error(ts("inviteFailed"));
} finally {
setInviting(false);
}
@@ -102,11 +106,11 @@ export function ShareMealPlanButton({ weekStart }: Props) {
`/api/v1/meal-plans/${weekStart}/members?memberId=${memberId}`,
{ method: "DELETE" },
);
if (!res.ok) { toast.error("Could not remove member"); return; }
if (!res.ok) { toast.error(ts("removeMemberFailed")); return; }
setMembers((prev) => prev.filter((m) => m.id !== memberId));
toast.success("Member removed");
toast.success(ts("memberRemoved"));
} catch {
toast.error("Could not remove member");
toast.error(ts("removeMemberFailed"));
}
}
@@ -114,22 +118,22 @@ export function ShareMealPlanButton({ weekStart }: Props) {
<>
<Button variant="outline" size="sm" onClick={() => handleOpenChange(true)}>
<UserPlus className="h-4 w-4" />
Share
{tCommon("share")}
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Share this week&apos;s plan</DialogTitle>
<DialogTitle>{t("shareTitle")}</DialogTitle>
<DialogDescription>
Invite household members to view or edit this week&apos;s meal plan.
{t("shareDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mt-2">
<Input
type="email"
placeholder="Email address"
placeholder={ts("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }}
@@ -140,21 +144,21 @@ export function ShareMealPlanButton({ weekStart }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="viewer">{ts("viewer")}</SelectItem>
<SelectItem value="editor">{ts("editor")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => void handleInvite()} disabled={inviting}>
Invite
{ts("invite")}
</Button>
</div>
<div className="mt-4 space-y-2">
{loading && (
<p className="text-sm text-muted-foreground">Loading members</p>
<p className="text-sm text-muted-foreground">{ts("loadingMembers")}</p>
)}
{!loading && members.length === 0 && (
<p className="text-sm text-muted-foreground">No members yet.</p>
<p className="text-sm text-muted-foreground">{ts("noMembers")}</p>
)}
{members.map((m) => (
<div
@@ -170,7 +174,7 @@ export function ShareMealPlanButton({ weekStart }: Props) {
)}
</div>
<Badge variant={m.role === "editor" ? "default" : "secondary"}>
{m.role}
{ts(m.role)}
</Badge>
<Button
variant="ghost"
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -33,6 +34,7 @@ export function SharedMealPlanView({
userRecipes: UserRecipe[];
canEdit: boolean;
}) {
const t = useTranslations("mealPlan");
const [entries, setEntries] = useState<Entry[]>(initialEntries);
const [addingCell, setAddingCell] = useState<string | null>(null);
@@ -46,7 +48,7 @@ export function SharedMealPlanView({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ day, mealType, recipeId, servings: 2 }),
});
if (!res.ok) { toast.error("Could not add recipe"); return; }
if (!res.ok) { toast.error(t("addFailed")); return; }
const { id } = await res.json() as { id: string };
const recipe = userRecipes.find((r) => r.id === recipeId) ?? null;
setEntries((prev) => [
@@ -60,7 +62,7 @@ export function SharedMealPlanView({
const res = await fetch(`/api/v1/meal-plans/shared/${mealPlanId}/entries?entryId=${entry.id}`, {
method: "DELETE",
});
if (!res.ok) { toast.error("Could not remove entry"); return; }
if (!res.ok) { toast.error(t("removeFailed")); return; }
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
}
@@ -97,7 +99,7 @@ export function SharedMealPlanView({
addingCell === key ? (
<Select onValueChange={(v) => void addEntry(day, mealType, v as string)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Pick recipe" />
<SelectValue placeholder={t("pickRecipe")} />
</SelectTrigger>
<SelectContent>
{userRecipes.map((r) => (
@@ -110,7 +112,7 @@ export function SharedMealPlanView({
className="w-full h-8 rounded-lg border border-dashed text-xs text-muted-foreground hover:bg-muted/30"
onClick={() => setAddingCell(key)}
>
+ Add
{t("addEntry")}
</button>
)
) : (
@@ -28,6 +28,7 @@ export function AdaptRecipeButton({
ingredients: Ingredient[];
}) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const router = useRouter();
const [open, setOpen] = useState(false);
const [excluded, setExcluded] = useState<Set<string>>(new Set());
@@ -76,7 +77,7 @@ export function AdaptRecipeButton({
const { id, adaptationNotes: notes } = await res.json() as { id: string; adaptationNotes: string };
setAdaptationNotes(notes);
toast.success("Adapted recipe saved as draft");
toast.success(t("adapted"));
setOpen(false);
reset();
router.push(`/recipes/${id}/edit`);
@@ -96,7 +97,7 @@ export function AdaptRecipeButton({
<Wand2 className="h-4 w-4" />
</Button>
} />
<TooltipContent>Adapt</TooltipContent>
<TooltipContent>{t("adaptTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -105,10 +106,10 @@ export function AdaptRecipeButton({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5 text-primary" />
Adapt this recipe
{t("adaptTitle")}
</DialogTitle>
<DialogDescription>
Tap ingredients to exclude them. AI will find the best substitutes while preserving the dish.
{t("adaptDescription")}
</DialogDescription>
</DialogHeader>
@@ -116,10 +117,10 @@ export function AdaptRecipeButton({
{/* Ingredient chips */}
<div className="space-y-2">
<Label>
Exclude ingredients
{t("excludeIngredients")}
{excluded.size > 0 && (
<span className="ml-2 text-xs text-destructive font-normal">
{excluded.size} excluded
{t("excludedCount", { count: excluded.size })}
</span>
)}
</Label>
@@ -149,8 +150,8 @@ export function AdaptRecipeButton({
{/* Free-text constraints */}
<div className="space-y-2">
<Label htmlFor="extra-constraints">
Additional constraints
<span className="ml-2 text-xs text-muted-foreground font-normal">optional</span>
{t("additionalConstraints")}
<span className="ml-2 text-xs text-muted-foreground font-normal">{t("optional")}</span>
</Label>
<Textarea
id="extra-constraints"
@@ -164,7 +165,7 @@ export function AdaptRecipeButton({
{adapting && (
<p className="text-sm text-muted-foreground">
Adapting recipe this may take 2030 seconds
{t("adapting")}
</p>
)}
@@ -174,17 +175,17 @@ export function AdaptRecipeButton({
onClick={() => { setOpen(false); reset(); }}
disabled={adapting}
>
Cancel
{tCommon("cancel")}
</Button>
{excluded.size > 0 && (
<Button variant="ghost" size="sm" onClick={() => setExcluded(new Set())} disabled={adapting}>
Clear
{t("clearExclusions")}
</Button>
)}
<Button onClick={handleAdapt} disabled={adapting || !hasConstraints}>
{adapting
? <><Loader2 className="h-4 w-4 animate-spin" />Adapting</>
: <><Wand2 className="h-4 w-4" />Adapt recipe</>
? <><Loader2 className="h-4 w-4 animate-spin" />{t("adaptingButton")}</>
: <><Wand2 className="h-4 w-4" />{t("adaptButton")}</>
}
</Button>
</div>
@@ -72,6 +72,8 @@ export function AiGenerateDialog({
onOpenChange: (open: boolean) => void;
}) {
const t = useTranslations("ai.generate");
const tCommon = useTranslations("common");
const tRecipe = useTranslations("recipe");
const router = useRouter();
const locale = useLocale();
const [tab, setTab] = useState<Tab>("describe");
@@ -177,18 +179,18 @@ export function AiGenerateDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Generate recipe with AI
{t("title")}
</DialogTitle>
<DialogDescription>
Describe a dish in words or snap a photo AI builds the full recipe.
{t("descriptionFull")}
</DialogDescription>
</DialogHeader>
{/* Tab switcher */}
<div className="flex gap-1 p-1 bg-muted rounded-lg">
{([
{ key: "describe", label: "Describe", icon: Type },
{ key: "photo", label: "From photo", icon: Camera },
{ key: "describe", label: t("tabDescribe"), icon: Type },
{ key: "photo", label: t("tabPhoto"), icon: Camera },
] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => (
<button
key={key}
@@ -212,7 +214,7 @@ export function AiGenerateDialog({
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="ai-prompt">What do you want to cook?</Label>
<Label htmlFor="ai-prompt">{t("prompt")}</Label>
<button
type="button"
onClick={() => {
@@ -223,7 +225,7 @@ export function AiGenerateDialog({
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors disabled:opacity-50"
>
<Shuffle className="h-3 w-3" />
Surprise me
{t("surpriseMe")}
</button>
</div>
<Textarea
@@ -237,7 +239,7 @@ export function AiGenerateDialog({
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Recipe language</Label>
<Label>{t("language")}</Label>
<Select value={language} onValueChange={(v) => v && setLanguage(v)} disabled={busy}>
<SelectTrigger>
<SelectValue />
@@ -250,16 +252,16 @@ export function AiGenerateDialog({
</Select>
</div>
<div className="space-y-2">
<Label>Difficulty</Label>
<Label>{t("difficulty")}</Label>
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
<SelectTrigger>
<SelectValue placeholder={t("anyDifficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any</SelectItem>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -300,14 +302,14 @@ export function AiGenerateDialog({
>
<Upload className="h-8 w-8" />
<div className="text-sm text-center">
<span className="font-medium text-foreground">Click to upload</span> or drag a food photo
<p className="text-xs mt-1">JPEG, PNG or WebP · AI will reverse-engineer the recipe</p>
<span className="font-medium text-foreground">{t("uploadClick")}</span> {t("uploadDrag")}
<p className="text-xs mt-1">{t("uploadFormats")}</p>
</div>
</button>
)}
{photoPreview && (
<p className="text-xs text-muted-foreground text-center">
AI will analyze this dish and infer ingredients, quantities, and cooking steps.
{t("photoAnalysisNote")}
</p>
)}
</div>
@@ -316,21 +318,21 @@ export function AiGenerateDialog({
<FakeProgressBar
active={busy}
durationMs={tab === "photo" ? 12000 : 10000}
label={busy ? (tab === "photo" ? "Analyzing dish photo" : "Generating recipe…") : undefined}
label={busy ? (tab === "photo" ? t("analyzePhoto") : t("generating")) : undefined}
/>
{/* Actions */}
<div className="flex gap-2 justify-end pt-1">
<Button variant="ghost" onClick={handleClose} disabled={busy}>
Cancel
{tCommon("cancel")}
</Button>
<Button
onClick={tab === "describe" ? () => { void handleDescribeGenerate(); } : () => { void handlePhotoGenerate(); }}
disabled={!canSubmit || busy}
>
{busy ? (
<><Loader2 className="h-4 w-4 animate-spin" />Analyzing</>
<><Loader2 className="h-4 w-4 animate-spin" />{t("analyzing")}</>
) : (
<><Sparkles className="h-4 w-4" />{tab === "photo" ? "Recognize dish" : "Generate"}</>
<><Sparkles className="h-4 w-4" />{tab === "photo" ? t("recognizeDish") : t("button")}</>
)}
</Button>
</div>
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const router = useRouter();
@@ -55,7 +56,7 @@ export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
<Trash2 className="h-4 w-4" />
</Button>
} />
<TooltipContent>Delete</TooltipContent>
<TooltipContent>{tCommon("delete")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialog open={open} onOpenChange={setOpen}>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Wine, Sparkles, Loader2, Coffee, Beer, GlassWater, Leaf } from "lucide-react";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { toast } from "sonner";
@@ -45,6 +46,7 @@ const TYPE_LABEL: Record<Drink["type"], string> = {
};
export function DrinkPairingButton({ recipeId }: { recipeId: string }) {
const t = useTranslations("recipe");
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [drinks, setDrinks] = useState<Drink[]>([]);
@@ -83,7 +85,7 @@ export function DrinkPairingButton({ recipeId }: { recipeId: string }) {
<Wine className="h-4 w-4" />
</Button>
} />
<TooltipContent>Drinks</TooltipContent>
<TooltipContent>{t("drinksTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -145,7 +145,7 @@ export function MealPairingButton({ recipeId }: { recipeId: string }) {
<UtensilsCrossed className="h-4 w-4" />
</Button>
} />
<TooltipContent>Pair meal</TooltipContent>
<TooltipContent>{t("pairMealTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -77,9 +77,9 @@ export function PhotoImportButton() {
) : (
<Camera className="mr-2 h-4 w-4" />
)}
Import from Photo
{t("importFromPhoto")}
</Button>
<FakeProgressBar active={loading} durationMs={12000} label={loading ? "Analyzing photo" : undefined} />
<FakeProgressBar active={loading} durationMs={12000} label={loading ? t("analyzingPhoto") : undefined} />
</div>
);
}
+3 -1
View File
@@ -1,10 +1,12 @@
"use client";
import { useTranslations } from "next-intl";
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export function PrintButton({ recipeId }: { recipeId: string }) {
const t = useTranslations("recipe");
function handlePrint() {
const win = window.open(`/print/${recipeId}`, "_blank", "width=800,height=900");
win?.focus();
@@ -18,7 +20,7 @@ export function PrintButton({ recipeId }: { recipeId: string }) {
<Printer className="h-4 w-4" />
</Button>
} />
<TooltipContent>Print</TooltipContent>
<TooltipContent>{t("print")}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
+5 -5
View File
@@ -341,31 +341,31 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<Label>{t("ingredients")}</Label>
<div className="space-y-2">
{ingredients.map((ing, i) => (
<div key={ing.id} className="flex gap-2 items-start">
<div key={ing.id} className="flex flex-wrap gap-2 items-start">
<GripVertical className="h-4 w-4 text-muted-foreground mt-2 cursor-grab shrink-0" />
<Input
value={ing.quantity}
onChange={(e) => updateIngredient(i, { quantity: e.target.value })}
placeholder={t("amount")}
className="w-16 shrink-0"
className="w-14 sm:w-16 shrink-0"
/>
<Input
value={ing.unit}
onChange={(e) => updateIngredient(i, { unit: e.target.value })}
placeholder={t("unit")}
className="w-24 shrink-0"
className="w-16 sm:w-24 shrink-0"
/>
<Input
value={ing.rawName}
onChange={(e) => updateIngredient(i, { rawName: e.target.value })}
placeholder={t("ingredient")}
className="flex-1 min-w-0"
className="flex-1 min-w-[100px]"
/>
<Input
value={ing.note}
onChange={(e) => updateIngredient(i, { note: e.target.value })}
placeholder={t("note")}
className="w-48 shrink-0"
className="w-full sm:w-48 sm:shrink-0"
/>
<button
type="button"
+13 -12
View File
@@ -176,6 +176,7 @@ function SelectableRecipeCard({
export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const [recipes, setRecipes] = useState(initialRecipes);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -201,7 +202,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
};
async function bulkDelete() {
if (!confirm(`Delete ${selected.size} recipe${selected.size !== 1 ? "s" : ""}? This cannot be undone.`)) return;
if (!confirm(t("bulkDeleteConfirm", { count: selected.size }))) return;
setBusy(true);
try {
const res = await fetch("/api/v1/recipes/bulk", {
@@ -255,12 +256,12 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
onClick={toggleAll}
className="text-sm font-medium text-primary hover:underline"
>
{allSelected ? "Deselect all" : "Select all"}
{allSelected ? t("deselectAll") : t("selectAll")}
</button>
<span className="text-sm text-muted-foreground">
{selected.size > 0
? `${selected.size} selected`
: "Click cards to select"}
? t("selectedCount", { count: selected.size })
: t("clickToSelect")}
</span>
</div>
) : (
@@ -273,9 +274,9 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
className={cn("gap-1.5", selectMode && "text-muted-foreground")}
>
{selectMode ? (
<><X className="h-4 w-4" />Cancel</>
<><X className="h-4 w-4" />{tCommon("cancel")}</>
) : (
<><ListChecks className="h-4 w-4" />Select</>
<><ListChecks className="h-4 w-4" />{t("select")}</>
)}
</Button>
</div>
@@ -298,7 +299,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 duration-200">
<div className="flex items-center gap-2 bg-popover border shadow-2xl rounded-2xl px-5 py-3">
<span className="text-sm font-semibold tabular-nums mr-1">
{selected.size} selected
{t("selectedCount", { count: selected.size })}
</span>
<div className="w-px h-5 bg-border mx-1" />
<DropdownMenu>
@@ -307,17 +308,17 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5"}
>
<Globe className="h-4 w-4" />
Visibility
{t("visibility")}
</DropdownMenuTrigger>
<DropdownMenuContent align="center" side="top">
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
<Globe className="h-4 w-4 mr-2 text-green-500" /> Make public
<Globe className="h-4 w-4 mr-2 text-green-500" /> {t("makePublic")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> Make unlisted
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> {t("makeUnlisted")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { void bulkSetVisibility("private"); }}>
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> Make private
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> {t("makePrivate")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -329,7 +330,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
className="gap-1.5"
>
<Trash2 className="h-4 w-4" />
Delete
{tCommon("delete")}
</Button>
</div>
</div>
+32 -31
View File
@@ -36,27 +36,27 @@ function TagFilterInput({ value, onChange }: { value: string; onChange: (v: stri
}
import { cn } from "@/lib/utils";
const SORT_LABELS: Record<string, string> = {
updated_desc: "Recently updated",
updated_asc: "Oldest updated",
created_desc: "Newest first",
created_asc: "Oldest first",
title_asc: "Title AZ",
title_desc: "Title ZA",
const SORT_KEYS: Record<string, string> = {
updated_desc: "sortRecentlyUpdated",
updated_asc: "sortOldestUpdated",
created_desc: "sortNewestFirst",
created_asc: "sortOldestFirst",
title_asc: "sortTitleAZ",
title_desc: "sortTitleZA",
};
const VISIBILITY_LABELS: Record<string, string> = {
"": "All",
private: "Private",
unlisted: "Unlisted",
public: "Public",
const VISIBILITY_KEYS: Record<string, string> = {
"": "all",
private: "private",
unlisted: "unlisted",
public: "public",
};
const DIFFICULTY_LABELS: Record<string, string> = {
"": "Any difficulty",
easy: "Easy",
medium: "Medium",
hard: "Hard",
const DIFFICULTY_KEYS: Record<string, string> = {
"": "anyDifficulty",
easy: "easy",
medium: "medium",
hard: "hard",
};
export function RecipesHeader({
@@ -77,6 +77,7 @@ export function RecipesHeader({
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("recipes");
const tRecipe = useTranslations("recipe");
const [aiOpen, setAiOpen] = useState(false);
const [urlOpen, setUrlOpen] = useState(false);
const [query, setQuery] = useState(initialQuery);
@@ -138,7 +139,7 @@ export function RecipesHeader({
<div className="flex flex-wrap gap-2">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm">
<div className="relative w-full sm:flex-1 sm:min-w-[200px] sm:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
value={query}
@@ -165,19 +166,19 @@ export function RecipesHeader({
)}
>
<ArrowUpDown className="h-4 w-4" />
{SORT_LABELS[initialSort] ?? "Sort"}
{SORT_KEYS[initialSort] ? t(SORT_KEYS[initialSort]) : t("sort")}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuLabel>{t("sortBy")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(SORT_LABELS).map(([value, label]) => (
{Object.entries(SORT_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ sort: value })}
className={cn(initialSort === value && "font-medium text-primary")}
>
{label}
{t(key)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
@@ -193,7 +194,7 @@ export function RecipesHeader({
)}
>
<SlidersHorizontal className="h-4 w-4" />
Filter
{t("filter")}
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1 h-4 min-w-4 px-1 text-xs">
{activeFilterCount}
@@ -202,33 +203,33 @@ export function RecipesHeader({
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Visibility</DropdownMenuLabel>
{Object.entries(VISIBILITY_LABELS).map(([value, label]) => (
<DropdownMenuLabel>{t("visibilityLabel")}</DropdownMenuLabel>
{Object.entries(VISIBILITY_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ visibility: value })}
className={cn(initialVisibility === value && "font-medium text-primary")}
>
{label}
{value === "" ? t(key) : tRecipe(`visibility.${key}`)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Difficulty</DropdownMenuLabel>
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
<DropdownMenuLabel>{t("difficultyLabel")}</DropdownMenuLabel>
{Object.entries(DIFFICULTY_KEYS).map(([value, key]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ difficulty: value })}
className={cn(initialDifficulty === value && "font-medium text-primary")}
>
{label}
{value === "" ? t(key) : tRecipe(`difficulty.${key}`)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Tag</DropdownMenuLabel>
<DropdownMenuLabel>{t("tagLabel")}</DropdownMenuLabel>
<div className="px-2 py-1">
<TagFilterInput
value={initialTag}
@@ -244,7 +245,7 @@ export function RecipesHeader({
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "" })}
className="text-muted-foreground"
>
<X className="h-3 w-3 mr-1.5" /> Clear filters
<X className="h-3 w-3 mr-1.5" /> {t("clearFilters")}
</DropdownMenuItem>
</DropdownMenuGroup>
</>
@@ -0,0 +1,39 @@
"use client";
import { useTranslations } from "next-intl";
import { Share2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export function ShareRecipeButton({ recipeId, visibility }: { recipeId: string; visibility: string }) {
const t = useTranslations("recipe");
async function handleShare() {
const url = `${window.location.origin}/r/${recipeId}`;
try {
await navigator.clipboard.writeText(url);
} catch {
toast.error(t("shareCopyFailed"));
return;
}
if (visibility === "public") {
toast.success(t("shareLinkCopied"));
} else {
toast.info(t("shareNotPublicNotice"));
}
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => void handleShare()}>
<Share2 className="h-4 w-4" />
</Button>
} />
<TooltipContent>{t("share")}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -34,6 +34,7 @@ const LANGUAGES = [
export function TranslateButton({ recipeId }: { recipeId: string }) {
const t = useTranslations("ai.translate");
const tRecipe = useTranslations("recipe");
const router = useRouter();
const [open, setOpen] = useState(false);
const [targetLanguage, setTargetLanguage] = useState("French");
@@ -72,7 +73,7 @@ export function TranslateButton({ recipeId }: { recipeId: string }) {
<Languages className="h-4 w-4" />
</Button>
} />
<TooltipContent>Translate</TooltipContent>
<TooltipContent>{tRecipe("translateTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@@ -23,6 +24,7 @@ export function VariationsButton({
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null; note?: string | null; order: number }>;
steps: Array<{ instruction: string; timerSeconds?: number | null; order: number }>;
}) {
const t = useTranslations("recipe");
const [open, setOpen] = useState(false);
return (
@@ -34,7 +36,7 @@ export function VariationsButton({
<GitBranch className="h-4 w-4" />
</Button>
} />
<TooltipContent>Variations</TooltipContent>
<TooltipContent>{t("variationsTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<VariationsDialog
@@ -63,6 +63,7 @@ export function VariationsDialog({
onOpenChange: (open: boolean) => void;
}) {
const t = useTranslations("recipe");
const tv = useTranslations("ai.variations");
const router = useRouter();
const [generating, setGenerating] = useState(false);
const [applying, setApplying] = useState<number | null>(null);
@@ -80,7 +81,7 @@ export function VariationsDialog({
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? "Failed to generate variations");
toast.error(err.error ?? tv("error"));
return;
}
const data = await res.json() as { variations: Variation[] };
@@ -140,12 +141,12 @@ export function VariationsDialog({
});
if (!saveRes.ok) {
toast.error("Failed to save variation");
toast.error(tv("saveError"));
return;
}
const saved = await saveRes.json() as { id: string };
toast.success("Variation created — review and edit before publishing");
toast.success(tv("success"));
onOpenChange(false);
router.push(`/recipes/${saved.id}/edit`);
} finally {
@@ -159,17 +160,17 @@ export function VariationsDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI Recipe Variations
{tv("title")}
</DialogTitle>
<DialogDescription>
Generate creative variations of this recipe dietary adaptations, flavor twists, technique changes.
{tv("description")}
</DialogDescription>
</DialogHeader>
{variations.length === 0 ? (
<div className="flex flex-col gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="directions">Directions <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Label htmlFor="directions">{tv("directions")} <span className="text-muted-foreground font-normal">({tv("optional")})</span></Label>
<Textarea
id="directions"
placeholder={t("variationsDirectionsPlaceholder")}
@@ -184,16 +185,16 @@ export function VariationsDialog({
{generating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Generating variations
{tv("generating")}
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Generate 3 variations
{tv("generate")}
</>
)}
</Button>
<FakeProgressBar active={generating} durationMs={22000} label={generating ? "Generating 3 creative variations…" : undefined} />
<FakeProgressBar active={generating} durationMs={22000} label={generating ? tv("generating") : undefined} />
</div>
) : (
<div className="space-y-4">
@@ -214,13 +215,13 @@ export function VariationsDialog({
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
Apply
{tv("apply")}
</Button>
</div>
{v.changedIngredients.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ingredient changes</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{tv("ingredientChanges")}</p>
<div className="space-y-1">
{v.changedIngredients.map((c, j) => (
<div key={j} className="flex items-center gap-2 text-sm">
@@ -237,11 +238,11 @@ export function VariationsDialog({
{v.changedSteps && v.changedSteps.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Step changes</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{tv("stepChanges")}</p>
<div className="space-y-1">
{v.changedSteps.map((c, j) => (
<div key={j} className="flex gap-2 text-sm">
<Badge variant="outline" className="text-xs shrink-0">Step {c.stepNumber}</Badge>
<Badge variant="outline" className="text-xs shrink-0">{tv("stepLabel", { n: c.stepNumber })}</Badge>
<span className="text-muted-foreground">{c.change}</span>
</div>
))}
@@ -265,7 +266,7 @@ export function VariationsDialog({
<Button variant="outline" className="w-full" onClick={generate} disabled={generating}>
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
Regenerate
{tv("regenerate")}
</Button>
</div>
)}
@@ -136,7 +136,7 @@ export function VersionHistoryButton({
<History className="h-4 w-4" />
</Button>
} />
<TooltipContent>History</TooltipContent>
<TooltipContent>{t("historyTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Sheet open={open} onOpenChange={handleOpen}>
@@ -160,42 +160,55 @@ export function VersionHistoryButton({
return (
<div key={v.id} className="border rounded-lg overflow-hidden">
<div className="flex items-center gap-2 p-3">
<button
className="flex-1 text-left text-sm"
onClick={() => void handleExpand(v)}
>
<span className="font-medium">v{v.version}</span>
<span className="text-muted-foreground"> {v.title} {formatDate(v.createdAt)}</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title={tForm("expand")}
onClick={() => void handleExpand(v)}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
</Button>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => void handleCompare(v)}
>
<GitCompare className="h-3 w-3" />
Compare
</Button>
<Button
variant="outline"
size="sm"
className="shrink-0"
disabled={restoringId === v.id}
onClick={() => void handleRestore(v)}
>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
<div className="flex flex-col gap-2 p-3 sm:flex-row sm:items-center">
<div className="flex items-center gap-2">
<button
className="flex-1 text-left text-sm"
onClick={() => void handleExpand(v)}
>
<span className="font-medium">v{v.version}</span>
<span className="text-muted-foreground"> {v.title} {formatDate(v.createdAt)}</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 sm:hidden"
title={tForm("expand")}
onClick={() => void handleExpand(v)}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 hidden sm:inline-flex"
title={tForm("expand")}
onClick={() => void handleExpand(v)}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 sm:flex-initial shrink-0"
onClick={() => void handleCompare(v)}
>
<GitCompare className="h-3 w-3" />
Compare
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 sm:flex-initial shrink-0"
disabled={restoringId === v.id}
onClick={() => void handleRestore(v)}
>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
</div>
</div>
{isExpanded && (
@@ -244,7 +244,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
<div className="max-w-5xl mx-auto space-y-10">
{/* Search bar */}
<div className="space-y-3">
<h1 className="text-3xl font-bold">Explore</h1>
<h1 className="text-3xl font-bold">{t("title")}</h1>
<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
@@ -264,10 +264,10 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
<SelectValue placeholder={tCommon("difficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any difficulty</SelectItem>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
<SelectItem value="any">{t("anyDifficulty")}</SelectItem>
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
</SelectContent>
</Select>
<Input
@@ -280,7 +280,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
/>
{!loading && (
<span className="text-sm text-muted-foreground ml-auto">
{total} {total === 1 ? "recipe" : "recipes"} found
{t(total === 1 ? "resultsFoundSingular" : "resultsFoundPlural", { count: total })}
</span>
)}
</div>
@@ -303,8 +303,8 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
{hasQuery && !loading && results.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<ChefHat className="h-12 w-12 mb-4 opacity-30" />
<p className="text-lg font-medium">No recipes found for &ldquo;{query}&rdquo;</p>
<p className="text-sm mt-1">Try different keywords or remove filters</p>
<p className="text-lg font-medium">{t("noResultsTitle", { query })}</p>
<p className="text-sm mt-1">{t("noResultsHint")}</p>
</div>
)}
@@ -323,7 +323,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
disabled={loadingMore}
className="min-w-32"
>
{loadingMore ? "Loading" : "Load more"}
{loadingMore ? tCommon("loading") : t("loadMore")}
</Button>
</div>
)}
@@ -337,9 +337,9 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
<section className="rounded-xl border bg-gradient-to-br from-violet-50 to-indigo-50 dark:from-violet-950/30 dark:to-indigo-950/30 p-6 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-violet-500" />
<h2 className="text-xl font-semibold">Recipe ideas</h2>
<h2 className="text-xl font-semibold">{t("recipeIdeas")}</h2>
</div>
<p className="text-sm text-muted-foreground">Tell the AI what you&apos;re in the mood for, or let it surprise you.</p>
<p className="text-sm text-muted-foreground">{t("recipeIdeasHint")}</p>
<form
onSubmit={(e) => {
e.preventDefault();
@@ -355,9 +355,9 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
/>
<Button type="submit" disabled={ideasLoading} variant="secondary" className="shrink-0">
{ideasLoading ? (
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4 animate-pulse" /> Generating</span>
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4 animate-pulse" /> {t("generating")}</span>
) : (
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4" /> Get ideas</span>
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4" /> {t("getIdeas")}</span>
)}
</Button>
<Button
@@ -367,7 +367,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
onClick={() => { setIdeasPrompt(""); fetchIdeas(""); }}
className="shrink-0"
>
Surprise me
{t("surpriseMe")}
</Button>
</form>
@@ -414,9 +414,9 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
onClick={() => generateFromIdea(idea)}
>
{generatingId === idea.title ? (
<span className="flex items-center gap-2"><Wand2 className="h-3.5 w-3.5 animate-pulse" /> Generating</span>
<span className="flex items-center gap-2"><Wand2 className="h-3.5 w-3.5 animate-pulse" /> {t("generating")}</span>
) : (
<span className="flex items-center gap-2"><ArrowRight className="h-3.5 w-3.5" /> Generate full recipe</span>
<span className="flex items-center gap-2"><ArrowRight className="h-3.5 w-3.5" /> {t("generateFullRecipe")}</span>
)}
</Button>
</div>
@@ -428,11 +428,11 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
<section>
<div className="flex items-center gap-2 mb-4">
<Flame className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-semibold">Trending this week</h2>
<h2 className="text-xl font-semibold">{t("trendingThisWeek")}</h2>
</div>
{trending.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
No trending recipes yet. Be the first to share one!
{t("noTrending")}
</p>
) : (
<HorizontalScroll>
@@ -448,11 +448,11 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold">Recently added</h2>
<h2 className="text-xl font-semibold">{t("recentlyAdded")}</h2>
</div>
{recent.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
No public recipes yet.
{t("noRecent")}
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@@ -95,7 +95,7 @@ export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) {
{/* Create form */}
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="key-name">Key name</Label>
<Label htmlFor="key-name">{t("apiKeyNameLabel")}</Label>
<div className="flex gap-2">
<Input
id="key-name"
@@ -106,7 +106,7 @@ export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) {
required
/>
<Button type="submit" disabled={creating || !name.trim()}>
{creating ? "Creating" : "Create"}
{creating ? t("apiKeyCreating") : t("apiKeyCreate")}
</Button>
</div>
</div>
@@ -116,14 +116,14 @@ export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) {
{newKey && (
<div className="rounded-md border border-yellow-400 bg-yellow-50 p-4 space-y-3 dark:bg-yellow-950 dark:border-yellow-700">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Save this key it will not be shown again.
{t("apiKeyRevealNotice")}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-white dark:bg-black border px-3 py-2 text-sm font-mono break-all">
{newKey}
</code>
<Button type="button" variant="outline" onClick={handleCopy}>
{copied ? "Copied!" : "Copy"}
{copied ? t("copied") : t("copy")}
</Button>
</div>
<Button
@@ -133,14 +133,14 @@ export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) {
onClick={() => setNewKey(null)}
className="text-muted-foreground"
>
Dismiss
{t("dismiss")}
</Button>
</div>
)}
{/* Keys list */}
{keys.length === 0 ? (
<p className="text-sm text-muted-foreground">No API keys yet.</p>
<p className="text-sm text-muted-foreground">{t("noApiKeys")}</p>
) : (
<div className="divide-y rounded-md border">
{keys.map((k) => (
@@ -148,12 +148,12 @@ export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) {
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium truncate">{k.name}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Created {formatDate(k.createdAt)}</span>
<span>{t("createdOn", { date: formatDate(k.createdAt) })}</span>
<span>·</span>
{k.lastUsedAt ? (
<span>Last used {formatDate(k.lastUsedAt)}</span>
<span>{t("lastUsedOn", { date: formatDate(k.lastUsedAt) })}</span>
) : (
<Badge variant="secondary" className="text-xs">Never used</Badge>
<Badge variant="secondary" className="text-xs">{t("neverUsed")}</Badge>
)}
</div>
</div>
@@ -163,7 +163,7 @@ export function ApiKeysManager({ initialKeys }: { initialKeys: ApiKey[] }) {
size="sm"
onClick={() => { void handleRevoke(k.id); }}
>
Revoke
{t("revoke")}
</Button>
</div>
))}
@@ -76,7 +76,7 @@ export function ByokManager({ initialKeys }: { initialKeys: string[] }) {
<div className="flex items-center gap-2 flex-1">
<Badge variant="secondary" className="gap-1">
<Check className="h-3 w-3" />
Configured
{t("byokConfigured")}
</Badge>
<Button
variant="ghost"
@@ -89,7 +89,7 @@ export function ByokManager({ initialKeys }: { initialKeys: string[] }) {
</Button>
</div>
) : isOllama ? (
<span className="text-sm text-muted-foreground">Set via OLLAMA_BASE_URL env var</span>
<span className="text-sm text-muted-foreground">{t("byokOllamaHint")}</span>
) : (
<div className="flex items-center gap-2 flex-1">
<Input
@@ -106,7 +106,7 @@ export function ByokManager({ initialKeys }: { initialKeys: string[] }) {
disabled={saving === p.id || !inputs[p.id]?.trim()}
onClick={() => { void saveKey(p.id); }}
>
{saving === p.id ? "Saving" : "Save"}
{saving === p.id ? t("byokSaving") : t("byokSave")}
</Button>
</div>
)}
@@ -114,7 +114,7 @@ export function ByokManager({ initialKeys }: { initialKeys: string[] }) {
);
})}
<p className="text-xs text-muted-foreground">
Your keys are encrypted at rest. When set, they override the app&apos;s default keys for your account.
{t("byokFooter")}
</p>
</div>
);
@@ -20,14 +20,14 @@ type Provider = "openai" | "anthropic" | "openrouter" | "ollama" | "";
type UseCase = {
key: "text" | "vision" | "mealPlan";
label: string;
description: string;
labelKey: "useCaseText" | "useCaseVision" | "useCaseMealPlan";
descriptionKey: "useCaseTextDesc" | "useCaseVisionDesc" | "useCaseMealPlanDesc";
};
const USE_CASES: UseCase[] = [
{ key: "text", label: "Text generation", description: "Recipe generation, variations, translations, adapt" },
{ key: "vision", label: "Vision / photo import", description: "Dish recognition, recipe import from photo" },
{ key: "mealPlan", label: "Meal planning", description: "Weekly meal plan generation" },
{ key: "text", labelKey: "useCaseText", descriptionKey: "useCaseTextDesc" },
{ key: "vision", labelKey: "useCaseVision", descriptionKey: "useCaseVisionDesc" },
{ key: "mealPlan", labelKey: "useCaseMealPlan", descriptionKey: "useCaseMealPlanDesc" },
];
const PRESET_MODELS: Record<string, { label: string; models: { value: string; label: string }[] }> = {
@@ -87,7 +87,7 @@ export function ModelPrefsForm({ initialPrefs }: { initialPrefs: Prefs | null })
return (
<div className="space-y-6">
{USE_CASES.map(({ key, label, description }) => {
{USE_CASES.map(({ key, labelKey, descriptionKey }) => {
const providerKey = `${key}Provider` as keyof Prefs;
const modelKey = `${key}Model` as keyof Prefs;
const provider = (prefs[providerKey] ?? "") as Provider;
@@ -97,12 +97,12 @@ export function ModelPrefsForm({ initialPrefs }: { initialPrefs: Prefs | null })
return (
<div key={key} className="rounded-lg border p-4 space-y-3">
<div>
<p className="font-medium text-sm">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
<p className="font-medium text-sm">{t(labelKey)}</p>
<p className="text-xs text-muted-foreground">{t(descriptionKey)}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Provider</Label>
<Label className="text-xs">{t("providerLabel")}</Label>
<Select
value={provider || "default"}
onValueChange={(v) => {
@@ -116,7 +116,7 @@ export function ModelPrefsForm({ initialPrefs }: { initialPrefs: Prefs | null })
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
<span className="text-muted-foreground">Default (auto)</span>
<span className="text-muted-foreground">{t("defaultAuto")}</span>
</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
@@ -127,7 +127,7 @@ export function ModelPrefsForm({ initialPrefs }: { initialPrefs: Prefs | null })
</div>
<div className="space-y-1.5">
<Label className="text-xs">Model</Label>
<Label className="text-xs">{t("modelLabel")}</Label>
{presets ? (
<Select
value={model || "default"}
@@ -138,7 +138,7 @@ export function ModelPrefsForm({ initialPrefs }: { initialPrefs: Prefs | null })
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
<span className="text-muted-foreground">Default</span>
<span className="text-muted-foreground">{t("modelDefault")}</span>
</SelectItem>
<SelectGroup>
<SelectLabel>{PRESET_MODELS[provider]?.label}</SelectLabel>
@@ -164,7 +164,7 @@ export function ModelPrefsForm({ initialPrefs }: { initialPrefs: Prefs | null })
})}
<Button onClick={() => { void handleSave(); }} disabled={saving} size="sm">
{saving ? "Saving" : "Save model preferences"}
{saving ? t("modelSaving") : t("saveModelPrefs")}
</Button>
</div>
);
@@ -2,26 +2,28 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { User, Shield, Bot, Bell, Apple, Key, Webhook } from "lucide-react";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ href: "/settings", label: "Profile", icon: User, exact: true },
{ href: "/settings/security", label: "Security", icon: Shield },
{ href: "/settings/ai", label: "AI & Models", icon: Bot },
{ href: "/settings/notifications", label: "Notifications", icon: Bell },
{ href: "/settings/nutrition", label: "Nutrition", icon: Apple },
{ href: "/settings/api-keys", label: "API Keys", icon: Key },
{ href: "/settings/webhooks", label: "Webhooks", icon: Webhook },
];
{ href: "/settings", key: "profile", icon: User, exact: true },
{ href: "/settings/security", key: "security", icon: Shield, exact: false },
{ href: "/settings/ai", key: "aiModels", icon: Bot, exact: false },
{ href: "/settings/notifications", key: "notifications", icon: Bell, exact: false },
{ href: "/settings/nutrition", key: "nutrition", icon: Apple, exact: false },
{ href: "/settings/api-keys", key: "apiKeys", icon: Key, exact: false },
{ href: "/settings/webhooks", key: "webhooks", icon: Webhook, exact: false },
] as const;
export function SettingsSidebar() {
const pathname = usePathname();
const t = useTranslations("settings");
return (
<nav className="w-48 shrink-0 sticky top-6">
<ul className="space-y-0.5">
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
{NAV_ITEMS.map(({ href, key, icon: Icon, exact }) => {
const active = exact ? pathname === href : pathname.startsWith(href);
return (
<li key={href}>
@@ -35,7 +37,7 @@ export function SettingsSidebar() {
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
{t(key)}
</Link>
</li>
);
@@ -67,6 +67,7 @@ function formatDateTime(iso: string) {
export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[] }) {
const t = useTranslations("settingsForm");
const tCommon = useTranslations("common");
const [webhookList, setWebhookList] = useState<Webhook[]>(initialWebhooks);
const [url, setUrl] = useState("");
const [selectedEvents, setSelectedEvents] = useState<WebhookEventType[]>([]);
@@ -201,14 +202,14 @@ export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[
<div className="space-y-8">
<div className="text-sm text-muted-foreground mb-4">
<Link href="/settings/webhooks/docs" className="text-primary hover:underline">
View webhook docs &amp; Zapier integration
{t("webhookDocsLink")}
</Link>
</div>
{/* Create form */}
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="webhook-url">Endpoint URL</Label>
<Label htmlFor="webhook-url">{t("endpointUrlLabel")}</Label>
<Input
id="webhook-url"
type="url"
@@ -221,9 +222,9 @@ export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[
</div>
<div className="space-y-2">
<Label>Events</Label>
<Label>{t("eventsLabel")}</Label>
<p className="text-xs text-muted-foreground">
Select which events trigger this webhook. Leave all unchecked to receive all events.
{t("eventsHint")}
</p>
<div className="flex flex-wrap gap-3">
{ALL_EVENTS.map((event) => {
@@ -252,28 +253,28 @@ export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[
{newSecret && (
<div className="rounded-md border border-yellow-400 bg-yellow-50 p-4 space-y-3 dark:bg-yellow-950 dark:border-yellow-700">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Save this signing secret it will not be shown again.
{t("secretRevealNotice")}
</p>
<p className="text-xs text-yellow-700 dark:text-yellow-300">
Use it to verify the <code className="font-mono">X-Epicure-Signature</code> header on incoming requests.
{t("secretUsageHint")}
</p>
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-white dark:bg-black border px-3 py-2 text-sm font-mono break-all">
{newSecret.secret}
</code>
<Button type="button" variant="outline" onClick={() => { void handleCopySecret(); }}>
{copied ? "Copied!" : "Copy"}
{copied ? t("copied") : t("copy")}
</Button>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => setNewSecret(null)} className="text-muted-foreground">
Dismiss
{t("dismiss")}
</Button>
</div>
)}
{/* Webhook list */}
{webhookList.length === 0 ? (
<p className="text-sm text-muted-foreground">No webhooks yet.</p>
<p className="text-sm text-muted-foreground">{t("noWebhooks")}</p>
) : (
<div className="divide-y rounded-md border">
{webhookList.map((w) => {
@@ -285,10 +286,10 @@ export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-mono truncate">{w.url}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Added {formatDate(w.createdAt)}</span>
<span>{t("addedOn", { date: formatDate(w.createdAt) })}</span>
<span>·</span>
<Badge variant={w.active ? "default" : "secondary"} className="text-xs">
{w.active ? "Active" : "Inactive"}
{w.active ? t("active") : t("inactive")}
</Badge>
</div>
{w.events.length > 0 && (
@@ -299,7 +300,7 @@ export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[
</div>
)}
{w.events.length === 0 && (
<p className="text-xs text-muted-foreground pt-1">All events</p>
<p className="text-xs text-muted-foreground pt-1">{t("allEvents")}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
@@ -311,13 +312,13 @@ export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[
className="text-muted-foreground gap-1"
>
{isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
Deliveries
{t("deliveries")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { void handleToggleActive(w.id, w.active); }}>
{w.active ? "Disable" : "Enable"}
{w.active ? t("disable") : t("enable")}
</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { void handleDelete(w.id); }}>
Delete
{tCommon("delete")}
</Button>
</div>
</div>
@@ -326,9 +327,9 @@ export function WebhooksManager({ initialWebhooks }: { initialWebhooks: Webhook[
{isExpanded && (
<div className="mt-2 rounded-md border bg-muted/30">
{loadingDeliveries === w.id ? (
<p className="text-xs text-muted-foreground px-3 py-2">Loading</p>
<p className="text-xs text-muted-foreground px-3 py-2">{tCommon("loading")}</p>
) : wDeliveries.length === 0 ? (
<p className="text-xs text-muted-foreground px-3 py-2">No deliveries yet.</p>
<p className="text-xs text-muted-foreground px-3 py-2">{t("noDeliveriesYet")}</p>
) : (
<div className="divide-y">
{wDeliveries.map((d) => (
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { ShoppingBag, Copy, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import {
@@ -20,12 +21,13 @@ interface Props {
}
export function GroceryExportButton({ listId, instacartEnabled }: Props) {
const t = useTranslations("shoppingLists");
const [loading, setLoading] = useState(false);
async function fetchPayload(): Promise<GroceryExportPayload | null> {
const res = await fetch(`/api/v1/shopping-lists/${listId}/export`);
if (!res.ok) {
toast.error("Could not build export");
toast.error(t("exportBuildFailed"));
return null;
}
return res.json() as Promise<GroceryExportPayload>;
@@ -37,7 +39,7 @@ export function GroceryExportButton({ listId, instacartEnabled }: Props) {
const payload = await fetchPayload();
if (!payload) return;
await navigator.clipboard.writeText(groceryExportToText(payload));
toast.success("List copied to clipboard");
toast.success(t("copiedToClipboard"));
} finally {
setLoading(false);
}
@@ -48,7 +50,7 @@ export function GroceryExportButton({ listId, instacartEnabled }: Props) {
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/export/instacart`, { method: "POST" });
if (!res.ok) {
toast.error("Instacart isn't configured yet");
toast.error(t("instacartNotConfigured"));
return;
}
const { url } = await res.json() as { url: string };
@@ -63,18 +65,18 @@ export function GroceryExportButton({ listId, instacartEnabled }: Props) {
<DropdownMenuTrigger render={
<Button variant="outline" size="sm" disabled={loading}>
<ShoppingBag className="h-4 w-4" />
Send to grocery delivery
{t("sendToGroceryDelivery")}
</Button>
} />
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => void handleCopy()}>
<Copy className="h-4 w-4" />
Copy list as text
{t("copyAsText")}
</DropdownMenuItem>
{instacartEnabled && (
<DropdownMenuItem onClick={() => void handleInstacart()}>
<ExternalLink className="h-4 w-4" />
Send to Instacart
{t("sendToInstacart")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { UserPlus, X } from "lucide-react";
import { toast } from "sonner";
import {
@@ -40,6 +41,9 @@ interface Props {
}
export function ShareShoppingListButton({ listId }: Props) {
const t = useTranslations("shoppingLists");
const ts = useTranslations("shareDialog");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [role, setRole] = useState<Role>("viewer");
@@ -55,7 +59,7 @@ export function ShareShoppingListButton({ listId }: Props) {
const data = await res.json() as Member[];
setMembers(data);
} catch {
toast.error("Could not load members");
toast.error(ts("loadMembersFailed"));
} finally {
setLoading(false);
}
@@ -73,7 +77,7 @@ export function ShareShoppingListButton({ listId }: Props) {
async function handleInvite() {
if (!email.trim()) {
toast.error("Enter an email address");
toast.error(ts("enterEmail"));
return;
}
setInviting(true);
@@ -83,14 +87,14 @@ export function ShareShoppingListButton({ listId }: Props) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), role }),
});
if (res.status === 409) { toast.error("Already a member"); return; }
if (res.status === 404) { toast.error("User not found"); return; }
if (!res.ok) { toast.error("Could not invite user"); return; }
toast.success("Invitation sent");
if (res.status === 409) { toast.error(ts("alreadyMember")); return; }
if (res.status === 404) { toast.error(ts("userNotFound")); return; }
if (!res.ok) { toast.error(ts("inviteFailed")); return; }
toast.success(ts("invitationSent"));
setEmail("");
await fetchMembers();
} catch {
toast.error("Could not invite user");
toast.error(ts("inviteFailed"));
} finally {
setInviting(false);
}
@@ -102,11 +106,11 @@ export function ShareShoppingListButton({ listId }: Props) {
`/api/v1/shopping-lists/${listId}/members?memberId=${memberId}`,
{ method: "DELETE" },
);
if (!res.ok) { toast.error("Could not remove member"); return; }
if (!res.ok) { toast.error(ts("removeMemberFailed")); return; }
setMembers((prev) => prev.filter((m) => m.id !== memberId));
toast.success("Member removed");
toast.success(ts("memberRemoved"));
} catch {
toast.error("Could not remove member");
toast.error(ts("removeMemberFailed"));
}
}
@@ -114,22 +118,22 @@ export function ShareShoppingListButton({ listId }: Props) {
<>
<Button variant="outline" size="sm" onClick={() => handleOpenChange(true)}>
<UserPlus className="h-4 w-4" />
Share
{tCommon("share")}
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Share shopping list</DialogTitle>
<DialogTitle>{t("shareTitle")}</DialogTitle>
<DialogDescription>
Invite household members to view or edit this list.
{t("shareDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mt-2">
<Input
type="email"
placeholder="Email address"
placeholder={ts("emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }}
@@ -140,21 +144,21 @@ export function ShareShoppingListButton({ listId }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="viewer">{ts("viewer")}</SelectItem>
<SelectItem value="editor">{ts("editor")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => void handleInvite()} disabled={inviting}>
Invite
{ts("invite")}
</Button>
</div>
<div className="mt-4 space-y-2">
{loading && (
<p className="text-sm text-muted-foreground">Loading members</p>
<p className="text-sm text-muted-foreground">{ts("loadingMembers")}</p>
)}
{!loading && members.length === 0 && (
<p className="text-sm text-muted-foreground">No members yet.</p>
<p className="text-sm text-muted-foreground">{ts("noMembers")}</p>
)}
{members.map((m) => (
<div
@@ -170,7 +174,7 @@ export function ShareShoppingListButton({ listId }: Props) {
)}
</div>
<Badge variant={m.role === "editor" ? "default" : "secondary"}>
{m.role}
{ts(m.role)}
</Badge>
<Button
variant="ghost"
@@ -26,6 +26,7 @@ type Props = {
export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
const t = useTranslations("shoppingLists");
const ts = useTranslations("shareDialog");
return (
<div className="space-y-6">
@@ -69,7 +70,7 @@ export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
{sharedLists.length > 0 && (
<div className="space-y-3 max-w-lg">
<h2 className="text-sm font-semibold text-muted-foreground">Shared with you</h2>
<h2 className="text-sm font-semibold text-muted-foreground">{t("sharedWithYou")}</h2>
{sharedLists.map((list) => (
<Link
key={list.id}
@@ -79,7 +80,7 @@ export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
<div className="space-y-1">
<h3 className="font-semibold">{list.name}</h3>
<p className="text-sm text-muted-foreground">
{list.ownerName} · {list.role}
{list.ownerName} · {ts(list.role)}
</p>
</div>
</Link>
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
@@ -11,6 +12,9 @@ import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
export function NewCollectionButton() {
const t = useTranslations("collections");
const tSocial = useTranslations("social");
const tCommon = useTranslations("common");
const router = useRouter();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
@@ -27,9 +31,9 @@ export function NewCollectionButton() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: description.trim() || undefined, isPublic }),
});
if (!res.ok) { toast.error("Failed to create"); return; }
if (!res.ok) { toast.error(t("createFailed")); return; }
const { id } = await res.json() as { id: string };
toast.success("Collection created");
toast.success(tSocial("collectionCreated"));
setOpen(false);
setName(""); setDescription(""); setIsPublic(false);
router.push(`/collections/${id}`);
@@ -42,27 +46,27 @@ export function NewCollectionButton() {
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" /> New collection
<Plus className="h-4 w-4" /> {t("new")}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader><DialogTitle>New collection</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{t("newTitle")}</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="col-name">Name</Label>
<Input id="col-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Weekend dinners" />
<Label htmlFor="col-name">{t("nameLabel")}</Label>
<Input id="col-name" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("namePlaceholder")} />
</div>
<div className="space-y-2">
<Label htmlFor="col-desc">Description</Label>
<Textarea id="col-desc" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="Optional…" />
<Label htmlFor="col-desc">{t("descriptionLabel")}</Label>
<Textarea id="col-desc" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder={t("descriptionPlaceholder")} />
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} className="rounded" />
Make public
{t("makePublic")}
</label>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? "Creating" : "Create"}</Button>
<Button variant="outline" onClick={() => setOpen(false)}>{tCommon("cancel")}</Button>
<Button onClick={create} disabled={!name.trim() || saving}>{saving ? t("creating") : t("create")}</Button>
</div>
</div>
</DialogContent>