@hadoku/task-ui-components 4.23.4 → 4.23.6

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.
Files changed (30) hide show
  1. package/dist/components/AvatarBuilder.d.ts +28 -0
  2. package/dist/components/AvatarBuilder.d.ts.map +1 -0
  3. package/dist/components/AvatarBuilder.js +674 -0
  4. package/dist/components/AvatarBuilder.js.map +1 -0
  5. package/dist/components/AvatarDialog.d.ts +24 -0
  6. package/dist/components/AvatarDialog.d.ts.map +1 -0
  7. package/dist/components/AvatarDialog.js +95 -0
  8. package/dist/components/AvatarDialog.js.map +1 -0
  9. package/dist/components/AvatarPicker.d.ts +19 -10
  10. package/dist/components/AvatarPicker.d.ts.map +1 -1
  11. package/dist/components/AvatarPicker.js +19 -71
  12. package/dist/components/AvatarPicker.js.map +1 -1
  13. package/dist/components/ConnectedSettings.d.ts +1 -1
  14. package/dist/components/ConnectedSettings.d.ts.map +1 -1
  15. package/dist/components/ConnectedSettings.js +17 -6
  16. package/dist/components/ConnectedSettings.js.map +1 -1
  17. package/dist/lib/avatarData.d.ts +27 -0
  18. package/dist/lib/avatarData.d.ts.map +1 -0
  19. package/dist/lib/avatarData.js +23 -0
  20. package/dist/lib/avatarData.js.map +1 -0
  21. package/dist/vendor/wolffm/prefs-client/avatar.js +7 -1
  22. package/dist/vendor/wolffm/prefs-client/avatar.js.map +1 -1
  23. package/dist/vendor/wolffm/prefs-client/cosmetics.js +491 -0
  24. package/dist/vendor/wolffm/prefs-client/cosmetics.js.map +1 -0
  25. package/package.json +2 -1
  26. package/src/components/AvatarBuilder.tsx +835 -0
  27. package/src/components/AvatarDialog.tsx +118 -0
  28. package/src/components/AvatarPicker.tsx +39 -121
  29. package/src/components/ConnectedSettings.tsx +21 -13
  30. package/src/lib/avatarData.ts +40 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * AvatarDialog — the avatar builder (picture, character and cosmetics, with a
3
+ * live preview) in its own dialog, opened from the settings popout's "Change".
4
+ * The popout is 300px wide; the builder needs the room.
5
+ *
6
+ * Rendered into document.body, so no ancestor of the popout (a transform, an
7
+ * overflow, a stacking context) can clip or trap it. The popout stays open
8
+ * underneath: closing the dialog returns to it, and focus goes back to Change.
9
+ *
10
+ * Styles are inline for the reason AvatarPicker's are: apps compile this
11
+ * package's CSS at their own build, so a new stylesheet rule reaches an app only
12
+ * when it rebuilds. Tokens only.
13
+ */
14
+ import React, { useEffect, useId, useRef } from 'react'
15
+ import { createPortal } from 'react-dom'
16
+ import { AvatarBuilder } from './AvatarBuilder'
17
+
18
+ const S: Record<string, React.CSSProperties> = {
19
+ overlay: {
20
+ position: 'fixed',
21
+ inset: 0,
22
+ // Above the popout's panel (100) and its click-catcher (99).
23
+ zIndex: 1000,
24
+ display: 'flex',
25
+ alignItems: 'flex-start',
26
+ justifyContent: 'center',
27
+ boxSizing: 'border-box',
28
+ padding: '6vh 16px',
29
+ background: 'var(--color-bg-overlay)'
30
+ },
31
+ card: {
32
+ boxSizing: 'border-box',
33
+ width: 'min(720px, 100%)',
34
+ maxHeight: '88vh',
35
+ overflowY: 'auto',
36
+ display: 'flex',
37
+ flexDirection: 'column',
38
+ gap: 14,
39
+ padding: 20,
40
+ borderRadius: 12,
41
+ background: 'var(--color-bg-card)',
42
+ border: '1px solid var(--color-border)',
43
+ boxShadow: '0 8px 28px rgba(0, 0, 0, 0.18)',
44
+ color: 'var(--color-text)',
45
+ // Outside the app's root, so nothing passes the app's font down to it.
46
+ fontFamily: 'var(--font-family)',
47
+ outline: 'none'
48
+ },
49
+ head: { display: 'flex', alignItems: 'center', gap: 10 },
50
+ title: { margin: 0, flex: 1, fontSize: '1.05rem', fontWeight: 600 }
51
+ }
52
+
53
+ export interface AvatarDialogProps {
54
+ open: boolean
55
+ onClose: () => void
56
+ /** Where focus goes when the dialog closes (the Change button). */
57
+ returnFocus?: React.RefObject<HTMLElement | null>
58
+ /** The person's display name, shown in the preview. */
59
+ name?: string | null
60
+ }
61
+
62
+ export function AvatarDialog({ open, onClose, returnFocus, name = null }: AvatarDialogProps) {
63
+ const titleId = useId()
64
+ const cardRef = useRef<HTMLDivElement>(null)
65
+ const onCloseRef = useRef(onClose)
66
+ onCloseRef.current = onClose
67
+
68
+ useEffect(() => {
69
+ if (!open) return
70
+ cardRef.current?.focus()
71
+ const onKey = (e: KeyboardEvent) => {
72
+ if (e.key !== 'Escape') return
73
+ e.preventDefault()
74
+ // Stop it here: closing re-arms the popout's own Escape listener on window
75
+ // (React flushes a keypress's effects at once), and this same keypress
76
+ // would reach it next and close the popout too.
77
+ e.stopPropagation()
78
+ onCloseRef.current()
79
+ }
80
+ document.addEventListener('keydown', onKey)
81
+ const back = returnFocus?.current
82
+ return () => {
83
+ document.removeEventListener('keydown', onKey)
84
+ back?.focus()
85
+ }
86
+ }, [open, returnFocus])
87
+
88
+ if (!open || typeof document === 'undefined') return null
89
+
90
+ return createPortal(
91
+ <div style={S.overlay} onClick={onClose}>
92
+ <div
93
+ ref={cardRef}
94
+ role="dialog"
95
+ aria-modal="true"
96
+ aria-labelledby={titleId}
97
+ tabIndex={-1}
98
+ style={S.card}
99
+ onClick={e => e.stopPropagation()}
100
+ >
101
+ <div style={S.head}>
102
+ <h2 id={titleId} style={S.title}>
103
+ Avatar
104
+ </h2>
105
+ <button
106
+ type="button"
107
+ className="settings-popout__btn settings-popout__btn--primary"
108
+ onClick={onClose}
109
+ >
110
+ Done
111
+ </button>
112
+ </div>
113
+ <AvatarBuilder name={name} />
114
+ </div>
115
+ </div>,
116
+ document.body
117
+ )
118
+ }
@@ -1,19 +1,19 @@
1
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).
2
+ * AvatarPicker — the Picture tab of AvatarBuilder (inside AvatarDialog, opened
3
+ * from ConnectedSettings): the picture people see (character, Discord, Steam or
4
+ * an upload) and the character games use as the piece. One resolved avatar per
5
+ * person, stored in hadoku_site's prefs-api and rendered by every app
6
+ * (`@wolffm/prefs-client/avatar`, bundled into this package at build so the link
7
+ * protocol cannot drift from the callback page).
7
8
  *
