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