@hadoku/task-ui-components 4.23.5 → 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.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * AvatarDialog — the avatar picker in its own dialog, opened from the settings
3
- * popout's "Change". The popout is 300px wide; the picker (and the cosmetics
4
- * that will join it) needs the room.
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
5
  *
6
6
  * Rendered into document.body, so no ancestor of the popout (a transform, an
7
7
  * overflow, a stacking context) can clip or trap it. The popout stays open
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import React, { useEffect, useId, useRef } from 'react'
15
15
  import { createPortal } from 'react-dom'
16
- import { AvatarPicker } from './AvatarPicker'
16
+ import { AvatarBuilder } from './AvatarBuilder'
17
17
 
18
18
  const S: Record<string, React.CSSProperties> = {
19
19
  overlay: {
@@ -55,9 +55,11 @@ export interface AvatarDialogProps {
55
55
  onClose: () => void
56
56
  /** Where focus goes when the dialog closes (the Change button). */
57
57
  returnFocus?: React.RefObject<HTMLElement | null>
58
+ /** The person's display name, shown in the preview. */
59
+ name?: string | null
58
60
  }
59
61
 
60
- export function AvatarDialog({ open, onClose, returnFocus }: AvatarDialogProps) {
62
+ export function AvatarDialog({ open, onClose, returnFocus, name = null }: AvatarDialogProps) {
61
63
  const titleId = useId()
62
64
  const cardRef = useRef<HTMLDivElement>(null)
63
65
  const onCloseRef = useRef(onClose)
@@ -108,7 +110,7 @@ export function AvatarDialog({ open, onClose, returnFocus }: AvatarDialogProps)
108
110
  Done
109
111
  </button>
110
112
  </div>
111
- <AvatarPicker />
113
+ <AvatarBuilder name={name} />
112
114
  </div>
113
115
  </div>,
114
116
  document.body
@@ -1,19 +1,19 @@
1
1
  /**
2
- * AvatarPicker — the body of AvatarDialog (opened from 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 the dialog ("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: 64,
88
- height: 64,
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: {
@@ -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
  }
@@ -734,6 +734,7 @@ export function ConnectedSettings({
734
734
  open={avatarOpen}
735
735
  onClose={() => setAvatarOpen(false)}
736
736
  returnFocus={avatarBtn}
737
+ name={displayName}
737
738
  />
738
739
  </section>
739
740
  )}
@@ -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
+ }