@hadoku/task-ui-components 4.22.0 → 4.22.3

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.
@@ -183,6 +183,11 @@
183
183
  * has something to grow into now that its basis is 0. */
184
184
  flex: 1 1 auto;
185
185
  min-width: 0;
186
+ flex-wrap: wrap;
187
+ }
188
+ /* A refused rename's reason, on its own line under the input. */
189
+ .settings-popout__inline > .settings-popout__error {
190
+ flex-basis: 100%;
186
191
  }
187
192
 
188
193
  .settings-popout__tier {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * AvatarPicker — the avatar section of ConnectedSettings: the picture people
3
+ * see (character, Discord, Steam or an upload) and the character games use as
4
+ * the piece. One resolved avatar per person, stored in hadoku_site's prefs-api
5
+ * and rendered by every app (`@wolffm/prefs-client/avatar`, bundled into this
6
+ * package at build so the link protocol cannot drift from the callback page).
7
+ *
8
+ * Everything happens in place: picking, uploading and removing are requests
9
+ * from here, and linking Discord or Steam signs in through a popup
10
+ * (`linkInPopup`), so the page under the popout never navigates and a game
11
+ * keeps running.
12
+ *
13
+ * Loaded only when the person opens it ("Change"), never at mount: the avatar
14
+ * read can refresh a linked picture from Discord or Steam, which no page load
15
+ * should cost, and opening the gear itself must stay free (settings-prefetch
16
+ * e2e).
17
+ *
18
+ * COMPLIANCE (Steam Web API terms; Discord developer terms), carried over from
19
+ * hadoku.me/account: nothing is fetched from a provider until the person reads
20
+ * what is read and kept and presses Continue; Steam attribution and a privacy
21
+ * link sit under the controls; no provider logos, and no implied endorsement.
22
+ *
23
+ * Styles are inline for the reason PASSKEY_DOT's are (ConnectedSettings.tsx):
24
+ * apps compile app-header.css at their own build, so a new rule there reaches
25
+ * an app only when it rebuilds.
26
+ */
27
+ import React from 'react';
28
+ export declare function AvatarPicker(): React.JSX.Element;
29
+ //# sourceMappingURL=AvatarPicker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AvatarPicker.d.ts","sourceRoot":"","sources":["../../src/components/AvatarPicker.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAA2C,MAAM,OAAO,CAAA;AAqI/D,wBAAgB,YAAY,sBAkR3B"}
@@ -0,0 +1,326 @@
1
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ import { createAvatarClient, AvatarError } from "../vendor/wolffm/prefs-client/avatar.js";
4
+ import { AVATAR_EVENT } from "../lib/avatarEvent.js";
5
+ const client = createAvatarClient({ baseUrl: "/prefs" });
6
+ const CHARACTERS_URL = "/watchparty/api/characters";
7
+ const WATCHPARTY_BASE = "/watchparty";
8
+ const LABEL = {
9
+ sprite: "Character",
10
+ discord: "Discord",
11
+ steam: "Steam",
12
+ upload: "Upload"
13
+ };
14
+ const CONSENT = {
15
+ discord: {
16
+ points: [
17
+ 'Discord asks you to share your basic profile ("identify").',
18
+ "Kept: your Discord user ID and the address of your profile picture, refreshed while linked.",
19
+ "Not kept: your username, email, servers or any token.",
20
+ "Unlink deletes both."
21
+ ],
22
+ go: "Continue to Discord"
23
+ },
24
+ steam: {
25
+ points: [
26
+ "You sign in on steamcommunity.com; your password never reaches hadoku.me.",
27
+ "Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.",
28
+ "Kept: your Steam ID and the address of that picture.",
29
+ "Unlink deletes both and stops the reads."
30
+ ],
31
+ go: "Continue to Steam"
32
+ }
33
+ };
34
+ const S = {
35
+ stack: { display: "flex", flexDirection: "column", gap: 10, width: "100%" },
36
+ current: { display: "flex", alignItems: "center", gap: 10 },
37
+ preview: {
38
+ width: 48,
39
+ height: 48,
40
+ borderRadius: "50%",
41
+ overflow: "hidden",
42
+ flex: "none",
43
+ background: "var(--color-bg-alt)",
44
+ border: "1px solid var(--color-border)"
45
+ },
46
+ img: { width: "100%", height: "100%", objectFit: "cover", display: "block" },
47
+ sources: { display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6 },
48
+ source: {
49
+ display: "flex",
50
+ flexDirection: "column",
51
+ alignItems: "center",
52
+ gap: 4,
53
+ padding: 6,
54
+ borderRadius: 8,
55
+ border: "1px solid var(--color-border)",
56
+ background: "var(--color-bg)",
57
+ color: "var(--color-text)",
58
+ fontSize: "0.72rem",
59
+ cursor: "pointer"
60
+ },
61
+ sourceOn: {
62
+ borderColor: "var(--color-primary)",
63
+ boxShadow: "inset 0 0 0 1px var(--color-primary)"
64
+ },
65
+ thumb: {
66
+ width: 32,
67
+ height: 32,
68
+ borderRadius: "50%",
69
+ overflow: "hidden",
70
+ background: "var(--color-bg-alt)"
71
+ },
72
+ sets: { display: "flex", gap: 4, flexWrap: "wrap" },
73
+ grid: { display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 4 },
74
+ cell: {
75
+ padding: 0,
76
+ aspectRatio: "1",
77
+ borderRadius: 6,
78
+ overflow: "hidden",
79
+ border: "2px solid transparent",
80
+ background: "var(--color-bg-alt)",
81
+ cursor: "pointer"
82
+ },
83
+ line: { display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" },
84
+ grow: { flex: 1, minWidth: 0 },
85
+ list: { margin: 0, paddingLeft: 18, fontSize: "0.75rem", lineHeight: 1.45 },
86
+ fine: { fontSize: "0.7rem", color: "var(--color-text-secondary)", lineHeight: 1.4, margin: 0 }
87
+ };
88
+ function portraitUrl(c) {
89
+ return c.portrait.startsWith("/") ? `${WATCHPARTY_BASE}${c.portrait}` : c.portrait;
90
+ }
91
+ function Picture({ src, style }) {
92
+ const [failed, setFailed] = useState(false);
93
+ useEffect(() => setFailed(false), [src]);
94
+ return /* @__PURE__ */ jsx("span", { style, children: src && !failed && /* @__PURE__ */ jsx(
95
+ "img",
96
+ {
97
+ src,
98
+ alt: "",
99
+ referrerPolicy: "no-referrer",
100
+ style: S.img,
101
+ onError: () => setFailed(true)
102
+ }
103
+ ) });
104
+ }
105
+ function AvatarPicker() {
106
+ const [settings, setSettings] = useState(null);
107
+ const [sets, setSets] = useState(null);
108
+ const [setIdx, setSetIdx] = useState(0);
109
+ const [busy, setBusy] = useState(false);
110
+ const [error, setError] = useState(null);
111
+ const [consent, setConsent] = useState(null);
112
+ useEffect(() => {
113
+ let live = true;
114
+ client.get().then((s) => live && setSettings(s)).catch((err) => live && setError(message(err)));
115
+ fetch(CHARACTERS_URL).then(
116
+ (r) => r.ok ? r.json() : Promise.reject(new Error(`characters ${r.status}`))
117
+ ).then((d) => live && setSets(d.sets)).catch((err) => live && setError(message(err)));
118
+ return () => {
119
+ live = false;
120
+ };
121
+ }, []);
122
+ const run = useCallback(async (work) => {
123
+ setBusy(true);
124
+ setError(null);
125
+ try {
126
+ const next = await work();
127
+ setSettings(next);
128
+ window.dispatchEvent(new window.CustomEvent(AVATAR_EVENT, { detail: next }));
129
+ } catch (err) {
130
+ setError(message(err));
131
+ } finally {
132
+ setBusy(false);
133
+ }
134
+ }, []);
135
+ const characters = new Map((sets ?? []).flatMap((s) => s.characters).map((c) => [c.id, c]));
136
+ const spriteSrc = (id) => {
137
+ const c = id ? characters.get(id) : void 0;
138
+ return c ? portraitUrl(c) : null;
139
+ };
140
+ const srcOf = (a) => a.url ?? spriteSrc(a.sprite);
141
+ if (!settings) {
142
+ return /* @__PURE__ */ jsx("span", { className: "settings-popout__hint", role: error ? "alert" : void 0, children: error ?? "…" });
143
+ }
144
+ const available = {
145
+ sprite: true,
146
+ discord: Boolean(settings.linked.discord?.avatarUrl),
147
+ steam: Boolean(settings.linked.steam?.avatarUrl),
148
+ upload: settings.upload !== null
149
+ };
150
+ const thumbOf = {
151
+ sprite: spriteSrc(settings.sprite),
152
+ discord: settings.linked.discord?.avatarUrl ?? null,
153
+ steam: settings.linked.steam?.avatarUrl ?? null,
154
+ upload: settings.upload?.url ?? null
155
+ };
156
+ const shown = settings.avatar;
157
+ const current = sets?.[setIdx];
158
+ const link = (provider) => {
159
+ setConsent(null);
160
+ run(async () => {
161
+ const next = await client.linkInPopup(provider);
162
+ return next.linked[provider]?.avatarUrl ? client.choose(provider) : next;
163
+ }).catch(reportErr);
164
+ };
165
+ return /* @__PURE__ */ jsxs("div", { style: S.stack, children: [
166
+ /* @__PURE__ */ jsxs("div", { style: S.current, children: [
167
+ /* @__PURE__ */ jsx(Picture, { src: srcOf(shown), style: S.preview }),
168
+ /* @__PURE__ */ jsxs("span", { className: "settings-popout__value", children: [
169
+ LABEL[shown.source],
170
+ settings.choice && settings.choice !== shown.source ? ` (${LABEL[settings.choice]} unavailable)` : ""
171
+ ] })
172
+ ] }),
173
+ /* @__PURE__ */ jsx("div", { style: S.sources, role: "radiogroup", "aria-label": "Picture", children: Object.keys(LABEL).map((src) => /* @__PURE__ */ jsxs(
174
+ "button",
175
+ {
176
+ type: "button",
177
+ role: "radio",
178
+ "aria-checked": shown.source === src,
179
+ disabled: busy || !available[src],
180
+ title: available[src] ? void 0 : "Link or upload one first",
181
+ style: {
182
+ ...S.source,
183
+ ...shown.source === src ? S.sourceOn : {},
184
+ opacity: available[src] ? 1 : 0.5
185
+ },
186
+ onClick: () => {
187
+ run(() => client.choose(src)).catch(reportErr);
188
+ },
189
+ children: [
190
+ /* @__PURE__ */ jsx(Picture, { src: thumbOf[src], style: S.thumb }),
191
+ LABEL[src]
192
+ ]
193
+ },
194
+ src
195
+ )) }),
196
+ /* @__PURE__ */ jsx("span", { className: "settings-popout__label", children: "Character" }),
197
+ sets ? /* @__PURE__ */ jsxs(Fragment, { children: [
198
+ sets.length > 1 && /* @__PURE__ */ jsx("div", { style: S.sets, children: sets.map((s, i) => /* @__PURE__ */ jsx(
199
+ "button",
200
+ {
201
+ type: "button",
202
+ className: `settings-popout__btn${i === setIdx ? " settings-popout__btn--primary" : ""}`,
203
+ onClick: () => setSetIdx(i),
204
+ children: s.id
205
+ },
206
+ s.id
207
+ )) }),
208
+ /* @__PURE__ */ jsx("div", { style: S.grid, role: "radiogroup", "aria-label": "Character", children: current?.characters.map((c) => /* @__PURE__ */ jsx(
209
+ "button",
210
+ {
211
+ type: "button",
212
+ role: "radio",
213
+ "aria-checked": settings.sprite === c.id,
214
+ "aria-label": c.name,
215
+ title: c.name,
216
+ disabled: busy,
217
+ style: {
218
+ ...S.cell,
219
+ borderColor: settings.sprite === c.id ? c.color : "transparent"
220
+ },
221
+ onClick: () => {
222
+ run(() => client.update({ sprite: c.id })).catch(reportErr);
223
+ },
224
+ children: /* @__PURE__ */ jsx("img", { src: portraitUrl(c), alt: "", style: S.img })
225
+ },
226
+ c.id
227
+ )) })
228
+ ] }) : /* @__PURE__ */ jsx("span", { className: "settings-popout__hint", children: "…" }),
229
+ ["discord", "steam"].map((p) => {
230
+ const linked = settings.linked[p];
231
+ return /* @__PURE__ */ jsxs("div", { style: S.line, children: [
232
+ /* @__PURE__ */ jsxs("span", { className: "settings-popout__label", style: S.grow, children: [
233
+ LABEL[p],
234
+ !settings.providers[p] ? " · not available yet" : linked ? linked.avatarUrl ? " · linked" : " · linked, no picture" : ""
235
+ ] }),
236
+ settings.providers[p] && !linked && /* @__PURE__ */ jsx(
237
+ "button",
238
+ {
239
+ type: "button",
240
+ className: "settings-popout__btn",
241
+ disabled: busy,
242
+ onClick: () => setConsent(consent === p ? null : p),
243
+ children: "Link"
244
+ }
245
+ ),
246
+ linked && /* @__PURE__ */ jsx(
247
+ "button",
248
+ {
249
+ type: "button",
250
+ className: "settings-popout__btn",
251
+ disabled: busy,
252
+ onClick: () => {
253
+ run(() => client.unlink(p)).catch(reportErr);
254
+ },
255
+ children: "Unlink"
256
+ }
257
+ )
258
+ ] }, p);
259
+ }),
260
+ consent && /* @__PURE__ */ jsxs("div", { style: S.stack, children: [
261
+ /* @__PURE__ */ jsx("ul", { style: S.list, children: CONSENT[consent].points.map((t) => /* @__PURE__ */ jsx("li", { children: t }, t)) }),
262
+ /* @__PURE__ */ jsxs("span", { style: S.line, children: [
263
+ /* @__PURE__ */ jsx(
264
+ "button",
265
+ {
266
+ type: "button",
267
+ className: "settings-popout__btn settings-popout__btn--primary",
268
+ disabled: busy,
269
+ onClick: () => link(consent),
270
+ children: CONSENT[consent].go
271
+ }
272
+ ),
273
+ /* @__PURE__ */ jsx("button", { type: "button", className: "settings-popout__btn", onClick: () => setConsent(null), children: "Cancel" })
274
+ ] })
275
+ ] }),
276
+ /* @__PURE__ */ jsxs("div", { style: S.line, children: [
277
+ /* @__PURE__ */ jsx("span", { className: "settings-popout__label", style: S.grow, children: "Upload" }),
278
+ /* @__PURE__ */ jsxs("label", { className: "settings-popout__btn", style: { opacity: busy ? 0.5 : 1 }, children: [
279
+ settings.upload ? "Replace" : "Choose image",
280
+ /* @__PURE__ */ jsx(
281
+ "input",
282
+ {
283
+ type: "file",
284
+ accept: settings.limits.acceptedTypes.join(","),
285
+ hidden: true,
286
+ disabled: busy,
287
+ onChange: (e) => {
288
+ const file = e.currentTarget.files?.[0];
289
+ e.currentTarget.value = "";
290
+ if (file) run(() => client.upload(file)).catch(reportErr);
291
+ }
292
+ }
293
+ )
294
+ ] }),
295
+ settings.upload && /* @__PURE__ */ jsx(
296
+ "button",
297
+ {
298
+ type: "button",
299
+ className: "settings-popout__btn",
300
+ disabled: busy,
301
+ onClick: () => {
302
+ run(() => client.removeUpload()).catch(reportErr);
303
+ },
304
+ children: "Remove"
305
+ }
306
+ )
307
+ ] }),
308
+ error && /* @__PURE__ */ jsx("span", { className: "settings-popout__error", role: "alert", children: error }),
309
+ /* @__PURE__ */ jsxs("p", { style: S.fine, children: [
310
+ "Steam avatars via the Steam Web API. Steam is a trademark of Valve; hadoku.me is not affiliated with Valve, Steam or Discord.",
311
+ " ",
312
+ /* @__PURE__ */ jsx("a", { href: "/privacy#avatars", target: "_blank", rel: "noopener", children: "What is kept" })
313
+ ] })
314
+ ] });
315
+ }
316
+ function message(err) {
317
+ if (err instanceof AvatarError && err.status === 401) return "Sign in to set an avatar.";
318
+ return err instanceof Error ? err.message : String(err);
319
+ }
320
+ function reportErr(err) {
321
+ console.error("[AvatarPicker] action failed", err?.message ?? err);
322
+ }
323
+ export {
324
+ AvatarPicker
325
+ };
326
+ //# sourceMappingURL=AvatarPicker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AvatarPicker.js","sources":["../../src/components/AvatarPicker.tsx"],"sourcesContent":["/**\n * AvatarPicker — the avatar section of ConnectedSettings: the picture people\n * see (character, Discord, Steam or an upload) and the character games use as\n * the piece. One resolved avatar per person, stored in hadoku_site's prefs-api\n * and rendered by every app (`@wolffm/prefs-client/avatar`, bundled into this\n * package at build so the link protocol cannot drift from the callback page).\n *\n * Everything happens in place: picking, uploading and removing are requests\n * from here, and linking Discord or Steam signs in through a popup\n * (`linkInPopup`), so the page under the popout never navigates and a game\n * keeps running.\n *\n * Loaded only when the person opens it (\"Change\"), never at mount: the avatar\n * read can refresh a linked picture from Discord or Steam, which no page load\n * should cost, and opening the gear itself must stay free (settings-prefetch\n * e2e).\n *\n * COMPLIANCE (Steam Web API terms; Discord developer terms), carried over from\n * hadoku.me/account: nothing is fetched from a provider until the person reads\n * what is read and kept and presses Continue; Steam attribution and a privacy\n * link sit under the controls; no provider logos, and no implied endorsement.\n *\n * Styles are inline for the reason PASSKEY_DOT's are (ConnectedSettings.tsx):\n * apps compile app-header.css at their own build, so a new rule there reaches\n * an app only when it rebuilds.\n */\nimport React, { useCallback, useEffect, useState } from 'react'\nimport {\n createAvatarClient,\n AvatarError,\n type AvatarSettings,\n type AvatarSource,\n type LinkProvider,\n type ResolvedAvatar\n} from '@wolffm/prefs-client/avatar'\nimport { AVATAR_EVENT } from '../lib/avatarEvent'\n\nconst client = createAvatarClient({ baseUrl: '/prefs' })\n\n/** watchparty's public character table: GET /watchparty/api/characters. */\nconst CHARACTERS_URL = '/watchparty/api/characters'\nconst WATCHPARTY_BASE = '/watchparty'\n\ninterface Character {\n id: string\n name: string\n color: string\n portrait: string\n}\ninterface CharacterSet {\n id: string\n characters: Character[]\n}\n\nconst LABEL: Record<AvatarSource, string> = {\n sprite: 'Character',\n discord: 'Discord',\n steam: 'Steam',\n upload: 'Upload'\n}\n\nconst CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {\n discord: {\n points: [\n 'Discord asks you to share your basic profile (\"identify\").',\n 'Kept: your Discord user ID and the address of your profile picture, refreshed while linked.',\n 'Not kept: your username, email, servers or any token.',\n 'Unlink deletes both.'\n ],\n go: 'Continue to Discord'\n },\n steam: {\n points: [\n 'You sign in on steamcommunity.com; your password never reaches hadoku.me.',\n 'Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.',\n 'Kept: your Steam ID and the address of that picture.',\n 'Unlink deletes both and stops the reads.'\n ],\n go: 'Continue to Steam'\n }\n}\n\nconst S = {\n stack: { display: 'flex', flexDirection: 'column', gap: 10, width: '100%' },\n current: { display: 'flex', alignItems: 'center', gap: 10 },\n preview: {\n width: 48,\n height: 48,\n borderRadius: '50%',\n overflow: 'hidden',\n flex: 'none',\n background: 'var(--color-bg-alt)',\n border: '1px solid var(--color-border)'\n },\n img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },\n sources: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 },\n source: {\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'center',\n gap: 4,\n padding: 6,\n borderRadius: 8,\n border: '1px solid var(--color-border)',\n background: 'var(--color-bg)',\n color: 'var(--color-text)',\n fontSize: '0.72rem',\n cursor: 'pointer'\n },\n sourceOn: {\n borderColor: 'var(--color-primary)',\n boxShadow: 'inset 0 0 0 1px var(--color-primary)'\n },\n thumb: {\n width: 32,\n height: 32,\n borderRadius: '50%',\n overflow: 'hidden',\n background: 'var(--color-bg-alt)'\n },\n sets: { display: 'flex', gap: 4, flexWrap: 'wrap' },\n grid: { display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 4 },\n cell: {\n padding: 0,\n aspectRatio: '1',\n borderRadius: 6,\n overflow: 'hidden',\n border: '2px solid transparent',\n background: 'var(--color-bg-alt)',\n cursor: 'pointer'\n },\n line: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },\n grow: { flex: 1, minWidth: 0 },\n list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 },\n fine: { fontSize: '0.7rem', color: 'var(--color-text-secondary)', lineHeight: 1.4, margin: 0 }\n} satisfies Record<string, React.CSSProperties>\n\nfunction portraitUrl(c: Character): string {\n return c.portrait.startsWith('/') ? `${WATCHPARTY_BASE}${c.portrait}` : c.portrait\n}\n\nfunction Picture({ src, style }: { src: string | null; style: React.CSSProperties }) {\n const [failed, setFailed] = useState(false)\n useEffect(() => setFailed(false), [src])\n return (\n <span style={style}>\n {src && !failed && (\n <img\n src={src}\n alt=\"\"\n referrerPolicy=\"no-referrer\"\n style={S.img}\n onError={() => setFailed(true)}\n />\n )}\n </span>\n )\n}\n\nexport function AvatarPicker() {\n const [settings, setSettings] = useState<AvatarSettings | null>(null)\n const [sets, setSets] = useState<CharacterSet[] | null>(null)\n const [setIdx, setSetIdx] = useState(0)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [consent, setConsent] = useState<LinkProvider | null>(null)\n\n useEffect(() => {\n let live = true\n client\n .get()\n .then(s => live && setSettings(s))\n .catch((err: unknown) => live && setError(message(err)))\n fetch(CHARACTERS_URL)\n .then(r =>\n r.ok\n ? (r.json() as Promise<{ sets: CharacterSet[] }>)\n : Promise.reject(new Error(`characters ${r.status}`))\n )\n .then(d => live && setSets(d.sets))\n .catch((err: unknown) => live && setError(message(err)))\n return () => {\n live = false\n }\n }, [])\n\n const run = useCallback(async (work: () => Promise<AvatarSettings>) => {\n setBusy(true)\n setError(null)\n try {\n const next = await work()\n setSettings(next)\n // The page under the popout (a watchparty room, a game) re-reads the\n // avatar from this instead of waiting for its next join.\n window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))\n } catch (err) {\n setError(message(err))\n } finally {\n setBusy(false)\n }\n }, [])\n\n const characters = new Map((sets ?? []).flatMap(s => s.characters).map(c => [c.id, c]))\n const spriteSrc = (id: string | null | undefined) => {\n const c = id ? characters.get(id) : undefined\n return c ? portraitUrl(c) : null\n }\n const srcOf = (a: ResolvedAvatar) => a.url ?? spriteSrc(a.sprite)\n\n if (!settings) {\n return (\n <span className=\"settings-popout__hint\" role={error ? 'alert' : undefined}>\n {error ?? '…'}\n </span>\n )\n }\n\n const available: Record<AvatarSource, boolean> = {\n sprite: true,\n discord: Boolean(settings.linked.discord?.avatarUrl),\n steam: Boolean(settings.linked.steam?.avatarUrl),\n upload: settings.upload !== null\n }\n const thumbOf: Record<AvatarSource, string | null> = {\n sprite: spriteSrc(settings.sprite),\n discord: settings.linked.discord?.avatarUrl ?? null,\n steam: settings.linked.steam?.avatarUrl ?? null,\n upload: settings.upload?.url ?? null\n }\n const shown = settings.avatar\n const current = sets?.[setIdx]\n\n // Must stay synchronous up to linkInPopup: the popup opens before its first\n // await, inside this click, which is what popup blockers allow.\n const link = (provider: LinkProvider) => {\n setConsent(null)\n run(async () => {\n const next = await client.linkInPopup(provider)\n // Linking is choosing, when the account came back with a picture.\n return next.linked[provider]?.avatarUrl ? client.choose(provider) : next\n }).catch(reportErr)\n }\n\n return (\n <div style={S.stack}>\n <div style={S.current}>\n <Picture src={srcOf(shown)} style={S.preview} />\n <span className=\"settings-popout__value\">\n {LABEL[shown.source]}\n {settings.choice && settings.choice !== shown.source\n ? ` (${LABEL[settings.choice]} unavailable)`\n : ''}\n </span>\n </div>\n\n <div style={S.sources} role=\"radiogroup\" aria-label=\"Picture\">\n {(Object.keys(LABEL) as AvatarSource[]).map(src => (\n <button\n key={src}\n type=\"button\"\n role=\"radio\"\n aria-checked={shown.source === src}\n disabled={busy || !available[src]}\n title={available[src] ? undefined : 'Link or upload one first'}\n style={{\n ...S.source,\n ...(shown.source === src ? S.sourceOn : {}),\n opacity: available[src] ? 1 : 0.5\n }}\n onClick={() => {\n run(() => client.choose(src)).catch(reportErr)\n }}\n >\n <Picture src={thumbOf[src]} style={S.thumb} />\n {LABEL[src]}\n </button>\n ))}\n </div>\n\n <span className=\"settings-popout__label\">Character</span>\n {sets ? (\n <>\n {sets.length > 1 && (\n <div style={S.sets}>\n {sets.map((s, i) => (\n <button\n key={s.id}\n type=\"button\"\n className={`settings-popout__btn${i === setIdx ? ' settings-popout__btn--primary' : ''}`}\n onClick={() => setSetIdx(i)}\n >\n {s.id}\n </button>\n ))}\n </div>\n )}\n <div style={S.grid} role=\"radiogroup\" aria-label=\"Character\">\n {current?.characters.map(c => (\n <button\n key={c.id}\n type=\"button\"\n role=\"radio\"\n aria-checked={settings.sprite === c.id}\n aria-label={c.name}\n title={c.name}\n disabled={busy}\n style={{\n ...S.cell,\n borderColor: settings.sprite === c.id ? c.color : 'transparent'\n }}\n onClick={() => {\n run(() => client.update({ sprite: c.id })).catch(reportErr)\n }}\n >\n <img src={portraitUrl(c)} alt=\"\" style={S.img} />\n </button>\n ))}\n </div>\n </>\n ) : (\n <span className=\"settings-popout__hint\">…</span>\n )}\n\n {(['discord', 'steam'] as const).map(p => {\n const linked = settings.linked[p]\n return (\n <div key={p} style={S.line}>\n <span className=\"settings-popout__label\" style={S.grow}>\n {LABEL[p]}\n {!settings.providers[p]\n ? ' · not available yet'\n : linked\n ? linked.avatarUrl\n ? ' · linked'\n : ' · linked, no picture'\n : ''}\n </span>\n {settings.providers[p] && !linked && (\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={busy}\n onClick={() => setConsent(consent === p ? null : p)}\n >\n Link\n </button>\n )}\n {linked && (\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={busy}\n onClick={() => {\n run(() => client.unlink(p)).catch(reportErr)\n }}\n >\n Unlink\n </button>\n )}\n </div>\n )\n })}\n\n {consent && (\n <div style={S.stack}>\n <ul style={S.list}>\n {CONSENT[consent].points.map(t => (\n <li key={t}>{t}</li>\n ))}\n </ul>\n <span style={S.line}>\n <button\n type=\"button\"\n className=\"settings-popout__btn settings-popout__btn--primary\"\n disabled={busy}\n onClick={() => link(consent)}\n >\n {CONSENT[consent].go}\n </button>\n <button type=\"button\" className=\"settings-popout__btn\" onClick={() => setConsent(null)}>\n Cancel\n </button>\n </span>\n </div>\n )}\n\n <div style={S.line}>\n <span className=\"settings-popout__label\" style={S.grow}>\n Upload\n </span>\n <label className=\"settings-popout__btn\" style={{ opacity: busy ? 0.5 : 1 }}>\n {settings.upload ? 'Replace' : 'Choose image'}\n <input\n type=\"file\"\n accept={settings.limits.acceptedTypes.join(',')}\n hidden\n disabled={busy}\n onChange={e => {\n const file = e.currentTarget.files?.[0]\n e.currentTarget.value = ''\n if (file) run(() => client.upload(file)).catch(reportErr)\n }}\n />\n </label>\n {settings.upload && (\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={busy}\n onClick={() => {\n run(() => client.removeUpload()).catch(reportErr)\n }}\n >\n Remove\n </button>\n )}\n </div>\n\n {error && (\n <span className=\"settings-popout__error\" role=\"alert\">\n {error}\n </span>\n )}\n\n <p style={S.fine}>\n Steam avatars via the Steam Web API. Steam is a trademark of Valve; hadoku.me is not\n affiliated with Valve, Steam or Discord.{' '}\n <a href=\"/privacy#avatars\" target=\"_blank\" rel=\"noopener\">\n What is kept\n </a>\n </p>\n </div>\n )\n}\n\nfunction message(err: unknown): string {\n if (err instanceof AvatarError && err.status === 401) return 'Sign in to set an avatar.'\n return err instanceof Error ? err.message : String(err)\n}\n\nfunction reportErr(err: unknown) {\n console.error('[AvatarPicker] action failed', (err as Error)?.message ?? err)\n}\n"],"names":[],"mappings":";;;;AAqCA,MAAM,SAAS,mBAAmB,EAAE,SAAS,UAAU;AAGvD,MAAM,iBAAiB;AACvB,MAAM,kBAAkB;AAaxB,MAAM,QAAsC;AAAA,EAC1C,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,OAAO;AAAA,EACP,QAAQ;AACV;AAEA,MAAM,UAAkE;AAAA,EACtE,SAAS;AAAA,IACP,QAAQ;AAAA,MACN;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA;AAAA,IAEF,IAAI;AAAA,EAAA;AAAA,EAEN,OAAO;AAAA,IACL,QAAQ;AAAA,MACN;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IAAA;AAAA,IAEF,IAAI;AAAA,EAAA;AAER;AAEA,MAAM,IAAI;AAAA,EACR,OAAO,EAAE,SAAS,QAAQ,eAAe,UAAU,KAAK,IAAI,OAAO,OAAA;AAAA,EACnE,SAAS,EAAE,SAAS,QAAQ,YAAY,UAAU,KAAK,GAAA;AAAA,EACvD,SAAS;AAAA,IACP,OAAO;AAAA,IACP,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,UAAU;AAAA,IACV,MAAM;AAAA,IACN,YAAY;AAAA,IACZ,QAAQ;AAAA,EAAA;AAAA,EAEV,KAAK,EAAE,OAAO,QAAQ,QAAQ,QAAQ,WAAW,SAAS,SAAS,QAAA;AAAA,EACnE,SAAS,EAAE,SAAS,QAAQ,qBAAqB,kBAAkB,KAAK,EAAA;AAAA,EACxE,QAAQ;AAAA,IACN,SAAS;AAAA,IACT,eAAe;AAAA,IACf,YAAY;AAAA,IACZ,KAAK;AAAA,IACL,SAAS;AAAA,IACT,cAAc;AAAA,IACd,QAAQ;AAAA,IACR,YAAY;AAAA,IACZ,OAAO;AAAA,IACP,UAAU;AAAA,IACV,QAAQ;AAAA,EAAA;AAAA,EAEV,UAAU;AAAA,IACR,aAAa;AAAA,IACb,WAAW;AAAA,EAAA;AAAA,EAEb,OAAO;AAAA,IACL,OAAO;AAAA,IACP,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,UAAU;AAAA,IACV,YAAY;AAAA,EAAA;AAAA,EAEd,MAAM,EAAE,SAAS,QAAQ,KAAK,GAAG,UAAU,OAAA;AAAA,EAC3C,MAAM,EAAE,SAAS,QAAQ,qBAAqB,kBAAkB,KAAK,EAAA;AAAA,EACrE,MAAM;AAAA,IACJ,SAAS;AAAA,IACT,aAAa;AAAA,IACb,cAAc;AAAA,IACd,UAAU;AAAA,IACV,QAAQ;AAAA,IACR,YAAY;AAAA,IACZ,QAAQ;AAAA,EAAA;AAAA,EAEV,MAAM,EAAE,SAAS,QAAQ,YAAY,UAAU,KAAK,GAAG,UAAU,OAAA;AAAA,EACjE,MAAM,EAAE,MAAM,GAAG,UAAU,EAAA;AAAA,EAC3B,MAAM,EAAE,QAAQ,GAAG,aAAa,IAAI,UAAU,WAAW,YAAY,KAAA;AAAA,EACrE,MAAM,EAAE,UAAU,UAAU,OAAO,+BAA+B,YAAY,KAAK,QAAQ,EAAA;AAC7F;AAEA,SAAS,YAAY,GAAsB;AACzC,SAAO,EAAE,SAAS,WAAW,GAAG,IAAI,GAAG,eAAe,GAAG,EAAE,QAAQ,KAAK,EAAE;AAC5E;AAEA,SAAS,QAAQ,EAAE,KAAK,SAA6D;AACnF,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAC1C,YAAU,MAAM,UAAU,KAAK,GAAG,CAAC,GAAG,CAAC;AACvC,SACE,oBAAC,QAAA,EAAK,OACH,UAAA,OAAO,CAAC,UACP;AAAA,IAAC;AAAA,IAAA;AAAA,MACC;AAAA,MACA,KAAI;AAAA,MACJ,gBAAe;AAAA,MACf,OAAO,EAAE;AAAA,MACT,SAAS,MAAM,UAAU,IAAI;AAAA,IAAA;AAAA,EAAA,GAGnC;AAEJ;AAEO,SAAS,eAAe;AAC7B,QAAM,CAAC,UAAU,WAAW,IAAI,SAAgC,IAAI;AACpE,QAAM,CAAC,MAAM,OAAO,IAAI,SAAgC,IAAI;AAC5D,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,CAAC;AACtC,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwB,IAAI;AACtD,QAAM,CAAC,SAAS,UAAU,IAAI,SAA8B,IAAI;AAEhE,YAAU,MAAM;AACd,QAAI,OAAO;AACX,WACG,MACA,KAAK,CAAA,MAAK,QAAQ,YAAY,CAAC,CAAC,EAChC,MAAM,CAAC,QAAiB,QAAQ,SAAS,QAAQ,GAAG,CAAC,CAAC;AACzD,UAAM,cAAc,EACjB;AAAA,MAAK,CAAA,MACJ,EAAE,KACG,EAAE,SACH,QAAQ,OAAO,IAAI,MAAM,cAAc,EAAE,MAAM,EAAE,CAAC;AAAA,IAAA,EAEvD,KAAK,CAAA,MAAK,QAAQ,QAAQ,EAAE,IAAI,CAAC,EACjC,MAAM,CAAC,QAAiB,QAAQ,SAAS,QAAQ,GAAG,CAAC,CAAC;AACzD,WAAO,MAAM;AACX,aAAO;AAAA,IACT;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,MAAM,YAAY,OAAO,SAAwC;AACrE,YAAQ,IAAI;AACZ,aAAS,IAAI;AACb,QAAI;AACF,YAAM,OAAO,MAAM,KAAA;AACnB,kBAAY,IAAI;AAGhB,aAAO,cAAc,IAAI,OAAO,YAA4B,cAAc,EAAE,QAAQ,KAAA,CAAM,CAAC;AAAA,IAC7F,SAAS,KAAK;AACZ,eAAS,QAAQ,GAAG,CAAC;AAAA,IACvB,UAAA;AACE,cAAQ,KAAK;AAAA,IACf;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,aAAa,IAAI,KAAK,QAAQ,IAAI,QAAQ,CAAA,MAAK,EAAE,UAAU,EAAE,IAAI,CAAA,MAAK,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;AACtF,QAAM,YAAY,CAAC,OAAkC;AACnD,UAAM,IAAI,KAAK,WAAW,IAAI,EAAE,IAAI;AACpC,WAAO,IAAI,YAAY,CAAC,IAAI;AAAA,EAC9B;AACA,QAAM,QAAQ,CAAC,MAAsB,EAAE,OAAO,UAAU,EAAE,MAAM;AAEhE,MAAI,CAAC,UAAU;AACb,WACE,oBAAC,UAAK,WAAU,yBAAwB,MAAM,QAAQ,UAAU,QAC7D,UAAA,SAAS,IAAA,CACZ;AAAA,EAEJ;AAEA,QAAM,YAA2C;AAAA,IAC/C,QAAQ;AAAA,IACR,SAAS,QAAQ,SAAS,OAAO,SAAS,SAAS;AAAA,IACnD,OAAO,QAAQ,SAAS,OAAO,OAAO,SAAS;AAAA,IAC/C,QAAQ,SAAS,WAAW;AAAA,EAAA;AAE9B,QAAM,UAA+C;AAAA,IACnD,QAAQ,UAAU,SAAS,MAAM;AAAA,IACjC,SAAS,SAAS,OAAO,SAAS,aAAa;AAAA,IAC/C,OAAO,SAAS,OAAO,OAAO,aAAa;AAAA,IAC3C,QAAQ,SAAS,QAAQ,OAAO;AAAA,EAAA;AAElC,QAAM,QAAQ,SAAS;AACvB,QAAM,UAAU,OAAO,MAAM;AAI7B,QAAM,OAAO,CAAC,aAA2B;AACvC,eAAW,IAAI;AACf,QAAI,YAAY;AACd,YAAM,OAAO,MAAM,OAAO,YAAY,QAAQ;AAE9C,aAAO,KAAK,OAAO,QAAQ,GAAG,YAAY,OAAO,OAAO,QAAQ,IAAI;AAAA,IACtE,CAAC,EAAE,MAAM,SAAS;AAAA,EACpB;AAEA,SACE,qBAAC,OAAA,EAAI,OAAO,EAAE,OACZ,UAAA;AAAA,IAAA,qBAAC,OAAA,EAAI,OAAO,EAAE,SACZ,UAAA;AAAA,MAAA,oBAAC,WAAQ,KAAK,MAAM,KAAK,GAAG,OAAO,EAAE,SAAS;AAAA,MAC9C,qBAAC,QAAA,EAAK,WAAU,0BACb,UAAA;AAAA,QAAA,MAAM,MAAM,MAAM;AAAA,QAClB,SAAS,UAAU,SAAS,WAAW,MAAM,SAC1C,KAAK,MAAM,SAAS,MAAM,CAAC,kBAC3B;AAAA,MAAA,EAAA,CACN;AAAA,IAAA,GACF;AAAA,IAEA,oBAAC,OAAA,EAAI,OAAO,EAAE,SAAS,MAAK,cAAa,cAAW,WAChD,UAAA,OAAO,KAAK,KAAK,EAAqB,IAAI,CAAA,QAC1C;AAAA,MAAC;AAAA,MAAA;AAAA,QAEC,MAAK;AAAA,QACL,MAAK;AAAA,QACL,gBAAc,MAAM,WAAW;AAAA,QAC/B,UAAU,QAAQ,CAAC,UAAU,GAAG;AAAA,QAChC,OAAO,UAAU,GAAG,IAAI,SAAY;AAAA,QACpC,OAAO;AAAA,UACL,GAAG,EAAE;AAAA,UACL,GAAI,MAAM,WAAW,MAAM,EAAE,WAAW,CAAA;AAAA,UACxC,SAAS,UAAU,GAAG,IAAI,IAAI;AAAA,QAAA;AAAA,QAEhC,SAAS,MAAM;AACb,cAAI,MAAM,OAAO,OAAO,GAAG,CAAC,EAAE,MAAM,SAAS;AAAA,QAC/C;AAAA,QAEA,UAAA;AAAA,UAAA,oBAAC,WAAQ,KAAK,QAAQ,GAAG,GAAG,OAAO,EAAE,OAAO;AAAA,UAC3C,MAAM,GAAG;AAAA,QAAA;AAAA,MAAA;AAAA,MAhBL;AAAA,IAAA,CAkBR,GACH;AAAA,IAEA,oBAAC,QAAA,EAAK,WAAU,0BAAyB,UAAA,aAAS;AAAA,IACjD,OACC,qBAAA,UAAA,EACG,UAAA;AAAA,MAAA,KAAK,SAAS,KACb,oBAAC,OAAA,EAAI,OAAO,EAAE,MACX,UAAA,KAAK,IAAI,CAAC,GAAG,MACZ;AAAA,QAAC;AAAA,QAAA;AAAA,UAEC,MAAK;AAAA,UACL,WAAW,uBAAuB,MAAM,SAAS,mCAAmC,EAAE;AAAA,UACtF,SAAS,MAAM,UAAU,CAAC;AAAA,UAEzB,UAAA,EAAE;AAAA,QAAA;AAAA,QALE,EAAE;AAAA,MAAA,CAOV,GACH;AAAA,MAEF,oBAAC,OAAA,EAAI,OAAO,EAAE,MAAM,MAAK,cAAa,cAAW,aAC9C,UAAA,SAAS,WAAW,IAAI,CAAA,MACvB;AAAA,QAAC;AAAA,QAAA;AAAA,UAEC,MAAK;AAAA,UACL,MAAK;AAAA,UACL,gBAAc,SAAS,WAAW,EAAE;AAAA,UACpC,cAAY,EAAE;AAAA,UACd,OAAO,EAAE;AAAA,UACT,UAAU;AAAA,UACV,OAAO;AAAA,YACL,GAAG,EAAE;AAAA,YACL,aAAa,SAAS,WAAW,EAAE,KAAK,EAAE,QAAQ;AAAA,UAAA;AAAA,UAEpD,SAAS,MAAM;AACb,gBAAI,MAAM,OAAO,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,MAAM,SAAS;AAAA,UAC5D;AAAA,UAEA,UAAA,oBAAC,OAAA,EAAI,KAAK,YAAY,CAAC,GAAG,KAAI,IAAG,OAAO,EAAE,IAAA,CAAK;AAAA,QAAA;AAAA,QAf1C,EAAE;AAAA,MAAA,CAiBV,EAAA,CACH;AAAA,IAAA,GACF,IAEA,oBAAC,QAAA,EAAK,WAAU,yBAAwB,UAAA,KAAC;AAAA,IAGzC,CAAC,WAAW,OAAO,EAAY,IAAI,CAAA,MAAK;AACxC,YAAM,SAAS,SAAS,OAAO,CAAC;AAChC,aACE,qBAAC,OAAA,EAAY,OAAO,EAAE,MACpB,UAAA;AAAA,QAAA,qBAAC,QAAA,EAAK,WAAU,0BAAyB,OAAO,EAAE,MAC/C,UAAA;AAAA,UAAA,MAAM,CAAC;AAAA,UACP,CAAC,SAAS,UAAU,CAAC,IAClB,yBACA,SACE,OAAO,YACL,cACA,0BACF;AAAA,QAAA,GACR;AAAA,QACC,SAAS,UAAU,CAAC,KAAK,CAAC,UACzB;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,MAAK;AAAA,YACL,WAAU;AAAA,YACV,UAAU;AAAA,YACV,SAAS,MAAM,WAAW,YAAY,IAAI,OAAO,CAAC;AAAA,YACnD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,QAIF,UACC;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,MAAK;AAAA,YACL,WAAU;AAAA,YACV,UAAU;AAAA,YACV,SAAS,MAAM;AACb,kBAAI,MAAM,OAAO,OAAO,CAAC,CAAC,EAAE,MAAM,SAAS;AAAA,YAC7C;AAAA,YACD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,MAED,EAAA,GA/BM,CAiCV;AAAA,IAEJ,CAAC;AAAA,IAEA,WACC,qBAAC,OAAA,EAAI,OAAO,EAAE,OACZ,UAAA;AAAA,MAAA,oBAAC,MAAA,EAAG,OAAO,EAAE,MACV,kBAAQ,OAAO,EAAE,OAAO,IAAI,OAC3B,oBAAC,MAAA,EAAY,UAAA,EAAA,GAAJ,CAAM,CAChB,GACH;AAAA,MACA,qBAAC,QAAA,EAAK,OAAO,EAAE,MACb,UAAA;AAAA,QAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,MAAK;AAAA,YACL,WAAU;AAAA,YACV,UAAU;AAAA,YACV,SAAS,MAAM,KAAK,OAAO;AAAA,YAE1B,UAAA,QAAQ,OAAO,EAAE;AAAA,UAAA;AAAA,QAAA;AAAA,QAEpB,oBAAC,UAAA,EAAO,MAAK,UAAS,WAAU,wBAAuB,SAAS,MAAM,WAAW,IAAI,GAAG,UAAA,SAAA,CAExF;AAAA,MAAA,EAAA,CACF;AAAA,IAAA,GACF;AAAA,IAGF,qBAAC,OAAA,EAAI,OAAO,EAAE,MACZ,UAAA;AAAA,MAAA,oBAAC,UAAK,WAAU,0BAAyB,OAAO,EAAE,MAAM,UAAA,UAExD;AAAA,MACA,qBAAC,SAAA,EAAM,WAAU,wBAAuB,OAAO,EAAE,SAAS,OAAO,MAAM,EAAA,GACpE,UAAA;AAAA,QAAA,SAAS,SAAS,YAAY;AAAA,QAC/B;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,MAAK;AAAA,YACL,QAAQ,SAAS,OAAO,cAAc,KAAK,GAAG;AAAA,YAC9C,QAAM;AAAA,YACN,UAAU;AAAA,YACV,UAAU,CAAA,MAAK;AACb,oBAAM,OAAO,EAAE,cAAc,QAAQ,CAAC;AACtC,gBAAE,cAAc,QAAQ;AACxB,kBAAI,UAAU,MAAM,OAAO,OAAO,IAAI,CAAC,EAAE,MAAM,SAAS;AAAA,YAC1D;AAAA,UAAA;AAAA,QAAA;AAAA,MACF,GACF;AAAA,MACC,SAAS,UACR;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,UAAU;AAAA,UACV,SAAS,MAAM;AACb,gBAAI,MAAM,OAAO,aAAA,CAAc,EAAE,MAAM,SAAS;AAAA,UAClD;AAAA,UACD,UAAA;AAAA,QAAA;AAAA,MAAA;AAAA,IAED,GAEJ;AAAA,IAEC,SACC,oBAAC,QAAA,EAAK,WAAU,0BAAyB,MAAK,SAC3C,UAAA,OACH;AAAA,IAGF,qBAAC,KAAA,EAAE,OAAO,EAAE,MAAM,UAAA;AAAA,MAAA;AAAA,MAEyB;AAAA,MACzC,oBAAC,OAAE,MAAK,oBAAmB,QAAO,UAAS,KAAI,YAAW,UAAA,eAAA,CAE1D;AAAA,IAAA,EAAA,CACF;AAAA,EAAA,GACF;AAEJ;AAEA,SAAS,QAAQ,KAAsB;AACrC,MAAI,eAAe,eAAe,IAAI,WAAW,IAAK,QAAO;AAC7D,SAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAEA,SAAS,UAAU,KAAc;AAC/B,UAAQ,MAAM,gCAAiC,KAAe,WAAW,GAAG;AAC9E;"}
@@ -4,7 +4,7 @@
4
4
  * property of the PERSON (not the app):
5
5
  * - current access tier (read-only, friendly label)
6
6
  * - display name (inline edit → POST /session/name)
7
- * - avatar (a link to hadoku.me/account, where it is managed)
7
+ * - avatar: picture and character, chosen in place (AvatarPicker)
8
8
  * - global content-visibility level (tiered pill → PUT /prefs/api/v1/content-level)
9
9
  * - switch key (an inline sign-in form, on the site's own sign-in client)
10
10
  *
@@ -1 +1 @@
1
- {"version":3,"file":"ConnectedSettings.d.ts","sourceRoot":"","sources":["../../src/components/ConnectedSettings.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAmD,MAAM,OAAO,CAAA;AAEvE,OAAO,EAWL,KAAK,IAAI,EACV,MAAM,uBAAuB,CAAA;AAC9B,OAAO,mBAAmB,CAAA;AAE1B,MAAM,WAAW,sBAAsB;IACrC;;;;;;+EAM2E;IAC3E,QAAQ,CAAC,EAAE,IAAI,CAAA;IACf,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,mEAAmE;IACnE,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IAC5C;;;;;;8BAM0B;IAC1B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC3B;AA0ED,wBAAgB,iBAAiB,CAAC,EAChC,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,QAAQ,EACd,YAAY,EACZ,QAAQ,EACT,EAAE,sBAAsB,qBAqnBxB"}
1
+ {"version":3,"file":"ConnectedSettings.d.ts","sourceRoot":"","sources":["../../src/components/ConnectedSettings.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAmD,MAAM,OAAO,CAAA;AAGvE,OAAO,EAWL,KAAK,IAAI,EACV,MAAM,uBAAuB,CAAA;AAC9B,OAAO,mBAAmB,CAAA;AAE1B,MAAM,WAAW,sBAAsB;IACrC;;;;;;+EAM2E;IAC3E,QAAQ,CAAC,EAAE,IAAI,CAAA;IACf,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,mEAAmE;IACnE,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IAC5C;;;;;;8BAM0B;IAC1B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC3B;AA0ED,wBAAgB,iBAAiB,CAAC,EAChC,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,QAAQ,EACd,YAAY,EACZ,QAAQ,EACT,EAAE,sBAAsB,qBAmpBxB"}
@@ -1,6 +1,7 @@
1
1
  import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { useState, useRef, useCallback, useEffect } from "react";
3
3
  import { SettingsIcon } from "./ThemeIcons.js";
4
+ import { AvatarPicker } from "./AvatarPicker.js";
4
5
  import { prefetchSettings, setDisplayName, loadSignIn, switchKeyHref, loadSignInClient, passkeyHref, setContentLevel, passkeysSupported, deleteOwnAccount } from "../lib/settingsClient.js";
5
6
  /* empty css */
6
7
  function untilAborted(signal) {
@@ -68,6 +69,7 @@ function ConnectedSettings({
68
69
  const [nameDraft, setNameDraft] = useState(resolvedName ?? "");
69
70
  const [editingName, setEditingName] = useState(false);
70
71
  const [nameSaving, setNameSaving] = useState(false);
72
+ const [nameError, setNameError] = useState(null);
71
73
  const [displayName, setLocalName] = useState(resolvedName);
72
74
  const [keyFormOpen, setKeyFormOpen] = useState(false);
73
75
  const [keyDraft, setKeyDraft] = useState("");
@@ -81,6 +83,7 @@ function ConnectedSettings({
81
83
  const [deleteError, setDeleteError] = useState(null);
82
84
  const passkeyCtl = useRef(null);
83
85
  const cancelPasskey = useCallback((reason) => passkeyCtl.current?.abort(reason), []);
86
+ const [avatarOpen, setAvatarOpen] = useState(false);
84
87
  const [content, setContent] = useState(null);
85
88
  const [levelSaving, setLevelSaving] = useState(false);
86
89
  const [resolving, setResolving] = useState(true);
@@ -131,13 +134,16 @@ function ConnectedSettings({
131
134
  return;
132
135
  }
133
136
  setNameSaving(true);
134
- const stored = await setDisplayName(trimmed);
137
+ const result = await setDisplayName(trimmed);
135
138
  setNameSaving(false);
136
- if (stored !== null) {
137
- setLocalName(stored);
138
- setNameDraft(stored);
139
- onNameChange?.(stored);
139
+ if (!result.ok) {
140
+ setNameError(result.error);
141
+ return;
140
142
  }
143
+ setNameError(null);
144
+ setLocalName(result.name);
145
+ setNameDraft(result.name ?? "");
146
+ if (result.name !== null) onNameChange?.(result.name);
141
147
  setEditingName(false);
142
148
  }, [nameDraft, displayName, onNameChange]);
143
149
  const openKeyForm = useCallback(() => {
@@ -272,7 +278,12 @@ function ConnectedSettings({
272
278
  maxLength: 64,
273
279
  autoFocus: true,
274
280
  disabled: nameSaving,
275
- onChange: (e) => setNameDraft(e.target.value),
281
+ "aria-invalid": nameError !== null,
282
+ "aria-describedby": nameError ? "settings-popout-name-error" : void 0,
283
+ onChange: (e) => {
284
+ setNameDraft(e.target.value);
285
+ setNameError(null);
286
+ },
276
287
  onKeyDown: (e) => {
277
288
  if (e.key === "Enter") {
278
289
  saveName().catch(reportErr);
@@ -280,6 +291,7 @@ function ConnectedSettings({
280
291
  if (e.key === "Escape") {
281
292
  e.stopPropagation();
282
293
  setEditingName(false);
294
+ setNameError(null);
283
295
  setNameDraft(displayName ?? "");
284
296
  }
285
297
  }
@@ -296,6 +308,15 @@ function ConnectedSettings({
296
308
  },
297
309
  children: nameSaving ? "…" : "Save"
298
310
  }
311
+ ),
312
+ nameError && /* @__PURE__ */ jsx(
313
+ "span",
314
+ {
315
+ id: "settings-popout-name-error",
316
+ className: "settings-popout__error",
317
+ role: "alert",
318
+ children: nameError
319
+ }
299
320
  )
300
321
  ] }) : /* @__PURE__ */ jsx(
301
322
  "button",
@@ -311,9 +332,21 @@ function ConnectedSettings({
311
332
  }
312
333
  )
313
334
  ] }),
314
- !resolving && userType !== "public" && /* @__PURE__ */ jsxs("section", { className: "settings-popout__row", children: [
315
- /* @__PURE__ */ jsx("span", { className: "settings-popout__label", children: "Avatar" }),
316
- /* @__PURE__ */ jsx("a", { className: "settings-popout__btn", href: "/account", target: "_blank", rel: "noopener", children: "Change" })
335
+ !resolving && userType !== "public" && /* @__PURE__ */ jsxs("section", { className: "settings-popout__row settings-popout__row--stack", children: [
336
+ /* @__PURE__ */ jsxs("span", { className: "settings-popout__inline", children: [
337
+ /* @__PURE__ */ jsx("span", { className: "settings-popout__label settings-popout__label--grow", children: "Avatar" }),
338
+ /* @__PURE__ */ jsx(
339
+ "button",
340
+ {
341
+ type: "button",
342
+ className: "settings-popout__btn",
343
+ "aria-expanded": avatarOpen,
344
+ onClick: () => setAvatarOpen((v) => !v),
345
+ children: avatarOpen ? "Done" : "Change"
346
+ }
347
+ )
348
+ ] }),
349
+ avatarOpen && /* @__PURE__ */ jsx(AvatarPicker, {})
317
350
  ] }),
318
351
  !resolving && showContentPill && /* @__PURE__ */ jsxs("section", { className: "settings-popout__row settings-popout__row--stack", children: [
319
352
  /* @__PURE__ */ jsx("span", { className: "settings-popout__label", children: "Content visibility" }),