feat(settings): sidebar layout with profile, security, AI, notifications, nutrition
Sticky sidebar nav. Sections: Profile (name/language), Security (email/password change), AI & Models (BYOK keys + per-use-case model prefs), Notifications (push subscribe), Nutrition goals. Sub-pages: API keys, Webhooks.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"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";
|
||||
|
||||
type Provider = "openai" | "anthropic" | "openrouter" | "ollama" | "";
|
||||
|
||||
type UseCase = {
|
||||
key: "text" | "vision" | "mealPlan";
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
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 [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("Model preferences saved");
|
||||
} catch {
|
||||
toast.error("Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{USE_CASES.map(({ key, label, description }) => {
|
||||
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">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Provider</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">Default (auto)</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">Model</Label>
|
||||
{presets ? (
|
||||
<Select
|
||||
value={model || "default"}
|
||||
onValueChange={(v) => setField(modelKey, v === "default" ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
<span className="text-muted-foreground">Default</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 ? "e.g. llama3.2" : "—"}
|
||||
value={model}
|
||||
onChange={(e) => setField(modelKey, e.target.value)}
|
||||
disabled={!provider}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button onClick={() => { void handleSave(); }} disabled={saving} size="sm">
|
||||
{saving ? "Saving…" : "Save model preferences"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user