8
9
  * Everything happens in place: picking, uploading and removing are requests
9
10
  * from here, and linking Discord or Steam signs in through a popup
10
11
  * (`linkInPopup`), so the page under the popout never navigates and a game
11
12
  * keeps running.
12
13
  *
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).
14
+ * AvatarBuilder owns the settings and the character table and loads them only
15
+ * when the dialog opens; this component draws them and sends changes through
16
+ * `run`, which applies the answer everywhere in the dialog.
17
17
  *
18
18
  * COMPLIANCE (Steam Web API terms; Discord developer terms), carried over from
19
19
  * hadoku.me/account: nothing is fetched from a provider until the person reads
@@ -24,33 +24,13 @@
24
24
  * apps compile app-header.css at their own build, so a new rule there reaches
25
25
  * an app only when it rebuilds.
26
26
  */
27
- import React, { useCallback, useEffect, useState } from 'react'
27
+ import React, { useEffect, useState } from 'react'
28
28
  import {
29
- createAvatarClient,
30
- AvatarError,
31
29
  type AvatarSettings,
32
30
  type AvatarSource,
33
- type LinkProvider,
34
- type ResolvedAvatar
31
+ type LinkProvider
35
32
  } from '@wolffm/prefs-client/avatar'
36
- import { AVATAR_EVENT } from '../lib/avatarEvent'
37
-
38
- const client = createAvatarClient({ baseUrl: '/prefs' })
39
-
40
- /** watchparty's public character table: GET /watchparty/api/characters. */
41
- const CHARACTERS_URL = '/watchparty/api/characters'
42
- const WATCHPARTY_BASE = '/watchparty'
43
-
44
- interface Character {
45
- id: string
46
- name: string
47
- color: string
48
- portrait: string
49
- }
50
- interface CharacterSet {
51
- id: string
52
- characters: Character[]
53
- }
33
+ import { avatarClient, portraitUrl, type CharacterSet } from '../lib/avatarData'
54
34
 
