Merge remote-tracking branch 'origin/feat/publish-realms-dialog' into prepare/manager-20261028

This commit is contained in:
Дмитрий committed 2026-10-28 08:22:09 +05:00
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.
+16
View File
@@ -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",
+16
View File
@@ -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": "Сохранить адрес",
+48
View File
@@ -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");
}
+16 -3
View File
@@ -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
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}