From 11596463452937ee9535cf4a7980824b4ce88de7 Mon Sep 17 00:00:00 2001 From: wenjie Date: Fri, 6 Mar 2026 15:35:40 +0800 Subject: [PATCH] refactor(models): refactor models page into modular components and improve UX - split /models route into dedicated components (page, provider section, card, add/edit sheets, delete dialog) - add provider grouping/sorting, provider labels/icons, and a no-default hint in the models page - add "Set as default model" toggle to add/edit flows with safer defaults - introduce shared form helpers and new UI primitives (field, label, switch) - update i18n strings (en/zh) for models and gateway header text usage - apply minor UI polish (models nav icon, separator client directive) --- web/frontend/src/components/app-sidebar.tsx | 4 +- .../src/components/models/add-model-sheet.tsx | 320 +++++ .../components/models/delete-model-dialog.tsx | 74 ++ .../components/models/edit-model-sheet.tsx | 301 +++++ .../src/components/models/model-card.tsx | 136 ++ .../src/components/models/models-page.tsx | 212 ++++ .../src/components/models/provider-icon.tsx | 95 ++ .../src/components/models/provider-label.ts | 33 + .../components/models/provider-section.tsx | 72 ++ .../src/components/models/shared-form.tsx | 100 ++ web/frontend/src/components/ui/field.tsx | 236 ++++ web/frontend/src/components/ui/label.tsx | 22 + web/frontend/src/components/ui/separator.tsx | 2 + web/frontend/src/components/ui/switch.tsx | 31 + web/frontend/src/i18n/locales/en.json | 9 +- web/frontend/src/i18n/locales/zh.json | 9 +- web/frontend/src/routes/models.tsx | 1098 +---------------- 17 files changed, 1651 insertions(+), 1103 deletions(-) create mode 100644 web/frontend/src/components/models/add-model-sheet.tsx create mode 100644 web/frontend/src/components/models/delete-model-dialog.tsx create mode 100644 web/frontend/src/components/models/edit-model-sheet.tsx create mode 100644 web/frontend/src/components/models/model-card.tsx create mode 100644 web/frontend/src/components/models/models-page.tsx create mode 100644 web/frontend/src/components/models/provider-icon.tsx create mode 100644 web/frontend/src/components/models/provider-label.ts create mode 100644 web/frontend/src/components/models/provider-section.tsx create mode 100644 web/frontend/src/components/models/shared-form.tsx create mode 100644 web/frontend/src/components/ui/field.tsx create mode 100644 web/frontend/src/components/ui/label.tsx create mode 100644 web/frontend/src/components/ui/switch.tsx diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index 66c3318e2..28e990a0e 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -1,7 +1,7 @@ import { IconChevronRight } from "@tabler/icons-react" import { + IconAtom, IconCloud, - IconCpu, IconKey, IconListDetails, IconMessageCircle, @@ -40,7 +40,7 @@ const navGroups = [ defaultOpen: true, items: [ { title: "navigation.providers", url: "/providers", icon: IconCloud }, - { title: "navigation.models", url: "/models", icon: IconCpu }, + { title: "navigation.models", url: "/models", icon: IconAtom }, { title: "navigation.credentials", url: "/credentials", icon: IconKey }, ], }, diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx new file mode 100644 index 000000000..b5c96dedf --- /dev/null +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -0,0 +1,320 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { addModel, setDefaultModel } from "@/api/models" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Switch } from "@/components/ui/switch" + +import { AdvancedSection, Field, KeyInput } from "./shared-form" + +interface AddForm { + modelName: string + model: string + apiBase: string + apiKey: string + proxy: string + authMethod: string + connectMode: string + workspace: string + rpm: string + maxTokensField: string + requestTimeout: string + thinkingLevel: string +} + +const EMPTY_ADD_FORM: AddForm = { + modelName: "", + model: "", + apiBase: "", + apiKey: "", + proxy: "", + authMethod: "", + connectMode: "", + workspace: "", + rpm: "", + maxTokensField: "", + requestTimeout: "", + thinkingLevel: "", +} + +interface AddModelSheetProps { + open: boolean + onClose: () => void + onSaved: () => void +} + +export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) { + const { t } = useTranslation() + const [form, setForm] = useState(EMPTY_ADD_FORM) + const [saving, setSaving] = useState(false) + const [setAsDefault, setSetAsDefault] = useState(false) + const [fieldErrors, setFieldErrors] = useState< + Partial> + >({}) + const [serverError, setServerError] = useState("") + + useEffect(() => { + if (open) { + setForm(EMPTY_ADD_FORM) + setSetAsDefault(false) + setFieldErrors({}) + setServerError("") + } + }, [open]) + + const validate = (): boolean => { + const errors: Partial> = {} + if (!form.modelName.trim()) errors.modelName = t("models.add.errorRequired") + if (!form.model.trim()) errors.model = t("models.add.errorRequired") + setFieldErrors(errors) + return Object.keys(errors).length === 0 + } + + const setField = + (key: keyof AddForm) => (e: React.ChangeEvent) => { + setForm((f) => ({ ...f, [key]: e.target.value })) + if (fieldErrors[key]) { + setFieldErrors((prev) => ({ ...prev, [key]: undefined })) + } + } + + const handleSave = async () => { + if (!validate()) return + setSaving(true) + setServerError("") + try { + const modelName = form.modelName.trim() + const modelId = form.model.trim() + await addModel({ + model_name: modelName, + model: modelId, + api_base: form.apiBase.trim() || undefined, + api_key: form.apiKey.trim() || undefined, + proxy: form.proxy.trim() || undefined, + auth_method: form.authMethod.trim() || undefined, + connect_mode: form.connectMode.trim() || undefined, + workspace: form.workspace.trim() || undefined, + rpm: form.rpm ? Number(form.rpm) : undefined, + max_tokens_field: form.maxTokensField.trim() || undefined, + request_timeout: form.requestTimeout + ? Number(form.requestTimeout) + : undefined, + thinking_level: form.thinkingLevel.trim() || undefined, + }) + if (setAsDefault) { + await setDefaultModel(modelName) + } + onSaved() + onClose() + } catch (e) { + setServerError(e instanceof Error ? e.message : t("models.add.saveError")) + } finally { + setSaving(false) + } + } + + return ( + !v && onClose()}> + + + {t("models.add.title")} + + {t("models.add.description")} + + + +
+
+ + + {fieldErrors.modelName && ( +

+ {fieldErrors.modelName} +

+ )} +
+ + + + {fieldErrors.model && ( +

{fieldErrors.model}

+ )} +
+ + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={t("models.field.apiKeyPlaceholder")} + /> + + + + + + +
+
+ + {t("models.defaultOnSave.label")} + + +
+

+ {t("models.defaultOnSave.description")} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {serverError && ( +

+ {serverError} +

+ )} +
+
+ + + + + +
+
+ ) +} diff --git a/web/frontend/src/components/models/delete-model-dialog.tsx b/web/frontend/src/components/models/delete-model-dialog.tsx new file mode 100644 index 000000000..72b8df002 --- /dev/null +++ b/web/frontend/src/components/models/delete-model-dialog.tsx @@ -0,0 +1,74 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useState } from "react" +import { useTranslation } from "react-i18next" + +import { type ModelInfo, deleteModel } from "@/api/models" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +interface DeleteModelDialogProps { + model: ModelInfo | null + onClose: () => void + onDeleted: () => void +} + +export function DeleteModelDialog({ + model, + onClose, + onDeleted, +}: DeleteModelDialogProps) { + const { t } = useTranslation() + const [deleting, setDeleting] = useState(false) + + const handleConfirm = async () => { + if (!model) return + if (model.is_default) { + onClose() + return + } + setDeleting(true) + try { + await deleteModel(model.index) + onDeleted() + } catch { + // ignore, user can retry from list + } finally { + setDeleting(false) + onClose() + } + } + + return ( + !v && onClose()}> + + + {t("models.delete.title")} + + {t("models.delete.description", { name: model?.model_name })} + + + + + {t("common.cancel")} + + + {deleting && } + {t("models.delete.confirm")} + + + + + ) +} diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx new file mode 100644 index 000000000..2c19bfb1e --- /dev/null +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -0,0 +1,301 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Switch } from "@/components/ui/switch" + +import { AdvancedSection, Field, KeyInput } from "./shared-form" + +interface EditForm { + apiKey: string + apiBase: string + proxy: string + authMethod: string + connectMode: string + workspace: string + rpm: string + maxTokensField: string + requestTimeout: string + thinkingLevel: string +} + +interface EditModelSheetProps { + model: ModelInfo | null + open: boolean + onClose: () => void + onSaved: () => void +} + +export function EditModelSheet({ + model, + open, + onClose, + onSaved, +}: EditModelSheetProps) { + const { t } = useTranslation() + const [form, setForm] = useState({ + apiKey: "", + apiBase: "", + proxy: "", + authMethod: "", + connectMode: "", + workspace: "", + rpm: "", + maxTokensField: "", + requestTimeout: "", + thinkingLevel: "", + }) + const [saving, setSaving] = useState(false) + const [setAsDefault, setSetAsDefault] = useState(false) + const [error, setError] = useState("") + + useEffect(() => { + if (model) { + setForm({ + apiKey: "", + apiBase: model.api_base ?? "", + proxy: model.proxy ?? "", + authMethod: model.auth_method ?? "", + connectMode: model.connect_mode ?? "", + workspace: model.workspace ?? "", + rpm: model.rpm ? String(model.rpm) : "", + maxTokensField: model.max_tokens_field ?? "", + requestTimeout: model.request_timeout + ? String(model.request_timeout) + : "", + thinkingLevel: model.thinking_level ?? "", + }) + setSetAsDefault(model.is_default) + setError("") + } + }, [model]) + + const setField = + (key: keyof EditForm) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [key]: e.target.value })) + + const handleSave = async () => { + if (!model) return + setSaving(true) + setError("") + try { + await updateModel(model.index, { + model_name: model.model_name, + model: model.model, + api_base: form.apiBase || undefined, + api_key: form.apiKey || undefined, + proxy: form.proxy || undefined, + auth_method: form.authMethod || undefined, + connect_mode: form.connectMode || undefined, + workspace: form.workspace || undefined, + rpm: form.rpm ? Number(form.rpm) : undefined, + max_tokens_field: form.maxTokensField || undefined, + request_timeout: form.requestTimeout + ? Number(form.requestTimeout) + : undefined, + thinking_level: form.thinkingLevel || undefined, + }) + if (setAsDefault) { + await setDefaultModel(model.model_name) + } + onSaved() + onClose() + } catch (e) { + setError(e instanceof Error ? e.message : t("models.edit.saveError")) + } finally { + setSaving(false) + } + } + + const isOAuth = model?.auth_method === "oauth" + + return ( + !v && onClose()}> + + + + {t("models.edit.title", { name: model?.model_name })} + + + {model?.model} + + + +
+
+ {!isOAuth && ( + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={ + model?.configured + ? t("models.field.apiKeyPlaceholderSet") + : t("models.field.apiKeyPlaceholder") + } + /> + + )} + + + + + +
+
+ + {t("models.defaultOnSave.label")} + + +
+

+ {t("models.defaultOnSave.description")} +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {error && ( +

+ {error} +

+ )} +
+
+ + + + + +
+
+ ) +} diff --git a/web/frontend/src/components/models/model-card.tsx b/web/frontend/src/components/models/model-card.tsx new file mode 100644 index 000000000..78821fbb0 --- /dev/null +++ b/web/frontend/src/components/models/model-card.tsx @@ -0,0 +1,136 @@ +import { + IconEdit, + IconKey, + IconLoader2, + IconStar, + IconStarFilled, + IconTrash, +} from "@tabler/icons-react" +import { useTranslation } from "react-i18next" + +import type { ModelInfo } from "@/api/models" +import { Button } from "@/components/ui/button" + +interface ModelCardProps { + model: ModelInfo + onEdit: (model: ModelInfo) => void + onSetDefault: (model: ModelInfo) => void + onDelete: (model: ModelInfo) => void + settingDefault: boolean +} + +export function ModelCard({ + model, + onEdit, + onSetDefault, + onDelete, + settingDefault, +}: ModelCardProps) { + const { t } = useTranslation() + const isOAuth = model.auth_method === "oauth" + + return ( +
+
+
+ + + {model.model_name} + + {model.is_default && ( + + {t("models.badge.default")} + + )} +
+ +
+ {model.is_default ? ( + + + + ) : ( + + )} + + + + +
+
+ +

+ {model.model} +

+ +
+ {isOAuth ? ( + + OAuth + + ) : model.configured && model.api_key ? ( + + + {model.api_key} + + ) : ( + + {t("models.status.unconfigured")} + + )} +
+
+ ) +} diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx new file mode 100644 index 000000000..5d971c2b1 --- /dev/null +++ b/web/frontend/src/components/models/models-page.tsx @@ -0,0 +1,212 @@ +import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react" +import { useCallback, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" + +import { AddModelSheet } from "./add-model-sheet" +import { DeleteModelDialog } from "./delete-model-dialog" +import { EditModelSheet } from "./edit-model-sheet" +import { getProviderKey, getProviderLabel } from "./provider-label" +import { ProviderSection } from "./provider-section" + +const PROVIDER_PRIORITY: Record = { + openai: 0, + gemini: 1, + anthropic: 2, + zhipu: 3, + deepseek: 4, + volcengine: 5, + openrouter: 6, + qwen: 7, + moonshot: 8, + groq: 9, + "github-copilot": 10, + antigravity: 11, + nvidia: 12, + cerebras: 13, + shengsuanyun: 14, + ollama: 15, + vllm: 16, + mistral: 17, + avian: 18, +} + +interface ProviderGroup { + key: string + label: string + models: ModelInfo[] + hasDefault: boolean + configuredCount: number +} + +export function ModelsPage() { + const { t } = useTranslation() + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(true) + const [fetchError, setFetchError] = useState("") + + const [editingModel, setEditingModel] = useState(null) + const [deletingModel, setDeletingModel] = useState(null) + const [addOpen, setAddOpen] = useState(false) + const [settingDefaultIndex, setSettingDefaultIndex] = useState( + null, + ) + + const fetchModels = useCallback(async () => { + try { + const data = await getModels() + const sorted = [...data.models].sort((a, b) => { + if (a.is_default && !b.is_default) return -1 + if (!a.is_default && b.is_default) return 1 + if (a.configured && !b.configured) return -1 + if (!a.configured && b.configured) return 1 + return a.model_name.localeCompare(b.model_name) + }) + setModels(sorted) + setFetchError("") + } catch (e) { + setFetchError(e instanceof Error ? e.message : t("models.loadError")) + } finally { + setLoading(false) + } + }, [t]) + + useEffect(() => { + fetchModels() + }, [fetchModels]) + + const handleSetDefault = async (model: ModelInfo) => { + setSettingDefaultIndex(model.index) + try { + await setDefaultModel(model.model_name) + await fetchModels() + } catch { + // ignore + } finally { + setSettingDefaultIndex(null) + } + } + + const grouped: Record = {} + for (const model of models) { + const providerKey = getProviderKey(model.model) + if (!grouped[providerKey]) { + grouped[providerKey] = { + label: getProviderLabel(model.model), + models: [], + } + } + grouped[providerKey].models.push(model) + } + + const providerGroups: ProviderGroup[] = Object.entries(grouped) + .map(([key, group]) => { + const configuredCount = group.models.filter( + (model) => model.configured, + ).length + return { + key, + label: group.label, + models: group.models, + hasDefault: group.models.some((model) => model.is_default), + configuredCount, + } + }) + .sort((a, b) => { + if (a.hasDefault && !b.hasDefault) return -1 + if (!a.hasDefault && b.hasDefault) return 1 + + if (a.configuredCount !== b.configuredCount) { + return b.configuredCount - a.configuredCount + } + + const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER + const bPriority = PROVIDER_PRIORITY[b.key] ?? Number.MAX_SAFE_INTEGER + if (aPriority !== bPriority) { + return aPriority - bPriority + } + + return a.label.localeCompare(b.label) + }) + + const defaultModel = models.find((model) => model.is_default) + + return ( +
+ +
+ +
+
+ +
+
+ {!defaultModel && ( +
+ {t("models.noDefaultHintPrefix")} + + {t("models.noDefaultHintSuffix")} +
+ )} +

+ {t("models.description")} +

+
+ + {loading && ( +
+ +
+ )} + + {fetchError && ( +
+ {fetchError} +
+ )} + + {!loading && !fetchError && ( +
+ {providerGroups.map((providerGroup) => ( + + ))} +
+ )} +
+ + setEditingModel(null)} + onSaved={fetchModels} + /> + + setAddOpen(false)} + onSaved={fetchModels} + /> + + setDeletingModel(null)} + onDeleted={fetchModels} + /> +
+ ) +} diff --git a/web/frontend/src/components/models/provider-icon.tsx b/web/frontend/src/components/models/provider-icon.tsx new file mode 100644 index 000000000..f6d6e93c6 --- /dev/null +++ b/web/frontend/src/components/models/provider-icon.tsx @@ -0,0 +1,95 @@ +import { useMemo, useState } from "react" + +const PROVIDER_ICON_SLUGS: Record = { + openai: "openai", + anthropic: "anthropic", + gemini: "googlegemini", + deepseek: "deepseek", + qwen: "alibabacloud", + groq: "groq", + openrouter: "openrouter", + nvidia: "nvidia", + cerebras: "cerebras", + volcengine: "bytedance", + "github-copilot": "githubcopilot", + ollama: "ollama", + mistral: "mistralai", + zhipu: "zhipu", +} + +const PROVIDER_DOMAINS: Record = { + openai: "openai.com", + anthropic: "anthropic.com", + gemini: "gemini.google.com", + deepseek: "deepseek.com", + qwen: "qwenlm.ai", + moonshot: "moonshot.ai", + groq: "groq.com", + openrouter: "openrouter.ai", + nvidia: "nvidia.com", + cerebras: "cerebras.ai", + volcengine: "volcengine.com", + shengsuanyun: "shengsuanyun.com", + antigravity: "antigravity.google", + "github-copilot": "github.com", + ollama: "ollama.com", + mistral: "mistral.ai", + avian: "avian.io", + vllm: "vllm.ai", + zhipu: "zhipuai.cn", +} + +interface ProviderIconProps { + providerKey: string + providerLabel: string +} + +export function ProviderIcon({ + providerKey, + providerLabel, +}: ProviderIconProps) { + const [sourceIndex, setSourceIndex] = useState(0) + const [loadFailed, setLoadFailed] = useState(false) + const initial = providerLabel.trim().charAt(0).toUpperCase() || "?" + const iconUrls = useMemo(() => { + const slug = PROVIDER_ICON_SLUGS[providerKey] + const domain = PROVIDER_DOMAINS[providerKey] + const urls: string[] = [] + if (slug) { + urls.push(`https://cdn.simpleicons.org/${slug}`) + } + if (domain) { + urls.push(`https://www.google.com/s2/favicons?domain=${domain}&sz=64`) + } + return urls + }, [providerKey]) + + const iconUrl = iconUrls[sourceIndex] + + if (!iconUrl || loadFailed) { + return ( + + {initial} + + ) + } + + return ( + + {`${providerLabel} { + if (sourceIndex < iconUrls.length - 1) { + setSourceIndex((idx) => idx + 1) + return + } + setLoadFailed(true) + }} + /> + + ) +} diff --git a/web/frontend/src/components/models/provider-label.ts b/web/frontend/src/components/models/provider-label.ts new file mode 100644 index 000000000..923cd9506 --- /dev/null +++ b/web/frontend/src/components/models/provider-label.ts @@ -0,0 +1,33 @@ +const PROVIDER_LABELS: Record = { + openai: "OpenAI", + anthropic: "Anthropic", + gemini: "Google Gemini", + deepseek: "DeepSeek", + qwen: "Qwen (阿里云)", + moonshot: "Moonshot (月之暗面)", + groq: "Groq", + openrouter: "OpenRouter", + nvidia: "NVIDIA", + cerebras: "Cerebras", + volcengine: "Volcengine (火山引擎)", + shengsuanyun: "ShengsuanYun (神算云)", + antigravity: "Google Code Assist", + "github-copilot": "GitHub Copilot", + ollama: "Ollama (local)", + mistral: "Mistral AI", + avian: "Avian", + vllm: "VLLM (local)", + zhipu: "Zhipu AI (智谱)", +} + +export function getProviderKey(model: string): string { + return model.split("/")[0] +} + +export function getProviderLabel(model: string): string { + const prefix = getProviderKey(model) + const labels: Record = { + ...PROVIDER_LABELS, + } + return labels[prefix] ?? prefix +} diff --git a/web/frontend/src/components/models/provider-section.tsx b/web/frontend/src/components/models/provider-section.tsx new file mode 100644 index 000000000..613364f0f --- /dev/null +++ b/web/frontend/src/components/models/provider-section.tsx @@ -0,0 +1,72 @@ +import { IconChevronDown } from "@tabler/icons-react" +import { useState } from "react" + +import type { ModelInfo } from "@/api/models" + +import { ModelCard } from "./model-card" +import { ProviderIcon } from "./provider-icon" + +interface ProviderSectionProps { + provider: string + providerKey: string + models: ModelInfo[] + onEdit: (model: ModelInfo) => void + onSetDefault: (model: ModelInfo) => void + onDelete: (model: ModelInfo) => void + settingDefaultIndex: number | null +} + +export function ProviderSection({ + provider, + providerKey, + models, + onEdit, + onSetDefault, + onDelete, + settingDefaultIndex, +}: ProviderSectionProps) { + const [open, setOpen] = useState(true) + + return ( +
+ + + {open && ( +
+ {models.map((model) => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/frontend/src/components/models/shared-form.tsx b/web/frontend/src/components/models/shared-form.tsx new file mode 100644 index 000000000..9c51c0c66 --- /dev/null +++ b/web/frontend/src/components/models/shared-form.tsx @@ -0,0 +1,100 @@ +import { IconChevronDown, IconEye, IconEyeOff } from "@tabler/icons-react" +import { type ReactNode, useState } from "react" +import { useTranslation } from "react-i18next" + +import { + FieldDescription, + FieldLabel, + Field as UiField, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" + +interface FieldProps { + label: string + hint?: string + children: ReactNode +} + +export function Field({ label, hint, children }: FieldProps) { + return ( + +
+ {label} + {hint && ( + + {hint} + + )} +
+ {children} +
+ ) +} + +interface KeyInputProps { + value: string + onChange: (v: string) => void + placeholder?: string +} + +export function KeyInput({ value, onChange, placeholder }: KeyInputProps) { + const [show, setShow] = useState(false) + + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + className="pr-10" + /> + +
+ ) +} + +interface AdvancedSectionProps { + children: ReactNode +} + +export function AdvancedSection({ children }: AdvancedSectionProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ) +} diff --git a/web/frontend/src/components/ui/field.tsx b/web/frontend/src/components/ui/field.tsx new file mode 100644 index 000000000..7cf58a8b4 --- /dev/null +++ b/web/frontend/src/components/ui/field.tsx @@ -0,0 +1,236 @@ +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +