@hadoku/task-ui-components 4.22.1 → 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.
@@ -0,0 +1,443 @@
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, { useCallback, useEffect, useState } from 'react'
28
+ import {
29
+ createAvatarClient,
30
+ AvatarError,
31
+ type AvatarSettings,
32
+ type AvatarSource,
33
+ type LinkProvider,
34
+ type ResolvedAvatar
35
+ } 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
+ }
54
+
55
+ const LABEL: Record<AvatarSource, string> = {
56
+ sprite: 'Character',
57
+ discord: 'Discord',
58
+ steam: 'Steam',
59
+ upload: 'Upload'
60
+ }
61
+
62
+ const CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {
63
+ discord: {
64
+ points: [
65
+ 'Discord asks you to share your basic profile ("identify").',
66
+ 'Kept: your Discord user ID and the address of your profile picture, refreshed while linked.',
67
+ 'Not kept: your username, email, servers or any token.',
68
+ 'Unlink deletes both.'
69
+ ],
70
+ go: 'Continue to Discord'
71
+ },
72
+ steam: {
73
+ points: [
74
+ 'You sign in on steamcommunity.com; your password never reaches hadoku.me.',
75
+ 'Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.',
76
+ 'Kept: your Steam ID and the address of that picture.',
77
+ 'Unlink deletes both and stops the reads.'
78
+ ],
79
+ go: 'Continue to Steam'
80
+ }
81
+ }
82
+
83
+ const S = {
84
+ 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
+ img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
96
+ sources: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 },
97
+ source: {
98
+ display: 'flex',
99
+ flexDirection: 'column',
100
+ alignItems: 'center',
101
+ gap: 4,
102
+ padding: 6,
103
+ borderRadius: 8,
104
+ border: '1px solid var(--color-border)',
105
+ background: 'var(--color-bg)',
106
+ color: 'var(--color-text)',
107
+ fontSize: '0.72rem',
108
+ cursor: 'pointer'
109
+ },
110
+ sourceOn: {
111
+ borderColor: 'var(--color-primary)',
112
+ boxShadow: 'inset 0 0 0 1px var(--color-primary)'
113
+ },
114
+ thumb: {
115
+ width: 32,
116
+ height: 32,
117
+ borderRadius: '50%',
118
+ overflow: 'hidden',
119
+ background: 'var(--color-bg-alt)'
120
+ },
121
+ sets: { display: 'flex', gap: 4, flexWrap: 'wrap' },
122
+ grid: { display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 4 },
123
+ cell: {
124
+ padding: 0,
125
+ aspectRatio: '1',
126
+ borderRadius: 6,
127
+ overflow: 'hidden',
128
+ border: '2px solid transparent',
129
+ background: 'var(--color-bg-alt)',
130
+ cursor: 'pointer'
131
+ },
132
+ line: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
133
+ grow: { flex: 1, minWidth: 0 },
134
+ list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 },
135
+ fine: { fontSize: '0.7rem', color: 'var(--color-text-secondary)', lineHeight: 1.4, margin: 0 }
136
+ } satisfies Record<string, React.CSSProperties>
137
+
138
+ function portraitUrl(c: Character): string {
139
+ return c.portrait.startsWith('/') ? `${WATCHPARTY_BASE}${c.portrait}` : c.portrait
140
+ }
141
+
142
+ function Picture({ src, style }: { src: string | null; style: React.CSSProperties }) {
143
+ const [failed, setFailed] = useState(false)
144
+ useEffect(() => setFailed(false), [src])
145
+ return (
146
+ <span style={style}>
147
+ {src && !failed && (
148
+ <img
149
+ src={src}
150
+ alt=""
151
+ referrerPolicy="no-referrer"
152
+ style={S.img}
153
+ onError={() => setFailed(true)}
154
+ />
155
+ )}
156
+ </span>
157
+ )
158
+ }
159
+
160
+ export function AvatarPicker() {
161
+ const [settings, setSettings] = useState<AvatarSettings | null>(null)
162
+ const [sets, setSets] = useState<CharacterSet[] | null>(null)
163
+ const [setIdx, setSetIdx] = useState(0)
164
+ const [busy, setBusy] = useState(false)
165
+ const [error, setError] = useState<string | null>(null)
166
+ const [consent, setConsent] = useState<LinkProvider | null>(null)
167
+
168
+ useEffect(() => {
169
+ let live = true
170
+ client
171
+ .get()
172
+ .then(s => live && setSettings(s))
173
+ .catch((err: unknown) => live && setError(message(err)))
174
+ fetch(CHARACTERS_URL)
175
+ .then(r =>
176
+ r.ok
177
+ ? (r.json() as Promise<{ sets: CharacterSet[] }>)
178
+ : Promise.reject(new Error(`characters ${r.status}`))
179
+ )
180
+ .then(d => live && setSets(d.sets))
181
+ .catch((err: unknown) => live && setError(message(err)))
182
+ return () => {
183
+ live = false
184
+ }
185
+ }, [])
186
+
187
+ const run = useCallback(async (work: () => Promise<AvatarSettings>) => {
188
+ setBusy(true)
189
+ setError(null)
190
+ try {
191
+ const next = await work()
192
+ setSettings(next)
193
+ // The page under the popout (a watchparty room, a game) re-reads the
194
+ // avatar from this instead of waiting for its next join.
195
+ window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))
196
+ } catch (err) {
197
+ setError(message(err))
198
+ } finally {
199
+ setBusy(false)
200
+ }
201
+ }, [])
202
+
203
+ const characters = new Map((sets ?? []).flatMap(s => s.characters).map(c => [c.id, c]))
204
+ const spriteSrc = (id: string | null | undefined) => {
205
+ const c = id ? characters.get(id) : undefined
206
+ return c ? portraitUrl(c) : null
207
+ }
208
+ const srcOf = (a: ResolvedAvatar) => a.url ?? spriteSrc(a.sprite)
209
+
210
+ if (!settings) {
211
+ return (
212
+ <span className="settings-popout__hint" role={error ? 'alert' : undefined}>
213
+ {error ?? '…'}
214
+ </span>
215
+ )
216
+ }
217
+
218
+ const available: Record<AvatarSource, boolean> = {
219
+ sprite: true,
220
+ discord: Boolean(settings.linked.discord?.avatarUrl),
221
+ steam: Boolean(settings.linked.steam?.avatarUrl),
222
+ upload: settings.upload !== null
223
+ }
224
+ const thumbOf: Record<AvatarSource, string | null> = {
225
+ sprite: spriteSrc(settings.sprite),
226
+ discord: settings.linked.discord?.avatarUrl ?? null,
227
+ steam: settings.linked.steam?.avatarUrl ?? null,
228
+ upload: settings.upload?.url ?? null
229
+ }
230
+ const shown = settings.avatar
231
+ const current = sets?.[setIdx]
232
+
233
+ // Must stay synchronous up to linkInPopup: the popup opens before its first
234
+ // await, inside this click, which is what popup blockers allow.
235
+ const link = (provider: LinkProvider) => {
236
+ setConsent(null)
237
+ run(async () => {
238
+ const next = await client.linkInPopup(provider)
239
+ // Linking is choosing, when the account came back with a picture.
240
+ return next.linked[provider]?.avatarUrl ? client.choose(provider) : next
241
+ }).catch(reportErr)
242
+ }
243
+
244
+ return (
245
+ <div style={S.stack}>
246
+ <div style={S.current}>
247
+ <Picture src={srcOf(shown)} style={S.preview} />
248
+ <span className="settings-popout__value">
249
+ {LABEL[shown.source]}
250
+ {settings.choice && settings.choice !== shown.source
251
+ ? ` (${LABEL[settings.choice]} unavailable)`
252
+ : ''}
253
+ </span>
254
+ </div>
255
+
256
+ <div style={S.sources} role="radiogroup" aria-label="Picture">
257
+ {(Object.keys(LABEL) as AvatarSource[]).map(src => (
258
+ <button
259
+ key={src}
260
+ type="button"
261
+ role="radio"
262
+ aria-checked={shown.source === src}
263
+ disabled={busy || !available[src]}
264
+ title={available[src] ? undefined : 'Link or upload one first'}
265
+ style={{
266
+ ...S.source,
267
+ ...(shown.source === src ? S.sourceOn : {}),
268
+ opacity: available[src] ? 1 : 0.5
269
+ }}
270
+ onClick={() => {
271
+ run(() => client.choose(src)).catch(reportErr)
272
+ }}
273
+ >
274
+ <Picture src={thumbOf[src]} style={S.thumb} />
275
+ {LABEL[src]}
276
+ </button>
277
+ ))}
278
+ </div>
279
+
280
+ <span className="settings-popout__label">Character</span>
281
+ {sets ? (
282
+ <>
283
+ {sets.length > 1 && (
284
+ <div style={S.sets}>
285
+ {sets.map((s, i) => (
286
+ <button
287
+ key={s.id}
288
+ type="button"
289
+ className={`settings-popout__btn${i === setIdx ? ' settings-popout__btn--primary' : ''}`}
290
+ onClick={() => setSetIdx(i)}
291
+ >
292
+ {s.id}
293
+ </button>
294
+ ))}
295
+ </div>
296
+ )}
297
+ <div style={S.grid} role="radiogroup" aria-label="Character">
298
+ {current?.characters.map(c => (
299
+ <button
300
+ key={c.id}
301
+ type="button"
302
+ role="radio"
303
+ aria-checked={settings.sprite === c.id}
304
+ aria-label={c.name}
305
+ title={c.name}
306
+ disabled={busy}
307
+ style={{
308
+ ...S.cell,
309
+ borderColor: settings.sprite === c.id ? c.color : 'transparent'
310
+ }}
311
+ onClick={() => {
312
+ run(() => client.update({ sprite: c.id })).catch(reportErr)
313
+ }}
314
+ >
315
+ <img src={portraitUrl(c)} alt="" style={S.img} />
316
+ </button>
317
+ ))}
318
+ </div>
319
+ </>
320
+ ) : (
321
+ <span className="settings-popout__hint">…</span>
322
+ )}
323
+
324
+ {(['discord', 'steam'] as const).map(p => {
325
+ const linked = settings.linked[p]
326
+ return (
327
+ <div key={p} style={S.line}>
328
+ <span className="settings-popout__label" style={S.grow}>
329
+ {LABEL[p]}
330
+ {!settings.providers[p]
331
+ ? ' · not available yet'
332
+ : linked
333
+ ? linked.avatarUrl
334
+ ? ' · linked'
335
+ : ' · linked, no picture'
336
+ : ''}
337
+ </span>
338
+ {settings.providers[p] && !linked && (
339
+ <button
340
+ type="button"
341
+ className="settings-popout__btn"
342
+ disabled={busy}
343
+ onClick={() => setConsent(consent === p ? null : p)}
344
+ >
345
+ Link
346
+ </button>
347
+ )}
348
+ {linked && (
349
+ <button
350
+ type="button"
351
+ className="settings-popout__btn"
352
+ disabled={busy}
353
+ onClick={() => {
354
+ run(() => client.unlink(p)).catch(reportErr)
355
+ }}
356
+ >
357
+ Unlink
358
+ </button>
359
+ )}
360
+ </div>
361
+ )
362
+ })}
363
+
364
+ {consent && (
365
+ <div style={S.stack}>
366
+ <ul style={S.list}>
367
+ {CONSENT[consent].points.map(t => (
368
+ <li key={t}>{t}</li>
369
+ ))}
370
+ </ul>
371
+ <span style={S.line}>
372
+ <button
373
+ type="button"
374
+ className="settings-popout__btn settings-popout__btn--primary"
375
+ disabled={busy}
376
+ onClick={() => link(consent)}
377
+ >
378
+ {CONSENT[consent].go}
379
+ </button>
380
+ <button type="button" className="settings-popout__btn" onClick={() => setConsent(null)}>
381
+ Cancel
382
+ </button>
383
+ </span>
384
+ </div>
385
+ )}
386
+
387
+ <div style={S.line}>
388
+ <span className="settings-popout__label" style={S.grow}>
389
+ Upload
390
+ </span>
391
+ <label className="settings-popout__btn" style={{ opacity: busy ? 0.5 : 1 }}>
392
+ {settings.upload ? 'Replace' : 'Choose image'}
393
+ <input
394
+ type="file"
395
+ accept={settings.limits.acceptedTypes.join(',')}
396
+ hidden
397
+ disabled={busy}
398
+ onChange={e => {
399
+ const file = e.currentTarget.files?.[0]
400
+ e.currentTarget.value = ''
401
+ if (file) run(() => client.upload(file)).catch(reportErr)
402
+ }}
403
+ />
404
+ </label>
405
+ {settings.upload && (
406
+ <button
407
+ type="button"
408
+ className="settings-popout__btn"
409
+ disabled={busy}
410
+ onClick={() => {
411
+ run(() => client.removeUpload()).catch(reportErr)
412
+ }}
413
+ >
414
+ Remove
415
+ </button>
416
+ )}
417
+ </div>
418
+
419
+ {error && (
420
+ <span className="settings-popout__error" role="alert">
421
+ {error}
422
+ </span>
423
+ )}
424
+
425
+ <p style={S.fine}>
426
+ Steam avatars via the Steam Web API. Steam is a trademark of Valve; hadoku.me is not
427
+ affiliated with Valve, Steam or Discord.{' '}
428
+ <a href="/privacy#avatars" target="_blank" rel="noopener">
429
+ What is kept
430
+ </a>
431
+ </p>
432
+ </div>
433
+ )
434
+ }
435
+
436
+ function message(err: unknown): string {
437
+ if (err instanceof AvatarError && err.status === 401) return 'Sign in to set an avatar.'
438
+ return err instanceof Error ? err.message : String(err)
439
+ }
440
+
441
+ function reportErr(err: unknown) {
442
+ console.error('[AvatarPicker] action failed', (err as Error)?.message ?? err)
443
+ }
@@ -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
  *
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import React, { useCallback, useEffect, useRef, useState } from 'react'
29
29
  import { SettingsIcon } from './ThemeIcons'
