133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from "react";
|
||
|
|
import { useTranslation } from "react-i18next";
|
||
|
|
import { AdminApi } from "@/lib/api";
|
||
|
|
import { useToastStore } from "@/stores/toastStore";
|
||
|
|
import type { AdminSettingsResponse } from "@/types";
|
||
|
|
import { Button } from "@/components/ui/Button";
|
||
|
|
import { Card } from "@/components/ui/Card";
|
||
|
|
import { Input } from "@/components/ui/Input";
|
||
|
|
import { Spinner } from "@/components/ui/Spinner";
|
||
|
|
|
||
|
|
const GROUP_PREFIXES: Array<{ group: string; prefixes: string[]; labelKey: string }> = [
|
||
|
|
{ group: "llm", prefixes: ["llm_", "llm."], labelKey: "admin.group_llm" },
|
||
|
|
{ group: "embeddings", prefixes: ["embeddings_", "embeddings."], labelKey: "admin.group_embeddings" },
|
||
|
|
{ group: "qdrant", prefixes: ["qdrant_", "qdrant."], labelKey: "admin.group_qdrant" },
|
||
|
|
{ group: "ui", prefixes: ["ui_", "ui.", "site_", "site."], labelKey: "admin.group_ui" },
|
||
|
|
{ group: "game", prefixes: ["game_", "game."], labelKey: "admin.group_game" },
|
||
|
|
];
|
||
|
|
|
||
|
|
function groupFor(key: string): string {
|
||
|
|
const lower = key.toLowerCase();
|
||
|
|
for (const g of GROUP_PREFIXES) {
|
||
|
|
if (g.prefixes.some((p) => lower.startsWith(p))) return g.group;
|
||
|
|
}
|
||
|
|
return "other";
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SettingsPanel() {
|
||
|
|
const { t } = useTranslation();
|
||
|
|
const pushToast = useToastStore((s) => s.push);
|
||
|
|
const [data, setData] = useState<AdminSettingsResponse | null>(null);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [saving, setSaving] = useState(false);
|
||
|
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setLoading(true);
|
||
|
|
AdminApi.settings()
|
||
|
|
.then((res) => {
|
||
|
|
setData(res);
|
||
|
|
setDraft({ ...res.settings });
|
||
|
|
})
|
||
|
|
.catch(() => pushToast("error", t("admin.settings_load_failed")))
|
||
|
|
.finally(() => setLoading(false));
|
||
|
|
}, [pushToast, t]);
|
||
|
|
|
||
|
|
const grouped = useMemo(() => {
|
||
|
|
if (!data) return {} as Record<string, Array<{ key: string; description?: string }>>;
|
||
|
|
const out: Record<string, Array<{ key: string; description?: string }>> = {};
|
||
|
|
for (const key of Object.keys(data.settings)) {
|
||
|
|
const g = groupFor(key);
|
||
|
|
(out[g] ||= []).push({ key, description: data.descriptions?.[key] });
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}, [data]);
|
||
|
|
|
||
|
|
const handleSave = async () => {
|
||
|
|
if (!data) return;
|
||
|
|
setSaving(true);
|
||
|
|
try {
|
||
|
|
// Save only changed keys
|
||
|
|
const diff: Record<string, string> = {};
|
||
|
|
for (const [k, v] of Object.entries(draft)) {
|
||
|
|
if (data.settings[k] !== v) diff[k] = v;
|
||
|
|
}
|
||
|
|
if (Object.keys(diff).length === 0) {
|
||
|
|
pushToast("info", "No changes to save.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const res = await AdminApi.updateSettings(diff);
|
||
|
|
setData(res);
|
||
|
|
setDraft({ ...res.settings });
|
||
|
|
pushToast("success", t("admin.settings_saved"));
|
||
|
|
} catch (err) {
|
||
|
|
const msg = err instanceof Error ? err.message : "Failed";
|
||
|
|
pushToast("error", msg);
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div className="flex items-center justify-center p-8">
|
||
|
|
<Spinner /> <span className="ml-2 text-sm text-fg-muted">{t("common.loading")}</span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!data) {
|
||
|
|
return <p className="text-sm text-fg-muted">{t("common.no_data")}</p>;
|
||
|
|
}
|
||
|
|
|
||
|
|
const groupOrder = ["llm", "embeddings", "qdrant", "ui", "game", "other"];
|
||
|
|
const groupLabelKey: Record<string, string> = {
|
||
|
|
llm: "admin.group_llm",
|
||
|
|
embeddings: "admin.group_embeddings",
|
||
|
|
qdrant: "admin.group_qdrant",
|
||
|
|
ui: "admin.group_ui",
|
||
|
|
game: "admin.group_game",
|
||
|
|
other: "common.details",
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
|
||
|
|
<Button onClick={handleSave} loading={saving}>
|
||
|
|
{t("common.save")}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
{groupOrder.map((g) => {
|
||
|
|
const entries = grouped[g];
|
||
|
|
if (!entries || entries.length === 0) return null;
|
||
|
|
return (
|
||
|
|
<Card key={g} title={t(groupLabelKey[g] || "common.details")}>
|
||
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
||
|
|
{entries.map(({ key, description }) => (
|
||
|
|
<Input
|
||
|
|
key={key}
|
||
|
|
label={key}
|
||
|
|
hint={description}
|
||
|
|
value={draft[key] ?? ""}
|
||
|
|
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.value }))}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|