diff --git a/changelog.d/20261027-public-listing-window.md b/changelog.d/20261027-public-listing-window.md new file mode 100644 index 0000000..520ac5f --- /dev/null +++ b/changelog.d/20261027-public-listing-window.md @@ -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. diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 322425c..103c5f3 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 8c08fea..3392abb 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -1104,6 +1104,22 @@ const ru: Partial> = { "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": "Сохранить адрес", diff --git a/src/lib/publishRealms.ts b/src/lib/publishRealms.ts new file mode 100644 index 0000000..5d1e2c7 --- /dev/null +++ b/src/lib/publishRealms.ts @@ -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 = { 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(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"); +} diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index 4d1f029..3703d37 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -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(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 && 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(null); @@ -317,7 +329,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o { onRealmChanged(); void poll(); }} /> -
{realms ? `${realms.first} · ${t("overview.server")}` : t("overview.server")}
+
{realms ? `${realms.first} · ${t("overview.server")}` : t("overview.server")}{chipOf(mainRealm)}
-
{realms ? `${realms.second} · ${t("overview.server")}` : t("realm.secondWorld")}
+
{realms ? `${realms.second} · ${t("overview.server")}` : t("realm.secondWorld")}{chipOf(secondRealm)}
-
{realms ? `${realms.reborn} · ${t("overview.server")}` : t("realm.rebornWorld")}
+
{realms ? `${realms.reborn} · ${t("overview.server")}` : t("realm.rebornWorld")}{chipOf(rebornRealm)}
)} + {publishing && setPublishing(null)} />} {showCrashModal && ( = { 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) => ( - - ))}; +/** 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 ( + + ); } -/** 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(null); - const [allowPortable, setAllowPortable] = useState(false); - const [existingOnly, setExistingOnly] = useState(false); - const [route, setRoute] = useState(""); - const [accessSaved, setAccessSaved] = useState(false); - const [ctl, setCtl] = useState(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) { - 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(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 ( -

{t("registry.title")}{realmLabel ? ` · ${realmLabel}` : ""}

+
+

{t("registry.title")}

+ {realms.length > 1 && someUnlisted && } +

{t("registry.text")}

- - - {!published && <> - - setName(e.target.value)} className="mt-1 w-full rounded-md border border-line bg-card px-3 py-2" /> - -