30
+ import { AvatarPicker } from './AvatarPicker'
30
31
  import {
31
32
  deleteOwnAccount,
32
33
  prefetchSettings,
@@ -180,6 +181,9 @@ export function ConnectedSettings({
180
181
  const passkeyCtl = useRef<AbortController | null>(null)
181
182
  const cancelPasskey = useCallback((reason?: string) => passkeyCtl.current?.abort(reason), [])
182
183
 
184
+ // Avatar: the picker loads only when opened, never at mount (AvatarPicker).
185
+ const [avatarOpen, setAvatarOpen] = useState(false)
186
+
183
187
  // Content level
184
188
  const [content, setContent] = useState<ContentLevelState | null>(null)
185
189
  const [levelSaving, setLevelSaving] = useState(false)
@@ -520,17 +524,24 @@ export function ConnectedSettings({
520
524
  )}
521
525
  </section>
522
526
 
523
- {/* Avatar. Managed on /account (character, Discord, Steam, upload),
524
- so this is only the way there: no thumbnail, because drawing one
525
- would cost every app a request at mount. A new tab, so following
526
- it never takes anyone out of a game mid-match. Hidden for public
527
- callers, like the content pill: /account needs a session. */}
527
+ {/* Avatar: picture and character, chosen in place (AvatarPicker).
528
+ Collapsed until Change, so opening the gear costs no request and no
529
+ page load reads the avatar. Hidden for public callers, like the
530
+ content pill: an avatar needs a session. */}
528
531
  {!resolving && userType !== 'public' && (
529
- <section className="settings-popout__row">
530
- <span className="settings-popout__label">Avatar</span>
531
- <a className="settings-popout__btn" href="/account" target="_blank" rel="noopener">
532
- Change
533
- </a>
532
+ <section className="settings-popout__row settings-popout__row--stack">
533
+ <span className="settings-popout__inline">
534
+ <span className="settings-popout__label settings-popout__label--grow">Avatar</span>
535
+ <button
536
+ type="button"
537
+ className="settings-popout__btn"
538
+ aria-expanded={avatarOpen}
539
+ onClick={() => setAvatarOpen(v => !v)}
540
+ >
541
+ {avatarOpen ? 'Done' : 'Change'}
542
+ </button>
543
+ </span>
544
+ {avatarOpen && <AvatarPicker />}
534
545
  </section>
535
546
  )}
536
547
 
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  export { HadokuThemePicker } from './components/HadokuThemePicker'
21
21
  export { AppHeader } from './components/AppHeader'
22
22
  export { ConnectedSettings } from './components/ConnectedSettings'
23
+ export { AVATAR_EVENT } from './lib/avatarEvent'
23
24
  // Re-exported from @wolffm/themes, which now owns the context AND the provider
24
25
  // that fills it. These are RE-EXPORTS, not re-declarations: every consumer gets
25
26
  // the one context object from themes, which is what makes a duplicated copy of
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Fired on `window` after every avatar change made in the settings popout
3
+ * (AvatarPicker), with the new avatar settings as `detail` (hadoku_site's
4
+ * `AvatarSettings`, @wolffm/prefs-client/avatar). A host page listens to update
5
+ * what it shows (watchparty sends the new character to its room) without
6
+ * polling prefs-api.
7
+ *
8
+ * Its own module, free of imports, so exporting it from the package entry adds
9
+ * no type dependency on prefs-client for consumers.
10
+ */
11
+ export const AVATAR_EVENT = 'hadoku:avatar'