@hadoku/watchparty-ui 0.0.0-stage → 0.43.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Matthaeus Wolff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # @wolffm/watchparty-ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React UI components for the Hadoku Watch Party application.
4
+
5
+ ## Installation
6
+
7
+ This package is published to GitHub Packages. To install:
8
+
9
+ 1. **Configure npm to use GitHub Packages** by creating/updating `.npmrc`:
10
+
11
+ ```
12
+ @wolffm:registry=https://npm.pkg.github.com
13
+ //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
14
+ ```
15
+
16
+ 2. **Install the package**:
17
+
18
+ ```bash
19
+ npm install @wolffm/watchparty-ui
20
+ # or
21
+ pnpm add @wolffm/watchparty-ui
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### Basic Setup
27
+
28
+ ```tsx
29
+ import { WatchPartyProvider, WatchPartyRoom } from '@wolffm/watchparty-ui';
30
+ import '@wolffm/watchparty-ui/dist/style.css';
31
+
32
+ function App() {
33
+ return (
34
+ <WatchPartyProvider serverOrigin="https://your-server.com">
35
+ <WatchPartyRoom defaultRoomKey="your-room-key" />
36
+ </WatchPartyProvider>
37
+ );
38
+ }
39
+ ```
40
+
41
+ ### Configuration
42
+
43
+ The UI components can be configured via props:
44
+
45
+ - `serverOrigin`: WebSocket server URL (default: `/`)
46
+ - `defaultRoomKey`: Room to join on mount (optional)
47
+
48
+ ## Development
49
+
50
+ To work on this package locally:
51
+
52
+ ```bash
53
+ # Install dependencies
54
+ pnpm install
55
+
56
+ # Run dev server
57
+ pnpm dev
58
+
59
+ # Build for production
60
+ pnpm build
61
+ ```
62
+
63
+ ## Publishing
64
+
65
+ This package is automatically published to GitHub Packages on every push to `main` that affects the UI code.
66
+
67
+ Version bumping is handled automatically by Husky pre-commit hooks. If the pre-commit hook doesn't run (e.g., when using `--no-verify`), the publish workflow will detect that the version already exists in GitHub Packages and fail with a helpful error message.
68
+
69
+ To manually bump the version if needed:
70
+
71
+ ```bash
72
+ pnpm run version:smart
73
+ git add .
74
+ git commit -m "Bump version"
75
+ git push
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Development Notes
81
+
82
+ Phase 0 scaffold (React 19 + Vite) is in place.
83
+
84
+ Key config:
85
+ - Base path: `/watchparty/`
86
+ - Router basename: `/watchparty`
87
+ - Tailwind + Zustand + socket.io-client
88
+
89
+ ## Runtime Server Integration (Phase 2)
90
+
91
+ The UI now auto-joins a placeholder room key `demo` when the user presses "Join Room" in the Identity drawer. It sends a Socket.IO `join` payload containing `{ room_key:'demo', leader_key?, name, sprite }`.
92
+
93
+ Configure a different backend origin (if UI served from a different host) via environment:
94
+
95
+ ```
96
+ VITE_SERVER_ORIGIN=https://api.example.com
97
+ ```
98
+
99
+ If omitted, the client connects relative to current origin (`/watchparty/ws`).
100
+
101
+ Duplicate joins are suppressed (if `me.clientId` already populated). This interim flow will be replaced once a room selection / creation UX exists.
102
+
103
+ ## CSS Reset / Preflight
104
+
105
+ Tailwind v4 preflight is disabled to eliminate console warnings about legacy vendor selectors. A minimal custom reset is applied in `src/styles.css`. The theme uses CSS variables from `@wolffm/themes` mapped in the `@theme` block.
106
+
107
+ ## Dark Theme Lock / Extension Interference
108
+
109
+ The app enforces a permanent dark theme to avoid color inversion by extensions like Dark Reader:
110
+ - Added `<meta name="color-scheme" content="dark">` & `data-theme="dark"` on `<html>`.
111
+ - Set `data-darkreader-skip` on `<html>` and `<body>` to hint Dark Reader not to transform.
112
+ - Declared stable color CSS variables (`--wp-*`) and `color-scheme: dark` to reduce heuristics.
113
+ - Explicit black background ensures no flash when extension toggles.
114
+
115
+ If future light mode is desired, introduce a controlled theme switch and remove `data-darkreader-skip` selectively rather than allowing uncontrolled extension mutation.
@@ -0,0 +1,227 @@
1
+ import { jsxs as n, jsx as t } from "react/jsx-runtime";
2
+ import { h as R, aK as M, W as E } from "./index-CwuV6ab3.js";
3
+ import { useState as f, useRef as A, useCallback as F, useEffect as I } from "react";
4
+ import { l as $, a as w, m as P, n as B, r as T, o as D, p as C } from "./submissionsApi-CqGsgEg5.js";
5
+ import { c as O, B as U } from "./Room-CPV26zAk.js";
6
+ const H = 5e3, K = 8e3;
7
+ function q() {
8
+ const [s, r] = f(null), [d, m] = f(null), [c, a] = f({}), [i, p] = f(null), [x, b] = f(null), [v, h] = f(""), [y, L] = f(() => Date.now()), g = A(!0), _ = F(async () => {
9
+ const e = await $();
10
+ g.current && (L(Date.now()), e.ok ? (r(e.value), m(null)) : m(e.failure));
11
+ }, []);
12
+ I(() => {
13
+ g.current = !0;
14
+ const e = setTimeout(() => {
15
+ _();
16
+ }, 0), o = setInterval(() => {
17
+ _();
18
+ }, H);
19
+ return () => {
20
+ g.current = !1, clearTimeout(e), clearInterval(o);
21
+ };
22
+ }, [_]);
23
+ function j(e, o, u) {
24
+ a((l) => ({ ...l, [e.id]: { row: e, message: o, tone: u } })), setTimeout(() => {
25
+ g.current && a((l) => Object.fromEntries(Object.entries(l).filter(([N]) => N !== e.id)));
26
+ }, K);
27
+ }
28
+ async function S(e, o) {
29
+ if (i) return;
30
+ p(e.id);
31
+ const u = o === "approve" ? await B(e.id) : await T(e.id, v);
32
+ p(null), g.current && (u.ok ? (b(null), h(""), r((l) => (l ?? []).filter((N) => N.id !== e.id)), j(e, u.value.state, "info")) : D(u.failure) ? (b(null), r((l) => (l ?? []).filter((N) => N.id !== e.id)), j(e, C(u.failure), "error")) : j(e, C(u.failure), "error"), _());
33
+ }
34
+ const k = [
35
+ ...(s ?? []).map((e) => ({ row: e, note: c[e.id] })),
36
+ ...Object.values(c).filter((e) => !(s ?? []).some((o) => o.id === e.row.id)).map((e) => ({ row: e.row, note: e }))
37
+ ];
38
+ return !d && k.length === 0 ? null : /* @__PURE__ */ n("section", { className: "border-t border-border pt-2", "data-testid": "submission-queue", children: [
39
+ /* @__PURE__ */ n("div", { className: "flex items-center px-1 pb-1", children: [
40
+ /* @__PURE__ */ t("span", { className: "text-[10px] uppercase tracking-wider text-text-muted", children: "Submissions" }),
41
+ /* @__PURE__ */ t("span", { className: "ml-1 text-[10px] text-text-muted", "data-testid": "submission-queue-count", children: s?.length ?? 0 })
42
+ ] }),
43
+ d && /* @__PURE__ */ t("div", { className: "px-2 py-1 text-[11px] bg-danger-bg text-on-danger-bg rounded", children: w(d) }),
44
+ /* @__PURE__ */ t("ul", { className: "list-none m-0 p-0", children: k.map(({ row: e, note: o }) => {
45
+ const u = o && !(s ?? []).some((l) => l.id === e.id);
46
+ return /* @__PURE__ */ n(
47
+ "li",
48
+ {
49
+ className: "px-1 py-1.5 border-b border-border/50 flex flex-col gap-1",
50
+ "data-submission-id": e.id,
51
+ "data-submission-state": u ? "decided" : e.state,
52
+ "data-submission-kind": e.kind,
53
+ "data-submission-series": e.series ?? "",
54
+ children: [
55
+ /* @__PURE__ */ n("div", { className: "flex items-center gap-2 text-[11px] min-w-0", children: [
56
+ /* @__PURE__ */ t("span", { className: "text-[10px] uppercase tracking-wider text-text-muted shrink-0", children: e.kind }),
57
+ /* @__PURE__ */ t("span", { className: "text-text truncate flex-1", title: e.source, children: e.series ? `${e.series} — ${e.title || e.source}` : e.title || e.source }),
58
+ /* @__PURE__ */ t("span", { className: "text-text-muted shrink-0", children: P(e.createdAt, y) })
59
+ ] }),
60
+ /* @__PURE__ */ t("div", { className: "text-[10px] text-text-muted truncate", title: e.ownerUserId, children: e.ownerUserId }),
61
+ o && /* @__PURE__ */ t(
62
+ "div",
63
+ {
64
+ "data-testid": "submission-decision-note",
65
+ className: `text-[11px] px-1.5 py-0.5 rounded ${o.tone === "error" ? "bg-danger-bg text-on-danger-bg" : "bg-success-bg text-on-success-bg"}`,
66
+ children: o.message
67
+ }
68
+ ),
69
+ !u && x === e.id ? /* @__PURE__ */ n("div", { className: "flex items-center gap-1", children: [
70
+ /* @__PURE__ */ t(
71
+ "input",
72
+ {
73
+ value: v,
74
+ onChange: (l) => h(l.target.value),
75
+ onKeyDown: (l) => {
76
+ l.key === "Enter" && S(e, "reject");
77
+ },
78
+ "aria-label": "Reject reason",
79
+ placeholder: "reason",
80
+ className: "flex-1 min-w-0 bg-bg-alt border border-border outline-none px-2 py-0.5 rounded text-[11px]"
81
+ }
82
+ ),
83
+ /* @__PURE__ */ t(
84
+ "button",
85
+ {
86
+ className: "drawer-btn drawer-btn-danger",
87
+ "aria-label": "Confirm reject",
88
+ disabled: i === e.id,
89
+ onClick: () => {
90
+ S(e, "reject");
91
+ },
92
+ children: "Reject"
93
+ }
94
+ ),
95
+ /* @__PURE__ */ t(
96
+ "button",
97
+ {
98
+ className: "drawer-btn",
99
+ "aria-label": "Cancel reject",
100
+ onClick: () => {
101
+ b(null), h("");
102
+ },
103
+ children: "Cancel"
104
+ }
105
+ )
106
+ ] }) : !u && /* @__PURE__ */ n("div", { className: "flex items-center gap-1", children: [
107
+ /* @__PURE__ */ t(
108
+ "button",
109
+ {
110
+ className: "drawer-btn drawer-btn-primary",
111
+ "aria-label": "Approve submission",
112
+ disabled: i === e.id,
113
+ onClick: () => {
114
+ S(e, "approve");
115
+ },
116
+ children: "Approve"
117
+ }
118
+ ),
119
+ /* @__PURE__ */ t(
120
+ "button",
121
+ {
122
+ className: "drawer-btn",
123
+ "aria-label": "Reject submission",
124
+ disabled: i === e.id,
125
+ onClick: () => {
126
+ b(e.id), h("");
127
+ },
128
+ children: "Reject"
129
+ }
130
+ )
131
+ ] })
132
+ ]
133
+ },
134
+ e.id
135
+ );
136
+ }) })
137
+ ] });
138
+ }
139
+ function Z() {
140
+ const r = R((a) => a.healthSnapshot)?.clients ?? [], d = [...r].sort((a, i) => a.avg_download_rate_bps - i.avg_download_rate_bps), m = Q(d), c = M() ? /* @__PURE__ */ t(q, {}) : null;
141
+ return r.length === 0 ? /* @__PURE__ */ n("div", { className: "flex-1 overflow-y-auto p-2 flex flex-col", children: [
142
+ /* @__PURE__ */ t("div", { className: "flex-1 flex items-center justify-center p-4", children: /* @__PURE__ */ n("p", { className: "text-text-muted text-xs text-center", children: [
143
+ "No health data yet.",
144
+ /* @__PURE__ */ t("br", {}),
145
+ "Clients report once media is loaded."
146
+ ] }) }),
147
+ c
148
+ ] }) : /* @__PURE__ */ n("div", { className: "flex-1 overflow-y-auto p-2 space-y-2", children: [
149
+ d.map((a) => /* @__PURE__ */ t(W, { client: a, initial: m[a.client_id] }, a.client_id)),
150
+ c
151
+ ] });
152
+ }
153
+ function G(s) {
154
+ const r = Math.floor(s / 1e3), d = Math.floor(r / 60), m = r % 60;
155
+ return `${d}:${m.toString().padStart(2, "0")}`;
156
+ }
157
+ function Q(s) {
158
+ const r = {}, d = /* @__PURE__ */ new Set(), m = [...s].sort((c, a) => c.client_id.localeCompare(a.client_id));
159
+ for (const c of m) {
160
+ const a = c.name ?? "";
161
+ let i = "?";
162
+ for (const p of a) {
163
+ if (!/[\p{L}\p{N}]/u.test(p)) continue;
164
+ const x = p.toUpperCase();
165
+ if (!d.has(x)) {
166
+ i = x, d.add(x);
167
+ break;
168
+ }
169
+ }
170
+ r[c.client_id] = i;
171
+ }
172
+ return r;
173
+ }
174
+ function W({ client: s, initial: r }) {
175
+ const m = (Math.round(s.avg_download_rate_bps / 1024) / 1024).toFixed(1), c = (s.buffered_ahead_ms / 1e3).toFixed(1), a = Math.round(s.rtt_ms), i = O(s), p = s.stale_ms !== void 0, x = p ? `Silent ${Math.round(s.stale_ms / 1e3)}s` : U[i], b = p || i === "critical" || i === "buffering", v = b ? "text-danger bg-danger/10" : i === "idle" ? "text-text-muted bg-bg-alt/40" : i === "warning" ? "text-on-neutral-bg bg-neutral/10" : "text-success bg-success/10", h = b ? "bg-danger" : i === "warning" ? "bg-neutral-bg" : "bg-success", y = Math.min(100, s.buffered_ahead_ms / E * 100);
176
+ return /* @__PURE__ */ n("div", { className: "rounded-lg border border-border/50 bg-bg-alt/20 p-2.5", "data-silent": p || void 0, children: [
177
+ /* @__PURE__ */ n("div", { className: "flex items-center justify-between mb-2", children: [
178
+ /* @__PURE__ */ n("div", { className: "flex items-center gap-2 min-w-0", children: [
179
+ /* @__PURE__ */ t("div", { className: "w-5 h-5 rounded-full bg-bg-alt flex items-center justify-center text-[9px] font-semibold shrink-0", children: r }),
180
+ /* @__PURE__ */ t("span", { className: "text-sm font-medium text-text truncate", children: s.name })
181
+ ] }),
182
+ /* @__PURE__ */ t("span", { className: `inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${v}`, children: x })
183
+ ] }),
184
+ /* @__PURE__ */ n("div", { className: `grid grid-cols-3 gap-x-2 gap-y-1 text-[11px] ${p ? "opacity-50" : ""}`, children: [
185
+ /* @__PURE__ */ n("div", { children: [
186
+ /* @__PURE__ */ t("span", { className: "text-text-muted", children: "DL" }),
187
+ /* @__PURE__ */ t("span", { className: "ml-1 font-mono text-text", children: m }),
188
+ /* @__PURE__ */ t("span", { className: "text-text-muted ml-0.5", children: "Mbps" })
189
+ ] }),
190
+ /* @__PURE__ */ n("div", { children: [
191
+ /* @__PURE__ */ t("span", { className: "text-text-muted", children: "Ping" }),
192
+ /* @__PURE__ */ t("span", { className: "ml-1 font-mono text-text", children: a }),
193
+ /* @__PURE__ */ t("span", { className: "text-text-muted ml-0.5", children: "ms" })
194
+ ] }),
195
+ /* @__PURE__ */ n("div", { children: [
196
+ /* @__PURE__ */ t("span", { className: "text-text-muted", children: "Pos" }),
197
+ /* @__PURE__ */ t("span", { className: "ml-1 font-mono text-text-secondary", children: G(s.playhead_ms) })
198
+ ] }),
199
+ /* @__PURE__ */ n("div", { className: "col-span-2 flex items-center gap-1.5", children: [
200
+ /* @__PURE__ */ t("span", { className: "text-text-muted", children: "Buf" }),
201
+ /* @__PURE__ */ n("span", { className: "font-mono text-text", children: [
202
+ c,
203
+ "s"
204
+ ] }),
205
+ /* @__PURE__ */ t("div", { className: "flex-1 h-1 bg-bg-alt rounded-full overflow-hidden", children: /* @__PURE__ */ t("div", { className: `h-full rounded-full transition-all ${h}`, style: { width: `${y}%` } }) })
206
+ ] }),
207
+ /* @__PURE__ */ n("div", { className: "flex gap-1 font-mono justify-end", children: [
208
+ /* @__PURE__ */ n("span", { className: "bg-success-bg text-on-success-bg px-1 rounded", children: [
209
+ s.fragments_loaded,
210
+ "L"
211
+ ] }),
212
+ /* @__PURE__ */ n("span", { className: "text-text-muted", children: [
213
+ s.fragments_pending,
214
+ "P"
215
+ ] }),
216
+ s.fragments_error > 0 && /* @__PURE__ */ n("span", { className: "bg-danger-bg text-on-danger-bg px-1 rounded", children: [
217
+ s.fragments_error,
218
+ "E"
219
+ ] })
220
+ ] })
221
+ ] })
222
+ ] });
223
+ }
224
+ export {
225
+ Z as default
226
+ };
227
+ //# sourceMappingURL=AdminPanel-Bvl3P2VE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AdminPanel-Bvl3P2VE.js","sources":["../../src/components/SubmissionQueue.tsx","../../src/components/AdminPanel.tsx"],"sourcesContent":["/**\n * The operator's moderation queue — every `pending` submission, with approve\n * and reject per row. Service tier only (the routes are `serviceAuth`; the\n * caller gates rendering on `isPrivileged()`, and a refusal is shown rather\n * than hidden if the gate and the server ever disagree).\n *\n * A row the server says has already moved (409, or 404 once it is gone) is not\n * an error in the click — a stale queue page is the ordinary way to meet it —\n * so it stays in place carrying the outcome, then drops off after\n * `NOTE_LINGER_MS`. Dropping it at once would make the click look like it did\n * nothing.\n *\n * Buttons carry real classes (`.drawer-btn`): the unlayered `button` reset\n * discards every Tailwind colour/font utility on a `<button>`.\n */\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport type { TSubmission } from '@watchparty/shared';\n/** `TSubmission` plus the `series` field the shared schema gains for magnets\n * (packages/shared/src/submissions.ts) — a cast rather than a schema edit,\n * since this file does not own that schema; harmless once the real field\n * lands, since the shapes agree. */\ntype SubmissionWithSeries = TSubmission & { series?: string };\nimport {\n approveSubmission,\n decisionFailureLabel,\n decisionMeansRowMoved,\n fetchPendingSubmissions,\n formatSubmissionAge,\n rejectSubmission,\n submissionFailureLabel,\n type SubmissionFailure,\n} from '../lib/submissionsApi';\n\nconst POLL_MS = 5000;\nconst NOTE_LINGER_MS = 8000;\n\ninterface Note { row: TSubmission; message: string; tone: 'info' | 'error' }\n\nexport default function SubmissionQueue() {\n const [rows, setRows] = useState<TSubmission[] | null>(null);\n const [listFailure, setListFailure] = useState<SubmissionFailure | null>(null);\n const [notes, setNotes] = useState<Record<string, Note>>({});\n const [busyId, setBusyId] = useState<string | null>(null);\n const [rejectingId, setRejectingId] = useState<string | null>(null);\n const [reason, setReason] = useState('');\n const [now, setNow] = useState(() => Date.now());\n const liveRef = useRef(true);\n\n const refresh = useCallback(async () => {\n const res = await fetchPendingSubmissions();\n if (!liveRef.current) return;\n setNow(Date.now());\n if (res.ok) { setRows(res.value); setListFailure(null); }\n else { setListFailure(res.failure); }\n }, []);\n\n useEffect(() => {\n liveRef.current = true;\n // Deferred a tick: the first read is a subscription callback, not a\n // synchronous setState in the effect body.\n const first = setTimeout(() => { void refresh(); }, 0);\n const timer = setInterval(() => { void refresh(); }, POLL_MS);\n return () => { liveRef.current = false; clearTimeout(first); clearInterval(timer); };\n }, [refresh]);\n\n function leaveNote(row: TSubmission, message: string, tone: Note['tone']) {\n setNotes(prev => ({ ...prev, [row.id]: { row, message, tone } }));\n setTimeout(() => {\n if (!liveRef.current) return;\n setNotes(prev => Object.fromEntries(Object.entries(prev).filter(([id]) => id !== row.id)));\n }, NOTE_LINGER_MS);\n }\n\n async function decide(row: TSubmission, verb: 'approve' | 'reject') {\n if (busyId) return;\n setBusyId(row.id);\n const res = verb === 'approve'\n ? await approveSubmission(row.id)\n : await rejectSubmission(row.id, reason);\n setBusyId(null);\n if (!liveRef.current) return;\n if (res.ok) {\n setRejectingId(null);\n setReason('');\n setRows(prev => (prev ?? []).filter(r => r.id !== row.id));\n leaveNote(row, res.value.state, 'info');\n } else if (decisionMeansRowMoved(res.failure)) {\n setRejectingId(null);\n setRows(prev => (prev ?? []).filter(r => r.id !== row.id));\n leaveNote(row, decisionFailureLabel(res.failure), 'error');\n } else {\n leaveNote(row, decisionFailureLabel(res.failure), 'error');\n }\n void refresh();\n }\n\n const shown = [\n ...(rows ?? []).map(r => ({ row: r, note: notes[r.id] })),\n ...Object.values(notes)\n .filter(n => !(rows ?? []).some(r => r.id === n.row.id))\n .map(n => ({ row: n.row, note: n })),\n ];\n\n // Nothing to moderate is shown as nothing — no header, no \"(none)\". A list\n // failure still renders, since that IS state the leader needs to see.\n if (!listFailure && shown.length === 0) return null;\n\n return (\n <section className=\"border-t border-border pt-2\" data-testid=\"submission-queue\">\n <div className=\"flex items-center px-1 pb-1\">\n <span className=\"text-[10px] uppercase tracking-wider text-text-muted\">Submissions</span>\n <span className=\"ml-1 text-[10px] text-text-muted\" data-testid=\"submission-queue-count\">\n {rows?.length ?? 0}\n </span>\n </div>\n {listFailure && (\n <div className=\"px-2 py-1 text-[11px] bg-danger-bg text-on-danger-bg rounded\">{submissionFailureLabel(listFailure)}</div>\n )}\n <ul className=\"list-none m-0 p-0\">\n {shown.map(({ row, note }) => {\n const decided = note && !(rows ?? []).some(r => r.id === row.id);\n return (\n <li\n key={row.id}\n className=\"px-1 py-1.5 border-b border-border/50 flex flex-col gap-1\"\n data-submission-id={row.id}\n data-submission-state={decided ? 'decided' : row.state}\n data-submission-kind={row.kind}\n data-submission-series={(row as SubmissionWithSeries).series ?? ''}\n >\n <div className=\"flex items-center gap-2 text-[11px] min-w-0\">\n <span className=\"text-[10px] uppercase tracking-wider text-text-muted shrink-0\">{row.kind}</span>\n <span className=\"text-text truncate flex-1\" title={row.source}>\n {(row as SubmissionWithSeries).series\n ? `${(row as SubmissionWithSeries).series} — ${row.title || row.source}`\n : (row.title || row.source)}\n </span>\n <span className=\"text-text-muted shrink-0\">{formatSubmissionAge(row.createdAt, now)}</span>\n </div>\n <div className=\"text-[10px] text-text-muted truncate\" title={row.ownerUserId}>{row.ownerUserId}</div>\n {note && (\n <div\n data-testid=\"submission-decision-note\"\n className={`text-[11px] px-1.5 py-0.5 rounded ${note.tone === 'error' ? 'bg-danger-bg text-on-danger-bg' : 'bg-success-bg text-on-success-bg'}`}\n >\n {note.message}\n </div>\n )}\n {!decided && rejectingId === row.id ? (\n <div className=\"flex items-center gap-1\">\n <input\n value={reason}\n onChange={e => setReason(e.target.value)}\n onKeyDown={e => { if (e.key === 'Enter') void decide(row, 'reject'); }}\n aria-label=\"Reject reason\"\n placeholder=\"reason\"\n className=\"flex-1 min-w-0 bg-bg-alt border border-border outline-none px-2 py-0.5 rounded text-[11px]\"\n />\n <button\n className=\"drawer-btn drawer-btn-danger\"\n aria-label=\"Confirm reject\"\n disabled={busyId === row.id}\n onClick={() => { void decide(row, 'reject'); }}\n >\n Reject\n </button>\n <button\n className=\"drawer-btn\"\n aria-label=\"Cancel reject\"\n onClick={() => { setRejectingId(null); setReason(''); }}\n >\n Cancel\n </button>\n </div>\n ) : !decided && (\n <div className=\"flex items-center gap-1\">\n <button\n className=\"drawer-btn drawer-btn-primary\"\n aria-label=\"Approve submission\"\n disabled={busyId === row.id}\n onClick={() => { void decide(row, 'approve'); }}\n >\n Approve\n </button>\n <button\n className=\"drawer-btn\"\n aria-label=\"Reject submission\"\n disabled={busyId === row.id}\n onClick={() => { setRejectingId(row.id); setReason(''); }}\n >\n Reject\n </button>\n </div>\n )}\n </li>\n );\n })}\n </ul>\n </section>\n );\n}\n","import { useAppStore } from '../lib/store';\nimport { BUFFER_AHEAD_MS, type TClientHealth } from '@watchparty/shared';\nimport { isPrivileged } from '../lib/auth';\nimport SubmissionQueue from './SubmissionQueue';\nimport { bufferHealthLevel, BUFFER_HEALTH_LABEL } from './bufferHealth';\n\n/**\n * Health monitor panel — renders as sidebar content (replaces chat when active).\n * Shows a card per connected client with live health metrics.\n */\nexport default function AdminPanel() {\n const healthSnapshot = useAppStore(s => s.healthSnapshot);\n\n const clients = healthSnapshot?.clients ?? [];\n // Slowest download first — easiest to spot struggling clients\n const sorted = [...clients].sort((a, b) => a.avg_download_rate_bps - b.avg_download_rate_bps);\n\n const initials = computeInitials(sorted);\n // Moderation is service tier, like the routes behind it.\n const queue = isPrivileged() ? <SubmissionQueue /> : null;\n\n if (clients.length === 0) {\n return (\n <div className=\"flex-1 overflow-y-auto p-2 flex flex-col\">\n <div className=\"flex-1 flex items-center justify-center p-4\">\n <p className=\"text-text-muted text-xs text-center\">\n No health data yet.<br />Clients report once media is loaded.\n </p>\n </div>\n {queue}\n </div>\n );\n }\n\n return (\n <div className=\"flex-1 overflow-y-auto p-2 space-y-2\">\n {sorted.map(c => <ClientCard key={c.client_id} client={c} initial={initials[c.client_id]} />)}\n {queue}\n </div>\n );\n}\n\n/* ------------------------------------------------------------------ */\n\nfunction formatTime(ms: number): string {\n const totalSecs = Math.floor(ms / 1000);\n const mins = Math.floor(totalSecs / 60);\n const secs = totalSecs % 60;\n return `${mins}:${secs.toString().padStart(2, '0')}`;\n}\n\n// Initials are first alphanumeric of display name; on conflict, advance to next\n// unused alphanumeric letter in the same name. Stable across renders via client_id sort.\nfunction computeInitials(clients: TClientHealth[]): Record<string, string> {\n const out: Record<string, string> = {};\n const taken = new Set<string>();\n const ordered = [...clients].sort((a, b) => a.client_id.localeCompare(b.client_id));\n for (const c of ordered) {\n const name = c.name ?? '';\n let chosen = '?';\n for (const ch of name) {\n if (!/[\\p{L}\\p{N}]/u.test(ch)) continue;\n const up = ch.toUpperCase();\n if (!taken.has(up)) { chosen = up; taken.add(up); break; }\n }\n out[c.client_id] = chosen;\n }\n return out;\n}\n\nfunction ClientCard({ client, initial }: { client: TClientHealth; initial: string }) {\n const dlKbps = Math.round(client.avg_download_rate_bps / 1024);\n const dlMbps = (dlKbps / 1024).toFixed(1);\n const bufferS = (client.buffered_ahead_ms / 1000).toFixed(1);\n const rtt = Math.round(client.rtt_ms);\n\n // Idle: not playing, no meaningful position — don't alarm about \"Critical\".\n // Near end-of-stream is exempt from the buffered-ahead thresholds (see\n // bufferHealth.ts) so the final seconds of every episode don't false-Critical.\n const level = bufferHealthLevel(client);\n // A connected client whose reports stopped arriving: the figures below are\n // its last known state, and the silence itself is the status.\n const silent = client.stale_ms !== undefined;\n const statusLabel = silent ? `Silent ${Math.round(client.stale_ms! / 1000)}s` : BUFFER_HEALTH_LABEL[level];\n const isRed = silent || level === 'critical' || level === 'buffering';\n const statusClasses = isRed\n ? 'text-danger bg-danger/10'\n : level === 'idle'\n ? 'text-text-muted bg-bg-alt/40'\n : level === 'warning'\n ? 'text-on-neutral-bg bg-neutral/10'\n : 'text-success bg-success/10';\n\n const barColor = isRed ? 'bg-danger' : level === 'warning' ? 'bg-neutral-bg' : 'bg-success';\n const barPct = Math.min(100, (client.buffered_ahead_ms / BUFFER_AHEAD_MS) * 100);\n\n return (\n <div className=\"rounded-lg border border-border/50 bg-bg-alt/20 p-2.5\" data-silent={silent || undefined}>\n {/* Row 1: Name + Status */}\n <div className=\"flex items-center justify-between mb-2\">\n <div className=\"flex items-center gap-2 min-w-0\">\n <div className=\"w-5 h-5 rounded-full bg-bg-alt flex items-center justify-center text-[9px] font-semibold shrink-0\">\n {initial}\n </div>\n <span className=\"text-sm font-medium text-text truncate\">{client.name}</span>\n </div>\n <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 ${statusClasses}`}>\n {statusLabel}\n </span>\n </div>\n\n {/* Row 2: Stats grid */}\n <div className={`grid grid-cols-3 gap-x-2 gap-y-1 text-[11px] ${silent ? 'opacity-50' : ''}`}>\n <div>\n <span className=\"text-text-muted\">DL</span>\n <span className=\"ml-1 font-mono text-text\">{dlMbps}</span>\n <span className=\"text-text-muted ml-0.5\">Mbps</span>\n </div>\n <div>\n <span className=\"text-text-muted\">Ping</span>\n <span className=\"ml-1 font-mono text-text\">{rtt}</span>\n <span className=\"text-text-muted ml-0.5\">ms</span>\n </div>\n <div>\n <span className=\"text-text-muted\">Pos</span>\n <span className=\"ml-1 font-mono text-text-secondary\">{formatTime(client.playhead_ms)}</span>\n </div>\n\n {/* Buffer row spans full width */}\n <div className=\"col-span-2 flex items-center gap-1.5\">\n <span className=\"text-text-muted\">Buf</span>\n <span className=\"font-mono text-text\">{bufferS}s</span>\n <div className=\"flex-1 h-1 bg-bg-alt rounded-full overflow-hidden\">\n <div className={`h-full rounded-full transition-all ${barColor}`} style={{ width: `${barPct}%` }} />\n </div>\n </div>\n <div className=\"flex gap-1 font-mono justify-end\">\n <span className=\"bg-success-bg text-on-success-bg px-1 rounded\">{client.fragments_loaded}L</span>\n <span className=\"text-text-muted\">{client.fragments_pending}P</span>\n {client.fragments_error > 0 && <span className=\"bg-danger-bg text-on-danger-bg px-1 rounded\">{client.fragments_error}E</span>}\n </div>\n </div>\n </div>\n );\n}\n"],"names":["POLL_MS","NOTE_LINGER_MS","SubmissionQueue","rows","setRows","useState","listFailure","setListFailure","notes","setNotes","busyId","setBusyId","rejectingId","setRejectingId","reason","setReason","now","setNow","liveRef","useRef","refresh","useCallback","res","fetchPendingSubmissions","useEffect","first","timer","leaveNote","row","message","tone","prev","id","decide","verb","approveSubmission","rejectSubmission","r","decisionMeansRowMoved","decisionFailureLabel","shown","n","jsxs","jsx","submissionFailureLabel","note","decided","e","AdminPanel","clients","useAppStore","s","sorted","b","initials","computeInitials","queue","isPrivileged","c","ClientCard","formatTime","ms","totalSecs","mins","secs","out","taken","ordered","a","name","chosen","ch","up","client","initial","dlMbps","bufferS","rtt","level","bufferHealthLevel","silent","statusLabel","BUFFER_HEALTH_LABEL","isRed","statusClasses","barColor","barPct","BUFFER_AHEAD_MS"],"mappings":";;;;;AAiCA,MAAMA,IAAU,KACVC,IAAiB;AAIvB,SAAwBC,IAAkB;AACxC,QAAM,CAACC,GAAMC,CAAO,IAAIC,EAA+B,IAAI,GACrD,CAACC,GAAaC,CAAc,IAAIF,EAAmC,IAAI,GACvE,CAACG,GAAOC,CAAQ,IAAIJ,EAA+B,CAAA,CAAE,GACrD,CAACK,GAAQC,CAAS,IAAIN,EAAwB,IAAI,GAClD,CAACO,GAAaC,CAAc,IAAIR,EAAwB,IAAI,GAC5D,CAACS,GAAQC,CAAS,IAAIV,EAAS,EAAE,GACjC,CAACW,GAAKC,CAAM,IAAIZ,EAAS,MAAM,KAAK,KAAK,GACzCa,IAAUC,EAAO,EAAI,GAErBC,IAAUC,EAAY,YAAY;AACtC,UAAMC,IAAM,MAAMC,EAAA;AAClB,IAAKL,EAAQ,YACbD,EAAO,KAAK,KAAK,GACbK,EAAI,MAAMlB,EAAQkB,EAAI,KAAK,GAAGf,EAAe,IAAI,KAC9CA,EAAee,EAAI,OAAO;AAAA,EACnC,GAAG,CAAA,CAAE;AAEL,EAAAE,EAAU,MAAM;AACd,IAAAN,EAAQ,UAAU;AAGlB,UAAMO,IAAQ,WAAW,MAAM;AAAE,MAAKL,EAAA;AAAA,IAAW,GAAG,CAAC,GAC/CM,IAAQ,YAAY,MAAM;AAAE,MAAKN,EAAA;AAAA,IAAW,GAAGpB,CAAO;AAC5D,WAAO,MAAM;AAAE,MAAAkB,EAAQ,UAAU,IAAO,aAAaO,CAAK,GAAG,cAAcC,CAAK;AAAA,IAAG;AAAA,EACrF,GAAG,CAACN,CAAO,CAAC;AAEZ,WAASO,EAAUC,GAAkBC,GAAiBC,GAAoB;AACxE,IAAArB,EAAS,CAAAsB,OAAS,EAAE,GAAGA,GAAM,CAACH,EAAI,EAAE,GAAG,EAAE,KAAAA,GAAK,SAAAC,GAAS,MAAAC,EAAA,IAAS,GAChE,WAAW,MAAM;AACf,MAAKZ,EAAQ,WACbT,EAAS,OAAQ,OAAO,YAAY,OAAO,QAAQsB,CAAI,EAAE,OAAO,CAAC,CAACC,CAAE,MAAMA,MAAOJ,EAAI,EAAE,CAAC,CAAC;AAAA,IAC3F,GAAG3B,CAAc;AAAA,EACnB;AAEA,iBAAegC,EAAOL,GAAkBM,GAA4B;AAClE,QAAIxB,EAAQ;AACZ,IAAAC,EAAUiB,EAAI,EAAE;AAChB,UAAMN,IAAMY,MAAS,YACjB,MAAMC,EAAkBP,EAAI,EAAE,IAC9B,MAAMQ,EAAiBR,EAAI,IAAId,CAAM;AAEzC,IADAH,EAAU,IAAI,GACTO,EAAQ,YACTI,EAAI,MACNT,EAAe,IAAI,GACnBE,EAAU,EAAE,GACZX,EAAQ,CAAA2B,OAASA,KAAQ,CAAA,GAAI,OAAO,OAAKM,EAAE,OAAOT,EAAI,EAAE,CAAC,GACzDD,EAAUC,GAAKN,EAAI,MAAM,OAAO,MAAM,KAC7BgB,EAAsBhB,EAAI,OAAO,KAC1CT,EAAe,IAAI,GACnBT,EAAQ,CAAA2B,OAASA,KAAQ,CAAA,GAAI,OAAO,OAAKM,EAAE,OAAOT,EAAI,EAAE,CAAC,GACzDD,EAAUC,GAAKW,EAAqBjB,EAAI,OAAO,GAAG,OAAO,KAEzDK,EAAUC,GAAKW,EAAqBjB,EAAI,OAAO,GAAG,OAAO,GAEtDF,EAAA;AAAA,EACP;AAEA,QAAMoB,IAAQ;AAAA,IACZ,IAAIrC,KAAQ,CAAA,GAAI,IAAI,CAAAkC,OAAM,EAAE,KAAKA,GAAG,MAAM7B,EAAM6B,EAAE,EAAE,IAAI;AAAA,IACxD,GAAG,OAAO,OAAO7B,CAAK,EACnB,OAAO,CAAAiC,MAAK,EAAEtC,KAAQ,CAAA,GAAI,KAAK,CAAAkC,MAAKA,EAAE,OAAOI,EAAE,IAAI,EAAE,CAAC,EACtD,IAAI,CAAAA,OAAM,EAAE,KAAKA,EAAE,KAAK,MAAMA,EAAA,EAAI;AAAA,EAAA;AAKvC,SAAI,CAACnC,KAAekC,EAAM,WAAW,IAAU,OAG7C,gBAAAE,EAAC,WAAA,EAAQ,WAAU,+BAA8B,eAAY,oBAC3D,UAAA;AAAA,IAAA,gBAAAA,EAAC,OAAA,EAAI,WAAU,+BACb,UAAA;AAAA,MAAA,gBAAAC,EAAC,QAAA,EAAK,WAAU,wDAAuD,UAAA,eAAW;AAAA,MAClF,gBAAAA,EAAC,UAAK,WAAU,oCAAmC,eAAY,0BAC5D,UAAAxC,GAAM,UAAU,EAAA,CACnB;AAAA,IAAA,GACF;AAAA,IACCG,KACC,gBAAAqC,EAAC,OAAA,EAAI,WAAU,gEAAgE,UAAAC,EAAuBtC,CAAW,GAAE;AAAA,IAErH,gBAAAqC,EAAC,MAAA,EAAG,WAAU,qBACX,UAAAH,EAAM,IAAI,CAAC,EAAE,KAAAZ,GAAK,MAAAiB,EAAA,MAAW;AAC5B,YAAMC,IAAUD,KAAQ,EAAE1C,KAAQ,CAAA,GAAI,KAAK,CAAAkC,MAAKA,EAAE,OAAOT,EAAI,EAAE;AAC/D,aACE,gBAAAc;AAAA,QAAC;AAAA,QAAA;AAAA,UAEC,WAAU;AAAA,UACV,sBAAoBd,EAAI;AAAA,UACxB,yBAAuBkB,IAAU,YAAYlB,EAAI;AAAA,UACjD,wBAAsBA,EAAI;AAAA,UAC1B,0BAAyBA,EAA6B,UAAU;AAAA,UAEhE,UAAA;AAAA,YAAA,gBAAAc,EAAC,OAAA,EAAI,WAAU,+CACb,UAAA;AAAA,cAAA,gBAAAC,EAAC,QAAA,EAAK,WAAU,iEAAiE,UAAAf,EAAI,MAAK;AAAA,cAC1F,gBAAAe,EAAC,UAAK,WAAU,6BAA4B,OAAOf,EAAI,QACnD,YAA6B,SAC3B,GAAIA,EAA6B,MAAM,MAAMA,EAAI,SAASA,EAAI,MAAM,KACnEA,EAAI,SAASA,EAAI,OAAA,CACxB;AAAA,cACA,gBAAAe,EAAC,UAAK,WAAU,4BAA4B,YAAoBf,EAAI,WAAWZ,CAAG,EAAA,CAAE;AAAA,YAAA,GACtF;AAAA,YACA,gBAAA2B,EAAC,SAAI,WAAU,wCAAuC,OAAOf,EAAI,aAAc,YAAI,YAAA,CAAY;AAAA,YAC9FiB,KACC,gBAAAF;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,eAAY;AAAA,gBACZ,WAAW,qCAAqCE,EAAK,SAAS,UAAU,mCAAmC,kCAAkC;AAAA,gBAE5I,UAAAA,EAAK;AAAA,cAAA;AAAA,YAAA;AAAA,YAGT,CAACC,KAAWlC,MAAgBgB,EAAI,KAC/B,gBAAAc,EAAC,OAAA,EAAI,WAAU,2BACb,UAAA;AAAA,cAAA,gBAAAC;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,OAAO7B;AAAA,kBACP,UAAU,CAAAiC,MAAKhC,EAAUgC,EAAE,OAAO,KAAK;AAAA,kBACvC,WAAW,CAAAA,MAAK;AAAE,oBAAIA,EAAE,QAAQ,WAAcd,EAAOL,GAAK,QAAQ;AAAA,kBAAG;AAAA,kBACrE,cAAW;AAAA,kBACX,aAAY;AAAA,kBACZ,WAAU;AAAA,gBAAA;AAAA,cAAA;AAAA,cAEZ,gBAAAe;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,WAAU;AAAA,kBACV,cAAW;AAAA,kBACX,UAAUjC,MAAWkB,EAAI;AAAA,kBACzB,SAAS,MAAM;AAAE,oBAAKK,EAAOL,GAAK,QAAQ;AAAA,kBAAG;AAAA,kBAC9C,UAAA;AAAA,gBAAA;AAAA,cAAA;AAAA,cAGD,gBAAAe;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,WAAU;AAAA,kBACV,cAAW;AAAA,kBACX,SAAS,MAAM;AAAE,oBAAA9B,EAAe,IAAI,GAAGE,EAAU,EAAE;AAAA,kBAAG;AAAA,kBACvD,UAAA;AAAA,gBAAA;AAAA,cAAA;AAAA,YAED,GACF,IACE,CAAC+B,KACH,gBAAAJ,EAAC,OAAA,EAAI,WAAU,2BACb,UAAA;AAAA,cAAA,gBAAAC;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,WAAU;AAAA,kBACV,cAAW;AAAA,kBACX,UAAUjC,MAAWkB,EAAI;AAAA,kBACzB,SAAS,MAAM;AAAE,oBAAKK,EAAOL,GAAK,SAAS;AAAA,kBAAG;AAAA,kBAC/C,UAAA;AAAA,gBAAA;AAAA,cAAA;AAAA,cAGD,gBAAAe;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,WAAU;AAAA,kBACV,cAAW;AAAA,kBACX,UAAUjC,MAAWkB,EAAI;AAAA,kBACzB,SAAS,MAAM;AAAE,oBAAAf,EAAee,EAAI,EAAE,GAAGb,EAAU,EAAE;AAAA,kBAAG;AAAA,kBACzD,UAAA;AAAA,gBAAA;AAAA,cAAA;AAAA,YAED,EAAA,CACF;AAAA,UAAA;AAAA,QAAA;AAAA,QArEGa,EAAI;AAAA,MAAA;AAAA,IAyEf,CAAC,EAAA,CACH;AAAA,EAAA,GACF;AAEJ;AC9LA,SAAwBoB,IAAa;AAGnC,QAAMC,IAFiBC,EAAY,CAAAC,MAAKA,EAAE,cAAc,GAExB,WAAW,CAAA,GAErCC,IAAS,CAAC,GAAGH,CAAO,EAAE,KAAK,CAAC,GAAGI,MAAM,EAAE,wBAAwBA,EAAE,qBAAqB,GAEtFC,IAAWC,EAAgBH,CAAM,GAEjCI,IAAQC,EAAA,IAAiB,gBAAAd,EAACzC,KAAgB,IAAK;AAErD,SAAI+C,EAAQ,WAAW,IAEnB,gBAAAP,EAAC,OAAA,EAAI,WAAU,4CACb,UAAA;AAAA,IAAA,gBAAAC,EAAC,SAAI,WAAU,+CACb,UAAA,gBAAAD,EAAC,KAAA,EAAE,WAAU,uCAAsC,UAAA;AAAA,MAAA;AAAA,wBAC7B,MAAA,EAAG;AAAA,MAAE;AAAA,IAAA,EAAA,CAC3B,EAAA,CACF;AAAA,IACCc;AAAA,EAAA,GACH,IAKF,gBAAAd,EAAC,OAAA,EAAI,WAAU,wCACZ,UAAA;AAAA,IAAAU,EAAO,IAAI,CAAAM,MAAK,gBAAAf,EAACgB,GAAA,EAA6B,QAAQD,GAAG,SAASJ,EAASI,EAAE,SAAS,EAAA,GAArDA,EAAE,SAAsD,CAAE;AAAA,IAC3FF;AAAA,EAAA,GACH;AAEJ;AAIA,SAASI,EAAWC,GAAoB;AACtC,QAAMC,IAAY,KAAK,MAAMD,IAAK,GAAI,GAChCE,IAAO,KAAK,MAAMD,IAAY,EAAE,GAChCE,IAAOF,IAAY;AACzB,SAAO,GAAGC,CAAI,IAAIC,EAAK,WAAW,SAAS,GAAG,GAAG,CAAC;AACpD;AAIA,SAAST,EAAgBN,GAAkD;AACzE,QAAMgB,IAA8B,CAAA,GAC9BC,wBAAY,IAAA,GACZC,IAAU,CAAC,GAAGlB,CAAO,EAAE,KAAK,CAACmB,GAAGf,MAAMe,EAAE,UAAU,cAAcf,EAAE,SAAS,CAAC;AAClF,aAAW,KAAKc,GAAS;AACvB,UAAME,IAAO,EAAE,QAAQ;AACvB,QAAIC,IAAS;AACb,eAAWC,KAAMF,GAAM;AACrB,UAAI,CAAC,gBAAgB,KAAKE,CAAE,EAAG;AAC/B,YAAMC,IAAKD,EAAG,YAAA;AACd,UAAI,CAACL,EAAM,IAAIM,CAAE,GAAG;AAAE,QAAAF,IAASE,GAAIN,EAAM,IAAIM,CAAE;AAAG;AAAA,MAAO;AAAA,IAC3D;AACA,IAAAP,EAAI,EAAE,SAAS,IAAIK;AAAA,EACrB;AACA,SAAOL;AACT;AAEA,SAASN,EAAW,EAAE,QAAAc,GAAQ,SAAAC,KAAuD;AAEnF,QAAMC,KADS,KAAK,MAAMF,EAAO,wBAAwB,IAAI,IACpC,MAAM,QAAQ,CAAC,GAClCG,KAAWH,EAAO,oBAAoB,KAAM,QAAQ,CAAC,GACrDI,IAAM,KAAK,MAAMJ,EAAO,MAAM,GAK9BK,IAAQC,EAAkBN,CAAM,GAGhCO,IAASP,EAAO,aAAa,QAC7BQ,IAAcD,IAAS,UAAU,KAAK,MAAMP,EAAO,WAAY,GAAI,CAAC,MAAMS,EAAoBJ,CAAK,GACnGK,IAAQH,KAAUF,MAAU,cAAcA,MAAU,aACpDM,IAAgBD,IAClB,6BACAL,MAAU,SACR,iCACAA,MAAU,YACR,qCACA,8BAEFO,IAAWF,IAAQ,cAAcL,MAAU,YAAY,kBAAkB,cACzEQ,IAAS,KAAK,IAAI,KAAMb,EAAO,oBAAoBc,IAAmB,GAAG;AAE/E,2BACG,OAAA,EAAI,WAAU,yDAAwD,eAAaP,KAAU,QAE5F,UAAA;AAAA,IAAA,gBAAAtC,EAAC,OAAA,EAAI,WAAU,0CACb,UAAA;AAAA,MAAA,gBAAAA,EAAC,OAAA,EAAI,WAAU,mCACb,UAAA;AAAA,QAAA,gBAAAC,EAAC,OAAA,EAAI,WAAU,qGACZ,UAAA+B,GACH;AAAA,QACA,gBAAA/B,EAAC,QAAA,EAAK,WAAU,0CAA0C,YAAO,KAAA,CAAK;AAAA,MAAA,GACxE;AAAA,wBACC,QAAA,EAAK,WAAW,mFAAmFyC,CAAa,IAC9G,UAAAH,EAAA,CACH;AAAA,IAAA,GACF;AAAA,sBAGC,OAAA,EAAI,WAAW,gDAAgDD,IAAS,eAAe,EAAE,IACxF,UAAA;AAAA,MAAA,gBAAAtC,EAAC,OAAA,EACC,UAAA;AAAA,QAAA,gBAAAC,EAAC,QAAA,EAAK,WAAU,mBAAkB,UAAA,MAAE;AAAA,QACpC,gBAAAA,EAAC,QAAA,EAAK,WAAU,4BAA4B,UAAAgC,GAAO;AAAA,QACnD,gBAAAhC,EAAC,QAAA,EAAK,WAAU,0BAAyB,UAAA,OAAA,CAAI;AAAA,MAAA,GAC/C;AAAA,wBACC,OAAA,EACC,UAAA;AAAA,QAAA,gBAAAA,EAAC,QAAA,EAAK,WAAU,mBAAkB,UAAA,QAAI;AAAA,QACtC,gBAAAA,EAAC,QAAA,EAAK,WAAU,4BAA4B,UAAAkC,GAAI;AAAA,QAChD,gBAAAlC,EAAC,QAAA,EAAK,WAAU,0BAAyB,UAAA,KAAA,CAAE;AAAA,MAAA,GAC7C;AAAA,wBACC,OAAA,EACC,UAAA;AAAA,QAAA,gBAAAA,EAAC,QAAA,EAAK,WAAU,mBAAkB,UAAA,OAAG;AAAA,0BACpC,QAAA,EAAK,WAAU,sCAAsC,UAAAiB,EAAWa,EAAO,WAAW,EAAA,CAAE;AAAA,MAAA,GACvF;AAAA,MAGA,gBAAA/B,EAAC,OAAA,EAAI,WAAU,wCACb,UAAA;AAAA,QAAA,gBAAAC,EAAC,QAAA,EAAK,WAAU,mBAAkB,UAAA,OAAG;AAAA,QACrC,gBAAAD,EAAC,QAAA,EAAK,WAAU,uBAAuB,UAAA;AAAA,UAAAkC;AAAA,UAAQ;AAAA,QAAA,GAAC;AAAA,0BAC/C,OAAA,EAAI,WAAU,qDACb,UAAA,gBAAAjC,EAAC,SAAI,WAAW,sCAAsC0C,CAAQ,IAAI,OAAO,EAAE,OAAO,GAAGC,CAAM,IAAA,GAAO,EAAA,CACpG;AAAA,MAAA,GACF;AAAA,MACA,gBAAA5C,EAAC,OAAA,EAAI,WAAU,oCACb,UAAA;AAAA,QAAA,gBAAAA,EAAC,QAAA,EAAK,WAAU,iDAAiD,UAAA;AAAA,UAAA+B,EAAO;AAAA,UAAiB;AAAA,QAAA,GAAC;AAAA,QAC1F,gBAAA/B,EAAC,QAAA,EAAK,WAAU,mBAAmB,UAAA;AAAA,UAAA+B,EAAO;AAAA,UAAkB;AAAA,QAAA,GAAC;AAAA,QAC5DA,EAAO,kBAAkB,KAAK,gBAAA/B,EAAC,QAAA,EAAK,WAAU,+CAA+C,UAAA;AAAA,UAAA+B,EAAO;AAAA,UAAgB;AAAA,QAAA,EAAA,CAAC;AAAA,MAAA,EAAA,CACxH;AAAA,IAAA,EAAA,CACF;AAAA,EAAA,GACF;AAEJ;"}
@@ -0,0 +1,158 @@
1
+ import { jsx as r, jsxs as s } from "react/jsx-runtime";
2
+ import { useState as m, useEffect as b } from "react";
3
+ import { aa as A, h as k, aw as N, ab as P, w as F, e as $, R as B, ax as O, s as R, ay as C, d as f, az as T } from "./index-CwuV6ab3.js";
4
+ const p = O();
5
+ function G({ open: n, onClose: a, roomKey: c, tierlist: u }) {
6
+ const j = A(), { name: x, sprite: i, setIdentity: M } = k(), h = k((e) => !!(e.me.clientId && e.roster[e.me.clientId]?.registryName)), [o, v] = m(i), [L, w] = m(/* @__PURE__ */ new Set()), [l, d] = m(() => N(p, i));
7
+ b(() => {
8
+ n && (v(i), d(N(p, i)));
9
+ }, [n, i]), b(() => {
10
+ if (!n || !c) return;
11
+ const e = `${P(j)}/api/rooms/${encodeURIComponent(c)}/sprites`;
12
+ fetch(e).then((t) => t.ok ? t.json() : (F("character", "sprites fetch non-OK", { status: t.status }), Promise.reject(new Error(`status ${t.status}`)))).then((t) => w(new Set(t.used ?? []))).catch((t) => {
13
+ $("character", "sprites fetch failed", B(t)), w(/* @__PURE__ */ new Set());
14
+ });
15
+ }, [n, c]);
16
+ const I = () => {
17
+ const e = h || !o ? x : C(o);
18
+ f("character", "Updating identity", { name: e, sprite: o }), M(e, o), T(e, o || void 0), a();
19
+ }, E = (e) => e === i ? !0 : !L.has(e), U = (e) => {
20
+ e.target === e.currentTarget && a();
21
+ };
22
+ b(() => {
23
+ if (!n) return;
24
+ const e = (t) => {
25
+ t.key === "Escape" ? a() : t.key === "ArrowLeft" ? d((g) => Math.max(0, g - 1)) : t.key === "ArrowRight" && d((g) => Math.min(p.length - 1, g + 1));
26
+ };
27
+ return window.addEventListener("keydown", e), () => window.removeEventListener("keydown", e);
28
+ }, [n, a]);
29
+ const y = o !== i;
30
+ return n ? /* @__PURE__ */ r(
31
+ "div",
32
+ {
33
+ className: "fixed inset-0 bg-black/70 flex items-center justify-center z-50",
34
+ onMouseDown: U,
35
+ role: "dialog",
36
+ "aria-modal": "true",
37
+ "aria-label": "Change character",
38
+ style: { willChange: "opacity" },
39
+ children: /* @__PURE__ */ s("div", { className: "bg-bg border border-border rounded-lg shadow-xl w-[340px] mx-4 overflow-hidden", children: [
40
+ /* @__PURE__ */ s("div", { className: "p-4 space-y-4 text-sm", children: [
41
+ /* @__PURE__ */ s("div", { children: [
42
+ /* @__PURE__ */ s("div", { className: "flex items-center justify-between mb-1", children: [
43
+ /* @__PURE__ */ r("label", { className: "text-xs uppercase tracking-wide opacity-70", children: h ? "Display Name" : "Character" }),
44
+ /* @__PURE__ */ r(
45
+ "button",
46
+ {
47
+ type: "button",
48
+ onClick: a,
49
+ className: "text-text-secondary hover:text-white hover:bg-bg-alt rounded p-1 transition-colors cursor-pointer",
50
+ "aria-label": "Close",
51
+ children: /* @__PURE__ */ r("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: /* @__PURE__ */ r("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) })
52
+ }
53
+ )
54
+ ] }),
55
+ h && /* @__PURE__ */ r("div", { className: "modal-input", "aria-readonly": "true", children: x })
56
+ ] }),
57
+ /* @__PURE__ */ s("div", { children: [
58
+ h && /* @__PURE__ */ r("label", { className: "block text-xs uppercase tracking-wide mb-2 opacity-70", children: "Character" }),
59
+ /* @__PURE__ */ s("div", { className: "h-[152px] flex items-center gap-1", children: [
60
+ /* @__PURE__ */ r(S, { direction: "prev", hidden: l === 0, onClick: () => d(l - 1) }),
61
+ /* @__PURE__ */ r("div", { className: "grid grid-cols-4 gap-2 h-full flex-1 content-start", children: (p[l] ?? []).map((e) => {
62
+ const t = E(e);
63
+ return /* @__PURE__ */ s(
64
+ "button",
65
+ {
66
+ onClick: () => {
67
+ if (!t) {
68
+ f("character", "Sprite unavailable", { sprite: e });
69
+ return;
70
+ }
71
+ f("character", "Sprite selected", { sprite: e }), v(e);
72
+ },
73
+ disabled: !t,
74
+ className: `relative group rounded-lg p-1 flex flex-col items-center gap-1 transition-all ${o === e ? "ring-2 ring-success ring-offset-2 ring-offset-bg bg-bg-card cursor-pointer" : t ? "hover:bg-bg-card border border-transparent hover:border-border-light cursor-pointer" : "opacity-40 cursor-not-allowed"}`,
75
+ children: [
76
+ /* @__PURE__ */ r(
77
+ "img",
78
+ {
79
+ src: R(e),
80
+ alt: e,
81
+ crossOrigin: "anonymous",
82
+ className: `w-12 h-12 object-cover rounded ${t ? "" : "grayscale"}`
83
+ }
84
+ ),
85
+ /* @__PURE__ */ r("span", { className: "text-[10px] opacity-70 group-hover:opacity-100 whitespace-nowrap", children: C(e) }),
86
+ !t && /* @__PURE__ */ r("span", { className: "absolute inset-0 flex items-center justify-center bg-bg/60 rounded", children: /* @__PURE__ */ r("span", { className: "text-[8px] font-medium bg-danger-bg text-on-danger-bg px-1 rounded", children: "In Use" }) })
87
+ ]
88
+ },
89
+ e
90
+ );
91
+ }) }),
92
+ /* @__PURE__ */ r(S, { direction: "next", hidden: l >= p.length - 1, onClick: () => d(l + 1) })
93
+ ] })
94
+ ] })
95
+ ] }),
96
+ /* @__PURE__ */ s("div", { className: "px-4 pb-4 flex justify-end gap-2", children: [
97
+ u && /* @__PURE__ */ s(
98
+ "button",
99
+ {
100
+ type: "button",
101
+ onClick: u.open,
102
+ onMouseEnter: u.warm,
103
+ onFocus: u.warm,
104
+ className: "modal-btn-cancel mr-auto inline-flex items-center gap-1",
105
+ "aria-label": "Tierlist",
106
+ children: [
107
+ /* @__PURE__ */ r("svg", { className: "w-3.5 h-3.5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, children: /* @__PURE__ */ r("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M4 6h16M4 12h11M4 18h6" }) }),
108
+ "Tierlist"
109
+ ]
110
+ }
111
+ ),
112
+ /* @__PURE__ */ r(
113
+ "button",
114
+ {
115
+ onClick: a,
116
+ className: "modal-btn-cancel",
117
+ children: "Cancel"
118
+ }
119
+ ),
120
+ /* @__PURE__ */ r(
121
+ "button",
122
+ {
123
+ onClick: I,
124
+ disabled: !y,
125
+ className: "modal-btn-update",
126
+ style: y ? void 0 : {
127
+ opacity: 0.4,
128
+ cursor: "not-allowed",
129
+ filter: "grayscale(50%)"
130
+ },
131
+ children: "Update"
132
+ }
133
+ )
134
+ ] })
135
+ ] })
136
+ }
137
+ ) : null;
138
+ }
139
+ function S({ direction: n, hidden: a, onClick: c }) {
140
+ return /* @__PURE__ */ r(
141
+ "button",
142
+ {
143
+ type: "button",
144
+ onClick: c,
145
+ className: "char-page-btn",
146
+ "data-hidden": a ? "1" : void 0,
147
+ disabled: a,
148
+ "aria-hidden": a,
149
+ tabIndex: a ? -1 : void 0,
150
+ "aria-label": n === "prev" ? "Previous characters" : "Next characters",
151
+ children: /* @__PURE__ */ r("svg", { className: "w-4 h-4", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: /* @__PURE__ */ r("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: n === "prev" ? "M15 19l-7-7 7-7" : "M9 5l7 7-7 7" }) })
152
+ }
153
+ );
154
+ }
155
+ export {
156
+ G as default
157
+ };
158
+ //# sourceMappingURL=CharacterModal-Dg_XSm4A.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CharacterModal-Dg_XSm4A.js","sources":["../../src/components/CharacterModal.tsx"],"sourcesContent":["import React, { useEffect, useState } from 'react';\nimport { useAppStore } from '../lib/store';\nimport { sendIdentityUpdate } from '../lib/socket';\nimport { characterPages, pageIndexFor, spriteUrlFor, displayNameFor } from '../lib/characters';\n\nconst PAGES = characterPages();\nimport { useConfig, getServerOrigin } from '../lib/config';\nimport { debug, error, errorFields, warn } from '../lib/debug';\n\ninterface Props {\n open: boolean;\n onClose: () => void;\n roomKey?: string;\n /** The global tierlist, when this page has one. The room chrome mounts this\n * modal around a host app with no tierlist, so it passes nothing and the\n * button is absent. Passed in rather than imported so the chrome bundle\n * never reaches the tierlist client. */\n tierlist?: { open: () => void; warm: () => void };\n}\n\nexport default function CharacterModal({ open, onClose, roomKey, tierlist }: Props) {\n const config = useConfig();\n const { name, sprite, setIdentity } = useAppStore();\n // An account's name is its key-registry name, shown READ-ONLY. It is not\n // renamed from here yet: hadoku_site's POST /session/name rewrites one\n // registry row and splits the key's active/standby rotation pair, so the\n // name reverts at the next swap (hadoku_site, 2026-10-08). Rename lands once\n // self-rename goes through the key lifecycle broker by userId. Without a\n // registry name (a public viewer) the name is simply the character's.\n const registryNamed = useAppStore(s => !!(s.me.clientId && s.roster[s.me.clientId]?.registryName));\n const [localSprite, setLocalSprite] = useState<string | null>(sprite);\n const [usedSprites, setUsedSprites] = useState<Set<string>>(new Set());\n const [page, setPage] = useState(() => pageIndexFor(PAGES, sprite));\n\n // Sync local state when modal opens\n useEffect(() => {\n if (open) {\n setLocalSprite(sprite);\n setPage(pageIndexFor(PAGES, sprite));\n }\n }, [open, sprite]);\n\n // Which sprites other people hold, for greying them out. The grid renders\n // at once with everything available and greys in place when this lands —\n // the portraits are static files and need not wait on it.\n useEffect(() => {\n if (!open || !roomKey) return;\n const url = `${getServerOrigin(config)}/api/rooms/${encodeURIComponent(roomKey)}/sprites`;\n fetch(url)\n .then(res => {\n if (!res.ok) {\n warn('character', 'sprites fetch non-OK', { status: res.status });\n return Promise.reject(new Error(`status ${res.status}`));\n }\n return res.json() as Promise<{ used?: string[] }>;\n })\n .then(data => setUsedSprites(new Set(data.used ?? [])))\n .catch((err: unknown) => {\n error('character', 'sprites fetch failed', errorFields(err));\n setUsedSprites(new Set());\n });\n }, [open, roomKey]);\n\n const updateIdentity = () => {\n // A registry name stays as it is; otherwise the name follows the character.\n const nextName = registryNamed || !localSprite ? name : displayNameFor(localSprite);\n debug('character', 'Updating identity', { name: nextName, sprite: localSprite });\n setIdentity(nextName, localSprite);\n sendIdentityUpdate(nextName, localSprite || undefined);\n onClose();\n };\n\n // Check if a sprite is available (not used by others, or is the current user's sprite)\n const isSpriteAvailable = (c: string) => {\n if (c === sprite) return true; // Current user's sprite is always selectable\n return !usedSprites.has(c);\n };\n\n // Handle backdrop click\n const handleBackdropClick = (e: React.MouseEvent) => {\n if (e.target === e.currentTarget) {\n onClose();\n }\n };\n\n // Handle escape key\n useEffect(() => {\n if (!open) return;\n const handleEscape = (e: KeyboardEvent) => {\n if (e.key === 'Escape') onClose();\n else if (e.key === 'ArrowLeft') setPage(p => Math.max(0, p - 1));\n else if (e.key === 'ArrowRight') setPage(p => Math.min(PAGES.length - 1, p + 1));\n };\n window.addEventListener('keydown', handleEscape);\n return () => window.removeEventListener('keydown', handleEscape);\n }, [open, onClose]);\n\n // Check if there are any changes\n const hasChanges = localSprite !== sprite;\n\n if (!open) return null;\n\n return (\n <div\n className=\"fixed inset-0 bg-black/70 flex items-center justify-center z-50\"\n onMouseDown={handleBackdropClick}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Change character\"\n style={{ willChange: 'opacity' }}\n >\n <div className=\"bg-bg border border-border rounded-lg shadow-xl w-[340px] mx-4 overflow-hidden\">\n {/* Content */}\n <div className=\"p-4 space-y-4 text-sm\">\n {/* Display Name with close button */}\n <div>\n <div className=\"flex items-center justify-between mb-1\">\n <label className=\"text-xs uppercase tracking-wide opacity-70\">{registryNamed ? 'Display Name' : 'Character'}</label>\n <button\n type=\"button\"\n onClick={onClose}\n className=\"text-text-secondary hover:text-white hover:bg-bg-alt rounded p-1 transition-colors cursor-pointer\"\n aria-label=\"Close\"\n >\n <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n {registryNamed && <div className=\"modal-input\" aria-readonly=\"true\">{name}</div>}\n </div>\n\n {/* Character gallery — one page per show, paged with the arrows.\n Rendered immediately, no loading spinner: the sprite <img>\n fetches run in parallel with the /api/rooms/.../sprites JSON\n fetch, and greying happens in place once that resolves. */}\n <div>\n {registryNamed && <label className=\"block text-xs uppercase tracking-wide mb-2 opacity-70\">Character</label>}\n <div className=\"h-[152px] flex items-center gap-1\">\n <PageArrow direction=\"prev\" hidden={page === 0} onClick={() => setPage(page - 1)} />\n <div className=\"grid grid-cols-4 gap-2 h-full flex-1 content-start\">\n {(PAGES[page] ?? []).map(c => {\n const available = isSpriteAvailable(c);\n const isSelected = localSprite === c;\n return (\n <button\n key={c}\n onClick={() => {\n if (!available) {\n debug('character', 'Sprite unavailable', { sprite: c });\n return;\n }\n debug('character', 'Sprite selected', { sprite: c });\n setLocalSprite(c);\n }}\n disabled={!available}\n className={`relative group rounded-lg p-1 flex flex-col items-center gap-1 transition-all ${\n isSelected\n ? 'ring-2 ring-success ring-offset-2 ring-offset-bg bg-bg-card cursor-pointer'\n : available\n ? 'hover:bg-bg-card border border-transparent hover:border-border-light cursor-pointer'\n : 'opacity-40 cursor-not-allowed'\n }`}\n >\n <img\n src={spriteUrlFor(c)}\n alt={c}\n crossOrigin=\"anonymous\"\n className={`w-12 h-12 object-cover rounded ${!available ? 'grayscale' : ''}`}\n />\n <span className=\"text-[10px] opacity-70 group-hover:opacity-100 whitespace-nowrap\">{displayNameFor(c)}</span>\n {!available && (\n <span className=\"absolute inset-0 flex items-center justify-center bg-bg/60 rounded\">\n <span className=\"text-[8px] font-medium bg-danger-bg text-on-danger-bg px-1 rounded\">In Use</span>\n </span>\n )}\n </button>\n );\n })}\n </div>\n <PageArrow direction=\"next\" hidden={page >= PAGES.length - 1} onClick={() => setPage(page + 1)} />\n </div>\n </div>\n </div>\n\n {/* Footer */}\n <div className=\"px-4 pb-4 flex justify-end gap-2\">\n {tierlist && (\n <button\n type=\"button\"\n onClick={tierlist.open}\n // Warm the posters on intent so the board opens populated.\n onMouseEnter={tierlist.warm}\n onFocus={tierlist.warm}\n className=\"modal-btn-cancel mr-auto inline-flex items-center gap-1\"\n aria-label=\"Tierlist\"\n >\n <svg className=\"w-3.5 h-3.5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4 6h16M4 12h11M4 18h6\" />\n </svg>\n Tierlist\n </button>\n )}\n <button\n onClick={onClose}\n className=\"modal-btn-cancel\"\n >\n Cancel\n </button>\n <button\n onClick={updateIdentity}\n disabled={!hasChanges}\n className=\"modal-btn-update\"\n style={!hasChanges ? {\n opacity: 0.4,\n cursor: 'not-allowed',\n filter: 'grayscale(50%)'\n } : undefined}\n >\n Update\n </button>\n </div>\n </div>\n </div>\n );\n}\n\n/** A gallery arrow. Hidden arrows keep their slot so the grid never shifts between pages. */\nfunction PageArrow({ direction, hidden, onClick }: { direction: 'prev' | 'next'; hidden: boolean; onClick: () => void }) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n className=\"char-page-btn\"\n data-hidden={hidden ? '1' : undefined}\n disabled={hidden}\n aria-hidden={hidden}\n tabIndex={hidden ? -1 : undefined}\n aria-label={direction === 'prev' ? 'Previous characters' : 'Next characters'}\n >\n <svg className=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d={direction === 'prev' ? 'M15 19l-7-7 7-7' : 'M9 5l7 7-7 7'} />\n </svg>\n </button>\n );\n}\n"],"names":["PAGES","characterPages","CharacterModal","open","onClose","roomKey","tierlist","config","useConfig","name","sprite","setIdentity","useAppStore","registryNamed","s","localSprite","setLocalSprite","useState","usedSprites","setUsedSprites","page","setPage","pageIndexFor","useEffect","url","getServerOrigin","res","warn","data","err","error","errorFields","updateIdentity","nextName","displayNameFor","debug","sendIdentityUpdate","isSpriteAvailable","c","handleBackdropClick","handleEscape","e","p","hasChanges","jsx","jsxs","PageArrow","available","spriteUrlFor","direction","hidden","onClick"],"mappings":"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"}