mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Merge remote-tracking branch 'origin/feat/publish-realms-dialog' into prepare/manager-20261028
This commit is contained in:
commit
0900a4a143
7 files changed
+381
-126
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: changed
|
||||
audience: admins
|
||||
title: Public listing is a short list of realms with a window for the details
|
||||
---
|
||||
Settings shows one line per realm with its state, and publishing opens a window. Publish all realms at once. Account creation and portable characters are chosen before publishing; portable characters are on by default.
|
||||
@@ -1122,6 +1122,22 @@ const en = {
|
||||
"browse.play": "Play",
|
||||
"browse.playSoon": "Joining arrives with the account support of the next step.",
|
||||
"registry.title": "Public listing",
|
||||
"registry.dialogTitle": "Public listing · {realm}",
|
||||
"registry.publishAll": "Publish all realms",
|
||||
"registry.publishAllText": "One description, language and region for every realm that is not listed yet. Each realm is listed under the server name; the realms that start next to the main one get their realm name added.",
|
||||
"registry.publishAllButton": "Publish {n} realms",
|
||||
"registry.alreadyListed": "Already published",
|
||||
"registry.configure": "Configure",
|
||||
"registry.publishShort": "Publish",
|
||||
"registry.short.starting": "Publishing…",
|
||||
"registry.accessFailed": "The realm is published, but its joining options were not saved: {reason}",
|
||||
"registry.short.waiting_for_realm": "Not published",
|
||||
"registry.short.realm_stopped": "Published, realm is off",
|
||||
"registry.short.online": "Published",
|
||||
"registry.short.retrying": "Registry unreachable",
|
||||
"registry.short.rejected": "Refused by the registry",
|
||||
"registry.short.unpublishing": "Removing from the list",
|
||||
"registry.short.disabled": "Not published",
|
||||
"registry.text": "Publish this realm in the central list so that players can find it. Only its name, description, language, region, rates, modules, player counts, version and capabilities are shared: no passwords, no characters, no accounts. The realm keeps running if the list is unreachable.",
|
||||
"registry.url": "Registry address",
|
||||
"registry.urlSave": "Save address",
|
||||
|
||||
@@ -1104,6 +1104,22 @@ const ru: Partial<Record<keyof typeof en, string>> = {
|
||||
"browse.play": "Играть",
|
||||
"browse.playSoon": "Вход на сервер появится вместе с поддержкой аккаунтов на следующем шаге.",
|
||||
"registry.title": "Публичный список",
|
||||
"registry.dialogTitle": "Публичный список · {realm}",
|
||||
"registry.publishAll": "Опубликовать все реалмы",
|
||||
"registry.publishAllText": "Одно описание, язык и регион для каждого реалма, которого ещё нет в списке. Реалмы публикуются под названием сервера; к реалмам, которые запускаются рядом с основным, добавляется название реалма.",
|
||||
"registry.publishAllButton": "Опубликовать реалмов: {n}",
|
||||
"registry.alreadyListed": "Уже опубликован",
|
||||
"registry.configure": "Настроить",
|
||||
"registry.publishShort": "Опубликовать",
|
||||
"registry.short.starting": "Публикуется…",
|
||||
"registry.accessFailed": "Реалм опубликован, но настройки входа игроков не сохранены: {reason}",
|
||||
"registry.short.waiting_for_realm": "Не опубликован",
|
||||
"registry.short.realm_stopped": "Опубликован, реалм выключен",
|
||||
"registry.short.online": "Опубликован",
|
||||
"registry.short.retrying": "Нет связи с реестром",
|
||||
"registry.short.rejected": "Реестр отклонил",
|
||||
"registry.short.unpublishing": "Снимается с публикации",
|
||||
"registry.short.disabled": "Не опубликован",
|
||||
"registry.text": "Опубликуйте этот реалм в общем списке, чтобы игроки могли его найти. Передаются только название, описание, язык, регион, рейты, модули, число игроков и ботов, версия и возможности реалма: никаких паролей, персонажей и аккаунтов. Если список недоступен, реалм продолжает работать.",
|
||||
"registry.url": "Адрес реестра",
|
||||
"registry.urlSave": "Сохранить адрес",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type RealmMode } from "@/lib/api";
|
||||
import type { PublishState, RealmPublishStatus } from "@/lib/registry";
|
||||
|
||||
export const REALM_LABEL: Record<RealmMode, string> = { coa: "Conquest of Azeroth", wildcard: "Wildcard", reborn: "Warcraft Reborn" };
|
||||
|
||||
/** One realm of this server that can be listed publicly. */
|
||||
export interface PublishRealm {
|
||||
/** The main realm keeps the plain id it was published under; each realm that starts next to it has its own. */
|
||||
localId: string;
|
||||
mode: RealmMode;
|
||||
label: string;
|
||||
/** The server name; the realms that start next to the main one get their realm name added. */
|
||||
defaultName: string;
|
||||
}
|
||||
|
||||
/** The realms this server runs, main realm first; `null` until they are known. */
|
||||
export function usePublishRealms(serverId: string, serverName: string): PublishRealm[] | null {
|
||||
const [realms, setRealms] = useState<PublishRealm[] | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const build = (active: RealmMode, extra: RealmMode[]) => [
|
||||
{ localId: `srv-${serverId}`, mode: active, label: REALM_LABEL[active], defaultName: serverName },
|
||||
...extra.map((mode) => ({ localId: `srv-${serverId}:${mode}`, mode, label: REALM_LABEL[mode], defaultName: `${serverName} · ${REALM_LABEL[mode]}` })),
|
||||
];
|
||||
void api.realmProfiles(serverId).then((r) => {
|
||||
if (!alive) return;
|
||||
const extra: RealmMode[] = [];
|
||||
if (r.simultaneous) extra.push(r.active === "coa" ? "wildcard" : "coa");
|
||||
if (r.with_reborn) extra.push("reborn");
|
||||
setRealms(build(r.active, extra));
|
||||
}).catch(() => { if (alive) setRealms(build("coa", [])); });
|
||||
return () => { alive = false; };
|
||||
}, [serverId, serverName]);
|
||||
return realms;
|
||||
}
|
||||
|
||||
export function stateTone(state: PublishState | undefined): string {
|
||||
if (state === "online") return "text-ok";
|
||||
if (state === "rejected") return "text-bad";
|
||||
if (state === "retrying" || state === "realm_stopped" || state === "waiting_for_realm") return "text-warn";
|
||||
return "text-muted";
|
||||
}
|
||||
|
||||
/** Listed on the public list now (or being removed from it). */
|
||||
export function isListed(s: RealmPublishStatus | undefined): boolean {
|
||||
return !!s && (s.enabled || s.state === "unpublishing");
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog";
|
||||
import { RealmlistMenu } from "@/screens/RealmlistMenu";
|
||||
import { RealmPicker } from "@/screens/RealmPicker";
|
||||
import { CrashHistoryModal } from "@/screens/CrashHistoryModal";
|
||||
import { ListingChip } from "@/screens/PublishCard";
|
||||
import { PublishRealmDialog } from "@/screens/PublishDialog";
|
||||
import { useRegistryStatus } from "@/lib/registry";
|
||||
import { usePublishRealms, type PublishRealm } from "@/lib/publishRealms";
|
||||
import { useAutoRestart } from "@/lib/autoRestartPrefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@@ -114,6 +118,14 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
const [crashCount, setCrashCount] = useState(0);
|
||||
const crashCountRef = useRef(0);
|
||||
const [showCrashModal, setShowCrashModal] = useState(false);
|
||||
const publishRealms = usePublishRealms(server.id, server.name);
|
||||
const { status: registry } = useRegistryStatus(5000);
|
||||
const [publishing, setPublishing] = useState<PublishRealm | null>(null);
|
||||
// The realm a card shows: the first card is the main realm, then the realm that starts next to it, then Warcraft Reborn.
|
||||
const chipOf = (realm: PublishRealm | undefined) => realm && <ListingChip status={registry?.realms.find((r) => r.local_id === realm.localId)} hasUrl={!!registry?.url} onOpen={() => setPublishing(realm)} />;
|
||||
const mainRealm = publishRealms?.[0];
|
||||
const secondRealm = publishRealms?.find((r) => r !== mainRealm && r.mode !== "reborn");
|
||||
const rebornRealm = publishRealms?.find((r) => r !== mainRealm && r.mode === "reborn");
|
||||
const [hasCrashHistory, setHasCrashHistory] = useState(false);
|
||||
const [crashLoopPaused, setCrashLoopPaused] = useState(false);
|
||||
const [crashRestartNotice, setCrashRestartNotice] = useState<string | null>(null);
|
||||
@@ -317,7 +329,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
<RealmPicker serverId={server.id} running={anyUp} disabled={transitioning || upd.applying} onBusy={setRealmBusy} onChanged={() => { onRealmChanged(); void poll(); }} />
|
||||
|
||||
<Card className="mt-6 p-7">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-muted">{realms ? `${realms.first} · ${t("overview.server")}` : t("overview.server")}</div>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs font-medium uppercase tracking-[0.14em] text-muted"><span>{realms ? `${realms.first} · ${t("overview.server")}` : t("overview.server")}</span>{chipOf(mainRealm)}</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className={cn("flex items-center gap-3 text-3xl font-semibold", tone)} role="status" aria-live="polite">
|
||||
<span
|
||||
@@ -565,7 +577,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
|
||||
{secondary_world && (
|
||||
<Card className="mt-4 p-7">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-muted">{realms ? `${realms.second} · ${t("overview.server")}` : t("realm.secondWorld")}</div>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs font-medium uppercase tracking-[0.14em] text-muted"><span>{realms ? `${realms.second} · ${t("overview.server")}` : t("realm.secondWorld")}</span>{chipOf(secondRealm)}</div>
|
||||
<div
|
||||
className={cn("flex items-center gap-3 text-3xl font-semibold", secondary_world.state === "running" ? "text-ok" : secondary_world.state === "starting" || transitioning ? "text-warn" : "text-muted")}
|
||||
role="status"
|
||||
@@ -587,7 +599,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
|
||||
{reborn_world && (
|
||||
<Card className="mt-4 p-7">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-muted">{realms ? `${realms.reborn} · ${t("overview.server")}` : t("realm.rebornWorld")}</div>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs font-medium uppercase tracking-[0.14em] text-muted"><span>{realms ? `${realms.reborn} · ${t("overview.server")}` : t("realm.rebornWorld")}</span>{chipOf(rebornRealm)}</div>
|
||||
<div
|
||||
className={cn("flex items-center gap-3 text-3xl font-semibold", reborn_world.state === "running" ? "text-ok" : reborn_world.state === "starting" || transitioning ? "text-warn" : "text-muted")}
|
||||
role="status"
|
||||
@@ -649,6 +661,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
/>
|
||||
)}
|
||||
|
||||
{publishing && <PublishRealmDialog realm={publishing} onClose={() => setPublishing(null)} />}
|
||||
{showCrashModal && (
|
||||
<CrashHistoryModal
|
||||
serverId={server.id}
|
||||
|
||||
+49
-123
@@ -1,138 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useT, type Key } from "@/i18n";
|
||||
import { api, asUiError, type RealmMode } from "@/lib/api";
|
||||
import { realmRegistry, useRegistryStatus } from "@/lib/registry";
|
||||
import { asControlError, control, type ControlStatus } from "@/lib/control";
|
||||
import { useRegistryStatus } from "@/lib/registry";
|
||||
import { isListed, stateTone, usePublishRealms, type PublishRealm } from "@/lib/publishRealms";
|
||||
import type { RealmPublishStatus } from "@/lib/registry";
|
||||
import { PublishAllDialog, PublishRealmDialog } from "@/screens/PublishDialog";
|
||||
|
||||
const LANGUAGES = ["en", "ru", "de", "fr", "es", "zh", "pt", "pl", "uk"];
|
||||
|
||||
const REALM_LABEL: Record<RealmMode, string> = { coa: "Conquest of Azeroth", wildcard: "Wildcard", reborn: "Warcraft Reborn" };
|
||||
|
||||
/** The realms this server runs: the main one keeps the plain id it was published under, each realm that starts next to it has its own. */
|
||||
export function PublishCard({ serverId, serverName }: { serverId: string; serverName: string }) {
|
||||
const [realms, setRealms] = useState<{ localId: string; mode: RealmMode }[] | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void api.realmProfiles(serverId).then((r) => {
|
||||
if (!alive) return;
|
||||
const extra: RealmMode[] = [];
|
||||
if (r.simultaneous) extra.push(r.active === "coa" ? "wildcard" : "coa");
|
||||
if (r.with_reborn) extra.push("reborn");
|
||||
setRealms([{ localId: `srv-${serverId}`, mode: r.active }, ...extra.map((mode) => ({ localId: `srv-${serverId}:${mode}`, mode }))]);
|
||||
}).catch(() => { if (alive) setRealms([{ localId: `srv-${serverId}`, mode: "coa" }]); });
|
||||
return () => { alive = false; };
|
||||
}, [serverId]);
|
||||
if (!realms) return null;
|
||||
const several = realms.length > 1;
|
||||
return <>{realms.map((r) => (
|
||||
<RealmPublish key={r.localId} localId={r.localId} serverName={several && r.mode !== realms[0].mode ? `${serverName} · ${REALM_LABEL[r.mode]}` : serverName} realmLabel={several ? REALM_LABEL[r.mode] : null} />
|
||||
))}</>;
|
||||
/** A small button on a realm card: the listing state of that realm; it opens the window to publish or configure it. */
|
||||
export function ListingChip({ status, hasUrl, onOpen }: { status: RealmPublishStatus | undefined; hasUrl: boolean; onOpen: () => void }) {
|
||||
const t = useT();
|
||||
const listed = isListed(status);
|
||||
if (!listed && !hasUrl) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn("inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium normal-case tracking-normal transition-colors", listed ? "border-line bg-card-2 hover:border-gold/40" : "border-gold/40 text-gold hover:bg-gold/10")}
|
||||
title={t("registry.title")}
|
||||
>
|
||||
{listed ? <span className={stateTone(status?.state)}>{t(`registry.short.${status!.state}` as Key)}</span> : t("registry.publishShort")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Publishing one realm to the central Registry: a name, a description, a language. Nothing else leaves this machine. */
|
||||
function RealmPublish({ localId, serverName, realmLabel }: { localId: string; serverName: string; realmLabel: string | null }) {
|
||||
/** The public listing of this server's realms: one line per realm; the form opens in a window. */
|
||||
export function PublishCard({ serverId, serverName }: { serverId: string; serverName: string }) {
|
||||
const t = useT();
|
||||
const { status, refresh } = useRegistryStatus();
|
||||
const mine = status?.realms.find((r) => r.local_id === localId);
|
||||
const published = !!mine?.enabled;
|
||||
const [name, setName] = useState(serverName);
|
||||
const [description, setDescription] = useState("");
|
||||
const [language, setLanguage] = useState("en");
|
||||
const [region, setRegion] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [allowPortable, setAllowPortable] = useState(false);
|
||||
const [existingOnly, setExistingOnly] = useState(false);
|
||||
const [route, setRoute] = useState("");
|
||||
const [accessSaved, setAccessSaved] = useState(false);
|
||||
const [ctl, setCtl] = useState<ControlStatus | null>(null);
|
||||
const host = ctl?.hosting.find((h) => h.local_id === localId);
|
||||
const link = host?.link;
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => void control.status().then(setCtl).catch(() => {});
|
||||
load();
|
||||
const timer = setInterval(load, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
useEffect(() => { if (mine) { setExistingOnly(mine.existing_only); setAllowPortable(mine.allow_portable); setRoute(mine.route ?? ""); } }, [mine?.existing_only, mine?.allow_portable, mine?.route]);
|
||||
|
||||
async function saveAccess() {
|
||||
setBusy(true); setError(null); setAccessSaved(false);
|
||||
try { await control.setAccess(localId, existingOnly, allowPortable, route.trim() === "" ? null : route.trim()); await refresh(); setAccessSaved(true); } catch (e) { setError(asControlError(e).message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (mine && mine.display_name) { setName(mine.display_name); setDescription(mine.description); setLanguage(mine.language || "en"); setRegion(mine.region ?? ""); }
|
||||
}, [mine?.display_name, mine?.description, mine?.language]);
|
||||
|
||||
async function run(action: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await action(); await refresh(); } catch (e) { setError(asUiError(e).technical); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const tone = mine?.state === "online" ? "text-ok" : mine?.state === "rejected" ? "text-bad" : mine?.state === "retrying" || mine?.state === "realm_stopped" || mine?.state === "waiting_for_realm" ? "text-warn" : "text-muted";
|
||||
const realms = usePublishRealms(serverId, serverName);
|
||||
const { status } = useRegistryStatus();
|
||||
const [open, setOpen] = useState<PublishRealm | "all" | null>(null);
|
||||
if (!realms) return null;
|
||||
const listed = (r: PublishRealm) => isListed(status?.realms.find((s) => s.local_id === r.localId));
|
||||
const someUnlisted = realms.some((r) => !listed(r));
|
||||
|
||||
return (
|
||||
<Card className="mt-6 p-6">
|
||||
<h2 className="font-semibold">{t("registry.title")}{realmLabel ? ` · ${realmLabel}` : ""}</h2>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="font-semibold">{t("registry.title")}</h2>
|
||||
{realms.length > 1 && someUnlisted && <Button size="sm" onClick={() => setOpen("all")}>{t("registry.publishAll")}</Button>}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted">{t("registry.text")}</p>
|
||||
|
||||
|
||||
{!published && <>
|
||||
<label htmlFor={`registry-name-${localId}`} className="mt-4 block text-sm font-medium">{t("registry.name")}</label>
|
||||
<input id={`registry-name-${localId}`} value={name} maxLength={80} onChange={(e) => setName(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" />
|
||||
<label htmlFor={`registry-description-${localId}`} className="mt-3 block text-sm font-medium">{t("registry.description")}</label>
|
||||
<textarea id={`registry-description-${localId}`} value={description} rows={3} onChange={(e) => setDescription(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" />
|
||||
<p className="mt-1 text-xs text-muted">{t("registry.descriptionHint")}</p>
|
||||
<label htmlFor={`registry-language-${localId}`} className="mt-3 block text-sm font-medium">{t("registry.language")}</label>
|
||||
<select id={`registry-language-${localId}`} value={language} onChange={(e) => setLanguage(e.target.value)} className="mt-1 rounded-md border border-line bg-card px-3 py-2">
|
||||
{LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
<label htmlFor={`registry-region-${localId}`} className="mt-3 block text-sm font-medium">{t("registry.region")}</label>
|
||||
<input id={`registry-region-${localId}`} value={region} maxLength={16} placeholder="EU" onChange={(e) => setRegion(e.target.value)} className="mt-1 w-32 rounded-md border border-line bg-card px-3 py-2" />
|
||||
<div className="mt-4">
|
||||
<Button variant="primary" disabled={busy || !status?.url || name.trim() === ""} onClick={() => void run(() => realmRegistry.publish(localId, name, description, language, region.trim() === "" ? null : region.trim()))}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}{t("registry.publish")}
|
||||
</Button>
|
||||
{!status?.url && <p className="mt-2 text-xs text-muted">{t("registry.noUrl")}</p>}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{mine && (published || mine.state === "unpublishing") && <div className="mt-4 rounded-md border border-line p-3 text-sm" role="status">
|
||||
<p className={cn("font-medium", tone)} data-state={mine.state}>{t(`registry.state.${mine.state}` as Key)}</p>
|
||||
<p className="mt-1 text-xs text-muted">{mine.display_name}{mine.metadata_revision ? ` · ${t("registry.revision", { n: mine.metadata_revision })}` : ""}</p>
|
||||
{mine.last_error && <p className="mt-1 text-xs text-warn">{mine.last_error}</p>}
|
||||
{mine.state === "retrying" && mine.retry_in_secs !== null && <p className="mt-1 text-xs text-muted">{t("registry.retryIn", { s: mine.retry_in_secs })}</p>}
|
||||
<div className="mt-3 flex gap-2">
|
||||
{mine.state === "rejected" && <Button size="sm" onClick={() => void run(async () => { await realmRegistry.retry(localId); })}>{t("registry.retry")}</Button>}
|
||||
<Button size="sm" disabled={busy} onClick={() => void run(() => realmRegistry.unpublish(localId))}>{t("registry.stop")}</Button>
|
||||
</div>
|
||||
</div>}
|
||||
{mine && (published || mine.state === "unpublishing") && <div className="mt-4 rounded-md border border-line p-3 text-sm" data-testid="access-options">
|
||||
<p className="font-medium">{t("access.title")}</p>
|
||||
<p className="mt-1 text-xs text-muted">{t("access.text")}</p>
|
||||
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={existingOnly} onChange={(e) => setExistingOnly(e.target.checked)} className="mt-1" data-testid="existing-only" /><span>{t("access.existingOnly")}<span className="block text-xs text-muted">{t("access.existingOnlyHint")}</span></span></label>
|
||||
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={allowPortable} onChange={(e) => setAllowPortable(e.target.checked)} className="mt-1" data-testid="allow-portable" /><span>{t("access.allowPortable")}<span className="block text-xs text-muted">{t("access.allowPortableHint")}</span></span></label>
|
||||
<label htmlFor={`access-route-${localId}`} className="mt-3 block font-medium">{t("access.route")}</label>
|
||||
<input id={`access-route-${localId}`} value={route} placeholder="play.example.org:3724" onChange={(e) => setRoute(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" data-testid={`access-route-${localId}`} />
|
||||
<p className="mt-1 text-xs text-muted">{t("access.routeHint")}</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button size="sm" disabled={busy} onClick={() => void saveAccess()}>{t("access.save")}</Button>
|
||||
{accessSaved && <span className="text-xs text-ok" role="status">{t("access.saved")}</span>}
|
||||
</div>
|
||||
<p className={cn("mt-3 text-xs", link?.connected ? "text-ok" : "text-muted")} data-testid="control-link" data-connected={link?.connected ? "yes" : "no"}>
|
||||
{link?.connected ? t("access.linkUp") : t("access.linkDown")}{link?.last_error && !link.connected ? ` — ${link.last_error}` : ""}
|
||||
</p>
|
||||
<p className={cn("mt-2 text-xs", host?.relay?.connected ? "text-ok" : "text-warn")} data-testid="relay-link">{host?.relay?.connected ? t("access.relayUp") : t("access.relayDown")}{host?.relay?.last_error ? ` — ${host.relay.last_error}` : ""}</p>
|
||||
{ctl && ctl.secret_store === "file" && <p className="mt-1 text-xs text-warn">{t("access.fileStore")}</p>}
|
||||
</div>}
|
||||
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
|
||||
<ul className="mt-4 divide-y divide-line border-y border-line">
|
||||
{realms.map((r) => {
|
||||
const mine = status?.realms.find((s) => s.local_id === r.localId);
|
||||
return (
|
||||
<li key={r.localId} className="flex flex-wrap items-center justify-between gap-3 py-3">
|
||||
<div>
|
||||
<p className="font-medium">{r.label}</p>
|
||||
<p className={cn("text-xs", stateTone(isListed(mine) ? mine?.state : undefined))} data-state={mine?.state}>{t(`registry.short.${isListed(mine) ? mine!.state : "disabled"}` as Key)}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setOpen(r)}>{isListed(mine) ? t("registry.configure") : t("registry.publishShort")}</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{!status?.url && <p className="mt-3 text-xs text-muted">{t("registry.noUrl")}</p>}
|
||||
{open === "all" && <PublishAllDialog realms={realms} onClose={() => setOpen(null)} />}
|
||||
{open && open !== "all" && <PublishRealmDialog realm={open} onClose={() => setOpen(null)} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useT, type Key } from "@/i18n";
|
||||
import { asUiError } from "@/lib/api";
|
||||
import { realmRegistry, useRegistryStatus } from "@/lib/registry";
|
||||
import { asControlError, control, type ControlStatus } from "@/lib/control";
|
||||
import { isListed, stateTone, type PublishRealm } from "@/lib/publishRealms";
|
||||
|
||||
const LANGUAGES = ["en", "ru", "de", "fr", "es", "zh", "pt", "pl", "uk"];
|
||||
|
||||
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||
const t = useT();
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 md:p-6" role="presentation" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<Card className="flex max-h-[88vh] w-full max-w-xl flex-col p-6 shadow-2xl" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div className="flex items-center justify-between border-b border-line pb-4">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button type="button" onClick={onClose} className="cursor-pointer text-muted transition-colors hover:text-ink" aria-label={t("btn.close")}>
|
||||
<X className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pt-4">{children}</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The name, description, language and region of a listing; the fields are the same for one realm and for all of them. */
|
||||
function ListingFields({ idPrefix, name, setName, description, setDescription, language, setLanguage, region, setRegion, hideName }: {
|
||||
idPrefix: string; name: string; setName: (v: string) => void; description: string; setDescription: (v: string) => void;
|
||||
language: string; setLanguage: (v: string) => void; region: string; setRegion: (v: string) => void; hideName?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
return <>
|
||||
{!hideName && <>
|
||||
<label htmlFor={`${idPrefix}-name`} className="block text-sm font-medium">{t("registry.name")}</label>
|
||||
<input id={`${idPrefix}-name`} value={name} maxLength={80} onChange={(e) => setName(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" />
|
||||
</>}
|
||||
<label htmlFor={`${idPrefix}-description`} className="mt-3 block text-sm font-medium">{t("registry.description")}</label>
|
||||
<textarea id={`${idPrefix}-description`} value={description} rows={3} onChange={(e) => setDescription(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" />
|
||||
<p className="mt-1 text-xs text-muted">{t("registry.descriptionHint")}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label htmlFor={`${idPrefix}-language`} className="block text-sm font-medium">{t("registry.language")}</label>
|
||||
<select id={`${idPrefix}-language`} value={language} onChange={(e) => setLanguage(e.target.value)} className="mt-1 rounded-md border border-line bg-card px-3 py-2">
|
||||
{LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${idPrefix}-region`} className="block text-sm font-medium">{t("registry.region")}</label>
|
||||
<input id={`${idPrefix}-region`} value={region} maxLength={16} placeholder="EU" onChange={(e) => setRegion(e.target.value)} className="mt-1 w-32 rounded-md border border-line bg-card px-3 py-2" />
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
/** Who may join and how: shown before publishing, so the choice is made once, not found later. */
|
||||
function JoinChoices({ existingOnly, setExistingOnly, allowPortable, setAllowPortable }: { existingOnly: boolean; setExistingOnly: (v: boolean) => void; allowPortable: boolean; setAllowPortable: (v: boolean) => void }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="mt-4 rounded-md border border-line p-3 text-sm">
|
||||
<p className="font-medium">{t("access.title")}</p>
|
||||
<p className="mt-1 text-xs text-muted">{t("access.text")}</p>
|
||||
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={existingOnly} onChange={(e) => setExistingOnly(e.target.checked)} className="mt-1" /><span>{t("access.existingOnly")}<span className="block text-xs text-muted">{t("access.existingOnlyHint")}</span></span></label>
|
||||
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={allowPortable} onChange={(e) => setAllowPortable(e.target.checked)} className="mt-1" /><span>{t("access.allowPortable")}<span className="block text-xs text-muted">{t("access.allowPortableHint")}</span></span></label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Publishing, stopping and the access options of one realm. */
|
||||
export function PublishRealmDialog({ realm, onClose }: { realm: PublishRealm; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const { localId } = realm;
|
||||
const { status, refresh } = useRegistryStatus();
|
||||
const mine = status?.realms.find((r) => r.local_id === localId);
|
||||
const published = !!mine?.enabled;
|
||||
const [name, setName] = useState(realm.defaultName);
|
||||
const [description, setDescription] = useState("");
|
||||
const [language, setLanguage] = useState("en");
|
||||
const [region, setRegion] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// New listings start with portable characters allowed and accounts created for new players; the choice is shown before publishing.
|
||||
const [allowPortable, setAllowPortable] = useState(true);
|
||||
const [existingOnly, setExistingOnly] = useState(false);
|
||||
const [route, setRoute] = useState("");
|
||||
const [accessSaved, setAccessSaved] = useState(false);
|
||||
const [ctl, setCtl] = useState<ControlStatus | null>(null);
|
||||
const host = ctl?.hosting.find((h) => h.local_id === localId);
|
||||
const link = host?.link;
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => void control.status().then(setCtl).catch(() => {});
|
||||
load();
|
||||
const timer = setInterval(load, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
useEffect(() => { if (mine) { setExistingOnly(mine.existing_only); setAllowPortable(mine.allow_portable); setRoute(mine.route ?? ""); } }, [mine?.existing_only, mine?.allow_portable, mine?.route]);
|
||||
useEffect(() => {
|
||||
if (mine && mine.display_name) { setName(mine.display_name); setDescription(mine.description); setLanguage(mine.language || "en"); setRegion(mine.region ?? ""); }
|
||||
}, [mine?.display_name, mine?.description, mine?.language]);
|
||||
|
||||
async function saveAccess() {
|
||||
setBusy(true); setError(null); setAccessSaved(false);
|
||||
try { await control.setAccess(localId, existingOnly, allowPortable, route.trim() === "" ? null : route.trim()); await refresh(); setAccessSaved(true); } catch (e) { setError(asControlError(e).message); } finally { setBusy(false); }
|
||||
}
|
||||
async function run(action: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try { await action(); await refresh(); } catch (e) { setError(asUiError(e).technical); } finally { setBusy(false); }
|
||||
}
|
||||
async function publish() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await realmRegistry.publish(localId, name, description, language, region.trim() === "" ? null : region.trim());
|
||||
// Saving the joining options needs the listing to exist; if it cannot be saved now (for example the server is running), the options stay available below.
|
||||
try { await control.setAccess(localId, existingOnly, allowPortable, route.trim() === "" ? null : route.trim()); }
|
||||
catch (e) { setError(t("registry.accessFailed", { reason: asControlError(e).message })); }
|
||||
await refresh();
|
||||
} catch (e) { setError(asUiError(e).technical); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={t("registry.dialogTitle", { realm: realm.label })} onClose={onClose}>
|
||||
<p className="text-sm text-muted">{t("registry.text")}</p>
|
||||
|
||||
{!published && <div className="mt-4">
|
||||
<ListingFields idPrefix={`pub-${localId}`} name={name} setName={setName} description={description} setDescription={setDescription} language={language} setLanguage={setLanguage} region={region} setRegion={setRegion} />
|
||||
<JoinChoices existingOnly={existingOnly} setExistingOnly={setExistingOnly} allowPortable={allowPortable} setAllowPortable={setAllowPortable} />
|
||||
<div className="mt-4">
|
||||
<Button variant="primary" disabled={busy || !status?.url || name.trim() === ""} onClick={() => void publish()}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}{t("registry.publish")}
|
||||
</Button>
|
||||
{!status?.url && <p className="mt-2 text-xs text-muted">{t("registry.noUrl")}</p>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{mine && isListed(mine) && <div className="mt-4 rounded-md border border-line p-3 text-sm" role="status">
|
||||
<p className={cn("font-medium", stateTone(mine.state))} data-state={mine.state}>{t(`registry.state.${mine.state}` as Key)}</p>
|
||||
<p className="mt-1 text-xs text-muted">{mine.display_name}{mine.metadata_revision ? ` · ${t("registry.revision", { n: mine.metadata_revision })}` : ""}</p>
|
||||
{mine.last_error && <p className="mt-1 text-xs text-warn">{mine.last_error}</p>}
|
||||
{mine.state === "retrying" && mine.retry_in_secs !== null && <p className="mt-1 text-xs text-muted">{t("registry.retryIn", { s: mine.retry_in_secs })}</p>}
|
||||
<div className="mt-3 flex gap-2">
|
||||
{mine.state === "rejected" && <Button size="sm" onClick={() => void run(async () => { await realmRegistry.retry(localId); })}>{t("registry.retry")}</Button>}
|
||||
<Button size="sm" disabled={busy} onClick={() => void run(() => realmRegistry.unpublish(localId))}>{t("registry.stop")}</Button>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{mine && isListed(mine) && <div className="mt-4 rounded-md border border-line p-3 text-sm" data-testid="access-options">
|
||||
<p className="font-medium">{t("access.title")}</p>
|
||||
<p className="mt-1 text-xs text-muted">{t("access.text")}</p>
|
||||
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={existingOnly} onChange={(e) => setExistingOnly(e.target.checked)} className="mt-1" data-testid="existing-only" /><span>{t("access.existingOnly")}<span className="block text-xs text-muted">{t("access.existingOnlyHint")}</span></span></label>
|
||||
<label className="mt-3 flex items-start gap-2"><input type="checkbox" checked={allowPortable} onChange={(e) => setAllowPortable(e.target.checked)} className="mt-1" data-testid="allow-portable" /><span>{t("access.allowPortable")}<span className="block text-xs text-muted">{t("access.allowPortableHint")}</span></span></label>
|
||||
<label htmlFor={`access-route-${localId}`} className="mt-3 block font-medium">{t("access.route")}</label>
|
||||
<input id={`access-route-${localId}`} value={route} placeholder="play.example.org:3724" onChange={(e) => setRoute(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" data-testid="access-route" />
|
||||
<p className="mt-1 text-xs text-muted">{t("access.routeHint")}</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button size="sm" disabled={busy} onClick={() => void saveAccess()}>{t("access.save")}</Button>
|
||||
{accessSaved && <span className="text-xs text-ok" role="status">{t("access.saved")}</span>}
|
||||
</div>
|
||||
<p className={cn("mt-3 text-xs", link?.connected ? "text-ok" : "text-muted")} data-testid="control-link" data-connected={link?.connected ? "yes" : "no"}>
|
||||
{link?.connected ? t("access.linkUp") : t("access.linkDown")}{link?.last_error && !link.connected ? ` — ${link.last_error}` : ""}
|
||||
</p>
|
||||
<p className={cn("mt-2 text-xs", host?.relay?.connected ? "text-ok" : "text-warn")} data-testid="relay-link">{host?.relay?.connected ? t("access.relayUp") : t("access.relayDown")}{host?.relay?.last_error ? ` — ${host.relay.last_error}` : ""}</p>
|
||||
{ctl && ctl.secret_store === "file" && <p className="mt-1 text-xs text-warn">{t("access.fileStore")}</p>}
|
||||
</div>}
|
||||
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** One description, language and region for every realm that is not listed yet. */
|
||||
export function PublishAllDialog({ realms, onClose }: { realms: PublishRealm[]; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const { status, refresh } = useRegistryStatus();
|
||||
const [description, setDescription] = useState("");
|
||||
const [language, setLanguage] = useState("en");
|
||||
const [region, setRegion] = useState("");
|
||||
const [allowPortable, setAllowPortable] = useState(true);
|
||||
const [existingOnly, setExistingOnly] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const todo = realms.filter((r) => !isListed(status?.realms.find((s) => s.local_id === r.localId)));
|
||||
|
||||
async function publishAll() {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
for (const r of todo) await realmRegistry.publish(r.localId, r.defaultName, description, language, region.trim() === "" ? null : region.trim());
|
||||
// The joining options are saved realm by realm; one that cannot be saved now (for example the server is running) does not stop the others.
|
||||
const failed: string[] = [];
|
||||
for (const r of todo) {
|
||||
try { await control.setAccess(r.localId, existingOnly, allowPortable, null); }
|
||||
catch (e) { failed.push(`${r.label}: ${asControlError(e).message}`); }
|
||||
}
|
||||
await refresh();
|
||||
if (failed.length === 0) onClose();
|
||||
else setError(t("registry.accessFailed", { reason: failed.join(" · ") }));
|
||||
} catch (e) { setError(asUiError(e).technical); await refresh(); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={t("registry.publishAll")} onClose={onClose}>
|
||||
<p className="text-sm text-muted">{t("registry.publishAllText")}</p>
|
||||
<ul className="mt-3 space-y-1 text-sm">
|
||||
{realms.map((r) => <li key={r.localId} className="flex justify-between gap-3"><span>{r.defaultName}</span><span className="text-muted">{todo.includes(r) ? t("registry.short.disabled") : t("registry.alreadyListed")}</span></li>)}
|
||||
</ul>
|
||||
{todo.length > 0 && <div className="mt-4">
|
||||
<ListingFields idPrefix="pub-all" name="" setName={() => undefined} description={description} setDescription={setDescription} language={language} setLanguage={setLanguage} region={region} setRegion={setRegion} hideName />
|
||||
<JoinChoices existingOnly={existingOnly} setExistingOnly={setExistingOnly} allowPortable={allowPortable} setAllowPortable={setAllowPortable} />
|
||||
<div className="mt-4">
|
||||
<Button variant="primary" disabled={busy || !status?.url} onClick={() => void publishAll()}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}{t("registry.publishAllButton", { n: todo.length })}
|
||||
</Button>
|
||||
{!status?.url && <p className="mt-2 text-xs text-muted">{t("registry.noUrl")}</p>}
|
||||
</div>
|
||||
</div>}
|
||||
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user