@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.
- package/dist/components/AvatarBuilder.d.ts +28 -0
- package/dist/components/AvatarBuilder.d.ts.map +1 -0
- package/dist/components/AvatarBuilder.js +674 -0
- package/dist/components/AvatarBuilder.js.map +1 -0
- package/dist/components/AvatarDialog.d.ts +24 -0
- package/dist/components/AvatarDialog.d.ts.map +1 -0
- package/dist/components/AvatarDialog.js +95 -0
- package/dist/components/AvatarDialog.js.map +1 -0
- package/dist/components/AvatarPicker.d.ts +19 -10
- package/dist/components/AvatarPicker.d.ts.map +1 -1
- package/dist/components/AvatarPicker.js +19 -71
- package/dist/components/AvatarPicker.js.map +1 -1
- package/dist/components/ConnectedSettings.d.ts +1 -1
- package/dist/components/ConnectedSettings.d.ts.map +1 -1
- package/dist/components/ConnectedSettings.js +17 -6
- package/dist/components/ConnectedSettings.js.map +1 -1
- package/dist/lib/avatarData.d.ts +27 -0
- package/dist/lib/avatarData.d.ts.map +1 -0
- package/dist/lib/avatarData.js +23 -0
- package/dist/lib/avatarData.js.map +1 -0
- package/dist/vendor/wolffm/prefs-client/avatar.js +7 -1
- package/dist/vendor/wolffm/prefs-client/avatar.js.map +1 -1
- package/dist/vendor/wolffm/prefs-client/cosmetics.js +491 -0
- package/dist/vendor/wolffm/prefs-client/cosmetics.js.map +1 -0
- package/package.json +2 -1
- package/src/components/AvatarBuilder.tsx +835 -0
- package/src/components/AvatarDialog.tsx +118 -0
- package/src/components/AvatarPicker.tsx +39 -121
- package/src/components/ConnectedSettings.tsx +21 -13
- 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
|
|
3
|
-
* see (character, Discord, Steam or
|
|
4
|
-
* the piece. One resolved avatar per
|
|
5
|
-
* and rendered by every app
|
|
6
|
-
* package at build so the link
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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, {
|
|
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 {
|
|
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(
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
246
|
-
<
|
|
247
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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(() =>
|
|
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(() =>
|
|
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(() =>
|
|
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
|
|
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 {
|
|
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
|
|
712
|
-
|
|
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(
|
|
728
|
+
onClick={() => setAvatarOpen(true)}
|
|
726
729
|
>
|
|
727
|
-
|
|
730
|
+
Change
|
|
728
731
|
</button>
|
|
729
732
|
</span>
|
|
730
|
-
|
|
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
|
|
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
|
+
}
|