55
35
  const LABEL: Record<AvatarSource, string> = {
56
36
  sprite: 'Character',
@@ -82,16 +62,6 @@ const CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {
82
62
 
83
63
  const S = {
84
64
  stack: { display: 'flex', flexDirection: 'column', gap: 10, width: '100%' },
85
- current: { display: 'flex', alignItems: 'center', gap: 10 },
86
- preview: {
87
- width: 48,
88
- height: 48,
89
- borderRadius: '50%',
90
- overflow: 'hidden',
91
- flex: 'none',
92
- background: 'var(--color-bg-alt)',
93
- border: '1px solid var(--color-border)'
94
- },
95
65
  img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
96
66
  sources: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 },
97
67
  source: {
@@ -119,7 +89,7 @@ const S = {
119
89
  background: 'var(--color-bg-alt)'
120
90
  },
121
91
  sets: { display: 'flex', gap: 4, flexWrap: 'wrap' },
122
- grid: { display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 4 },
92
+ grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(64px, 1fr))', gap: 6 },
123
93
  cell: {
124
94
  padding: 0,
125
95
  aspectRatio: '1',
@@ -134,10 +104,6 @@ const S = {
134
104
  list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 }
135
105
  } satisfies Record<string, React.CSSProperties>
136
106
 
137
- function portraitUrl(c: Character): string {
138
- return c.portrait.startsWith('/') ? `${WATCHPARTY_BASE}${c.portrait}` : c.portrait
139
- }
140
-
141
107
  function Picture({ src, style }: { src: string | null; style: React.CSSProperties }) {
142
108
  const [failed, setFailed] = useState(false)
143
109
  useEffect(() => setFailed(false), [src])
@@ -156,63 +122,30 @@ function Picture({ src, style }: { src: string | null; style: React.CSSPropertie
156
122
  )
157
123
  }
158
124
 
159
- export function AvatarPicker() {
160
- const [settings, setSettings] = useState<AvatarSettings | null>(null)
161
- const [sets, setSets] = useState<CharacterSet[] | null>(null)
125
+ export interface AvatarPickerProps {
126
+ settings: AvatarSettings
127
+ sets: CharacterSet[] | null
128
+ busy: boolean
129
+ /** Send a change; the builder applies the answer and reports failures. */
130
+ run: (work: () => Promise<AvatarSettings>) => Promise<void>
131
+ }
132
+
133
+ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
162
134
  const [setIdx, setSetIdx] = useState(0)
163
- const [busy, setBusy] = useState(false)
164
- const [error, setError] = useState<string | null>(null)
165
135
  const [consent, setConsent] = useState<LinkProvider | null>(null)
166
-
136
+ // Open on the set holding the person's character.
167
137
  useEffect(() => {
168
- let live = true
169
- client
170
- .get()
171
- .then(s => live && setSettings(s))
172
- .catch((err: unknown) => live && setError(message(err)))
173
- fetch(CHARACTERS_URL)
174
- .then(r =>
175
- r.ok
176
- ? (r.json() as Promise<{ sets: CharacterSet[] }>)
177
- : Promise.reject(new Error(`characters ${r.status}`))
178
- )
179
- .then(d => live && setSets(d.sets))
180
- .catch((err: unknown) => live && setError(message(err)))
181
- return () => {
182
- live = false
183
- }
184
- }, [])
185
-
186
- const run = useCallback(async (work: () => Promise<AvatarSettings>) => {
187
- setBusy(true)
188
- setError(null)
189
- try {
190
- const next = await work()
191
- setSettings(next)
192
- // The page under the popout (a watchparty room, a game) re-reads the
193
- // avatar from this instead of waiting for its next join.
194
- window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))
195
- } catch (err) {
196
- setError(message(err))
197
- } finally {
198
- setBusy(false)
199
- }
200
- }, [])
138
+ const at = sets?.findIndex(s => s.characters.some(c => c.id === settings.sprite)) ?? -1
139
+ if (at > 0) setSetIdx(at)
140
+ // Only when the table arrives: afterwards the person moves between sets.
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, [sets])
201
143
 
202
144
  const characters = new Map((sets ?? []).flatMap(s => s.characters).map(c => [c.id, c]))
203
145
  const spriteSrc = (id: string | null | undefined) => {
204
146
  const c = id ? characters.get(id) : undefined
205
147
  return c ? portraitUrl(c) : null
206
148
  }
207
- const srcOf = (a: ResolvedAvatar) => a.url ?? spriteSrc(a.sprite)
208
-
209
- if (!settings) {
210
- return (
211
- <span className="settings-popout__hint" role={error ? 'alert' : undefined}>
212
- {error ?? '…'}
213
- </span>
214
- )
215
- }
216
149
 
217
150
  const available: Record<AvatarSource, boolean> = {
218
151
  sprite: true,
@@ -234,23 +167,19 @@ export function AvatarPicker() {
234
167
  const link = (provider: LinkProvider) => {
235
168
  setConsent(null)
236
169
  run(async () => {
237
- const next = await client.linkInPopup(provider)
170
+ const next = await avatarClient.linkInPopup(provider)
238
171
  // Linking is choosing, when the account came back with a picture.
239
- return next.linked[provider]?.avatarUrl ? client.choose(provider) : next
172
+ return next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next
240
173
  }).catch(reportErr)
241
174
  }
242
175
 
243
176
  return (
244
177
  <div style={S.stack}>
245
- <div style={S.current}>
246
- <Picture src={srcOf(shown)} style={S.preview} />
247
- <span className="settings-popout__value">
248
- {LABEL[shown.source]}
249
- {settings.choice && settings.choice !== shown.source
250
- ? ` (${LABEL[settings.choice]} unavailable)`
251
- : ''}
178
+ {settings.choice && settings.choice !== shown.source && (
179
+ <span className="settings-popout__hint">
180
+ {LABEL[settings.choice]} is unavailable, so your character shows instead.
252
181
  </span>
253
- </div>
182
+ )}
254
183
 
255
184
  <div style={S.sources} role="radiogroup" aria-label="Picture">
256
185
  {(Object.keys(LABEL) as AvatarSource[]).map(src => (
@@ -267,7 +196,7 @@ export function AvatarPicker() {
267
196
  opacity: available[src] ? 1 : 0.5
268
197
  }}
269
198
  onClick={() => {
270
- run(() => client.choose(src)).catch(reportErr)
199
+ run(() => avatarClient.choose(src)).catch(reportErr)
271
200
  }}
272
201
  >
273
202
  <Picture src={thumbOf[src]} style={S.thumb} />
@@ -308,7 +237,7 @@ export function AvatarPicker() {
308
237
  borderColor: settings.sprite === c.id ? c.color : 'transparent'
309
238
  }}
310
239
  onClick={() => {
311
- run(() => client.update({ sprite: c.id })).catch(reportErr)
240
+ run(() => avatarClient.update({ sprite: c.id })).catch(reportErr)
312
241
  }}
313
242
  >
314
243
  <img src={portraitUrl(c)} alt="" style={S.img} />
@@ -350,7 +279,7 @@ export function AvatarPicker() {
350
279
  className="settings-popout__btn"
351
280
  disabled={busy}
352
281
  onClick={() => {
353
- run(() => client.unlink(p)).catch(reportErr)
282
+ run(() => avatarClient.unlink(p)).catch(reportErr)
354
283
  }}
355
284
  >
356
285
  Unlink
@@ -397,7 +326,7 @@ export function AvatarPicker() {
397
326
  onChange={e => {
398
327
  const file = e.currentTarget.files?.[0]
399
328
  e.currentTarget.value = ''
400
- if (file) run(() => client.upload(file)).catch(reportErr)
329
+ if (file) run(() => avatarClient.upload(file)).catch(reportErr)
401
330
  }}
402
331
  />
403
332
  </label>
@@ -407,28 +336,17 @@ export function AvatarPicker() {
407
336
  className="settings-popout__btn"
408
337
  disabled={busy}
409
338
  onClick={() => {
410
- run(() => client.removeUpload()).catch(reportErr)
339
+ run(() => avatarClient.removeUpload()).catch(reportErr)
411
340
  }}
412
341
  >
413
342
  Remove
414
343
  </button>
415
344
  )}
416
345
  </div>
417
-
418
- {error && (
419
- <span className="settings-popout__error" role="alert">
420
- {error}
421
- </span>
422
- )}
423
346
  </div>
424
347
  )
425
348
  }
426
349
 
427
- function message(err: unknown): string {
428
- if (err instanceof AvatarError && err.status === 401) return 'Sign in to set an avatar.'
429
- return err instanceof Error ? err.message : String(err)
430
- }
431
-
432
350
  function reportErr(err: unknown) {
433
351
  console.error('[AvatarPicker] action failed', (err as Error)?.message ?? err)
434
352
  }
@@ -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: picture and character, chosen in place (AvatarPicker)
7
+ * - avatar: picture and character, chosen in a dialog (AvatarDialog)
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
  *
@@ -32,7 +32,7 @@
32
32
  import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
33
33
  import { useSettingsTab } from '@wolffm/themes'
34
34
  import { SettingsIcon } from './ThemeIcons'
35
- import { AvatarPicker } from './AvatarPicker'
35
+ import { AvatarDialog } from './AvatarDialog'
36
36
 
37
37
  // A grantee's own service credentials, with a QR encoder for TOTP enrolment:
38
38
  // its own chunk, fetched only when a grantee opens it.
@@ -265,8 +265,9 @@ export function ConnectedSettings({
265
265
  const passkeyCtl = useRef<AbortController | null>(null)
266
266
  const cancelPasskey = useCallback((reason?: string) => passkeyCtl.current?.abort(reason), [])
267
267
 
268
- // Avatar: the picker loads only when opened, never at mount (AvatarPicker).
268
+ // Avatar: the dialog's picker loads only when opened, never at mount (AvatarPicker).
269
269
  const [avatarOpen, setAvatarOpen] = useState(false)
270
+ const avatarBtn = useRef<HTMLButtonElement>(null)
270
271
  // Service identities this person may mint for (whoami.serviceGrants).
271
272
  const [serviceGrants, setServiceGrants] = useState(0)
272
273
  const [serviceOpen, setServiceOpen] = useState(false)
@@ -414,15 +415,15 @@ export function ConnectedSettings({
414
415
  // eslint-disable-next-line react-hooks/exhaustive-deps
415
416
  }, [])
416
417
 
417
- // Escape closes.
418
+ // Escape closes — unless the avatar dialog is open: that Escape is the dialog's.
418
419
  useEffect(() => {
419
- if (!open) return
420
+ if (!open || avatarOpen) return
420
421
  const onKey = (e: KeyboardEvent) => {
421
422
  if (e.key === 'Escape') setOpen(false)
422
423
  }
423
424
  window.addEventListener('keydown', onKey)
424
425
  return () => window.removeEventListener('keydown', onKey)
425
- }, [open])
426
+ }, [open, avatarOpen])
426
427
 
427
428
  const saveName = useCallback(async () => {
428
429
  const trimmed = nameDraft.trim()
@@ -708,9 +709,9 @@ export function ConnectedSettings({
708
709
  )}
709
710
  </section>
710
711
 
711
- {/* Avatar: picture and character, chosen in place (AvatarPicker).
712
- Collapsed until Change, so opening the gear costs no request and no
713
- page load reads the avatar. Hidden for public callers, like the
712
+ {/* Avatar: picture and character, chosen in a dialog (AvatarDialog).
713
+ Nothing loads until Change, so opening the gear costs no request and
714
+ no page load reads the avatar. Hidden for public callers, like the
714
715
  content pill: an avatar needs a session. */}
715
716
  {!resolving && userType !== 'public' && (
716
717
  <section className="settings-popout__row settings-popout__row--stack">
@@ -719,20 +720,27 @@ export function ConnectedSettings({
719
720
  Avatar
720
721
  </span>
721
722
  <button
723
+ ref={avatarBtn}
722
724
  type="button"
723
725
  className="settings-popout__btn"
726
+ aria-haspopup="dialog"
724
727
  aria-expanded={avatarOpen}
725
- onClick={() => setAvatarOpen(v => !v)}
728
+ onClick={() => setAvatarOpen(true)}
726
729
  >
727
- {avatarOpen ? 'Done' : 'Change'}
730
+ Change
728
731
  </button>
729
732
  </span>
730
- {avatarOpen && <AvatarPicker />}
733
+ <AvatarDialog
734
+ open={avatarOpen}
735
+ onClose={() => setAvatarOpen(false)}
736
+ returnFocus={avatarBtn}
737
+ name={displayName}
738
+ />
731
739
  </section>
732
740
  )}
733
741
 
734
742
  {/* Service access: only for someone the operator granted a
735
- service identity. Collapsed until Open, like Avatar. */}
743
+ service identity. Collapsed until Open. */}
736
744
  {!resolving && serviceGrants > 0 && (
737
745
  <section className="settings-popout__row settings-popout__row--stack">
738
746
  <span className="settings-popout__inline">
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What the avatar dialog reads: the person's avatar settings (prefs-api, through
3
+ * `@wolffm/prefs-client/avatar`) and watchparty's public character table, which
4
+ * gives each character its portrait and the colour every name cosmetic derives
5
+ * from.
6
+ */
7
+ import { createAvatarClient, AvatarError } from '@wolffm/prefs-client/avatar'
8
+
9
+ export const avatarClient = createAvatarClient({ baseUrl: '/prefs' })
10
+
11
+ /** watchparty's public character table: GET /watchparty/api/characters. */
12
+ const CHARACTERS_URL = '/watchparty/api/characters'
13
+ const WATCHPARTY_BASE = '/watchparty'
14
+
15
+ export interface Character {
16
+ id: string
17
+ name: string
18
+ color: string
19
+ portrait: string
20
+ }
21
+ export interface CharacterSet {
22
+ id: string
23
+ characters: Character[]
24
+ }
25
+
26
+ export async function fetchCharacters(): Promise<CharacterSet[]> {
27
+ const r = await fetch(CHARACTERS_URL)
28
+ if (!r.ok) throw new Error(`Could not load the characters (HTTP ${r.status})`)
29
+ return ((await r.json()) as { sets: CharacterSet[] }).sets
30
+ }
31
+
32
+ export function portraitUrl(c: Character): string {
33
+ return c.portrait.startsWith('/') ? `${WATCHPARTY_BASE}${c.portrait}` : c.portrait
34
+ }
35
+
36
+ /** A failure as the dialog shows it. */
37
+ export function avatarErrorText(err: unknown): string {
38
+ if (err instanceof AvatarError && err.status === 401) return 'Sign in to set an avatar.'
39
+ return err instanceof Error ? err.message : String(err)
40
+ }