200 lines
6.9 KiB
TypeScript
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" />;
|
|
}
|