mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 12:01:02 +00:00
feat(ui): unify server browser with play with friends, improve client setup flow and tooltips
This commit is contained in:
1 parent
64b3511436
commit
b69fa6b867
10 files changed
+111
-28
No files matched your search
@@ -212,7 +212,7 @@ fn handle_auth_client(
|
||||
}
|
||||
done.store(true, Ordering::Relaxed);
|
||||
if let Some(c) = client_shutdown {
|
||||
let _ = c.shutdown(std::net::Shutdown::Both);
|
||||
let _ = c.shutdown(std::net::Shutdown::Write);
|
||||
}
|
||||
|
||||
let _ = t_c2s.join();
|
||||
@@ -352,6 +352,7 @@ mod tests {
|
||||
fake_realm_list[1..3].copy_from_slice(&body_len.to_le_bytes());
|
||||
|
||||
stream.write_all(&fake_realm_list).unwrap();
|
||||
let _ = stream.shutdown(std::net::Shutdown::Write);
|
||||
});
|
||||
|
||||
let mut client_auth = TcpStream::connect(format!("127.0.0.1:{ingress_auth}")).unwrap();
|
||||
@@ -422,6 +423,7 @@ mod tests {
|
||||
fake_realm_list[1..3].copy_from_slice(&body_len.to_le_bytes());
|
||||
|
||||
stream.write_all(&fake_realm_list).unwrap();
|
||||
let _ = stream.shutdown(std::net::Shutdown::Write);
|
||||
});
|
||||
|
||||
let mut client_auth = TcpStream::connect(format!("127.0.0.1:{}", ingress.auth_port())).unwrap();
|
||||
@@ -444,6 +446,7 @@ mod tests {
|
||||
"Must NOT contain actual internal ingress port"
|
||||
);
|
||||
|
||||
drop(client_auth);
|
||||
t_auth.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -401,10 +401,12 @@ AC>";
|
||||
#[test]
|
||||
fn a_failed_account_command_is_reported_and_bad_names_never_reach_the_console() {
|
||||
let _lock = serial();
|
||||
let (port, _h) = fake_ra("Account not found.");
|
||||
let (port, h) = fake_ra("Account not found.");
|
||||
let mut ra = Ra::connect_to(port, "u", "p").unwrap();
|
||||
assert!(ra.set_account_password("x", "newpass9").is_err(), "the name is validated before anything is sent");
|
||||
assert!(ra.set_account_password("Nobody", "newpass9").unwrap_err().to_string().contains("not found"));
|
||||
drop(ra);
|
||||
let _ = h.join();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -421,9 +423,11 @@ AC>";
|
||||
#[test]
|
||||
fn duplicate_account_and_input_validation() {
|
||||
let _lock = serial();
|
||||
let (port, _h) = fake_ra("Account already exist.");
|
||||
let (port, h) = fake_ra("Account already exist.");
|
||||
let mut ra = Ra::connect_to(port, "u", "p").unwrap();
|
||||
assert!(ra.create_account("Player1", "hunter22").unwrap_err().to_string().contains("already taken"));
|
||||
drop(ra);
|
||||
let _ = h.join();
|
||||
for (n, p) in [("ab", "hunter22"), ("bad name", "hunter22"), ("Player1", "short"), ("Player1", "has space1"), ("Player1", "quote\"pw1"), ("Pl\r\nayer", "hunter22"), ("x".repeat(18).as_str(), "hunter22")] {
|
||||
assert!(validate_account(n, p).is_err(), "{n:?} {p:?}");
|
||||
}
|
||||
|
||||
@@ -7,5 +7,12 @@ fn main() {
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if std::env::var_os("WEBVIEW2_USER_DATA_FOLDER").is_none() {
|
||||
if let Some(dir) = std::env::var_os("COA_MANAGER_DATA_DIR") {
|
||||
let wv = std::path::PathBuf::from(dir).join("webview");
|
||||
std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", wv);
|
||||
}
|
||||
}
|
||||
coa_server_manager_lib::run()
|
||||
}
|
||||
@@ -52,9 +52,11 @@ const en = {
|
||||
|
||||
"nav.overview": "Overview",
|
||||
"nav.bots": "Bots",
|
||||
"nav.server": "Server",
|
||||
"nav.server": "Server Configuration",
|
||||
"nav.players": "Accounts",
|
||||
"nav.friends": "Play with Friends",
|
||||
"fr.tab.servers": "Public Servers",
|
||||
"fr.tab.lan": "LAN & Direct Connection",
|
||||
"nav.backups": "Backups",
|
||||
"nav.console": "Console",
|
||||
"nav.settings": "Settings",
|
||||
|
||||
@@ -52,9 +52,11 @@ const ru: Partial<Record<keyof typeof en, string>> = {
|
||||
"realm.experimental": "Экспериментально в Wildcard · игровая совместимость пока не проверена.",
|
||||
"nav.overview": "Обзор",
|
||||
"nav.bots": "Боты",
|
||||
"nav.server": "Сервер",
|
||||
"nav.server": "Конфигурация сервера",
|
||||
"nav.players": "Аккаунты",
|
||||
"nav.friends": "Игра с друзьями",
|
||||
"fr.tab.servers": "Список серверов",
|
||||
"fr.tab.lan": "Локальная сеть",
|
||||
"nav.backups": "Резервные копии",
|
||||
"nav.console": "Консоль",
|
||||
"nav.settings": "Настройки",
|
||||
|
||||
+21
-14
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n, type Key } from "@/i18n";
|
||||
import { asUiError } from "@/lib/api";
|
||||
import { realmRegistry, useRegistryStatus } from "@/lib/registry";
|
||||
import { useRegistryStatus } from "@/lib/registry";
|
||||
import { asPortableError, portable, usePortable, type PreflightView } from "@/lib/portable";
|
||||
import { Notes, Projection } from "@/screens/PortablePage";
|
||||
import { JoinPanel } from "@/screens/JoinPanel";
|
||||
@@ -22,9 +22,10 @@ function ModuleChip({ m, catalog }: { m: ModuleEntry; catalog: Catalog }) {
|
||||
const known = catalog[m.id];
|
||||
const name = known?.name ?? m.id;
|
||||
const description = known ? known.description[locale] ?? known.description.en ?? "" : "";
|
||||
return <span className="group relative inline-block" tabIndex={0}>
|
||||
const titleText = description ? `${name} — ${description}` : name;
|
||||
return <span className="group relative inline-block" tabIndex={0} title={titleText}>
|
||||
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-xs", m.enabled ? "border-line bg-white/5 text-ink" : "border-line/60 text-muted line-through")}>{name}</span>
|
||||
<span role="tooltip" className="pointer-events-none absolute left-0 top-full z-30 mt-1 hidden w-64 rounded-md border border-line bg-card p-3 text-left text-xs shadow-lg group-hover:block group-focus:block">
|
||||
<span role="tooltip" className="pointer-events-none absolute left-0 bottom-full z-50 mb-1.5 hidden w-64 rounded-md border border-line bg-card p-3 text-left text-xs shadow-xl group-hover:block group-focus:block">
|
||||
<span className="block text-sm font-semibold text-ink">{name}</span>
|
||||
{description && <span className="mt-1 block text-muted">{description}</span>}
|
||||
{m.version && <span className="mt-1 block text-muted">{m.version}</span>}
|
||||
@@ -66,7 +67,7 @@ function SortHeader({ id, sort, order, onSort, children, className }: { id: Sort
|
||||
}
|
||||
|
||||
function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog: Catalog; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { state, refresh: refreshPortable } = usePortable();
|
||||
const [detail, setDetail] = useState<RealmDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -117,8 +118,20 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="mb-1 font-medium">{t("browse.modules")}</h3>
|
||||
{summary.modules.length === 0 ? <p className="text-muted">—</p> : <ul className="space-y-1.5">
|
||||
{summary.modules.map((m) => <li key={m.id} className="flex items-baseline justify-between gap-3"><span className={cn(!m.enabled && "text-muted line-through")}>{catalog[m.id]?.name ?? m.id}</span><span className="text-xs text-muted">{m.version ?? ""}{!m.enabled && ` ${t("browse.moduleOff")}`}</span></li>)}
|
||||
{summary.modules.length === 0 ? <p className="text-muted">—</p> : <ul className="space-y-2">
|
||||
{summary.modules.map((m) => {
|
||||
const known = catalog[m.id];
|
||||
const desc = known ? (known.description[locale] ?? known.description.en ?? "") : "";
|
||||
return (
|
||||
<li key={m.id} className="rounded-md border border-line/40 bg-white/[0.02] p-2.5">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className={cn("font-medium", !m.enabled && "text-muted line-through")}>{known?.name ?? m.id}</span>
|
||||
<span className="text-xs text-muted">{m.version ?? ""}{!m.enabled && ` ${t("browse.moduleOff")}`}</span>
|
||||
</div>
|
||||
{desc && <p className="mt-1 text-xs text-muted">{desc}</p>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>}
|
||||
</section>
|
||||
<section>
|
||||
@@ -151,8 +164,7 @@ function Drawer({ summary, catalog, onClose }: { summary: RealmSummary; catalog:
|
||||
|
||||
export function BrowsePage() {
|
||||
const { t } = useI18n();
|
||||
const { status, refresh } = useRegistryStatus(5000);
|
||||
const [url, setUrl] = useState("");
|
||||
const { status } = useRegistryStatus(5000);
|
||||
const [q, setQ] = useState("");
|
||||
const [ruleset, setRuleset] = useState<"" | "coa" | "wildcard">("");
|
||||
const [capMin, setCapMin] = useState("");
|
||||
@@ -174,7 +186,6 @@ export function BrowsePage() {
|
||||
|
||||
useEffect(() => { void control.status().then((c) => { setPreferred(c.preferred_username); setPreferredSaved(c.preferred_username); }).catch(() => {}); }, []);
|
||||
useEffect(() => { void browse.modules().then((m) => setCatalog(Object.fromEntries(m.map((x) => [x.id, x])))).catch(() => {}); }, []);
|
||||
useEffect(() => { if (status?.url && url === "") setUrl(status.url); }, [status?.url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const params = useMemo<BrowseParams>(() => ({
|
||||
q: q.trim() || undefined,
|
||||
@@ -225,11 +236,7 @@ export function BrowsePage() {
|
||||
<Button size="sm" onClick={() => void load()} disabled={loading || noUrl}>{loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : <RefreshCw className="h-4 w-4" aria-hidden />}{t("browse.refresh")}</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<input aria-label={t("registry.url")} value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://coa-manager.duckdns.org" className="min-w-0 flex-1 rounded-md border border-line bg-card px-3 py-2 text-sm" />
|
||||
<Button size="sm" disabled={url.trim() === (status?.url ?? "")} onClick={() => void realmRegistry.setUrl(url.trim() || null).then(refresh).catch((e) => setError(asUiError(e).technical))}>{t("registry.urlSave")}</Button>
|
||||
</div>
|
||||
{noUrl && <p className="mt-2 text-sm text-muted">{t("browse.noUrl")}</p>}
|
||||
{noUrl && <p className="mt-2 text-sm text-warn">{t("browse.noUrl")}</p>}
|
||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
||||
<label htmlFor="preferred-name" className="shrink-0 text-muted">{t("join.preferred")}</label>
|
||||
<input id="preferred-name" value={preferred} maxLength={16} placeholder="PLAYER" onChange={(e) => { setPreferred(e.target.value); setPreferredNote(null); }} className="w-44 rounded-md border border-line bg-card px-3 py-1.5" data-testid="preferred-name" />
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useI18n, type Key } from "@/i18n";
|
||||
import { asControlError, control, type AccountView, type Credentials, type JoinOutcome, type RemoteCharacter } from "@/lib/control";
|
||||
import { REMOTE_CLIENT_ID } from "@/lib/api";
|
||||
import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog";
|
||||
import { Notes } from "@/screens/PortablePage";
|
||||
|
||||
const STATUS_TEXT: Record<string, Key> = {
|
||||
@@ -121,6 +123,7 @@ export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: s
|
||||
const [err, setErr] = useState<{ code: string; message: string } | null>(null);
|
||||
const [linking, setLinking] = useState(false);
|
||||
const [bring, setBring] = useState(false);
|
||||
const [clientDialog, setClientDialog] = useState<ClientDialogMode | null>(null);
|
||||
const refresh = () => control.account(realm).then(setAccount).catch(() => setAccount(null));
|
||||
useEffect(() => { setOutcome(null); setErr(null); setLinking(false); void refresh(); }, [realm]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -130,6 +133,7 @@ export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: s
|
||||
const o = await control.join(realm, bring && character ? character : null, true);
|
||||
setOutcome(o);
|
||||
if (o.status === "needs_link") setLinking(true);
|
||||
if (o.status === "needs_client") setClientDialog("setup");
|
||||
await refresh();
|
||||
} catch (e) { setErr(asControlError(e)); } finally { setBusy(false); }
|
||||
}
|
||||
@@ -145,6 +149,11 @@ export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: s
|
||||
{!account && automatic && !linking && <button className="mt-2 cursor-pointer text-xs text-muted underline hover:text-ink" onClick={() => setLinking(true)}>{t("join.haveAccount")}</button>}
|
||||
{outcome && text && <div className={cn("mt-3 rounded-md border p-3 text-sm", bad ? "border-warn/50 bg-warn/10" : "border-ok/40 bg-ok/10")} role="status" data-status={outcome.status}>
|
||||
<p>{t(text)}</p>
|
||||
{outcome.status === "needs_client" && (
|
||||
<Button size="sm" className="mt-2" onClick={() => setClientDialog("setup")}>
|
||||
{t("btn.setupClient")}
|
||||
</Button>
|
||||
)}
|
||||
{outcome.account_created && <p className="mt-1 text-xs text-muted">{t("join.created", { name: outcome.username ?? "" })}</p>}
|
||||
{outcome.password_reset && <p className="mt-1 text-xs text-muted">{t("join.reset")}</p>}
|
||||
{outcome.notes.length > 0 && <Notes notes={outcome.notes} />}
|
||||
@@ -152,5 +161,16 @@ export function JoinPanel({ realm, character, automatic, onClaimed }: { realm: s
|
||||
{err && <ErrorLine code={err.code} message={err.message} />}
|
||||
</div>
|
||||
<ServerCharacters realm={realm} onClaimed={onClaimed} />
|
||||
{clientDialog && (
|
||||
<ClientDialog
|
||||
serverId={REMOTE_CLIENT_ID}
|
||||
mode={clientDialog}
|
||||
onClose={() => setClientDialog(null)}
|
||||
onChanged={() => {
|
||||
setClientDialog(null);
|
||||
void join();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from "react";
|
||||
import { Globe, Network } from "lucide-react";
|
||||
import { useT } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrowsePage } from "@/screens/BrowsePage";
|
||||
import { FriendsPage } from "@/screens/FriendsPage";
|
||||
|
||||
export function PlayWithFriends({ serverId }: { serverId: string; path?: string }) {
|
||||
const t = useT();
|
||||
const [tab, setTab] = useState<"servers" | "lan">("servers");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex border-b border-line pb-2" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === "servers"}
|
||||
onClick={() => setTab("servers")}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors cursor-pointer",
|
||||
tab === "servers" ? "bg-white/[0.08] font-medium text-ink" : "text-muted hover:bg-white/[0.04] hover:text-ink"
|
||||
)}
|
||||
>
|
||||
<Globe className="h-4 w-4" aria-hidden />
|
||||
{t("fr.tab.servers")}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === "lan"}
|
||||
onClick={() => setTab("lan")}
|
||||
className={cn(
|
||||
"ml-2 flex items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors cursor-pointer",
|
||||
tab === "lan" ? "bg-white/[0.08] font-medium text-ink" : "text-muted hover:bg-white/[0.04] hover:text-ink"
|
||||
)}
|
||||
>
|
||||
<Network className="h-4 w-4" aria-hidden />
|
||||
{t("fr.tab.lan")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "servers" && <BrowsePage />}
|
||||
{tab === "lan" && <FriendsPage serverId={serverId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
|
||||
const { status, refresh } = useRegistryStatus();
|
||||
const mine = status?.realms.find((r) => r.local_id === localId);
|
||||
const published = !!mine?.enabled;
|
||||
const [url, setUrl] = useState("");
|
||||
const [name, setName] = useState(serverName);
|
||||
const [description, setDescription] = useState("");
|
||||
const [language, setLanguage] = useState("en");
|
||||
@@ -43,7 +42,6 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
|
||||
try { await control.setAccess(localId, existingOnly, route.trim() === "" ? null : route.trim()); await refresh(); setAccessSaved(true); } catch (e) { setError(asControlError(e).message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { if (status && status.url !== null) setUrl((u) => (u === "" ? status.url ?? "" : u)); }, [status?.url]);
|
||||
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]);
|
||||
@@ -61,11 +59,6 @@ export function PublishCard({ serverId, serverName }: { serverId: string; server
|
||||
<h2 className="font-semibold">{t("registry.title")}</h2>
|
||||
<p className="mt-1 text-sm text-muted">{t("registry.text")}</p>
|
||||
|
||||
<label htmlFor="registry-url" className="mt-4 block text-sm font-medium">{t("registry.url")}</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input id="registry-url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://coa-manager.duckdns.org" className="min-w-0 flex-1 rounded-md border border-line bg-card px-3 py-2" />
|
||||
<Button size="sm" disabled={busy || url.trim() === (status?.url ?? "")} onClick={() => void run(() => realmRegistry.setUrl(url.trim() || null))}>{t("registry.urlSave")}</Button>
|
||||
</div>
|
||||
|
||||
{!published && <>
|
||||
<label htmlFor="registry-name" className="mt-4 block text-sm font-medium">{t("registry.name")}</label>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SettingsPage } from "@/screens/SettingsPage";
|
||||
import { BackupsPage } from "@/screens/BackupsPage";
|
||||
import { PlayersPage } from "@/screens/PlayersPage";
|
||||
import { SettingsHome } from "@/screens/SettingsHome";
|
||||
import { FriendsPage } from "@/screens/FriendsPage";
|
||||
import { PlayWithFriends } from "@/screens/PlayWithFriends";
|
||||
import { ReportPage } from "@/screens/ReportPage";
|
||||
import { ModulesPage } from "@/screens/ModulesPage";
|
||||
import { ConsolePage } from "@/screens/ConsolePage";
|
||||
@@ -143,7 +143,7 @@ export function Shell(props: {
|
||||
) : page === "report" ? (
|
||||
<ReportPage key={server.id} serverId={server.id} />
|
||||
) : page === "friends" ? (
|
||||
<FriendsPage key={server.id} serverId={server.id} />
|
||||
<PlayWithFriends key={server.id} serverId={server.id} path={server.path} />
|
||||
) : page === "settings" ? (
|
||||
<SettingsHome key={server.id} serverId={server.id} serverName={server.name} path={server.path} onForget={() => props.onForget(server.id)} />
|
||||
) : page === "characters" ? (
|
||||
|
||||
Reference in new issue
Block a user