mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 12:01:02 +00:00
The page was a mock: it never read the database (always 0 accounts), showed a hard-coded schema and core version, looked for server files in the wrong folder, and its "transfer" reported accounts, characters and tables as moved without moving anything. Its locale was fixed to "ru". - remove server_migration.rs and its three commands - modular_copy::preview: classic/modular core, version, database state, counts and schemas from a running database (read only), database size, settings files, extra worlds; nothing is started or changed - the page shows that and hands over to the same modular-copy install screen as "Manage another server"; texts in en and ru via i18n - the modular copy now carries world settings changed in Server Configuration (Settings/worldserver.conf.template was not copied) - E2E preview_bare_and_copy_e2e checks the preview and the carried setting; passes against preview core 0.261108.9501 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
11 KiB
TypeScript
235 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Archive, BarChart3, Bot, Bug, Gauge, Globe, Loader2, Puzzle, Server, Settings, Swords, Terminal, Users, RotateCcw, type LucideIcon } from "lucide-react";
|
|
import { api, type ServerSummary } from "@/lib/api";
|
|
import { useT, type Key } from "@/i18n";
|
|
import logo from "@/assets/logo.png";
|
|
import { cn } from "@/lib/utils";
|
|
import { Overview } from "@/screens/Overview";
|
|
import { Placeholder } from "@/screens/Placeholder";
|
|
import { SettingsPage } from "@/screens/SettingsPage";
|
|
import { BackupsPage } from "@/screens/BackupsPage";
|
|
import { PlayersPage } from "@/screens/PlayersPage";
|
|
import { SettingsHome } from "@/screens/SettingsHome";
|
|
import { PlayWithFriends } from "@/screens/PlayWithFriends";
|
|
import { ReportPage } from "@/screens/ReportPage";
|
|
import { ModulesPage } from "@/screens/ModulesPage";
|
|
import { ConsolePage } from "@/screens/ConsolePage";
|
|
import { ServerMigrationWizard } from "@/screens/ServerMigrationWizard";
|
|
import { PortablePage } from "@/screens/PortablePage";
|
|
import { DashboardPage } from "@/screens/DashboardPage";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { startServerUpdatePolling, useServerUpdate } from "@/lib/serverUpdate";
|
|
import { startClientPolling } from "@/lib/clientUpdate";
|
|
import type { UnsavedChangesGuard } from "@/lib/unsavedGuard";
|
|
|
|
type Page = "overview" | "bots" | "dashboard" | "server" | "modules" | "players" | "characters" | "friends" | "backups" | "console" | "report" | "settings" | "migration";
|
|
|
|
const NAV: { id: Page; label: Key; icon: LucideIcon; question: Key }[] = [
|
|
{ id: "overview", label: "nav.overview", icon: Gauge, question: "q.overview" },
|
|
{ id: "bots", label: "nav.bots", icon: Bot, question: "q.bots" },
|
|
{ id: "dashboard", label: "nav.dashboard", icon: BarChart3, question: "q.dashboard" },
|
|
{ id: "server", label: "nav.server", icon: Server, question: "q.server" },
|
|
{ id: "modules", label: "nav.modules", icon: Puzzle, question: "q.modules" },
|
|
{ id: "players", label: "nav.players", icon: Users, question: "q.players" },
|
|
{ id: "characters", label: "nav.characters", icon: Swords, question: "q.characters" },
|
|
{ id: "friends", label: "nav.friends", icon: Globe, question: "q.friends" },
|
|
{ id: "backups", label: "nav.backups", icon: Archive, question: "q.backups" },
|
|
{ id: "migration", label: "nav.migration", icon: RotateCcw, question: "q.migration" },
|
|
{ id: "console", label: "nav.console", icon: Terminal, question: "q.console" },
|
|
{ id: "report", label: "nav.report", icon: Bug, question: "q.report" },
|
|
{ id: "settings", label: "nav.settings", icon: Settings, question: "q.settings" },
|
|
];
|
|
|
|
export function Shell(props: {
|
|
servers: ServerSummary[];
|
|
activeId: string;
|
|
onSelect: (id: string) => void;
|
|
onAddAnother: () => void;
|
|
/** Make a modular copy of a classic server (the install screen takes over). */
|
|
onCopy: (path: string) => void;
|
|
onForget: (id: string) => Promise<void>;
|
|
}) {
|
|
const t = useT();
|
|
const [page, setPage] = useState<Page>("overview");
|
|
const server = props.servers.find((s) => s.id === props.activeId)!;
|
|
const current = NAV.find((n) => n.id === page)!;
|
|
const update = useServerUpdate(props.activeId);
|
|
|
|
// A module that is switched off takes its page away (the companions' "Bots" page, for one).
|
|
const [botModule, setBotModule] = useState<"companions" | "playerbots">("companions");
|
|
const [hiddenPages, setHiddenPages] = useState<string[]>([]);
|
|
const guardRef = useRef<UnsavedChangesGuard | null>(null);
|
|
const registerGuard = useCallback((g: UnsavedChangesGuard | null) => {
|
|
guardRef.current = g;
|
|
}, []);
|
|
const [pendingNav, setPendingNav] = useState<(() => void) | null>(null);
|
|
const [modalSaving, setModalSaving] = useState(false);
|
|
const [modalError, setModalError] = useState<string | null>(null);
|
|
|
|
const requestNavigation = (action: () => void) => {
|
|
if (guardRef.current && guardRef.current.isDirty()) {
|
|
setPendingNav(() => action);
|
|
} else {
|
|
action();
|
|
}
|
|
};
|
|
|
|
const reloadModules = useCallback(() => {
|
|
void api
|
|
.modulesList(props.activeId)
|
|
.then((list) => {
|
|
const available = (m: typeof list[number]) => m.installed && m.compatibility !== "unsupported" && (!m.switchable || m.enabled);
|
|
setHiddenPages([...new Set(list.flatMap(m => m.page ? [m.page] : []))].filter(page => !list.some(m => m.page === page && available(m))));
|
|
setBotModule(list.some(m => m.id === "playerbots" && available(m)) ? "playerbots" : "companions");
|
|
})
|
|
.catch(() => setHiddenPages([]));
|
|
}, [props.activeId]);
|
|
useEffect(() => {
|
|
reloadModules();
|
|
}, [reloadModules]);
|
|
useEffect(() => {
|
|
if (hiddenPages.includes(page)) setPage("overview");
|
|
}, [hiddenPages, page]);
|
|
|
|
// Look for a server and a game client update when the app starts and then every five minutes.
|
|
useEffect(() => {
|
|
const stopServer = startServerUpdatePolling(props.activeId);
|
|
const stopClient = startClientPolling(props.activeId);
|
|
return () => {
|
|
stopServer();
|
|
stopClient();
|
|
};
|
|
}, [props.activeId]);
|
|
|
|
return (
|
|
<div className="flex h-full">
|
|
<nav className="flex w-60 shrink-0 flex-col border-r border-line bg-[#0b0c0e] p-3" aria-label={t("nav.main")}>
|
|
<div className="flex items-center gap-2 px-3 pb-4 pt-2 text-sm font-semibold tracking-wide text-gold">
|
|
<img src={logo} alt="" aria-hidden className="h-7 w-7" />
|
|
<div className="flex flex-col">
|
|
<span className="leading-tight">{t("app.name")}</span>
|
|
<span className="text-[10px] font-mono font-normal tracking-wider text-sky-400">PREVIEW EDITION</span>
|
|
</div>
|
|
</div>
|
|
<ul className="flex flex-col gap-0.5">
|
|
{NAV.filter((n) => !hiddenPages.includes(n.id) && (n.id !== "dashboard" || botModule === "playerbots")).map(({ id, label, icon: Icon }) => (
|
|
<li key={id}>
|
|
<button
|
|
onClick={() => requestNavigation(() => setPage(id))}
|
|
aria-current={page === id ? "page" : undefined}
|
|
className={cn(
|
|
"flex h-10 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-[15px] transition-colors",
|
|
page === id ? "bg-white/[0.07] text-ink" : "text-muted hover:bg-white/5 hover:text-ink",
|
|
)}
|
|
>
|
|
<Icon className={cn("h-[18px] w-[18px]", page === id && "text-gold")} aria-hidden />
|
|
{t(label)}
|
|
{id === "settings" && update.available && (
|
|
<span className="ml-auto h-2 w-2 rounded-full bg-gold" role="img" aria-label={t("nav.updateDot")} title={t("nav.updateDot")} />
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<div className="mt-auto border-t border-line pt-3">
|
|
{props.servers.length > 1 && (
|
|
<select
|
|
className="mb-2 w-full rounded-md border border-line bg-card px-2 py-2 text-sm"
|
|
value={props.activeId}
|
|
onChange={(e) => requestNavigation(() => props.onSelect(e.target.value))}
|
|
aria-label={t("nav.serverPicker")}
|
|
>
|
|
{props.servers.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={() => requestNavigation(props.onAddAnother)}>
|
|
{t("nav.addAnother")}
|
|
</Button>
|
|
</div>
|
|
</nav>
|
|
|
|
<main className="relative h-full flex-1 overflow-y-auto px-10 py-8">
|
|
{page === "overview" ? (
|
|
<Overview key={server.id} server={server} companions={botModule === "companions" && !hiddenPages.includes("bots")} onForget={() => props.onForget(server.id)} onOpenUpdates={() => requestNavigation(() => setPage("settings"))} onRealmChanged={reloadModules} onReport={() => requestNavigation(() => setPage("report"))} />
|
|
) : page === "bots" || page === "server" ? (
|
|
<SettingsPage key={`${server.id}-${page}-${botModule}`} serverId={server.id} scope={page} botModule={botModule} title={t(current.label)} question={t(current.question)} onRegisterGuard={registerGuard} />
|
|
) : page === "dashboard" ? (
|
|
<DashboardPage key={server.id} serverId={server.id} />
|
|
) : page === "console" ? (
|
|
<ConsolePage key={server.id} serverId={server.id} />
|
|
) : page === "modules" ? (
|
|
<ModulesPage key={server.id} serverId={server.id} onChanged={reloadModules} onRegisterGuard={registerGuard} />
|
|
) : page === "report" ? (
|
|
<ReportPage key={server.id} serverId={server.id} />
|
|
) : page === "friends" ? (
|
|
<PlayWithFriends key={server.id} serverName={server.name} serverId={server.id} path={server.path} />
|
|
) : page === "settings" ? (
|
|
<SettingsHome key={server.id} serverId={server.id} serverName={server.name} path={server.path} onForget={() => props.onForget(server.id)} />
|
|
) : page === "characters" ? (
|
|
<PortablePage key="characters" />
|
|
) : page === "players" ? (
|
|
<PlayersPage key={server.id} serverId={server.id} />
|
|
) : page === "migration" ? (
|
|
<ServerMigrationWizard key={server.id} servers={props.servers} currentName={server.name} onCopy={props.onCopy} />
|
|
) : page === "backups" ? (
|
|
<BackupsPage key={server.id} serverId={server.id} />
|
|
) : (
|
|
<Placeholder title={t(current.label)} question={t(current.question)} />
|
|
)}
|
|
</main>
|
|
|
|
{pendingNav && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" role="presentation">
|
|
<Card className="w-full max-w-md p-6 shadow-2xl" role="dialog" aria-modal="true" aria-label={t("unsaved.title")}>
|
|
<h2 className="text-lg font-semibold">{t("unsaved.title")}</h2>
|
|
<p className="mt-2 text-sm text-muted">{t("unsaved.message")}</p>
|
|
{modalError && <p className="mt-3 text-sm text-bad" role="alert">{modalError}</p>}
|
|
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
|
<Button variant="ghost" disabled={modalSaving} onClick={() => { setPendingNav(null); setModalError(null); }}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button variant="ghost" disabled={modalSaving} onClick={() => {
|
|
guardRef.current?.discard();
|
|
const act = pendingNav;
|
|
setPendingNav(null);
|
|
setModalError(null);
|
|
act?.();
|
|
}}>
|
|
{t("set.discard")}
|
|
</Button>
|
|
<Button variant="primary" disabled={modalSaving} onClick={async () => {
|
|
if (!guardRef.current) return;
|
|
setModalSaving(true);
|
|
setModalError(null);
|
|
try {
|
|
const ok = await guardRef.current.save();
|
|
if (ok) {
|
|
const act = pendingNav;
|
|
setPendingNav(null);
|
|
act?.();
|
|
} else {
|
|
setModalError(t("unsaved.saveFailed"));
|
|
}
|
|
} catch (e) {
|
|
setModalError(String(e));
|
|
} finally {
|
|
setModalSaving(false);
|
|
}
|
|
}}>
|
|
{modalSaving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />}
|
|
{t("acc.save")}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|