eb424d8c04
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>
172 lines
6.1 KiB
TypeScript
172 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { toast } from "sonner";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
type Provider = "openai" | "anthropic" | "openrouter" | "ollama" | "";
|
|
|
|
type UseCase = {
|
|
key: "text" | "vision" | "mealPlan";
|
|
labelKey: "useCaseText" | "useCaseVision" | "useCaseMealPlan";
|
|
descriptionKey: "useCaseTextDesc" | "useCaseVisionDesc" | "useCaseMealPlanDesc";
|
|
};
|
|
|
|
const USE_CASES: UseCase[] = [
|
|
{ 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 }[] }> = {
|
|
openai: {
|
|
label: "OpenAI",
|
|
models: [
|
|
{ value: "gpt-4o", label: "GPT-4o" },
|
|
{ value: "gpt-4o-mini", label: "GPT-4o mini (faster)" },
|
|
{ value: "o3-mini", label: "o3-mini (reasoning)" },
|
|
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
|
|
],
|
|
},
|
|
anthropic: {
|
|
label: "Anthropic",
|
|
models: [
|
|
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
|
{ value: "claude-opus-4-8", label: "Claude Opus 4.8 (most capable)" },
|
|
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (fastest)" },
|
|
],
|
|
},
|
|
};
|
|
|
|
type Prefs = {
|
|
textProvider?: string | null;
|
|
textModel?: string | null;
|
|
visionProvider?: string | null;
|
|
visionModel?: string | null;
|
|
mealPlanProvider?: string | null;
|
|
mealPlanModel?: string | null;
|
|
};
|
|
|
|
export function ModelPrefsForm({ initialPrefs }: { initialPrefs: Prefs | null }) {
|
|
const t = useTranslations("settingsForm");
|
|
const [prefs, setPrefs] = useState<Prefs>(initialPrefs ?? {});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
function setField(field: keyof Prefs, value: string | null) {
|
|
setPrefs((prev) => ({ ...prev, [field]: value || null }));
|
|
}
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch("/api/v1/users/me/model-prefs", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(prefs),
|
|
});
|
|
if (!res.ok) throw new Error("Save failed");
|
|
toast.success(t("modelSaved"));
|
|
} catch {
|
|
toast.error(t("modelSaveFailed"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{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;
|
|
const model = (prefs[modelKey] ?? "") as string;
|
|
const presets = provider && PRESET_MODELS[provider]?.models;
|
|
|
|
return (
|
|
<div key={key} className="rounded-lg border p-4 space-y-3">
|
|
<div>
|
|
<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">{t("providerLabel")}</Label>
|
|
<Select
|
|
value={provider || "default"}
|
|
onValueChange={(v) => {
|
|
const val = v === "default" ? null : v;
|
|
setField(providerKey, val);
|
|
setField(modelKey, null);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default">
|
|
<span className="text-muted-foreground">{t("defaultAuto")}</span>
|
|
</SelectItem>
|
|
<SelectItem value="openai">OpenAI</SelectItem>
|
|
<SelectItem value="anthropic">Anthropic</SelectItem>
|
|
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
|
<SelectItem value="ollama">Ollama (local)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">{t("modelLabel")}</Label>
|
|
{presets ? (
|
|
<Select
|
|
value={model || "default"}
|
|
onValueChange={(v) => setField(modelKey, v === "default" ? null : v)}
|
|
>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue placeholder={t("modelDefaultPlaceholder")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default">
|
|
<span className="text-muted-foreground">{t("modelDefault")}</span>
|
|
</SelectItem>
|
|
<SelectGroup>
|
|
<SelectLabel>{PRESET_MODELS[provider]?.label}</SelectLabel>
|
|
{presets.map((m) => (
|
|
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
|
))}
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
className="h-8 text-sm"
|
|
placeholder={provider ? t("modelNamePlaceholder") : "—"}
|
|
value={model}
|
|
onChange={(e) => setField(modelKey, e.target.value)}
|
|
disabled={!provider}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<Button onClick={() => { void handleSave(); }} disabled={saving} size="sm">
|
|
{saving ? t("modelSaving") : t("saveModelPrefs")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|