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:
@@ -10,6 +10,7 @@ import { ForkCollectionButton } from "@/components/collections/fork-collection-b
|
||||
import { ShareCollectionButton } from "@/components/collections/share-collection-button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getMessages } from "@/lib/i18n/server";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -19,6 +20,7 @@ export default async function CollectionPage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const col = await db.query.collections.findFirst({
|
||||
where: and(
|
||||
@@ -46,7 +48,7 @@ export default async function CollectionPage({ params }: Params) {
|
||||
{col.recipes.length > 0 && (
|
||||
<Link href={`/print/collection/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Export as PDF
|
||||
{m.collections.exportPdf}
|
||||
</Link>
|
||||
)}
|
||||
{isOwner && <ShareCollectionButton collectionId={id} />}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MealPlanner } from "@/components/meal-plan/meal-planner";
|
||||
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
|
||||
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = { title: "Meal Plan" };
|
||||
|
||||
@@ -39,6 +40,7 @@ export default async function MealPlanPage({
|
||||
const { week } = await searchParams;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const msgs = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const monday = getMonday(week);
|
||||
const weekStart = toDateStr(monday);
|
||||
@@ -83,18 +85,18 @@ export default async function MealPlanPage({
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Meal Plan</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{msgs.mealPlan.title}</h1>
|
||||
<p className="text-muted-foreground mt-1">{label}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ShareMealPlanButton weekStart={weekStart} />
|
||||
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Shopping lists
|
||||
{msgs.mealPlan.shoppingLists}
|
||||
</Link>
|
||||
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Print
|
||||
{msgs.common.print}
|
||||
</Link>
|
||||
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -110,17 +112,19 @@ export default async function MealPlanPage({
|
||||
|
||||
{sharedMemberships.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">Shared with you</h2>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">{msgs.mealPlan.sharedWithYou}</h2>
|
||||
<div className="space-y-2 max-w-lg">
|
||||
{sharedMemberships.map((m) => (
|
||||
{sharedMemberships.map((membership) => (
|
||||
<Link
|
||||
key={m.id}
|
||||
href={`/meal-plan/shared/${m.mealPlan.id}`}
|
||||
key={membership.id}
|
||||
href={`/meal-plan/shared/${membership.mealPlan.id}`}
|
||||
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{`${m.mealPlan.user?.name ?? "Unknown"}'s plan`}</p>
|
||||
<p className="text-sm text-muted-foreground">Week of {m.mealPlan.weekStart} · {m.role}</p>
|
||||
<p className="font-medium">{formatMessage(msgs.mealPlan.sharedPlanOf, { name: membership.mealPlan.user?.name ?? "?" })}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage(msgs.mealPlan.weekOf, { date: membership.mealPlan.weekStart })} · {msgs.shareDialog[membership.role]}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auth } from "@/lib/auth/server";
|
||||
import { db, mealPlans, recipes, eq, desc } from "@epicure/db";
|
||||
import { getMealPlanAccessById, canWriteMealPlan } from "@/lib/meal-plan-access";
|
||||
import { SharedMealPlanView } from "@/components/meal-plan/shared-meal-plan-view";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
|
||||
type Params = { params: Promise<{ mealPlanId: string }> };
|
||||
|
||||
@@ -14,6 +15,7 @@ export default async function SharedMealPlanPage({ params }: Params) {
|
||||
const { mealPlanId } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const access = await getMealPlanAccessById(mealPlanId, session.user.id);
|
||||
if (!access) notFound();
|
||||
@@ -35,8 +37,8 @@ export default async function SharedMealPlanPage({ params }: Params) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{`${plan.user?.name ?? "Shared"}'s Meal Plan`}</h1>
|
||||
<p className="text-muted-foreground mt-1">Week of {plan.weekStart} · {access.role}</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{formatMessage(m.mealPlan.sharedMealPlanTitle, { name: plan.user?.name ?? "?" })}</h1>
|
||||
<p className="text-muted-foreground mt-1">{formatMessage(m.mealPlan.weekOf, { date: plan.weekStart })} · {m.shareDialog[access.role]}</p>
|
||||
</div>
|
||||
<SharedMealPlanView
|
||||
mealPlanId={mealPlanId}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MealPairingButton } from "@/components/recipe/meal-pairing-button";
|
||||
import { DrinkPairingButton } from "@/components/recipe/drink-pairing-button";
|
||||
import { AdaptRecipeButton } from "@/components/recipe/adapt-recipe-button";
|
||||
import { PrintButton } from "@/components/recipe/print-button";
|
||||
import { ShareRecipeButton } from "@/components/recipe/share-recipe-button";
|
||||
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
|
||||
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
|
||||
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||
@@ -105,7 +106,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>View publicly</TooltipContent>
|
||||
<TooltipContent>{m.recipe.viewPublicly}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
@@ -148,6 +149,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
order: s.order,
|
||||
}))}
|
||||
/>
|
||||
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
|
||||
<PrintButton recipeId={id} />
|
||||
<VersionHistoryButton
|
||||
recipeId={id}
|
||||
@@ -172,7 +174,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
<TooltipContent>{m.recipe.edit}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DeleteRecipeButton recipeId={id} />
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,14 @@ import { auth } from "@/lib/auth/server";
|
||||
import { db, userAiKeys, userModelPrefs, eq } from "@epicure/db";
|
||||
import { ByokManager } from "@/components/settings/byok-manager";
|
||||
import { ModelPrefsForm } from "@/components/settings/model-prefs-form";
|
||||
import { getMessages } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = { title: "AI & Models – Settings" };
|
||||
|
||||
export default async function AiSettingsPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const [aiKeys, modelPrefs] = await Promise.all([
|
||||
db.query.userAiKeys.findMany({
|
||||
@@ -25,9 +27,9 @@ export default async function AiSettingsPage() {
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Your API Keys (BYOK)</h2>
|
||||
<h2 className="font-semibold text-lg">{m.settings.byok.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Use your own API keys instead of the app's shared quota. Keys are encrypted at rest.
|
||||
{m.settings.byok.description}
|
||||
</p>
|
||||
</div>
|
||||
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
|
||||
@@ -35,9 +37,9 @@ export default async function AiSettingsPage() {
|
||||
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Model Preferences</h2>
|
||||
<h2 className="font-semibold text-lg">{m.settings.modelPrefs.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Choose which model to use for each task. Defaults to your first configured provider.
|
||||
{m.settings.modelPrefs.description}
|
||||
</p>
|
||||
</div>
|
||||
<ModelPrefsForm initialPrefs={modelPrefs ?? null} />
|
||||
|
||||
@@ -3,12 +3,14 @@ import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, apiKeys, eq } from "@epicure/db";
|
||||
import { ApiKeysManager } from "@/components/settings/api-keys-manager";
|
||||
import { getMessages } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = { title: "API Keys" };
|
||||
|
||||
export default async function ApiKeysPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const keys = await db
|
||||
.select({
|
||||
@@ -24,9 +26,9 @@ export default async function ApiKeysPage() {
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">API Keys</h2>
|
||||
<h2 className="font-semibold text-lg">{m.settings.apiKeysPage.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage API keys for programmatic access to the Epicure API.
|
||||
{m.settings.apiKeysPage.description}
|
||||
</p>
|
||||
</div>
|
||||
<ApiKeysManager
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button";
|
||||
import { getMessages } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = { title: "Notifications – Settings" };
|
||||
|
||||
export default function NotificationsPage() {
|
||||
export default async function NotificationsPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const m = getMessages((session?.user as { locale?: string })?.locale);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Push Notifications</h2>
|
||||
<h2 className="font-semibold text-lg">{m.settings.pushNotifications.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Get notified when someone comments on your recipes or likes your content.
|
||||
{m.settings.pushNotifications.description}
|
||||
</p>
|
||||
</div>
|
||||
<PushSubscribeButton />
|
||||
|
||||
@@ -3,12 +3,14 @@ import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, userNutritionGoals, eq } from "@epicure/db";
|
||||
import { NutritionGoalsForm } from "@/components/nutrition/nutrition-goals-form";
|
||||
import { getMessages } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = { title: "Nutrition – Settings" };
|
||||
|
||||
export default async function NutritionPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const goals = await db.query.userNutritionGoals.findFirst({
|
||||
where: eq(userNutritionGoals.userId, session.user.id),
|
||||
@@ -18,9 +20,9 @@ export default async function NutritionPage() {
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Daily Nutrition Goals</h2>
|
||||
<h2 className="font-semibold text-lg">{m.settings.nutritionGoals.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Set your daily targets. These are shown as progress bars on your meal plan.
|
||||
{m.settings.nutritionGoals.description}
|
||||
</p>
|
||||
</div>
|
||||
<NutritionGoalsForm
|
||||
|
||||
@@ -3,12 +3,14 @@ import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, webhooks, eq } from "@epicure/db";
|
||||
import { WebhooksManager } from "@/components/settings/webhooks-manager";
|
||||
import { getMessages } from "@/lib/i18n/server";
|
||||
|
||||
export const metadata: Metadata = { title: "Webhooks" };
|
||||
|
||||
export default async function WebhooksPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -25,9 +27,9 @@ export default async function WebhooksPage() {
|
||||
<div className="space-y-8">
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">Webhooks</h2>
|
||||
<h2 className="font-semibold text-lg">{m.settings.webhooksPage.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Receive HTTP callbacks when events happen in your Epicure account.
|
||||
{m.settings.webhooksPage.description}
|
||||
</p>
|
||||
</div>
|
||||
<WebhooksManager
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GroceryExportButton } from "@/components/shopping-lists/grocery-export-
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -20,6 +21,7 @@ export default async function ShoppingListPage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
const m = getMessages((session.user as { locale?: string }).locale);
|
||||
|
||||
const access = await getShoppingListAccess(id, session.user.id);
|
||||
if (!access) notFound();
|
||||
@@ -39,7 +41,8 @@ export default async function ShoppingListPage({ params }: Params) {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
|
||||
{formatMessage(list.items.length === 1 ? m.shoppingLists.itemCount : m.shoppingLists.itemCountPlural, { count: list.items.length })}
|
||||
{list.generatedAt ? m.shoppingLists.fromMealPlan : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -47,7 +50,7 @@ export default async function ShoppingListPage({ params }: Params) {
|
||||
{access.role === "owner" && <ShareShoppingListButton listId={id} />}
|
||||
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Print
|
||||
{m.common.print}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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's plan</DialogTitle>
|
||||
<DialogTitle>{t("shareTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Invite household members to view or edit this week'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 20–30 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 A–Z",
|
||||
title_desc: "Title Z–A",
|
||||
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 “{query}”</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'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'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 & 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>
|
||||
|
||||
+225
-9
@@ -16,6 +16,11 @@
|
||||
"language": "Language"
|
||||
},
|
||||
"recipe": {
|
||||
"share": "Share",
|
||||
"shareLinkCopied": "Link copied to clipboard",
|
||||
"shareCopyFailed": "Failed to copy link",
|
||||
"shareNotPublicNotice": "This link will only work once the recipe is set to Public — link copied anyway.",
|
||||
"print": "Print",
|
||||
"ingredients": "Ingredients",
|
||||
"instructions": "Instructions",
|
||||
"photos": "Photos",
|
||||
@@ -37,6 +42,17 @@
|
||||
"adapted": "Adapted recipe saved as draft",
|
||||
"adaptFailed": "Failed to adapt recipe",
|
||||
"adaptConstraintRequired": "Select at least one ingredient to exclude or add a constraint",
|
||||
"adaptTooltip": "Adapt",
|
||||
"adaptTitle": "Adapt this recipe",
|
||||
"adaptDescription": "Tap ingredients to exclude them. AI will find the best substitutes while preserving the dish.",
|
||||
"excludeIngredients": "Exclude ingredients",
|
||||
"excludedCount": "{count} excluded",
|
||||
"additionalConstraints": "Additional constraints",
|
||||
"optional": "optional",
|
||||
"adapting": "Adapting recipe — this may take 20–30 seconds…",
|
||||
"adaptingButton": "Adapting…",
|
||||
"adaptButton": "Adapt recipe",
|
||||
"clearExclusions": "Clear",
|
||||
"variationsDirectionsPlaceholder": "e.g. make it vegan, use only pantry staples, reduce sugar…",
|
||||
"historyRestoreFailed": "Failed to restore version",
|
||||
"pairingMealFailed": "Failed to suggest pairings",
|
||||
@@ -48,12 +64,30 @@
|
||||
"cookAction": "Cook",
|
||||
"adaptConstraintPlaceholder": "e.g. Make it vegan, lower the calories, use pantry staples only, gluten-free…",
|
||||
"photoImportFailed": "Failed to import recipe from photo.",
|
||||
"importFromPhoto": "Import from Photo",
|
||||
"analyzingPhoto": "Analyzing photo…",
|
||||
"pairMealTooltip": "Pair meal",
|
||||
"historyTooltip": "History",
|
||||
"translateTooltip": "Translate",
|
||||
"drinksTooltip": "Drinks",
|
||||
"variationsTooltip": "Variations",
|
||||
"viewPublicly": "View publicly",
|
||||
"shoppingListCreateFailed": "Failed to create list",
|
||||
"shoppingListAddFailed": "Failed to add ingredients",
|
||||
"bulkDeleted": "{count, plural, one {1 recipe deleted} other {{count} recipes deleted}}",
|
||||
"bulkVisibility": "{count, plural, one {1 recipe set to {visibility}} other {{count} recipes set to {visibility}}}",
|
||||
"bulkDeleteFailed": "Delete failed",
|
||||
"bulkUpdateFailed": "Update failed",
|
||||
"bulkDeleteConfirm": "{count, plural, one {Delete 1 recipe?} other {Delete {count} recipes?}} This cannot be undone.",
|
||||
"deselectAll": "Deselect all",
|
||||
"selectAll": "Select all",
|
||||
"clickToSelect": "Click cards to select",
|
||||
"selectedCount": "{count} selected",
|
||||
"select": "Select",
|
||||
"visibility": "Visibility",
|
||||
"makePublic": "Make public",
|
||||
"makeUnlisted": "Make unlisted",
|
||||
"makePrivate": "Make private",
|
||||
"visibility": {
|
||||
"private": "Private",
|
||||
"unlisted": "Unlisted",
|
||||
@@ -103,7 +137,8 @@
|
||||
"contentGenerated": "Ingredients and steps generated!",
|
||||
"contentGenerateFailed": "Generation failed",
|
||||
"contentSaveFailed": "Failed to save generated content",
|
||||
"generatingContent": "Generating recipe content…"
|
||||
"generatingContent": "Generating recipe content…",
|
||||
"photoAnalysisNote": "AI will analyze this dish and infer ingredients, quantities, and cooking steps."
|
||||
},
|
||||
"variations": {
|
||||
"title": "AI Recipe Variations",
|
||||
@@ -118,7 +153,10 @@
|
||||
"success": "Variation created — review and edit before publishing.",
|
||||
"error": "Failed to generate variations",
|
||||
"saveError": "Failed to save variation",
|
||||
"wait": "This may take 20–30 seconds"
|
||||
"wait": "This may take 20–30 seconds",
|
||||
"directions": "Directions",
|
||||
"optional": "optional",
|
||||
"stepLabel": "Step {n}"
|
||||
},
|
||||
"translate": {
|
||||
"title": "Translate Recipe",
|
||||
@@ -138,7 +176,37 @@
|
||||
"language": "Language",
|
||||
"units": "Units",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial"
|
||||
"imperial": "Imperial",
|
||||
"security": "Security",
|
||||
"aiModels": "AI & Models",
|
||||
"notifications": "Notifications",
|
||||
"nutrition": "Nutrition",
|
||||
"apiKeys": "API Keys",
|
||||
"webhooks": "Webhooks",
|
||||
"byok": {
|
||||
"title": "Your API Keys (BYOK)",
|
||||
"description": "Use your own API keys instead of the app's shared quota. Keys are encrypted at rest."
|
||||
},
|
||||
"modelPrefs": {
|
||||
"title": "Model Preferences",
|
||||
"description": "Choose which model to use for each task. Defaults to your first configured provider."
|
||||
},
|
||||
"apiKeysPage": {
|
||||
"title": "API Keys",
|
||||
"description": "Manage API keys for programmatic access to the Epicure API."
|
||||
},
|
||||
"pushNotifications": {
|
||||
"title": "Push Notifications",
|
||||
"description": "Get notified when someone comments on your recipes or likes your content."
|
||||
},
|
||||
"nutritionGoals": {
|
||||
"title": "Daily Nutrition Goals",
|
||||
"description": "Set your daily targets. These are shown as progress bars on your meal plan."
|
||||
},
|
||||
"webhooksPage": {
|
||||
"title": "Webhooks",
|
||||
"description": "Receive HTTP callbacks when events happen in your Epicure account."
|
||||
}
|
||||
},
|
||||
"print": {
|
||||
"footer": "Printed from Epicure",
|
||||
@@ -190,9 +258,26 @@
|
||||
"aiScaledNote": "AI-scaled quantities shown below"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explore",
|
||||
"searchPlaceholder": "Search public recipes…",
|
||||
"maxMinutes": "Max minutes",
|
||||
"aiSearchPlaceholder": "e.g. quick weeknight dinners, Italian comfort food…"
|
||||
"aiSearchPlaceholder": "e.g. quick weeknight dinners, Italian comfort food…",
|
||||
"anyDifficulty": "Any difficulty",
|
||||
"resultsFoundSingular": "{count} recipe found",
|
||||
"resultsFoundPlural": "{count} recipes found",
|
||||
"noResultsTitle": "No recipes found for “{query}”",
|
||||
"noResultsHint": "Try different keywords or remove filters",
|
||||
"loadMore": "Load more",
|
||||
"recipeIdeas": "Recipe ideas",
|
||||
"recipeIdeasHint": "Tell the AI what you're in the mood for, or let it surprise you.",
|
||||
"generating": "Generating…",
|
||||
"getIdeas": "Get ideas",
|
||||
"surpriseMe": "Surprise me",
|
||||
"generateFullRecipe": "Generate full recipe",
|
||||
"trendingThisWeek": "Trending this week",
|
||||
"noTrending": "No trending recipes yet. Be the first to share one!",
|
||||
"recentlyAdded": "Recently added",
|
||||
"noRecent": "No public recipes yet."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading…",
|
||||
@@ -200,6 +285,8 @@
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"print": "Print",
|
||||
"share": "Share",
|
||||
"deleteFailed": "Delete failed",
|
||||
"updateFailed": "Update failed",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
@@ -276,10 +363,25 @@
|
||||
"noMatch": "No recipes match",
|
||||
"clearSearch": "Clear search",
|
||||
"createFirst": "Create your first recipe",
|
||||
"resultSingular": "result",
|
||||
"resultPlural": "results",
|
||||
"resultSingular": "{count} recipe",
|
||||
"resultPlural": "{count} recipes",
|
||||
"for": "for",
|
||||
"filterByTag": "Filter by tag…"
|
||||
"filterByTag": "Filter by tag…",
|
||||
"sortRecentlyUpdated": "Recently updated",
|
||||
"sortOldestUpdated": "Oldest updated",
|
||||
"sortNewestFirst": "Newest first",
|
||||
"sortOldestFirst": "Oldest first",
|
||||
"sortTitleAZ": "Title A–Z",
|
||||
"sortTitleZA": "Title Z–A",
|
||||
"sort": "Sort",
|
||||
"sortBy": "Sort by",
|
||||
"filter": "Filter",
|
||||
"all": "All",
|
||||
"anyDifficulty": "Any difficulty",
|
||||
"visibilityLabel": "Visibility",
|
||||
"difficultyLabel": "Difficulty",
|
||||
"tagLabel": "Tag",
|
||||
"clearFilters": "Clear filters"
|
||||
},
|
||||
"recipeForm": {
|
||||
"titleLabel": "Title *",
|
||||
@@ -333,6 +435,13 @@
|
||||
"mealPlan": {
|
||||
"title": "Meal Plan",
|
||||
"shoppingLists": "Shopping lists",
|
||||
"print": "Print",
|
||||
"shareTitle": "Share this week's plan",
|
||||
"shareDescription": "Invite household members to view or edit this week's meal plan.",
|
||||
"sharedWithYou": "Shared with you",
|
||||
"sharedPlanOf": "{name}'s plan",
|
||||
"weekOf": "Week of {date}",
|
||||
"sharedMealPlanTitle": "{name}'s Meal Plan",
|
||||
"addTo": "Add to {day} – {meal}",
|
||||
"searchRecipes": "Search recipes…",
|
||||
"servings": "Servings",
|
||||
@@ -373,7 +482,26 @@
|
||||
"aiGenerated": "Meal plan generated!",
|
||||
"includePantryItems": "Include pantry items",
|
||||
"pantryMode": "Pantry mode — maximize use of what you have",
|
||||
"pantryModeDescription": "Prefer meals that use multiple pantry ingredients and minimize extra shopping."
|
||||
"pantryModeDescription": "Prefer meals that use multiple pantry ingredients and minimize extra shopping.",
|
||||
"removeFailed": "Failed to remove",
|
||||
"generationFailed": "Generation failed",
|
||||
"anyDifficulty": "Any",
|
||||
"aiPhaseAnalyzing": "Analyzing your preferences…",
|
||||
"aiPhasePlanning": "Planning breakfast, lunch & dinner…",
|
||||
"aiPhaseSelecting": "Selecting recipes for each day…",
|
||||
"aiPhaseBalancing": "Balancing nutrition across the week…",
|
||||
"aiPhaseFinalizing": "Finalizing your meal plan…",
|
||||
"newList": "New list",
|
||||
"newListTitle": "New shopping list",
|
||||
"listNameLabel": "Name",
|
||||
"listNamePlaceholder": "Weekly groceries",
|
||||
"generateFromWeek": "Generate from meal plan week (optional)",
|
||||
"generateFromWeekHint": "Picks Monday of the selected week",
|
||||
"listCreateFailed": "Failed to create",
|
||||
"listCreating": "Creating…",
|
||||
"listCreate": "Create",
|
||||
"pickRecipe": "Pick recipe",
|
||||
"addEntry": "+ Add"
|
||||
},
|
||||
"pantry": {
|
||||
"title": "Pantry",
|
||||
@@ -397,9 +525,20 @@
|
||||
"empty": "No shopping lists yet",
|
||||
"listEmpty": "Empty",
|
||||
"generated": "Generated",
|
||||
"shareTitle": "Share shopping list",
|
||||
"shareDescription": "Invite household members to view or edit this list.",
|
||||
"sharedWithYou": "Shared with you",
|
||||
"sendToGroceryDelivery": "Send to grocery delivery",
|
||||
"copyAsText": "Copy list as text",
|
||||
"sendToInstacart": "Send to Instacart",
|
||||
"copiedToClipboard": "List copied to clipboard",
|
||||
"exportBuildFailed": "Could not build export",
|
||||
"instacartNotConfigured": "Instacart isn't configured yet",
|
||||
"items": "{checked}/{total} items",
|
||||
"itemsChecked": "{checked}/{total} items checked",
|
||||
"fromMealPlan": " · From meal plan",
|
||||
"itemCount": "{count} item",
|
||||
"itemCountPlural": "{count} items",
|
||||
"addToList": "Add to list",
|
||||
"dialogTitle": "Add to shopping list",
|
||||
"dialogDescription": "Add the ingredients of <strong>{title}</strong> to a shopping list.",
|
||||
@@ -419,7 +558,40 @@
|
||||
"empty": "No collections yet",
|
||||
"public": "Public",
|
||||
"recipeCount": "{count} recipe",
|
||||
"recipeCountPlural": "{count} recipes"
|
||||
"recipeCountPlural": "{count} recipes",
|
||||
"exportPdf": "Export as PDF",
|
||||
"shareTitle": "Share collection",
|
||||
"shareDescription": "Invite people to view or edit this collection.",
|
||||
"new": "New collection",
|
||||
"newTitle": "New collection",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Weekend dinners",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Optional…",
|
||||
"makePublic": "Make public",
|
||||
"creating": "Creating…",
|
||||
"create": "Create",
|
||||
"createFailed": "Failed to create",
|
||||
"forking": "Forking…",
|
||||
"forkCollection": "Fork collection",
|
||||
"forkFailed": "Fork failed"
|
||||
},
|
||||
"shareDialog": {
|
||||
"invite": "Invite",
|
||||
"emailPlaceholder": "Email address",
|
||||
"owner": "Owner",
|
||||
"viewer": "Viewer",
|
||||
"editor": "Editor",
|
||||
"loadingMembers": "Loading members…",
|
||||
"noMembers": "No members yet.",
|
||||
"invitationSent": "Invitation sent",
|
||||
"alreadyMember": "Already a member",
|
||||
"userNotFound": "User not found",
|
||||
"inviteFailed": "Could not invite user",
|
||||
"loadMembersFailed": "Could not load members",
|
||||
"memberRemoved": "Member removed",
|
||||
"removeMemberFailed": "Could not remove member",
|
||||
"enterEmail": "Enter an email address"
|
||||
},
|
||||
"social": {
|
||||
"followed": "Following",
|
||||
@@ -513,9 +685,36 @@
|
||||
"apiKeyCreateFailed": "Failed to create API key",
|
||||
"apiKeyRevokeFailed": "Failed to revoke API key",
|
||||
"apiKeyNamePlaceholder": "e.g. My app",
|
||||
"apiKeyNameLabel": "Key name",
|
||||
"apiKeyCreating": "Creating…",
|
||||
"apiKeyCreate": "Create",
|
||||
"apiKeyRevealNotice": "Save this key — it will not be shown again.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"dismiss": "Dismiss",
|
||||
"noApiKeys": "No API keys yet.",
|
||||
"createdOn": "Created {date}",
|
||||
"lastUsedOn": "Last used {date}",
|
||||
"neverUsed": "Never used",
|
||||
"revoke": "Revoke",
|
||||
"webhookUrlPlaceholder": "https://example.com/webhook",
|
||||
"webhookAdding": "Adding…",
|
||||
"webhookAddButton": "Add webhook",
|
||||
"webhookDocsLink": "View webhook docs & Zapier integration →",
|
||||
"endpointUrlLabel": "Endpoint URL",
|
||||
"eventsLabel": "Events",
|
||||
"eventsHint": "Select which events trigger this webhook. Leave all unchecked to receive all events.",
|
||||
"secretRevealNotice": "Save this signing secret — it will not be shown again.",
|
||||
"secretUsageHint": "Use it to verify the X-Epicure-Signature header on incoming requests.",
|
||||
"noWebhooks": "No webhooks yet.",
|
||||
"addedOn": "Added {date}",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"allEvents": "All events",
|
||||
"deliveries": "Deliveries",
|
||||
"disable": "Disable",
|
||||
"enable": "Enable",
|
||||
"noDeliveriesYet": "No deliveries yet.",
|
||||
"modelDefaultPlaceholder": "Default",
|
||||
"modelNamePlaceholder": "e.g. llama3.2",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
@@ -523,8 +722,25 @@
|
||||
"byokSaveFailed": "Failed to save key",
|
||||
"byokRemoved": "{provider} key removed",
|
||||
"byokRemoveFailed": "Failed to remove key",
|
||||
"byokConfigured": "Configured",
|
||||
"byokOllamaHint": "Set via OLLAMA_BASE_URL env var",
|
||||
"byokSaving": "Saving…",
|
||||
"byokSave": "Save",
|
||||
"byokFooter": "Your keys are encrypted at rest. When set, they override the app's default keys for your account.",
|
||||
"modelSaved": "Model preferences saved",
|
||||
"modelSaveFailed": "Failed to save",
|
||||
"useCaseText": "Text generation",
|
||||
"useCaseTextDesc": "Recipe generation, variations, translations, adapt",
|
||||
"useCaseVision": "Vision / photo import",
|
||||
"useCaseVisionDesc": "Dish recognition, recipe import from photo",
|
||||
"useCaseMealPlan": "Meal planning",
|
||||
"useCaseMealPlanDesc": "Weekly meal plan generation",
|
||||
"providerLabel": "Provider",
|
||||
"modelLabel": "Model",
|
||||
"defaultAuto": "Default (auto)",
|
||||
"modelDefault": "Default",
|
||||
"modelSaving": "Saving…",
|
||||
"saveModelPrefs": "Save model preferences",
|
||||
"userUpdated": "User updated successfully",
|
||||
"userUpdateFailed": "Failed to save",
|
||||
"adminSettingsSaved": "Settings saved",
|
||||
|
||||
+225
-9
@@ -16,6 +16,11 @@
|
||||
"language": "Langue"
|
||||
},
|
||||
"recipe": {
|
||||
"share": "Partager",
|
||||
"shareLinkCopied": "Lien copié dans le presse-papiers",
|
||||
"shareCopyFailed": "Échec de la copie du lien",
|
||||
"shareNotPublicNotice": "Ce lien ne fonctionnera qu'une fois la recette définie sur Public — lien copié quand même.",
|
||||
"print": "Imprimer",
|
||||
"ingredients": "Ingrédients",
|
||||
"instructions": "Instructions",
|
||||
"photos": "Photos",
|
||||
@@ -37,6 +42,17 @@
|
||||
"adapted": "Recette adaptée enregistrée comme brouillon",
|
||||
"adaptFailed": "Échec de l'adaptation de la recette",
|
||||
"adaptConstraintRequired": "Sélectionnez au moins un ingrédient à exclure ou ajoutez une contrainte",
|
||||
"adaptTooltip": "Adapter",
|
||||
"adaptTitle": "Adapter cette recette",
|
||||
"adaptDescription": "Touchez les ingrédients pour les exclure. L'IA trouvera les meilleurs substituts tout en préservant le plat.",
|
||||
"excludeIngredients": "Exclure des ingrédients",
|
||||
"excludedCount": "{count} exclus",
|
||||
"additionalConstraints": "Contraintes supplémentaires",
|
||||
"optional": "facultatif",
|
||||
"adapting": "Adaptation de la recette — cela peut prendre 20 à 30 secondes…",
|
||||
"adaptingButton": "Adaptation…",
|
||||
"adaptButton": "Adapter la recette",
|
||||
"clearExclusions": "Effacer",
|
||||
"adaptConstraintPlaceholder": "ex. Rendre végétalien, réduire les calories, utiliser seulement les produits du garde-manger, sans gluten…",
|
||||
"variationsDirectionsPlaceholder": "ex. rendre végétalien, utiliser uniquement les produits du garde-manger, réduire le sucre…",
|
||||
"historyRestoreFailed": "Échec de la restauration de la version",
|
||||
@@ -48,12 +64,30 @@
|
||||
"pairingBulkSuccess": "{count} recettes générées — retrouvez-les dans votre bibliothèque",
|
||||
"cookAction": "Cuisiner",
|
||||
"photoImportFailed": "Échec de l'importation de la recette depuis la photo.",
|
||||
"importFromPhoto": "Importer depuis une photo",
|
||||
"analyzingPhoto": "Analyse de la photo…",
|
||||
"pairMealTooltip": "Accorder un plat",
|
||||
"historyTooltip": "Historique",
|
||||
"translateTooltip": "Traduire",
|
||||
"drinksTooltip": "Boissons",
|
||||
"variationsTooltip": "Variations",
|
||||
"viewPublicly": "Voir publiquement",
|
||||
"shoppingListCreateFailed": "Échec de la création de la liste",
|
||||
"shoppingListAddFailed": "Échec de l'ajout des ingrédients",
|
||||
"bulkDeleted": "{count, plural, one {1 recette supprimée} other {{count} recettes supprimées}}",
|
||||
"bulkVisibility": "{count, plural, one {1 recette définie sur {visibility}} other {{count} recettes définies sur {visibility}}}",
|
||||
"bulkDeleteFailed": "Échec de la suppression",
|
||||
"bulkUpdateFailed": "Échec de la mise à jour",
|
||||
"bulkDeleteConfirm": "{count, plural, one {Supprimer 1 recette ?} other {Supprimer {count} recettes ?}} Cette action est irréversible.",
|
||||
"deselectAll": "Tout désélectionner",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"clickToSelect": "Cliquez sur les cartes pour sélectionner",
|
||||
"selectedCount": "{count} sélectionnée(s)",
|
||||
"select": "Sélectionner",
|
||||
"visibility": "Visibilité",
|
||||
"makePublic": "Rendre publique",
|
||||
"makeUnlisted": "Rendre non répertoriée",
|
||||
"makePrivate": "Rendre privée",
|
||||
"visibility": {
|
||||
"private": "Privée",
|
||||
"unlisted": "Non répertoriée",
|
||||
@@ -103,7 +137,8 @@
|
||||
"contentGenerated": "Ingrédients et étapes générés !",
|
||||
"contentGenerateFailed": "Échec de la génération",
|
||||
"contentSaveFailed": "Échec de l'enregistrement du contenu généré",
|
||||
"generatingContent": "Génération du contenu de la recette…"
|
||||
"generatingContent": "Génération du contenu de la recette…",
|
||||
"photoAnalysisNote": "L'IA analysera ce plat et déduira les ingrédients, quantités et étapes de préparation."
|
||||
},
|
||||
"variations": {
|
||||
"title": "Variations de recette par IA",
|
||||
@@ -118,7 +153,10 @@
|
||||
"success": "Variation créée — vérifiez et modifiez avant de publier.",
|
||||
"error": "Échec de la génération des variations",
|
||||
"saveError": "Échec de l'enregistrement de la variation",
|
||||
"wait": "Cela peut prendre 20 à 30 secondes"
|
||||
"wait": "Cela peut prendre 20 à 30 secondes",
|
||||
"directions": "Instructions",
|
||||
"optional": "facultatif",
|
||||
"stepLabel": "Étape {n}"
|
||||
},
|
||||
"translate": {
|
||||
"title": "Traduire la recette",
|
||||
@@ -138,7 +176,37 @@
|
||||
"language": "Langue",
|
||||
"units": "Unités",
|
||||
"metric": "Métrique",
|
||||
"imperial": "Impérial"
|
||||
"imperial": "Impérial",
|
||||
"security": "Sécurité",
|
||||
"aiModels": "IA et modèles",
|
||||
"notifications": "Notifications",
|
||||
"nutrition": "Nutrition",
|
||||
"apiKeys": "Clés API",
|
||||
"webhooks": "Webhooks",
|
||||
"byok": {
|
||||
"title": "Vos clés API (BYOK)",
|
||||
"description": "Utilisez vos propres clés API au lieu du quota partagé de l'application. Les clés sont chiffrées au repos."
|
||||
},
|
||||
"modelPrefs": {
|
||||
"title": "Préférences de modèle",
|
||||
"description": "Choisissez le modèle à utiliser pour chaque tâche. Par défaut, votre premier fournisseur configuré."
|
||||
},
|
||||
"apiKeysPage": {
|
||||
"title": "Clés API",
|
||||
"description": "Gérez les clés API pour un accès programmatique à l'API Epicure."
|
||||
},
|
||||
"pushNotifications": {
|
||||
"title": "Notifications push",
|
||||
"description": "Soyez averti lorsqu'on commente vos recettes ou aime votre contenu."
|
||||
},
|
||||
"nutritionGoals": {
|
||||
"title": "Objectifs nutritionnels quotidiens",
|
||||
"description": "Définissez vos objectifs quotidiens. Ils apparaissent sous forme de barres de progression dans votre planning repas."
|
||||
},
|
||||
"webhooksPage": {
|
||||
"title": "Webhooks",
|
||||
"description": "Recevez des appels HTTP lorsque des événements se produisent sur votre compte Epicure."
|
||||
}
|
||||
},
|
||||
"print": {
|
||||
"footer": "Imprimé depuis Epicure",
|
||||
@@ -190,9 +258,26 @@
|
||||
"aiScaledNote": "Quantités ajustées par l'IA affichées ci-dessous"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explorer",
|
||||
"searchPlaceholder": "Rechercher des recettes publiques…",
|
||||
"maxMinutes": "Minutes max",
|
||||
"aiSearchPlaceholder": "ex. dîners rapides en semaine, cuisine réconfortante italienne…"
|
||||
"aiSearchPlaceholder": "ex. dîners rapides en semaine, cuisine réconfortante italienne…",
|
||||
"anyDifficulty": "Toute difficulté",
|
||||
"resultsFoundSingular": "{count} recette trouvée",
|
||||
"resultsFoundPlural": "{count} recettes trouvées",
|
||||
"noResultsTitle": "Aucune recette trouvée pour « {query} »",
|
||||
"noResultsHint": "Essayez d'autres mots-clés ou retirez des filtres",
|
||||
"loadMore": "Charger plus",
|
||||
"recipeIdeas": "Idées de recettes",
|
||||
"recipeIdeasHint": "Dites à l'IA ce dont vous avez envie, ou laissez-la vous surprendre.",
|
||||
"generating": "Génération…",
|
||||
"getIdeas": "Obtenir des idées",
|
||||
"surpriseMe": "Surprenez-moi",
|
||||
"generateFullRecipe": "Générer la recette complète",
|
||||
"trendingThisWeek": "Tendances cette semaine",
|
||||
"noTrending": "Aucune recette tendance pour l'instant. Soyez le premier à en partager une !",
|
||||
"recentlyAdded": "Récemment ajoutées",
|
||||
"noRecent": "Aucune recette publique pour l'instant."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Chargement…",
|
||||
@@ -200,6 +285,8 @@
|
||||
"save": "Enregistrer",
|
||||
"saved": "Enregistré",
|
||||
"saveFailed": "Échec de l'enregistrement",
|
||||
"print": "Imprimer",
|
||||
"share": "Partager",
|
||||
"deleteFailed": "Échec de la suppression",
|
||||
"updateFailed": "Échec de la mise à jour",
|
||||
"copyFailed": "Échec de la copie dans le presse-papiers",
|
||||
@@ -267,10 +354,25 @@
|
||||
"noMatch": "Aucune recette ne correspond",
|
||||
"clearSearch": "Effacer la recherche",
|
||||
"createFirst": "Créer votre première recette",
|
||||
"resultSingular": "résultat",
|
||||
"resultPlural": "résultats",
|
||||
"resultSingular": "{count} recette",
|
||||
"resultPlural": "{count} recettes",
|
||||
"for": "pour",
|
||||
"filterByTag": "Filtrer par tag…"
|
||||
"filterByTag": "Filtrer par tag…",
|
||||
"sortRecentlyUpdated": "Mise à jour récemment",
|
||||
"sortOldestUpdated": "Mise à jour la plus ancienne",
|
||||
"sortNewestFirst": "Plus récentes",
|
||||
"sortOldestFirst": "Plus anciennes",
|
||||
"sortTitleAZ": "Titre A–Z",
|
||||
"sortTitleZA": "Titre Z–A",
|
||||
"sort": "Trier",
|
||||
"sortBy": "Trier par",
|
||||
"filter": "Filtrer",
|
||||
"all": "Toutes",
|
||||
"anyDifficulty": "Toute difficulté",
|
||||
"visibilityLabel": "Visibilité",
|
||||
"difficultyLabel": "Difficulté",
|
||||
"tagLabel": "Tag",
|
||||
"clearFilters": "Effacer les filtres"
|
||||
},
|
||||
"recipeForm": {
|
||||
"titleLabel": "Titre *",
|
||||
@@ -321,6 +423,13 @@
|
||||
"mealPlan": {
|
||||
"title": "Planning repas",
|
||||
"shoppingLists": "Listes de courses",
|
||||
"print": "Imprimer",
|
||||
"shareTitle": "Partager le planning de cette semaine",
|
||||
"shareDescription": "Invitez des membres du foyer à voir ou modifier le planning repas de cette semaine.",
|
||||
"sharedWithYou": "Partagé avec vous",
|
||||
"sharedPlanOf": "Planning de {name}",
|
||||
"weekOf": "Semaine du {date}",
|
||||
"sharedMealPlanTitle": "Planning repas de {name}",
|
||||
"addTo": "Ajouter à {day} – {meal}",
|
||||
"searchRecipes": "Rechercher des recettes…",
|
||||
"servings": "Portions",
|
||||
@@ -361,7 +470,26 @@
|
||||
"aiGenerated": "Plan de repas généré !",
|
||||
"includePantryItems": "Inclure les articles du garde-manger",
|
||||
"pantryMode": "Mode garde-manger — maximiser ce que vous avez",
|
||||
"pantryModeDescription": "Préférer les repas utilisant plusieurs ingrédients du garde-manger et limiter les achats supplémentaires."
|
||||
"pantryModeDescription": "Préférer les repas utilisant plusieurs ingrédients du garde-manger et limiter les achats supplémentaires.",
|
||||
"removeFailed": "Échec de la suppression",
|
||||
"generationFailed": "Échec de la génération",
|
||||
"anyDifficulty": "Toute",
|
||||
"aiPhaseAnalyzing": "Analyse de vos préférences…",
|
||||
"aiPhasePlanning": "Planification du petit-déjeuner, déjeuner et dîner…",
|
||||
"aiPhaseSelecting": "Sélection des recettes pour chaque jour…",
|
||||
"aiPhaseBalancing": "Équilibrage nutritionnel de la semaine…",
|
||||
"aiPhaseFinalizing": "Finalisation de votre planning repas…",
|
||||
"newList": "Nouvelle liste",
|
||||
"newListTitle": "Nouvelle liste de courses",
|
||||
"listNameLabel": "Nom",
|
||||
"listNamePlaceholder": "Courses de la semaine",
|
||||
"generateFromWeek": "Générer depuis une semaine du planning (facultatif)",
|
||||
"generateFromWeekHint": "Sélectionne le lundi de la semaine choisie",
|
||||
"listCreateFailed": "Échec de la création",
|
||||
"listCreating": "Création…",
|
||||
"listCreate": "Créer",
|
||||
"pickRecipe": "Choisir une recette",
|
||||
"addEntry": "+ Ajouter"
|
||||
},
|
||||
"pantry": {
|
||||
"title": "Garde-manger",
|
||||
@@ -385,9 +513,20 @@
|
||||
"empty": "Aucune liste de courses",
|
||||
"listEmpty": "Vide",
|
||||
"generated": "Générée",
|
||||
"shareTitle": "Partager la liste de courses",
|
||||
"shareDescription": "Invitez des membres du foyer à voir ou modifier cette liste.",
|
||||
"sharedWithYou": "Partagées avec vous",
|
||||
"sendToGroceryDelivery": "Envoyer à un service de livraison",
|
||||
"copyAsText": "Copier la liste en texte",
|
||||
"sendToInstacart": "Envoyer à Instacart",
|
||||
"copiedToClipboard": "Liste copiée dans le presse-papiers",
|
||||
"exportBuildFailed": "Impossible de générer l'export",
|
||||
"instacartNotConfigured": "Instacart n'est pas encore configuré",
|
||||
"items": "{checked}/{total} articles",
|
||||
"itemsChecked": "{checked}/{total} articles cochés",
|
||||
"fromMealPlan": " · Depuis le planning repas",
|
||||
"itemCount": "{count} article",
|
||||
"itemCountPlural": "{count} articles",
|
||||
"addToList": "Ajouter à la liste",
|
||||
"dialogTitle": "Ajouter à la liste de courses",
|
||||
"dialogDescription": "Ajouter les ingrédients de <strong>{title}</strong> à une liste de courses.",
|
||||
@@ -407,7 +546,40 @@
|
||||
"empty": "Aucune collection pour l'instant",
|
||||
"public": "Publique",
|
||||
"recipeCount": "{count} recette",
|
||||
"recipeCountPlural": "{count} recettes"
|
||||
"recipeCountPlural": "{count} recettes",
|
||||
"exportPdf": "Exporter en PDF",
|
||||
"shareTitle": "Partager la collection",
|
||||
"shareDescription": "Invitez des personnes à voir ou modifier cette collection.",
|
||||
"new": "Nouvelle collection",
|
||||
"newTitle": "Nouvelle collection",
|
||||
"nameLabel": "Nom",
|
||||
"namePlaceholder": "Dîners du week-end",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Facultatif…",
|
||||
"makePublic": "Rendre publique",
|
||||
"creating": "Création…",
|
||||
"create": "Créer",
|
||||
"createFailed": "Échec de la création",
|
||||
"forking": "Duplication…",
|
||||
"forkCollection": "Dupliquer la collection",
|
||||
"forkFailed": "Échec de la duplication"
|
||||
},
|
||||
"shareDialog": {
|
||||
"invite": "Inviter",
|
||||
"emailPlaceholder": "Adresse e-mail",
|
||||
"owner": "Propriétaire",
|
||||
"viewer": "Lecteur",
|
||||
"editor": "Éditeur",
|
||||
"loadingMembers": "Chargement des membres…",
|
||||
"noMembers": "Aucun membre pour l'instant.",
|
||||
"invitationSent": "Invitation envoyée",
|
||||
"alreadyMember": "Déjà membre",
|
||||
"userNotFound": "Utilisateur introuvable",
|
||||
"inviteFailed": "Impossible d'inviter cet utilisateur",
|
||||
"loadMembersFailed": "Impossible de charger les membres",
|
||||
"memberRemoved": "Membre retiré",
|
||||
"removeMemberFailed": "Impossible de retirer le membre",
|
||||
"enterEmail": "Saisissez une adresse e-mail"
|
||||
},
|
||||
"social": {
|
||||
"followed": "Abonné",
|
||||
@@ -499,18 +671,62 @@
|
||||
"webhookUrlPlaceholder": "https://exemple.fr/webhook",
|
||||
"webhookAdding": "Ajout…",
|
||||
"webhookAddButton": "Ajouter un webhook",
|
||||
"webhookDocsLink": "Voir la doc webhook et l'intégration Zapier →",
|
||||
"endpointUrlLabel": "URL de destination",
|
||||
"eventsLabel": "Événements",
|
||||
"eventsHint": "Sélectionnez les événements qui déclenchent ce webhook. Laissez tout décoché pour recevoir tous les événements.",
|
||||
"secretRevealNotice": "Enregistrez ce secret de signature — il ne sera plus affiché.",
|
||||
"secretUsageHint": "Utilisez-le pour vérifier l'en-tête X-Epicure-Signature des requêtes entrantes.",
|
||||
"noWebhooks": "Aucun webhook pour l'instant.",
|
||||
"addedOn": "Ajouté le {date}",
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif",
|
||||
"allEvents": "Tous les événements",
|
||||
"deliveries": "Livraisons",
|
||||
"disable": "Désactiver",
|
||||
"enable": "Activer",
|
||||
"noDeliveriesYet": "Aucune livraison pour l'instant.",
|
||||
"redeliverySuccess": "Renvoi mis en file d'attente",
|
||||
"redeliveryFailed": "Échec du renvoi",
|
||||
"apiKeyCreateFailed": "Échec de la création de la clé API",
|
||||
"apiKeyRevokeFailed": "Échec de la révocation de la clé API",
|
||||
"apiKeyNamePlaceholder": "ex. Mon application",
|
||||
"apiKeyNameLabel": "Nom de la clé",
|
||||
"apiKeyCreating": "Création…",
|
||||
"apiKeyCreate": "Créer",
|
||||
"apiKeyRevealNotice": "Enregistrez cette clé — elle ne sera plus affichée.",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié !",
|
||||
"dismiss": "Ignorer",
|
||||
"noApiKeys": "Aucune clé API pour l'instant.",
|
||||
"createdOn": "Créée le {date}",
|
||||
"lastUsedOn": "Utilisée le {date}",
|
||||
"neverUsed": "Jamais utilisée",
|
||||
"revoke": "Révoquer",
|
||||
"copyFailed": "Échec de la copie dans le presse-papiers",
|
||||
"byokSaved": "Clé {provider} enregistrée",
|
||||
"byokSaveFailed": "Échec de l'enregistrement de la clé",
|
||||
"byokRemoved": "Clé {provider} supprimée",
|
||||
"byokRemoveFailed": "Échec de la suppression de la clé",
|
||||
"byokConfigured": "Configurée",
|
||||
"byokOllamaHint": "Définie via la variable d'environnement OLLAMA_BASE_URL",
|
||||
"byokSaving": "Enregistrement…",
|
||||
"byokSave": "Enregistrer",
|
||||
"byokFooter": "Vos clés sont chiffrées au repos. Une fois définies, elles remplacent les clés par défaut de l'application pour votre compte.",
|
||||
"modelSaved": "Préférences de modèle enregistrées",
|
||||
"modelSaveFailed": "Échec de l'enregistrement",
|
||||
"useCaseText": "Génération de texte",
|
||||
"useCaseTextDesc": "Génération de recettes, variations, traductions, adaptation",
|
||||
"useCaseVision": "Vision / import photo",
|
||||
"useCaseVisionDesc": "Reconnaissance de plats, import de recette depuis une photo",
|
||||
"useCaseMealPlan": "Planning repas",
|
||||
"useCaseMealPlanDesc": "Génération du planning repas hebdomadaire",
|
||||
"providerLabel": "Fournisseur",
|
||||
"modelLabel": "Modèle",
|
||||
"defaultAuto": "Par défaut (auto)",
|
||||
"modelDefault": "Par défaut",
|
||||
"modelSaving": "Enregistrement…",
|
||||
"saveModelPrefs": "Enregistrer les préférences de modèle",
|
||||
"modelDefaultPlaceholder": "Par défaut",
|
||||
"modelNamePlaceholder": "ex. llama3.2",
|
||||
"userUpdated": "Utilisateur mis à jour avec succès",
|
||||
|
||||
Reference in New Issue
Block a user