@hadoku/task-ui-components 4.22.1 → 4.22.4
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/app-header.css +0 -6
- package/dist/components/AppHeader.d.ts +10 -7
- package/dist/components/AppHeader.d.ts.map +1 -1
- package/dist/components/AppHeader.js +2 -1
- package/dist/components/AppHeader.js.map +1 -1
- package/dist/components/AvatarPicker.d.ts +29 -0
- package/dist/components/AvatarPicker.d.ts.map +1 -0
- package/dist/components/AvatarPicker.js +326 -0
- package/dist/components/AvatarPicker.js.map +1 -0
- package/dist/components/ConnectedSettings.d.ts +20 -7
- package/dist/components/ConnectedSettings.d.ts.map +1 -1
- package/dist/components/ConnectedSettings.js +432 -276
- package/dist/components/ConnectedSettings.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/avatarEvent.d.ts +12 -0
- package/dist/lib/avatarEvent.d.ts.map +1 -0
- package/dist/lib/avatarEvent.js +5 -0
- package/dist/lib/avatarEvent.js.map +1 -0
- package/dist/styles.css +0 -5
- package/dist/task-ui-components.css +0 -5
- package/dist/vendor/wolffm/prefs-client/avatar.js +112 -0
- package/dist/vendor/wolffm/prefs-client/avatar.js.map +1 -0
- package/package.json +1 -1
- package/src/app-header.css +0 -6
- package/src/components/AppHeader.tsx +17 -7
- package/src/components/AvatarPicker.tsx +443 -0
- package/src/components/ConnectedSettings.tsx +543 -344
- package/src/index.ts +2 -1
- package/src/lib/avatarEvent.ts +11 -0
|
@@ -4,10 +4,14 @@
|
|
|
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
|
|
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
|
*
|
|
11
|
+
* Those are grouped into tabs (Profile, Content, Account), followed by the
|
|
12
|
+
* app's own: `children` as one "App" tab, `appTabs` as one tab each. The open
|
|
13
|
+
* tab is remembered per user and per app (useSettingsTab, @wolffm/themes).
|
|
14
|
+
*
|
|
11
15
|
* "Connected" (parallel to ConnectedThemePicker): it self-wires to the
|
|
12
16
|
* hadoku.me edge-router via same-origin fetches (see lib/settingsClient), so
|
|
13
17
|
* dropping `<ConnectedSettings />` into any app under hadoku.me needs zero
|
|
@@ -25,8 +29,10 @@
|
|
|
25
29
|
* maxLevel comes from the server so the tier ceiling is never trusted from the
|
|
26
30
|
* client.
|
|
27
31
|
*/
|
|
28
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
32
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
33
|
+
import { useSettingsTab } from '@wolffm/themes'
|
|
29
34
|
import { SettingsIcon } from './ThemeIcons'
|
|
35
|
+
import { AvatarPicker } from './AvatarPicker'
|
|
30
36
|
import {
|
|
31
37
|
deleteOwnAccount,
|
|
32
38
|
prefetchSettings,
|
|
@@ -55,14 +61,24 @@ export interface ConnectedSettingsProps {
|
|
|
55
61
|
name?: string | null
|
|
56
62
|
/** Notify the host (e.g. a "Signed in as …" label) of a rename. */
|
|
57
63
|
onNameChange?: (name: string | null) => void
|
|
58
|
-
/** App-specific settings
|
|
59
|
-
* an app with its own preferences keeps
|
|
60
|
-
* control.
|
|
64
|
+
/** App-specific settings as ONE tab, labelled "App", after the site's own
|
|
65
|
+
* tabs — so an app with its own preferences keeps one unified gear instead
|
|
66
|
+
* of a second control. For more than one section, use `appTabs`.
|
|
61
67
|
*
|
|
62
68
|
* This slot is deliberately unconstrained — put whatever the app needs in
|
|
63
|
-
* it. The constraint is only that it
|
|
64
|
-
* cannot change them. */
|
|
69
|
+
* it. The constraint is only that it cannot change the site's tabs. */
|
|
65
70
|
children?: React.ReactNode
|
|
71
|
+
/** App-specific settings as tabs of their own, after `children`'s "App" tab
|
|
72
|
+
* if both are given. `id` is stable and unique within the app: the last-open
|
|
73
|
+
* tab is remembered by it. `render` runs only while its tab is open. */
|
|
74
|
+
appTabs?: AppSettingsTab[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One app-contributed tab in the settings popout. */
|
|
78
|
+
export interface AppSettingsTab {
|
|
79
|
+
id: string
|
|
80
|
+
label: string
|
|
81
|
+
render: () => React.ReactNode
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
/** Rejects once `signal` aborts, so a call that ignores the signal (an older
|
|
@@ -104,7 +120,6 @@ const PASSKEY_DOT: React.CSSProperties = {
|
|
|
104
120
|
* inline, so the rule is injected once into the document instead, for the
|
|
105
121
|
* same reason PASSKEY_DOT is inline. */
|
|
106
122
|
const PASSKEY_PULSE_CLASS = 'settings-popout__btn--pulse'
|
|
107
|
-
const PASSKEY_PULSE_STYLE_ID = 'settings-passkey-pulse'
|
|
108
123
|
const PASSKEY_PULSE_CSS = `
|
|
109
124
|
@keyframes settings-passkey-pulse {
|
|
110
125
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 70%, transparent); }
|
|
@@ -118,14 +133,72 @@ const PASSKEY_PULSE_CSS = `
|
|
|
118
133
|
}
|
|
119
134
|
`
|
|
120
135
|
|
|
121
|
-
|
|
136
|
+
/** The tab strip and its panels. Injected for the same reason as the pulse:
|
|
137
|
+
* an app's copy of app-header.css predates the tabs until it rebuilds. The
|
|
138
|
+
* strip stays pinned while a long tab scrolls, and scrolls sideways itself
|
|
139
|
+
* when the tabs outgrow a phone-width panel. */
|
|
140
|
+
const TABS_CSS = `
|
|
141
|
+
.settings-popout__top {
|
|
142
|
+
position: sticky;
|
|
143
|
+
top: -16px;
|
|
144
|
+
z-index: 1;
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
gap: 10px;
|
|
148
|
+
margin: -16px -16px 0;
|
|
149
|
+
padding: 16px 16px 0;
|
|
150
|
+
background: var(--color-bg-card);
|
|
151
|
+
border-bottom: 1px solid var(--color-border);
|
|
152
|
+
}
|
|
153
|
+
.settings-popout__tabs {
|
|
154
|
+
display: flex;
|
|
155
|
+
gap: 2px;
|
|
156
|
+
margin: 0 -16px -1px;
|
|
157
|
+
padding: 0 16px;
|
|
158
|
+
overflow-x: auto;
|
|
159
|
+
scrollbar-width: thin;
|
|
160
|
+
}
|
|
161
|
+
.settings-popout__tab {
|
|
162
|
+
position: relative;
|
|
163
|
+
flex: none;
|
|
164
|
+
padding: 6px 10px;
|
|
165
|
+
border: 0;
|
|
166
|
+
border-bottom: 2px solid transparent;
|
|
167
|
+
background: transparent;
|
|
168
|
+
color: var(--color-text-secondary);
|
|
169
|
+
font: inherit;
|
|
170
|
+
font-size: 0.8rem;
|
|
171
|
+
font-weight: 600;
|
|
172
|
+
white-space: nowrap;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
}
|
|
175
|
+
.settings-popout__tab:hover {
|
|
176
|
+
color: var(--color-text);
|
|
177
|
+
}
|
|
178
|
+
.settings-popout__tab[aria-selected='true'] {
|
|
179
|
+
color: var(--color-text);
|
|
180
|
+
border-bottom-color: var(--color-primary);
|
|
181
|
+
}
|
|
182
|
+
.settings-popout__tab:focus-visible {
|
|
183
|
+
outline: 2px solid var(--color-primary);
|
|
184
|
+
outline-offset: -2px;
|
|
185
|
+
border-radius: 6px 6px 0 0;
|
|
186
|
+
}
|
|
187
|
+
.settings-popout__tabpanel {
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-direction: column;
|
|
190
|
+
gap: 14px;
|
|
191
|
+
}
|
|
192
|
+
`
|
|
193
|
+
|
|
194
|
+
function useInjectedStyle(id: string, css: string, active: boolean) {
|
|
122
195
|
useEffect(() => {
|
|
123
|
-
if (!active || document.getElementById(
|
|
196
|
+
if (!active || document.getElementById(id)) return
|
|
124
197
|
const el = document.createElement('style')
|
|
125
|
-
el.id =
|
|
126
|
-
el.textContent =
|
|
198
|
+
el.id = id
|
|
199
|
+
el.textContent = css
|
|
127
200
|
document.head.appendChild(el)
|
|
128
|
-
}, [active])
|
|
201
|
+
}, [id, css, active])
|
|
129
202
|
}
|
|
130
203
|
|
|
131
204
|
const TIER_LABEL: Record<Tier, string> = {
|
|
@@ -141,10 +214,16 @@ export function ConnectedSettings({
|
|
|
141
214
|
userType: userTypeProp,
|
|
142
215
|
name: nameProp,
|
|
143
216
|
onNameChange,
|
|
144
|
-
children
|
|
217
|
+
children,
|
|
218
|
+
appTabs
|
|
145
219
|
}: ConnectedSettingsProps) {
|
|
146
220
|
const [open, setOpen] = useState(false)
|
|
147
221
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
222
|
+
const tabIdBase = useId()
|
|
223
|
+
const tabRefs = useRef(new Map<string, HTMLButtonElement>())
|
|
224
|
+
const [savedTab, saveTab] = useSettingsTab()
|
|
225
|
+
// This visit's pick; wins over the saved one while it is still offered.
|
|
226
|
+
const [pickedTab, setPickedTab] = useState<string | null>(null)
|
|
148
227
|
|
|
149
228
|
// Identity — from props if supplied, else resolved lazily via whoami().
|
|
150
229
|
const [userType, setUserType] = useState<Tier>(userTypeProp ?? 'public')
|
|
@@ -180,6 +259,9 @@ export function ConnectedSettings({
|
|
|
180
259
|
const passkeyCtl = useRef<AbortController | null>(null)
|
|
181
260
|
const cancelPasskey = useCallback((reason?: string) => passkeyCtl.current?.abort(reason), [])
|
|
182
261
|
|
|
262
|
+
// Avatar: the picker loads only when opened, never at mount (AvatarPicker).
|
|
263
|
+
const [avatarOpen, setAvatarOpen] = useState(false)
|
|
264
|
+
|
|
183
265
|
// Content level
|
|
184
266
|
const [content, setContent] = useState<ContentLevelState | null>(null)
|
|
185
267
|
const [levelSaving, setLevelSaving] = useState(false)
|
|
@@ -206,7 +288,64 @@ export function ConnectedSettings({
|
|
|
206
288
|
passkeyCount === 0 &&
|
|
207
289
|
!passkeySaved &&
|
|
208
290
|
passkeysSupported()
|
|
209
|
-
|
|
291
|
+
useInjectedStyle('settings-passkey-pulse', PASSKEY_PULSE_CSS, needsPasskey)
|
|
292
|
+
useInjectedStyle('settings-tabs', TABS_CSS, open)
|
|
293
|
+
|
|
294
|
+
// The site's own groups first, then the app's. App ids are namespaced so an
|
|
295
|
+
// app tab called "account" can never collide with the site's.
|
|
296
|
+
const tabs: { id: string; label: string; dot?: boolean }[] = [
|
|
297
|
+
{ id: 'profile', label: 'Profile' },
|
|
298
|
+
...(!resolving && showContentPill ? [{ id: 'content', label: 'Content' }] : []),
|
|
299
|
+
// The gear's dot asks for a passkey; this one says where Save passkey is.
|
|
300
|
+
{ id: 'account', label: 'Account', dot: needsPasskey },
|
|
301
|
+
...(children ? [{ id: 'app:', label: 'App' }] : []),
|
|
302
|
+
...(appTabs ?? []).map(t => ({ id: `app:${t.id}`, label: t.label }))
|
|
303
|
+
]
|
|
304
|
+
const tabIds = tabs.map(t => t.id)
|
|
305
|
+
// Nothing remembered: open on the app's first tab, which is usually why the
|
|
306
|
+
// gear was clicked inside an app.
|
|
307
|
+
const defaultTab = tabIds.find(id => id.startsWith('app:')) ?? 'profile'
|
|
308
|
+
const activeTab =
|
|
309
|
+
[pickedTab, savedTab].find((id): id is string => id !== null && tabIds.includes(id)) ??
|
|
310
|
+
defaultTab
|
|
311
|
+
const appTab = appTabs?.find(t => `app:${t.id}` === activeTab)
|
|
312
|
+
const tabDomId = (id: string) => `${tabIdBase}tab-${id}`
|
|
313
|
+
const panelDomId = `${tabIdBase}panel`
|
|
314
|
+
|
|
315
|
+
const selectTab = useCallback(
|
|
316
|
+
(id: string) => {
|
|
317
|
+
setPickedTab(id)
|
|
318
|
+
saveTab(id)
|
|
319
|
+
},
|
|
320
|
+
[saveTab]
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
// Arrow keys, Home and End move between tabs and open the one they land on
|
|
324
|
+
// (the ARIA tablist pattern with automatic activation).
|
|
325
|
+
const onTabKey = (e: React.KeyboardEvent) => {
|
|
326
|
+
const i = tabIds.indexOf(activeTab)
|
|
327
|
+
const n = tabIds.length
|
|
328
|
+
const next =
|
|
329
|
+
e.key === 'ArrowRight'
|
|
330
|
+
? tabIds[(i + 1) % n]
|
|
331
|
+
: e.key === 'ArrowLeft'
|
|
332
|
+
? tabIds[(i - 1 + n) % n]
|
|
333
|
+
: e.key === 'Home'
|
|
334
|
+
? tabIds[0]
|
|
335
|
+
: e.key === 'End'
|
|
336
|
+
? tabIds[n - 1]
|
|
337
|
+
: undefined
|
|
338
|
+
if (next === undefined) return
|
|
339
|
+
e.preventDefault()
|
|
340
|
+
selectTab(next)
|
|
341
|
+
tabRefs.current.get(next)?.focus()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// A remembered tab past the edge of a narrow strip is scrolled into sight.
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
if (open)
|
|
347
|
+
tabRefs.current.get(activeTab)?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
348
|
+
}, [open, activeTab])
|
|
210
349
|
|
|
211
350
|
// Keep local identity in sync when the host controls it via props.
|
|
212
351
|
useEffect(() => {
|
|
@@ -415,7 +554,7 @@ export function ConnectedSettings({
|
|
|
415
554
|
// No caller-supplied class on the root. The four rows below are the
|
|
416
555
|
// platform's, not the app's, and a className here is enough to restyle or
|
|
417
556
|
// hide any of them — which is the drift this component exists to prevent.
|
|
418
|
-
// Apps extend via `children`, never by reaching into this markup.
|
|
557
|
+
// Apps extend via `children` and `appTabs`, never by reaching into this markup.
|
|
419
558
|
return (
|
|
420
559
|
<div className="settings-popout" ref={rootRef}>
|
|
421
560
|
<button
|
|
@@ -436,357 +575,417 @@ export function ConnectedSettings({
|
|
|
436
575
|
|
|
437
576
|
{open && (
|
|
438
577
|
<div className="settings-popout__panel" role="dialog" aria-label="User settings">
|
|
439
|
-
<div className="settings-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
<span className="settings-popout__value">…</span>
|
|
449
|
-
) : (
|
|
450
|
-
<span className={`settings-popout__tier settings-popout__tier--${userType}`}>
|
|
451
|
-
{TIER_LABEL[userType]}
|
|
452
|
-
</span>
|
|
453
|
-
)}
|
|
454
|
-
</section>
|
|
455
|
-
|
|
456
|
-
{/* Display name */}
|
|
457
|
-
<section className="settings-popout__row">
|
|
458
|
-
<span className="settings-popout__label">Display name</span>
|
|
459
|
-
{resolving ? (
|
|
460
|
-
<span className="settings-popout__value">…</span>
|
|
461
|
-
) : editingName ? (
|
|
462
|
-
<span className="settings-popout__inline">
|
|
463
|
-
<input
|
|
464
|
-
className="settings-popout__input"
|
|
465
|
-
value={nameDraft}
|
|
466
|
-
maxLength={64}
|
|
467
|
-
autoFocus
|
|
468
|
-
disabled={nameSaving}
|
|
469
|
-
aria-invalid={nameError !== null}
|
|
470
|
-
aria-describedby={nameError ? 'settings-popout-name-error' : undefined}
|
|
471
|
-
onChange={e => {
|
|
472
|
-
setNameDraft(e.target.value)
|
|
473
|
-
setNameError(null)
|
|
474
|
-
}}
|
|
475
|
-
onKeyDown={e => {
|
|
476
|
-
if (e.key === 'Enter') {
|
|
477
|
-
saveName().catch(reportErr)
|
|
478
|
-
}
|
|
479
|
-
if (e.key === 'Escape') {
|
|
480
|
-
// The edit's Escape, not the popout's (window listener).
|
|
481
|
-
e.stopPropagation()
|
|
482
|
-
setEditingName(false)
|
|
483
|
-
setNameError(null)
|
|
484
|
-
setNameDraft(displayName ?? '')
|
|
485
|
-
}
|
|
486
|
-
}}
|
|
487
|
-
/>
|
|
578
|
+
<div className="settings-popout__top">
|
|
579
|
+
<div className="settings-popout__header">Settings</div>
|
|
580
|
+
<div
|
|
581
|
+
className="settings-popout__tabs"
|
|
582
|
+
role="tablist"
|
|
583
|
+
aria-label="Settings sections"
|
|
584
|
+
onKeyDown={onTabKey}
|
|
585
|
+
>
|
|
586
|
+
{tabs.map(t => (
|
|
488
587
|
<button
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
saveName().catch(reportErr)
|
|
588
|
+
key={t.id}
|
|
589
|
+
ref={el => {
|
|
590
|
+
if (el) tabRefs.current.set(t.id, el)
|
|
591
|
+
else tabRefs.current.delete(t.id)
|
|
494
592
|
}}
|
|
593
|
+
type="button"
|
|
594
|
+
role="tab"
|
|
595
|
+
id={tabDomId(t.id)}
|
|
596
|
+
className="settings-popout__tab"
|
|
597
|
+
aria-selected={t.id === activeTab}
|
|
598
|
+
aria-controls={panelDomId}
|
|
599
|
+
tabIndex={t.id === activeTab ? 0 : -1}
|
|
600
|
+
onClick={() => selectTab(t.id)}
|
|
495
601
|
>
|
|
496
|
-
{
|
|
602
|
+
{t.label}
|
|
603
|
+
{t.dot && <span style={PASSKEY_DOT} aria-hidden="true" />}
|
|
497
604
|
</button>
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
605
|
+
))}
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
<div
|
|
610
|
+
className="settings-popout__tabpanel"
|
|
611
|
+
role="tabpanel"
|
|
612
|
+
id={panelDomId}
|
|
613
|
+
aria-labelledby={tabDomId(activeTab)}
|
|
614
|
+
>
|
|
615
|
+
{activeTab === 'profile' && (
|
|
616
|
+
<>
|
|
617
|
+
{/* Access tier. While resolving, the state still says 'public' —
|
|
618
|
+
rendering "Guest" there would be a wrong answer that corrects
|
|
619
|
+
itself a moment later, which reads exactly like the lag this
|
|
620
|
+
placeholder exists to remove. */}
|
|
621
|
+
<section className="settings-popout__row">
|
|
622
|
+
<span className="settings-popout__label">Access tier</span>
|
|
623
|
+
{resolving ? (
|
|
624
|
+
<span className="settings-popout__value">…</span>
|
|
625
|
+
) : (
|
|
626
|
+
<span className={`settings-popout__tier settings-popout__tier--${userType}`}>
|
|
627
|
+
{TIER_LABEL[userType]}
|
|
628
|
+
</span>
|
|
629
|
+
)}
|
|
630
|
+
</section>
|
|
631
|
+
|
|
632
|
+
{/* Display name */}
|
|
633
|
+
<section className="settings-popout__row">
|
|
634
|
+
<span className="settings-popout__label">Display name</span>
|
|
635
|
+
{resolving ? (
|
|
636
|
+
<span className="settings-popout__value">…</span>
|
|
637
|
+
) : editingName ? (
|
|
638
|
+
<span className="settings-popout__inline">
|
|
639
|
+
<input
|
|
640
|
+
className="settings-popout__input"
|
|
641
|
+
value={nameDraft}
|
|
642
|
+
maxLength={64}
|
|
643
|
+
autoFocus
|
|
644
|
+
disabled={nameSaving}
|
|
645
|
+
aria-invalid={nameError !== null}
|
|
646
|
+
aria-describedby={nameError ? 'settings-popout-name-error' : undefined}
|
|
647
|
+
onChange={e => {
|
|
648
|
+
setNameDraft(e.target.value)
|
|
649
|
+
setNameError(null)
|
|
650
|
+
}}
|
|
651
|
+
onKeyDown={e => {
|
|
652
|
+
if (e.key === 'Enter') {
|
|
653
|
+
saveName().catch(reportErr)
|
|
654
|
+
}
|
|
655
|
+
if (e.key === 'Escape') {
|
|
656
|
+
// The edit's Escape, not the popout's (window listener).
|
|
657
|
+
e.stopPropagation()
|
|
658
|
+
setEditingName(false)
|
|
659
|
+
setNameError(null)
|
|
660
|
+
setNameDraft(displayName ?? '')
|
|
661
|
+
}
|
|
662
|
+
}}
|
|
663
|
+
/>
|
|
552
664
|
<button
|
|
553
|
-
key={lvl}
|
|
554
665
|
type="button"
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
aria-label={`Level ${lvl}`}
|
|
558
|
-
className={`settings-popout__seg${
|
|
559
|
-
lvl <= content.level ? ' settings-popout__seg--filled' : ''
|
|
560
|
-
}`}
|
|
561
|
-
disabled={levelSaving}
|
|
666
|
+
className="settings-popout__btn settings-popout__btn--primary"
|
|
667
|
+
disabled={nameSaving}
|
|
562
668
|
onClick={() => {
|
|
563
|
-
|
|
669
|
+
saveName().catch(reportErr)
|
|
564
670
|
}}
|
|
565
671
|
>
|
|
566
|
-
{
|
|
672
|
+
{nameSaving ? '…' : 'Save'}
|
|
567
673
|
</button>
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
)}
|
|
580
|
-
</section>
|
|
581
|
-
)}
|
|
582
|
-
|
|
583
|
-
{/* Switch key. Signs in inline through the SITE's sign-in client
|
|
584
|
-
(the /auth page's own code path), never a post of ours. A real
|
|
585
|
-
<form> with a username + current-password pair, so password
|
|
586
|
-
managers offer and save the key. */}
|
|
587
|
-
{keyFormOpen ? (
|
|
588
|
-
<section className="settings-popout__row settings-popout__row--stack">
|
|
589
|
-
<form
|
|
590
|
-
className="settings-popout__key-form"
|
|
591
|
-
onSubmit={e => {
|
|
592
|
-
e.preventDefault()
|
|
593
|
-
submitKey().catch(reportErr)
|
|
594
|
-
}}
|
|
595
|
-
>
|
|
596
|
-
<input
|
|
597
|
-
type="text"
|
|
598
|
-
name="username"
|
|
599
|
-
autoComplete="username"
|
|
600
|
-
value="hadoku"
|
|
601
|
-
readOnly
|
|
602
|
-
hidden
|
|
603
|
-
/>
|
|
604
|
-
<label className="settings-popout__label" htmlFor="settings-popout-key">
|
|
605
|
-
Access key
|
|
606
|
-
</label>
|
|
607
|
-
<span className="settings-popout__inline">
|
|
608
|
-
<input
|
|
609
|
-
id="settings-popout-key"
|
|
610
|
-
className="settings-popout__input"
|
|
611
|
-
type="password"
|
|
612
|
-
name="password"
|
|
613
|
-
autoComplete="current-password"
|
|
614
|
-
autoCapitalize="none"
|
|
615
|
-
spellCheck={false}
|
|
616
|
-
autoFocus
|
|
617
|
-
value={keyDraft}
|
|
618
|
-
disabled={keyBusy}
|
|
619
|
-
aria-invalid={keyError !== null}
|
|
620
|
-
aria-describedby={keyError ? 'settings-popout-key-error' : undefined}
|
|
621
|
-
onChange={e => setKeyDraft(e.target.value)}
|
|
622
|
-
/>
|
|
623
|
-
<button
|
|
624
|
-
type="submit"
|
|
625
|
-
className="settings-popout__btn settings-popout__btn--primary"
|
|
626
|
-
disabled={keyBusy}
|
|
627
|
-
>
|
|
628
|
-
{keyBusy ? 'Signing in…' : 'Sign in'}
|
|
629
|
-
</button>
|
|
630
|
-
</span>
|
|
631
|
-
{passkeysSupported() && (
|
|
632
|
-
<span className="settings-popout__inline">
|
|
674
|
+
{nameError && (
|
|
675
|
+
<span
|
|
676
|
+
id="settings-popout-name-error"
|
|
677
|
+
className="settings-popout__error"
|
|
678
|
+
role="alert"
|
|
679
|
+
>
|
|
680
|
+
{nameError}
|
|
681
|
+
</span>
|
|
682
|
+
)}
|
|
683
|
+
</span>
|
|
684
|
+
) : (
|
|
633
685
|
<button
|
|
634
686
|
type="button"
|
|
635
|
-
className="settings-
|
|
636
|
-
|
|
687
|
+
className="settings-popout__value settings-popout__value--editable"
|
|
688
|
+
aria-label="Edit display name"
|
|
637
689
|
onClick={() => {
|
|
638
|
-
|
|
690
|
+
setNameDraft(displayName ?? '')
|
|
691
|
+
setEditingName(true)
|
|
639
692
|
}}
|
|
640
693
|
>
|
|
641
|
-
{
|
|
694
|
+
{displayName || 'Unnamed'}
|
|
642
695
|
</button>
|
|
643
|
-
|
|
696
|
+
)}
|
|
697
|
+
</section>
|
|
698
|
+
|
|
699
|
+
{/* Avatar: picture and character, chosen in place (AvatarPicker).
|
|
700
|
+
Collapsed until Change, so opening the gear costs no request and no
|
|
701
|
+
page load reads the avatar. Hidden for public callers, like the
|
|
702
|
+
content pill: an avatar needs a session. */}
|
|
703
|
+
{!resolving && userType !== 'public' && (
|
|
704
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
705
|
+
<span className="settings-popout__inline">
|
|
706
|
+
<span className="settings-popout__label settings-popout__label--grow">
|
|
707
|
+
Avatar
|
|
708
|
+
</span>
|
|
709
|
+
<button
|
|
710
|
+
type="button"
|
|
711
|
+
className="settings-popout__btn"
|
|
712
|
+
aria-expanded={avatarOpen}
|
|
713
|
+
onClick={() => setAvatarOpen(v => !v)}
|
|
714
|
+
>
|
|
715
|
+
{avatarOpen ? 'Done' : 'Change'}
|
|
716
|
+
</button>
|
|
717
|
+
</span>
|
|
718
|
+
{avatarOpen && <AvatarPicker />}
|
|
719
|
+
</section>
|
|
644
720
|
)}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
721
|
+
</>
|
|
722
|
+
)}
|
|
723
|
+
|
|
724
|
+
{/* Content visibility level. Held back until the first-open fetch
|
|
725
|
+
settles, so it lands in the same paint as the tier and name
|
|
726
|
+
rather than a round trip behind them. `content` is fetched
|
|
727
|
+
concurrently with whoami(), so by then it is already here. */}
|
|
728
|
+
{activeTab === 'content' && (
|
|
729
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
730
|
+
<span className="settings-popout__label">Content visibility</span>
|
|
731
|
+
{content ? (
|
|
732
|
+
<>
|
|
733
|
+
<div
|
|
734
|
+
className="settings-popout__pill"
|
|
735
|
+
role="radiogroup"
|
|
736
|
+
aria-label="Content visibility level"
|
|
653
737
|
>
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
738
|
+
{Array.from({ length: content.maxLevel }, (_, i) => i + 1).map(lvl => (
|
|
739
|
+
<button
|
|
740
|
+
key={lvl}
|
|
741
|
+
type="button"
|
|
742
|
+
role="radio"
|
|
743
|
+
aria-checked={lvl === content.level}
|
|
744
|
+
aria-label={`Level ${lvl}`}
|
|
745
|
+
className={`settings-popout__seg${
|
|
746
|
+
lvl <= content.level ? ' settings-popout__seg--filled' : ''
|
|
747
|
+
}`}
|
|
748
|
+
disabled={levelSaving}
|
|
749
|
+
onClick={() => {
|
|
750
|
+
pickLevel(lvl).catch(reportErr)
|
|
751
|
+
}}
|
|
752
|
+
>
|
|
753
|
+
{lvl}
|
|
754
|
+
</button>
|
|
755
|
+
))}
|
|
756
|
+
</div>
|
|
757
|
+
<span className="settings-popout__hint">
|
|
758
|
+
Higher levels reveal more mature content across apps. Level 1 is
|
|
759
|
+
safe-for-everyone.
|
|
760
|
+
</span>
|
|
761
|
+
</>
|
|
762
|
+
) : (
|
|
763
|
+
// Not "Loading…" — the fetch has already settled by the time
|
|
764
|
+
// this section renders at all, so a null here means it failed.
|
|
765
|
+
<span className="settings-popout__hint">Currently unavailable.</span>
|
|
666
766
|
)}
|
|
667
|
-
</
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
{
|
|
677
|
-
<
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
767
|
+
</section>
|
|
768
|
+
)}
|
|
769
|
+
|
|
770
|
+
{/* Switch key. Signs in inline through the SITE's sign-in client
|
|
771
|
+
(the /auth page's own code path), never a post of ours. A real
|
|
772
|
+
<form> with a username + current-password pair, so password
|
|
773
|
+
managers offer and save the key. */}
|
|
774
|
+
{activeTab === 'account' && (
|
|
775
|
+
<>
|
|
776
|
+
{keyFormOpen ? (
|
|
777
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
778
|
+
<form
|
|
779
|
+
className="settings-popout__key-form"
|
|
780
|
+
onSubmit={e => {
|
|
781
|
+
e.preventDefault()
|
|
782
|
+
submitKey().catch(reportErr)
|
|
783
|
+
}}
|
|
784
|
+
>
|
|
785
|
+
<input
|
|
786
|
+
type="text"
|
|
787
|
+
name="username"
|
|
788
|
+
autoComplete="username"
|
|
789
|
+
value="hadoku"
|
|
790
|
+
readOnly
|
|
791
|
+
hidden
|
|
792
|
+
/>
|
|
793
|
+
<label className="settings-popout__label" htmlFor="settings-popout-key">
|
|
794
|
+
Access key
|
|
795
|
+
</label>
|
|
796
|
+
<span className="settings-popout__inline">
|
|
797
|
+
<input
|
|
798
|
+
id="settings-popout-key"
|
|
799
|
+
className="settings-popout__input"
|
|
800
|
+
type="password"
|
|
801
|
+
name="password"
|
|
802
|
+
autoComplete="current-password"
|
|
803
|
+
autoCapitalize="none"
|
|
804
|
+
spellCheck={false}
|
|
805
|
+
autoFocus
|
|
806
|
+
value={keyDraft}
|
|
807
|
+
disabled={keyBusy}
|
|
808
|
+
aria-invalid={keyError !== null}
|
|
809
|
+
aria-describedby={keyError ? 'settings-popout-key-error' : undefined}
|
|
810
|
+
onChange={e => setKeyDraft(e.target.value)}
|
|
811
|
+
/>
|
|
812
|
+
<button
|
|
813
|
+
type="submit"
|
|
814
|
+
className="settings-popout__btn settings-popout__btn--primary"
|
|
815
|
+
disabled={keyBusy}
|
|
816
|
+
>
|
|
817
|
+
{keyBusy ? 'Signing in…' : 'Sign in'}
|
|
818
|
+
</button>
|
|
819
|
+
</span>
|
|
820
|
+
{passkeysSupported() && (
|
|
821
|
+
<span className="settings-popout__inline">
|
|
822
|
+
<button
|
|
823
|
+
type="button"
|
|
824
|
+
className="settings-popout__btn"
|
|
825
|
+
disabled={keyBusy}
|
|
826
|
+
onClick={() => {
|
|
827
|
+
signInWithPasskey().catch(reportErr)
|
|
828
|
+
}}
|
|
829
|
+
>
|
|
830
|
+
{passkeyMode === 'signin' ? 'Waiting for passkey…' : 'Use a passkey'}
|
|
831
|
+
</button>
|
|
832
|
+
</span>
|
|
833
|
+
)}
|
|
834
|
+
{/* Cancel on a line of its own: in the row it would slide under
|
|
835
|
+
the pointer, and a double click would cancel what it started. */}
|
|
836
|
+
{passkeyMode === 'signin' && (
|
|
837
|
+
<span className="settings-popout__inline">
|
|
838
|
+
<button
|
|
839
|
+
type="button"
|
|
840
|
+
className="settings-popout__btn"
|
|
841
|
+
onClick={() => cancelPasskey()}
|
|
842
|
+
>
|
|
843
|
+
Cancel
|
|
844
|
+
</button>
|
|
845
|
+
</span>
|
|
846
|
+
)}
|
|
847
|
+
{keyError && (
|
|
848
|
+
<span
|
|
849
|
+
id="settings-popout-key-error"
|
|
850
|
+
className="settings-popout__error"
|
|
851
|
+
role="alert"
|
|
852
|
+
>
|
|
853
|
+
{keyError}
|
|
854
|
+
</span>
|
|
855
|
+
)}
|
|
856
|
+
</form>
|
|
857
|
+
</section>
|
|
858
|
+
) : (
|
|
859
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
860
|
+
<span className="settings-popout__inline">
|
|
861
|
+
<span className="settings-popout__label settings-popout__label--grow">
|
|
862
|
+
Access key
|
|
863
|
+
</span>
|
|
864
|
+
{/* Signed-in people only: the site refuses to save one for anyone else. */}
|
|
865
|
+
{!resolving && userType !== 'public' && passkeysSupported() && (
|
|
866
|
+
<button
|
|
867
|
+
type="button"
|
|
868
|
+
className={`settings-popout__btn${needsPasskey ? ` settings-popout__btn--primary ${PASSKEY_PULSE_CLASS}` : ''}`}
|
|
869
|
+
disabled={passkeyMode !== null || passkeySaved}
|
|
870
|
+
onClick={() => {
|
|
871
|
+
savePasskey().catch(reportErr)
|
|
872
|
+
}}
|
|
873
|
+
>
|
|
874
|
+
{passkeySaved
|
|
875
|
+
? 'Passkey saved'
|
|
876
|
+
: passkeyMode === 'save'
|
|
877
|
+
? 'Saving…'
|
|
878
|
+
: 'Save passkey'}
|
|
879
|
+
</button>
|
|
880
|
+
)}
|
|
881
|
+
<button
|
|
882
|
+
type="button"
|
|
883
|
+
className="settings-popout__btn"
|
|
884
|
+
disabled={passkeyMode === 'save'}
|
|
885
|
+
onClick={openKeyForm}
|
|
886
|
+
>
|
|
887
|
+
Switch key
|
|
888
|
+
</button>
|
|
889
|
+
</span>
|
|
890
|
+
{/* Its own line, so nothing moves under a double click. */}
|
|
891
|
+
{passkeyMode === 'save' && (
|
|
892
|
+
<span className="settings-popout__inline">
|
|
893
|
+
<button
|
|
894
|
+
type="button"
|
|
895
|
+
className="settings-popout__btn"
|
|
896
|
+
onClick={() => cancelPasskey()}
|
|
897
|
+
>
|
|
898
|
+
Cancel
|
|
899
|
+
</button>
|
|
900
|
+
</span>
|
|
901
|
+
)}
|
|
902
|
+
{passkeyError && (
|
|
903
|
+
<span className="settings-popout__error" role="alert">
|
|
904
|
+
{passkeyError}
|
|
905
|
+
</span>
|
|
906
|
+
)}
|
|
907
|
+
</section>
|
|
691
908
|
)}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
className="settings-popout__btn"
|
|
695
|
-
disabled={passkeyMode === 'save'}
|
|
696
|
-
onClick={openKeyForm}
|
|
697
|
-
>
|
|
698
|
-
Switch key
|
|
699
|
-
</button>
|
|
700
|
-
</span>
|
|
701
|
-
{/* Its own line, so nothing moves under a double click. */}
|
|
702
|
-
{passkeyMode === 'save' && (
|
|
703
|
-
<span className="settings-popout__inline">
|
|
704
|
-
<button
|
|
705
|
-
type="button"
|
|
706
|
-
className="settings-popout__btn"
|
|
707
|
-
onClick={() => cancelPasskey()}
|
|
708
|
-
>
|
|
709
|
-
Cancel
|
|
710
|
-
</button>
|
|
711
|
-
</span>
|
|
712
|
-
)}
|
|
713
|
-
{passkeyError && (
|
|
714
|
-
<span className="settings-popout__error" role="alert">
|
|
715
|
-
{passkeyError}
|
|
716
|
-
</span>
|
|
717
|
-
)}
|
|
718
|
-
</section>
|
|
719
|
-
)}
|
|
720
|
-
|
|
721
|
-
{/* A self-made account (member) can delete itself. The confirm is a
|
|
909
|
+
|
|
910
|
+
{/* A self-made account (member) can delete itself. The confirm is a
|
|
722
911
|
separate button on its own line, so a double click on Delete
|
|
723
912
|
never confirms. */}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
>
|
|
737
|
-
Delete account
|
|
738
|
-
</button>
|
|
739
|
-
) : (
|
|
740
|
-
<button
|
|
741
|
-
type="button"
|
|
742
|
-
className="settings-popout__btn"
|
|
743
|
-
disabled={deleteStage === 'busy'}
|
|
744
|
-
onClick={() => setDeleteStage('idle')}
|
|
745
|
-
>
|
|
746
|
-
Keep
|
|
747
|
-
</button>
|
|
748
|
-
)}
|
|
749
|
-
</span>
|
|
750
|
-
{deleteStage !== 'idle' && (
|
|
751
|
-
<span className="settings-popout__inline">
|
|
752
|
-
<button
|
|
753
|
-
type="button"
|
|
754
|
-
className="settings-popout__btn settings-popout__btn--primary"
|
|
755
|
-
disabled={deleteStage === 'busy'}
|
|
756
|
-
onClick={() => {
|
|
757
|
-
setDeleteStage('busy')
|
|
758
|
-
deleteOwnAccount()
|
|
759
|
-
.then(deleted => {
|
|
760
|
-
if (!deleted) {
|
|
761
|
-
setDeleteError('Account not deleted')
|
|
913
|
+
{!resolving && userType === 'member' && (
|
|
914
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
915
|
+
<span className="settings-popout__inline">
|
|
916
|
+
<span className="settings-popout__label settings-popout__label--grow">
|
|
917
|
+
Account
|
|
918
|
+
</span>
|
|
919
|
+
{deleteStage === 'idle' ? (
|
|
920
|
+
<button
|
|
921
|
+
type="button"
|
|
922
|
+
className="settings-popout__btn"
|
|
923
|
+
onClick={() => {
|
|
924
|
+
setDeleteError(null)
|
|
762
925
|
setDeleteStage('confirm')
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
926
|
+
}}
|
|
927
|
+
>
|
|
928
|
+
Delete account
|
|
929
|
+
</button>
|
|
930
|
+
) : (
|
|
931
|
+
<button
|
|
932
|
+
type="button"
|
|
933
|
+
className="settings-popout__btn"
|
|
934
|
+
disabled={deleteStage === 'busy'}
|
|
935
|
+
onClick={() => setDeleteStage('idle')}
|
|
936
|
+
>
|
|
937
|
+
Keep
|
|
938
|
+
</button>
|
|
939
|
+
)}
|
|
940
|
+
</span>
|
|
941
|
+
{deleteStage !== 'idle' && (
|
|
942
|
+
<span className="settings-popout__inline">
|
|
943
|
+
<button
|
|
944
|
+
type="button"
|
|
945
|
+
className="settings-popout__btn settings-popout__btn--primary"
|
|
946
|
+
disabled={deleteStage === 'busy'}
|
|
947
|
+
onClick={() => {
|
|
948
|
+
setDeleteStage('busy')
|
|
949
|
+
deleteOwnAccount()
|
|
950
|
+
.then(deleted => {
|
|
951
|
+
if (!deleted) {
|
|
952
|
+
setDeleteError('Account not deleted')
|
|
953
|
+
setDeleteStage('confirm')
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
localStorage.removeItem('hadoku_session_id')
|
|
957
|
+
localStorage.removeItem('hadoku_user_type')
|
|
958
|
+
window.location.assign('/')
|
|
959
|
+
})
|
|
960
|
+
.catch(reportErr)
|
|
961
|
+
}}
|
|
962
|
+
>
|
|
963
|
+
{deleteStage === 'busy' ? 'Deleting…' : 'Delete for good'}
|
|
964
|
+
</button>
|
|
965
|
+
</span>
|
|
966
|
+
)}
|
|
967
|
+
{deleteError && (
|
|
968
|
+
<span className="settings-popout__error" role="alert">
|
|
969
|
+
{deleteError}
|
|
970
|
+
</span>
|
|
971
|
+
)}
|
|
972
|
+
</section>
|
|
973
|
+
)}
|
|
974
|
+
</>
|
|
975
|
+
)}
|
|
976
|
+
|
|
977
|
+
{/* App-specific settings (optional) — same popout, one gear. */}
|
|
978
|
+
{activeTab === 'app:' && (
|
|
979
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
980
|
+
{children}
|
|
981
|
+
</section>
|
|
982
|
+
)}
|
|
983
|
+
{appTab && (
|
|
984
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
985
|
+
{appTab.render()}
|
|
986
|
+
</section>
|
|
987
|
+
)}
|
|
988
|
+
</div>
|
|
790
989
|
</div>
|
|
791
990
|
)}
|
|
792
991
|
</div>
|