@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.
- 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 +6 -4
- package/dist/components/AvatarDialog.d.ts.map +1 -1
- package/dist/components/AvatarDialog.js +3 -3
- package/dist/components/AvatarDialog.js.map +1 -1
- package/dist/components/AvatarPicker.d.ts +19 -10
- package/dist/components/AvatarPicker.d.ts.map +1 -1
- package/dist/components/AvatarPicker.js +18 -70
- package/dist/components/AvatarPicker.js.map +1 -1
- package/dist/components/ConnectedSettings.d.ts.map +1 -1
- package/dist/components/ConnectedSettings.js +2 -1
- 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 -2
- package/src/components/AvatarBuilder.tsx +835 -0
- package/src/components/AvatarDialog.tsx +8 -6
- package/src/components/AvatarPicker.tsx +38 -120
- package/src/components/ConnectedSettings.tsx +1 -0
- package/src/lib/avatarData.ts +40 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AvatarDialog — the avatar
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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
|
-
<
|
|
113
|
+
<AvatarBuilder name={name} />
|
|
112
114
|
</div>
|
|
113
115
|
</div>,
|
|
114
116
|
document.body
|
|
@@ -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: 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
|
|
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
|
}
|
|
@@ -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
|
+
}
|