Files
MelodyMuse/src/pages/SettingsPage.tsx
T

200 lines
6.9 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowLeft,
faCheckCircle,
faExclamationTriangle,
faCircleNotch,
faPlug,
faTrashAlt,
faServer,
} from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../lib/toast";
import { getServerStatus, type ServerStatus } from "../lib/llm";
import { clearHistory } from "../lib/history";
import { useI18n } from "../lib/i18n";
const DRAFT_KEY = "melodymuse-draft";
interface LocalDataSummary {
hasDraft: boolean;
}
function readLocalDataSummary(): LocalDataSummary {
if (typeof window === "undefined") {
return { hasDraft: false };
}
const hasDraft = window.localStorage.getItem(DRAFT_KEY) !== null;
return { hasDraft };
}
export function SettingsPage() {
const toast = useToast();
const navigate = useNavigate();
const { t } = useI18n();
const [status, setStatus] = useState<ServerStatus | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [checking, setChecking] = useState(true);
const [summary, setSummary] = useState<LocalDataSummary>({
hasDraft: false,
});
const check = async (signal?: AbortSignal) => {
setChecking(true);
setStatusError(null);
try {
const s = await getServerStatus(signal);
setStatus(s);
} catch (err) {
setStatusError(err instanceof Error ? err.message : t.unreachable);
setStatus(null);
} finally {
setChecking(false);
}
};
useEffect(() => {
const ctrl = new AbortController();
check(ctrl.signal);
setSummary(readLocalDataSummary());
return () => ctrl.abort();
}, []);
const onClearHistory = async () => {
if (!window.confirm(t.clearHistoryConfirm)) return;
await clearHistory();
toast.info(t.clearHistory);
};
const onClearDraft = () => {
if (!summary.hasDraft) return;
if (!window.confirm(t.clearDraftConfirm)) return;
window.localStorage.removeItem(DRAFT_KEY);
setSummary((s) => ({ ...s, hasDraft: false }));
toast.info(t.clearDraft);
};
return (
<div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-md mx-auto px-4 h-14 flex items-center gap-2">
<button
type="button"
onClick={() => navigate("/")}
className="p-2 -ml-2 rounded-md text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
aria-label="Back"
>
<FontAwesomeIcon icon={faArrowLeft} className="w-4 h-4" />
</button>
<h1 className="text-sm font-semibold text-fg">{t.settings}</h1>
</div>
</header>
<main className="max-w-md mx-auto px-4 mt-16">
<div className="card p-6 space-y-6">
<section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-accent-primary" />
{t.serverStatus}
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 p-3 rounded-lg border border-border bg-bg-card/40">
<div className="flex items-center gap-3 min-w-0">
<StatusDot checking={checking} ok={Boolean(status)} />
<div className="min-w-0">
<p className="text-sm font-medium text-fg">
{checking
? t.checking
: status
? t.connected
: statusError
? t.unreachable
: "Unknown"}
</p>
{status && (
<p className="text-xs text-fg-muted truncate mt-1">
{t.model}: <span className="font-mono">{status.model}</span>
<br />
{t.endpoint}:{" "}
<span className="font-mono">{status.endpoint}</span>
</p>
)}
{statusError && !checking && (
<p className="text-xs text-rose-400 break-words mt-1">
{statusError}
</p>
)}
</div>
</div>
<button
type="button"
onClick={() => check()}
disabled={checking}
className="btn-ghost"
>
{checking ? (
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
) : (
<FontAwesomeIcon icon={faPlug} className="w-4 h-4" />
)}
<span>{t.recheck}</span>
</button>
</div>
</div>
</section>
<hr className="border-border" />
<section>
<h2 className="text-sm font-semibold text-fg mb-3">{t.localData}</h2>
<ul className="text-xs text-fg-muted space-y-1 mb-4">
<li>
<code className="font-mono text-accent-primary">melodymuse-draft</code> {" "}
{summary.hasDraft ? t.draftSaved : t.draftEmpty}
</li>
</ul>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={onClearDraft}
disabled={!summary.hasDraft}
className="btn-secondary"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
{t.clearDraft}
</button>
<button
type="button"
onClick={onClearHistory}
className="btn-secondary text-rose-400 hover:text-rose-300 border-rose-400/20"
>
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
{t.clearHistory}
</button>
</div>
</section>
<div className="text-center pt-2">
<Link
to="/"
className="text-xs font-semibold text-fg-muted hover:text-accent-primary transition-colors flex items-center justify-center gap-2"
>
<FontAwesomeIcon icon={faArrowLeft} /> {t.backToGenerator}
</Link>
</div>
</div>
</main>
</div>
);
}
function StatusDot({ checking, ok }: { checking: boolean; ok: boolean }) {
if (checking)
return <FontAwesomeIcon icon={faCircleNotch} spin className="w-5 h-5 text-fg-muted shrink-0" />;
if (ok) return <FontAwesomeIcon icon={faCheckCircle} className="w-5 h-5 text-emerald-400 shrink-0" />;
return <FontAwesomeIcon icon={faExclamationTriangle} className="w-5 h-5 text-rose-400 shrink-0" />;
}