@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.
@@ -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 (a link to hadoku.me/account, where it is managed)
7
+ * - avatar: picture and character, chosen in place (AvatarPicker)
8
8
  * - global content-visibility level (tiered pill → PUT /prefs/api/v1/content-level)
9
9
  * - switch key (an inline sign-in form, on the site's own sign-in client)
10
10
  *
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 rendered as a final section in the same popout, so
59
- * an app with its own preferences keeps ONE unified gear instead of a second
60
- * control. Shown under an "App preferences" divider.
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 lands AFTER the four canonical rows and
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
- function usePasskeyPulseStyle(active: boolean) {
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(PASSKEY_PULSE_STYLE_ID)) return
196
+ if (!active || document.getElementById(id)) return
124
197
  const el = document.createElement('style')
125
- el.id = PASSKEY_PULSE_STYLE_ID
126
- el.textContent = PASSKEY_PULSE_CSS
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
- usePasskeyPulseStyle(needsPasskey)
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-popout__header">Settings</div>
440
-
441
- {/* Access tier. While resolving, the state still says 'public' —
442
- rendering "Guest" there would be a wrong answer that corrects
443
- itself a moment later, which reads exactly like the lag this
444
- placeholder exists to remove. */}
445
- <section className="settings-popout__row">
446
- <span className="settings-popout__label">Access tier</span>
447
- {resolving ? (
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
- type="button"
490
- className="settings-popout__btn settings-popout__btn--primary"
491
- disabled={nameSaving}
492
- onClick={() => {
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
- {nameSaving ? '…' : 'Save'}
602
+ {t.label}
603
+ {t.dot && <span style={PASSKEY_DOT} aria-hidden="true" />}
497
604
  </button>
498
- {nameError && (
499
- <span
500
- id="settings-popout-name-error"
501
- className="settings-popout__error"
502
- role="alert"
503
- >
504
- {nameError}
505
- </span>
506
- )}
507
- </span>
508
- ) : (
509
- <button
510
- type="button"
511
- className="settings-popout__value settings-popout__value--editable"
512
- aria-label="Edit display name"
513
- onClick={() => {
514
- setNameDraft(displayName ?? '')
515
- setEditingName(true)
516
- }}
517
- >
518
- {displayName || 'Unnamed'}
519
- </button>
520
- )}
521
- </section>
522
-
523
- {/* Avatar. Managed on /account (character, Discord, Steam, upload),
524
- so this is only the way there: no thumbnail, because drawing one
525
- would cost every app a request at mount. A new tab, so following
526
- it never takes anyone out of a game mid-match. Hidden for public
527
- callers, like the content pill: /account needs a session. */}
528
- {!resolving && userType !== 'public' && (
529
- <section className="settings-popout__row">
530
- <span className="settings-popout__label">Avatar</span>
531
- <a className="settings-popout__btn" href="/account" target="_blank" rel="noopener">
532
- Change
533
- </a>
534
- </section>
535
- )}
536
-
537
- {/* Content visibility level. Held back until the first-open fetch
538
- settles, so it lands in the same paint as the tier and name
539
- rather than a round trip behind them. `content` is fetched
540
- concurrently with whoami(), so by then it is already here. */}
541
- {!resolving && showContentPill && (
542
- <section className="settings-popout__row settings-popout__row--stack">
543
- <span className="settings-popout__label">Content visibility</span>
544
- {content ? (
545
- <>
546
- <div
547
- className="settings-popout__pill"
548
- role="radiogroup"
549
- aria-label="Content visibility level"
550
- >
551
- {Array.from({ length: content.maxLevel }, (_, i) => i + 1).map(lvl => (
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
- role="radio"
556
- aria-checked={lvl === content.level}
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
- pickLevel(lvl).catch(reportErr)
669
+ saveName().catch(reportErr)
564
670
  }}
565
671
  >
566
- {lvl}
672
+ {nameSaving ? '…' : 'Save'}
567
673
  </button>
568
- ))}
569
- </div>
570
- <span className="settings-popout__hint">
571
- Higher levels reveal more mature content across apps. Level 1 is
572
- safe-for-everyone.
573
- </span>
574
- </>
575
- ) : (
576
- // Not "Loading…" — the fetch has already settled by the time
577
- // this section renders at all, so a null here means it failed.
578
- <span className="settings-popout__hint">Currently unavailable.</span>
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-popout__btn"
636
- disabled={keyBusy}
687
+ className="settings-popout__value settings-popout__value--editable"
688
+ aria-label="Edit display name"
637
689
  onClick={() => {
638
- signInWithPasskey().catch(reportErr)
690
+ setNameDraft(displayName ?? '')
691
+ setEditingName(true)
639
692
  }}
640
693
  >
641
- {passkeyMode === 'signin' ? 'Waiting for passkey…' : 'Use a passkey'}
694
+ {displayName || 'Unnamed'}
642
695
  </button>
643
- </span>
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
- {/* Cancel on a line of its own: in the row it would slide under
646
- the pointer, and a double click would cancel what it started. */}
647
- {passkeyMode === 'signin' && (
648
- <span className="settings-popout__inline">
649
- <button
650
- type="button"
651
- className="settings-popout__btn"
652
- onClick={() => cancelPasskey()}
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
- Cancel
655
- </button>
656
- </span>
657
- )}
658
- {keyError && (
659
- <span
660
- id="settings-popout-key-error"
661
- className="settings-popout__error"
662
- role="alert"
663
- >
664
- {keyError}
665
- </span>
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
- </form>
668
- </section>
669
- ) : (
670
- <section className="settings-popout__row settings-popout__row--stack">
671
- <span className="settings-popout__inline">
672
- <span className="settings-popout__label settings-popout__label--grow">
673
- Access key
674
- </span>
675
- {/* Signed-in people only: the site refuses to save one for anyone else. */}
676
- {!resolving && userType !== 'public' && passkeysSupported() && (
677
- <button
678
- type="button"
679
- className={`settings-popout__btn${needsPasskey ? ` settings-popout__btn--primary ${PASSKEY_PULSE_CLASS}` : ''}`}
680
- disabled={passkeyMode !== null || passkeySaved}
681
- onClick={() => {
682
- savePasskey().catch(reportErr)
683
- }}
684
- >
685
- {passkeySaved
686
- ? 'Passkey saved'
687
- : passkeyMode === 'save'
688
- ? 'Saving…'
689
- : 'Save passkey'}
690
- </button>
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
- <button
693
- type="button"
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
- {!resolving && userType === 'member' && (
725
- <section className="settings-popout__row settings-popout__row--stack">
726
- <span className="settings-popout__inline">
727
- <span className="settings-popout__label settings-popout__label--grow">Account</span>
728
- {deleteStage === 'idle' ? (
729
- <button
730
- type="button"
731
- className="settings-popout__btn"
732
- onClick={() => {
733
- setDeleteError(null)
734
- setDeleteStage('confirm')
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
- return
764
- }
765
- localStorage.removeItem('hadoku_session_id')
766
- localStorage.removeItem('hadoku_user_type')
767
- window.location.assign('/')
768
- })
769
- .catch(reportErr)
770
- }}
771
- >
772
- {deleteStage === 'busy' ? 'Deleting…' : 'Delete for good'}
773
- </button>
774
- </span>
775
- )}
776
- {deleteError && (
777
- <span className="settings-popout__error" role="alert">
778
- {deleteError}
779
- </span>
780
- )}
781
- </section>
782
- )}
783
-
784
- {/* App-specific settings (optional) — same popout, one gear. */}
785
- {children && (
786
- <section className="settings-popout__row settings-popout__row--stack settings-popout__app">
787
- {children}
788
- </section>
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>