@hadoku/task-ui-components 4.23.5 → 4.23.7
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 +731 -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 +21 -18
- package/dist/components/AvatarPicker.d.ts.map +1 -1
- package/dist/components/AvatarPicker.js +17 -145
- package/dist/components/AvatarPicker.js.map +1 -1
- package/dist/components/ConnectedSettings.d.ts.map +1 -1
- package/dist/components/ConnectedSettings.js +20 -1
- package/dist/components/ConnectedSettings.js.map +1 -1
- package/dist/components/LinkedAccounts.d.ts +21 -0
- package/dist/components/LinkedAccounts.d.ts.map +1 -0
- package/dist/components/LinkedAccounts.js +119 -0
- package/dist/components/LinkedAccounts.js.map +1 -0
- 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 +901 -0
- package/src/components/AvatarDialog.tsx +8 -6
- package/src/components/AvatarPicker.tsx +45 -228
- package/src/components/ConnectedSettings.tsx +26 -0
- package/src/components/LinkedAccounts.tsx +176 -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,56 +1,26 @@
|
|
|
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
|
-
* from here
|
|
10
|
-
* (
|
|
11
|
-
* keeps running.
|
|
10
|
+
* from here. Linking Discord or Steam lives in the popout's Account tab
|
|
11
|
+
* (LinkedAccounts); a linked account's picture is a choice here.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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.
|
|
13
|
+
* AvatarBuilder owns the settings and the character table and loads them only
|
|
14
|
+
* when the dialog opens; this component draws them and sends changes through
|
|
15
|
+
* `run`, which applies the answer everywhere in the dialog.
|
|
22
16
|
*
|
|
23
17
|
* Styles are inline for the reason PASSKEY_DOT's are (ConnectedSettings.tsx):
|
|
24
18
|
* apps compile app-header.css at their own build, so a new rule there reaches
|
|
25
19
|
* an app only when it rebuilds.
|
|
26
20
|
*/
|
|
27
|
-
import React, {
|
|
28
|
-
import {
|
|
29
|
-
|
|
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
|
-
}
|
|
21
|
+
import React, { useEffect, useState } from 'react'
|
|
22
|
+
import { type AvatarSettings, type AvatarSource } from '@wolffm/prefs-client/avatar'
|
|
23
|
+
import { avatarClient, portraitUrl, type CharacterSet } from '../lib/avatarData'
|
|
54
24
|
|
|
55
25
|
const LABEL: Record<AvatarSource, string> = {
|
|
56
26
|
sprite: 'Character',
|
|
@@ -59,39 +29,8 @@ const LABEL: Record<AvatarSource, string> = {
|
|
|
59
29
|
upload: 'Upload'
|
|
60
30
|
}
|
|
61
31
|
|
|
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
32
|
const S = {
|
|
84
33
|
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
34
|
img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
|
|
96
35
|
sources: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 },
|
|
97
36
|
source: {
|
|
@@ -130,14 +69,9 @@ const S = {
|
|
|
130
69
|
cursor: 'pointer'
|
|
131
70
|
},
|
|
132
71
|
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 }
|
|
72
|
+
grow: { flex: 1, minWidth: 0 }
|
|
135
73
|
} satisfies Record<string, React.CSSProperties>
|
|
136
74
|
|
|
137
|
-
function portraitUrl(c: Character): string {
|
|
138
|
-
return c.portrait.startsWith('/') ? `${WATCHPARTY_BASE}${c.portrait}` : c.portrait
|
|
139
|
-
}
|
|
140
|
-
|
|
141
75
|
function Picture({ src, style }: { src: string | null; style: React.CSSProperties }) {
|
|
142
76
|
const [failed, setFailed] = useState(false)
|
|
143
77
|
useEffect(() => setFailed(false), [src])
|
|
@@ -156,63 +90,29 @@ function Picture({ src, style }: { src: string | null; style: React.CSSPropertie
|
|
|
156
90
|
)
|
|
157
91
|
}
|
|
158
92
|
|
|
159
|
-
export
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
93
|
+
export interface AvatarPickerProps {
|
|
94
|
+
settings: AvatarSettings
|
|
95
|
+
sets: CharacterSet[] | null
|
|
96
|
+
busy: boolean
|
|
97
|
+
/** Send a change; the builder applies the answer and reports failures. */
|
|
98
|
+
run: (work: () => Promise<AvatarSettings>) => Promise<void>
|
|
99
|
+
}
|
|
166
100
|
|
|
101
|
+
export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
|
|
102
|
+
const [setIdx, setSetIdx] = useState(0)
|
|
103
|
+
// Open on the set holding the person's character.
|
|
167
104
|
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
|
-
}, [])
|
|
105
|
+
const at = sets?.findIndex(s => s.characters.some(c => c.id === settings.sprite)) ?? -1
|
|
106
|
+
if (at > 0) setSetIdx(at)
|
|
107
|
+
// Only when the table arrives: afterwards the person moves between sets.
|
|
108
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
|
+
}, [sets])
|
|
201
110
|
|
|
202
111
|
const characters = new Map((sets ?? []).flatMap(s => s.characters).map(c => [c.id, c]))
|
|
203
112
|
const spriteSrc = (id: string | null | undefined) => {
|
|
204
113
|
const c = id ? characters.get(id) : undefined
|
|
205
114
|
return c ? portraitUrl(c) : null
|
|
206
115
|
}
|
|
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
116
|
|
|
217
117
|
const available: Record<AvatarSource, boolean> = {
|
|
218
118
|
sprite: true,
|
|
@@ -229,28 +129,13 @@ export function AvatarPicker() {
|
|
|
229
129
|
const shown = settings.avatar
|
|
230
130
|
const current = sets?.[setIdx]
|
|
231
131
|
|
|
232
|
-
// Must stay synchronous up to linkInPopup: the popup opens before its first
|
|
233
|
-
// await, inside this click, which is what popup blockers allow.
|
|
234
|
-
const link = (provider: LinkProvider) => {
|
|
235
|
-
setConsent(null)
|
|
236
|
-
run(async () => {
|
|
237
|
-
const next = await client.linkInPopup(provider)
|
|
238
|
-
// Linking is choosing, when the account came back with a picture.
|
|
239
|
-
return next.linked[provider]?.avatarUrl ? client.choose(provider) : next
|
|
240
|
-
}).catch(reportErr)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
132
|
return (
|
|
244
133
|
<div style={S.stack}>
|
|
245
|
-
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
{LABEL[shown.source]}
|
|
249
|
-
{settings.choice && settings.choice !== shown.source
|
|
250
|
-
? ` (${LABEL[settings.choice]} unavailable)`
|
|
251
|
-
: ''}
|
|
134
|
+
{settings.choice && settings.choice !== shown.source && (
|
|
135
|
+
<span className="settings-popout__hint">
|
|
136
|
+
{LABEL[settings.choice]} is unavailable, so your character shows instead.
|
|
252
137
|
</span>
|
|
253
|
-
|
|
138
|
+
)}
|
|
254
139
|
|
|
255
140
|
<div style={S.sources} role="radiogroup" aria-label="Picture">
|
|
256
141
|
{(Object.keys(LABEL) as AvatarSource[]).map(src => (
|
|
@@ -260,14 +145,20 @@ export function AvatarPicker() {
|
|
|
260
145
|
role="radio"
|
|
261
146
|
aria-checked={shown.source === src}
|
|
262
147
|
disabled={busy || !available[src]}
|
|
263
|
-
title={
|
|
148
|
+
title={
|
|
149
|
+
available[src]
|
|
150
|
+
? undefined
|
|
151
|
+
: src === 'upload'
|
|
152
|
+
? 'Upload a picture below first'
|
|
153
|
+
: `Link ${LABEL[src]} under Settings › Account first`
|
|
154
|
+
}
|
|
264
155
|
style={{
|
|
265
156
|
...S.source,
|
|
266
157
|
...(shown.source === src ? S.sourceOn : {}),
|
|
267
158
|
opacity: available[src] ? 1 : 0.5
|
|
268
159
|
}}
|
|
269
160
|
onClick={() => {
|
|
270
|
-
run(() =>
|
|
161
|
+
run(() => avatarClient.choose(src)).catch(reportErr)
|
|
271
162
|
}}
|
|
272
163
|
>
|
|
273
164
|
<Picture src={thumbOf[src]} style={S.thumb} />
|
|
@@ -308,7 +199,7 @@ export function AvatarPicker() {
|
|
|
308
199
|
borderColor: settings.sprite === c.id ? c.color : 'transparent'
|
|
309
200
|
}}
|
|
310
201
|
onClick={() => {
|
|
311
|
-
run(() =>
|
|
202
|
+
run(() => avatarClient.update({ sprite: c.id })).catch(reportErr)
|
|
312
203
|
}}
|
|
313
204
|
>
|
|
314
205
|
<img src={portraitUrl(c)} alt="" style={S.img} />
|
|
@@ -320,69 +211,6 @@ export function AvatarPicker() {
|
|
|
320
211
|
<span className="settings-popout__hint">…</span>
|
|
321
212
|
))}
|
|
322
213
|
|
|
323
|
-
{(['discord', 'steam'] as const).map(p => {
|
|
324
|
-
const linked = settings.linked[p]
|
|
325
|
-
return (
|
|
326
|
-
<div key={p} style={S.line}>
|
|
327
|
-
<span className="settings-popout__label" style={S.grow}>
|
|
328
|
-
{LABEL[p]}
|
|
329
|
-
{!settings.providers[p]
|
|
330
|
-
? ' · not available yet'
|
|
331
|
-
: linked
|
|
332
|
-
? linked.avatarUrl
|
|
333
|
-
? ' · linked'
|
|
334
|
-
: ' · linked, no picture'
|
|
335
|
-
: ''}
|
|
336
|
-
</span>
|
|
337
|
-
{settings.providers[p] && !linked && (
|
|
338
|
-
<button
|
|
339
|
-
type="button"
|
|
340
|
-
className="settings-popout__btn"
|
|
341
|
-
disabled={busy}
|
|
342
|
-
onClick={() => setConsent(consent === p ? null : p)}
|
|
343
|
-
>
|
|
344
|
-
Link
|
|
345
|
-
</button>
|
|
346
|
-
)}
|
|
347
|
-
{linked && (
|
|
348
|
-
<button
|
|
349
|
-
type="button"
|
|
350
|
-
className="settings-popout__btn"
|
|
351
|
-
disabled={busy}
|
|
352
|
-
onClick={() => {
|
|
353
|
-
run(() => client.unlink(p)).catch(reportErr)
|
|
354
|
-
}}
|
|
355
|
-
>
|
|
356
|
-
Unlink
|
|
357
|
-
</button>
|
|
358
|
-
)}
|
|
359
|
-
</div>
|
|
360
|
-
)
|
|
361
|
-
})}
|
|
362
|
-
|
|
363
|
-
{consent && (
|
|
364
|
-
<div style={S.stack}>
|
|
365
|
-
<ul style={S.list}>
|
|
366
|
-
{CONSENT[consent].points.map(t => (
|
|
367
|
-
<li key={t}>{t}</li>
|
|
368
|
-
))}
|
|
369
|
-
</ul>
|
|
370
|
-
<span style={S.line}>
|
|
371
|
-
<button
|
|
372
|
-
type="button"
|
|
373
|
-
className="settings-popout__btn settings-popout__btn--primary"
|
|
374
|
-
disabled={busy}
|
|
375
|
-
onClick={() => link(consent)}
|
|
376
|
-
>
|
|
377
|
-
{CONSENT[consent].go}
|
|
378
|
-
</button>
|
|
379
|
-
<button type="button" className="settings-popout__btn" onClick={() => setConsent(null)}>
|
|
380
|
-
Cancel
|
|
381
|
-
</button>
|
|
382
|
-
</span>
|
|
383
|
-
</div>
|
|
384
|
-
)}
|
|
385
|
-
|
|
386
214
|
<div style={S.line}>
|
|
387
215
|
<span className="settings-popout__label" style={S.grow}>
|
|
388
216
|
Upload
|
|
@@ -397,7 +225,7 @@ export function AvatarPicker() {
|
|
|
397
225
|
onChange={e => {
|
|
398
226
|
const file = e.currentTarget.files?.[0]
|
|
399
227
|
e.currentTarget.value = ''
|
|
400
|
-
if (file) run(() =>
|
|
228
|
+
if (file) run(() => avatarClient.upload(file)).catch(reportErr)
|
|
401
229
|
}}
|
|
402
230
|
/>
|
|
403
231
|
</label>
|
|
@@ -407,28 +235,17 @@ export function AvatarPicker() {
|
|
|
407
235
|
className="settings-popout__btn"
|
|
408
236
|
disabled={busy}
|
|
409
237
|
onClick={() => {
|
|
410
|
-
run(() =>
|
|
238
|
+
run(() => avatarClient.removeUpload()).catch(reportErr)
|
|
411
239
|
}}
|
|
412
240
|
>
|
|
413
241
|
Remove
|
|
414
242
|
</button>
|
|
415
243
|
)}
|
|
416
244
|
</div>
|
|
417
|
-
|
|
418
|
-
{error && (
|
|
419
|
-
<span className="settings-popout__error" role="alert">
|
|
420
|
-
{error}
|
|
421
|
-
</span>
|
|
422
|
-
)}
|
|
423
245
|
</div>
|
|
424
246
|
)
|
|
425
247
|
}
|
|
426
248
|
|
|
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
249
|
function reportErr(err: unknown) {
|
|
433
250
|
console.error('[AvatarPicker] action failed', (err as Error)?.message ?? err)
|
|
434
251
|
}
|
|
@@ -33,6 +33,7 @@ import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
|
33
33
|
import { useSettingsTab } from '@wolffm/themes'
|
|
34
34
|
import { SettingsIcon } from './ThemeIcons'
|
|
35
35
|
import { AvatarDialog } from './AvatarDialog'
|
|
36
|
+
import { LinkedAccounts } from './LinkedAccounts'
|
|
36
37
|
|
|
37
38
|
// A grantee's own service credentials, with a QR encoder for TOTP enrolment:
|
|
38
39
|
// its own chunk, fetched only when a grantee opens it.
|
|
@@ -267,6 +268,8 @@ export function ConnectedSettings({
|
|
|
267
268
|
|
|
268
269
|
// Avatar: the dialog's picker loads only when opened, never at mount (AvatarPicker).
|
|
269
270
|
const [avatarOpen, setAvatarOpen] = useState(false)
|
|
271
|
+
// Linked accounts: loaded only when the row is opened (LinkedAccounts).
|
|
272
|
+
const [linksOpen, setLinksOpen] = useState(false)
|
|
270
273
|
const avatarBtn = useRef<HTMLButtonElement>(null)
|
|
271
274
|
// Service identities this person may mint for (whoami.serviceGrants).
|
|
272
275
|
const [serviceGrants, setServiceGrants] = useState(0)
|
|
@@ -734,6 +737,7 @@ export function ConnectedSettings({
|
|
|
734
737
|
open={avatarOpen}
|
|
735
738
|
onClose={() => setAvatarOpen(false)}
|
|
736
739
|
returnFocus={avatarBtn}
|
|
740
|
+
name={displayName}
|
|
737
741
|
/>
|
|
738
742
|
</section>
|
|
739
743
|
)}
|
|
@@ -951,6 +955,28 @@ export function ConnectedSettings({
|
|
|
951
955
|
</section>
|
|
952
956
|
)}
|
|
953
957
|
|
|
958
|
+
{/* Discord and Steam: linked here, so their pictures can be chosen in
|
|
959
|
+
the avatar dialog. Collapsed until Open: the read can refresh a
|
|
960
|
+
linked picture, which opening the gear must not cost. */}
|
|
961
|
+
{!resolving && userType !== 'public' && (
|
|
962
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
963
|
+
<span className="settings-popout__inline">
|
|
964
|
+
<span className="settings-popout__label settings-popout__label--grow">
|
|
965
|
+
Linked accounts
|
|
966
|
+
</span>
|
|
967
|
+
<button
|
|
968
|
+
type="button"
|
|
969
|
+
className="settings-popout__btn"
|
|
970
|
+
aria-expanded={linksOpen}
|
|
971
|
+
onClick={() => setLinksOpen(v => !v)}
|
|
972
|
+
>
|
|
973
|
+
{linksOpen ? 'Done' : 'Open'}
|
|
974
|
+
</button>
|
|
975
|
+
</span>
|
|
976
|
+
{linksOpen && <LinkedAccounts />}
|
|
977
|
+
</section>
|
|
978
|
+
)}
|
|
979
|
+
|
|
954
980
|
{/* A self-made account (member) can delete itself. The confirm is a
|
|
955
981
|
separate button on its own line, so a double click on Delete
|
|
956
982
|
never confirms. */}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedAccounts — Discord and Steam, linked and unlinked from the settings
|
|
3
|
+
* popout's Account tab (operator, 2026-11-02: linking is an account matter, not
|
|
4
|
+
* part of changing the avatar). A linked account's picture then shows up as a
|
|
5
|
+
* choice in the avatar dialog's Picture tab.
|
|
6
|
+
*
|
|
7
|
+
* Loaded only when the person opens the row, never at mount: the read can
|
|
8
|
+
* refresh a linked picture from Discord or Steam, which opening the gear must
|
|
9
|
+
* not cost (settings-prefetch e2e).
|
|
10
|
+
*
|
|
11
|
+
* Linking signs in through a popup (`linkInPopup`), so the page under the
|
|
12
|
+
* popout never navigates and a game keeps running.
|
|
13
|
+
*
|
|
14
|
+
* COMPLIANCE (Steam Web API terms; Discord developer terms), carried over from
|
|
15
|
+
* hadoku.me/account: nothing is fetched from a provider until the person reads
|
|
16
|
+
* what is read and kept and presses Continue; no provider logos, and no implied
|
|
17
|
+
* endorsement.
|
|
18
|
+
*/
|
|
19
|
+
import React, { useEffect, useState } from 'react'
|
|
20
|
+
import type { AvatarSettings, LinkProvider } from '@wolffm/prefs-client/avatar'
|
|
21
|
+
import { AVATAR_EVENT } from '../lib/avatarEvent'
|
|
22
|
+
import { avatarClient, avatarErrorText } from '../lib/avatarData'
|
|
23
|
+
|
|
24
|
+
const LABEL: Record<LinkProvider, string> = { discord: 'Discord', steam: 'Steam' }
|
|
25
|
+
|
|
26
|
+
const CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {
|
|
27
|
+
discord: {
|
|
28
|
+
points: [
|
|
29
|
+
'Discord asks you to share your basic profile ("identify").',
|
|
30
|
+
'Kept: your Discord user ID and the address of your profile picture, refreshed while linked.',
|
|
31
|
+
'Not kept: your username, email, servers or any token.',
|
|
32
|
+
'Unlink deletes both.'
|
|
33
|
+
],
|
|
34
|
+
go: 'Continue to Discord'
|
|
35
|
+
},
|
|
36
|
+
steam: {
|
|
37
|
+
points: [
|
|
38
|
+
'You sign in on steamcommunity.com; your password never reaches hadoku.me.',
|
|
39
|
+
'Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.',
|
|
40
|
+
'Kept: your Steam ID and the address of that picture.',
|
|
41
|
+
'Unlink deletes both and stops the reads.'
|
|
42
|
+
],
|
|
43
|
+
go: 'Continue to Steam'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const S = {
|
|
48
|
+
stack: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' },
|
|
49
|
+
line: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
|
|
50
|
+
grow: { flex: 1, minWidth: 0 },
|
|
51
|
+
list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 }
|
|
52
|
+
} satisfies Record<string, React.CSSProperties>
|
|
53
|
+
|
|
54
|
+
export function LinkedAccounts() {
|
|
55
|
+
const [settings, setSettings] = useState<AvatarSettings | null>(null)
|
|
56
|
+
const [busy, setBusy] = useState(false)
|
|
57
|
+
const [error, setError] = useState<string | null>(null)
|
|
58
|
+
const [consent, setConsent] = useState<LinkProvider | null>(null)
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
let live = true
|
|
62
|
+
avatarClient
|
|
63
|
+
.get()
|
|
64
|
+
.then(s => live && setSettings(s))
|
|
65
|
+
.catch((err: unknown) => live && setError(avatarErrorText(err)))
|
|
66
|
+
return () => {
|
|
67
|
+
live = false
|
|
68
|
+
}
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
const run = async (work: () => Promise<AvatarSettings>) => {
|
|
72
|
+
setBusy(true)
|
|
73
|
+
setError(null)
|
|
74
|
+
try {
|
|
75
|
+
const next = await work()
|
|
76
|
+
setSettings(next)
|
|
77
|
+
// The page under the popout (a watchparty room, a game) re-reads the
|
|
78
|
+
// avatar from this, as it does after a change in the avatar dialog.
|
|
79
|
+
window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))
|
|
80
|
+
} catch (err) {
|
|
81
|
+
setError(avatarErrorText(err))
|
|
82
|
+
} finally {
|
|
83
|
+
setBusy(false)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!settings) {
|
|
88
|
+
return (
|
|
89
|
+
<span className="settings-popout__hint" role={error ? 'alert' : undefined}>
|
|
90
|
+
{error ?? '…'}
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Must stay synchronous up to linkInPopup: the popup opens before its first
|
|
96
|
+
// await, inside this click, which is what popup blockers allow.
|
|
97
|
+
const link = (provider: LinkProvider) => {
|
|
98
|
+
setConsent(null)
|
|
99
|
+
void run(async () => {
|
|
100
|
+
const next = await avatarClient.linkInPopup(provider)
|
|
101
|
+
// Linking is choosing, when the account came back with a picture.
|
|
102
|
+
return next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div style={S.stack}>
|
|
108
|
+
{(['discord', 'steam'] as const).map(p => {
|
|
109
|
+
const linked = settings.linked[p]
|
|
110
|
+
return (
|
|
111
|
+
<div key={p} style={S.line}>
|
|
112
|
+
<span className="settings-popout__label" style={S.grow}>
|
|
113
|
+
{LABEL[p]}
|
|
114
|
+
{!settings.providers[p]
|
|
115
|
+
? ' · not available yet'
|
|
116
|
+
: linked
|
|
117
|
+
? linked.avatarUrl
|
|
118
|
+
? ' · linked'
|
|
119
|
+
: ' · linked, no picture'
|
|
120
|
+
: ''}
|
|
121
|
+
</span>
|
|
122
|
+
{settings.providers[p] && !linked && (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
className="settings-popout__btn"
|
|
126
|
+
disabled={busy}
|
|
127
|
+
onClick={() => setConsent(consent === p ? null : p)}
|
|
128
|
+
>
|
|
129
|
+
Link
|
|
130
|
+
</button>
|
|
131
|
+
)}
|
|
132
|
+
{linked && (
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className="settings-popout__btn"
|
|
136
|
+
disabled={busy}
|
|
137
|
+
onClick={() => void run(() => avatarClient.unlink(p))}
|
|
138
|
+
>
|
|
139
|
+
Unlink
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
})}
|
|
145
|
+
|
|
146
|
+
{consent && (
|
|
147
|
+
<div style={S.stack}>
|
|
148
|
+
<ul style={S.list}>
|
|
149
|
+
{CONSENT[consent].points.map(t => (
|
|
150
|
+
<li key={t}>{t}</li>
|
|
151
|
+
))}
|
|
152
|
+
</ul>
|
|
153
|
+
<span style={S.line}>
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
className="settings-popout__btn settings-popout__btn--primary"
|
|
157
|
+
disabled={busy}
|
|
158
|
+
onClick={() => link(consent)}
|
|
159
|
+
>
|
|
160
|
+
{CONSENT[consent].go}
|
|
161
|
+
</button>
|
|
162
|
+
<button type="button" className="settings-popout__btn" onClick={() => setConsent(null)}>
|
|
163
|
+
Cancel
|
|
164
|
+
</button>
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{error && (
|
|
170
|
+
<span className="settings-popout__error" role="alert">
|
|
171
|
+
{error}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|