@hadoku/task-ui-components 4.22.0 → 4.22.3

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,7 +4,7 @@
4
4
  * property of the PERSON (not the app):
5
5
  * - current access tier (read-only, friendly label)
6
6
  * - display name (inline edit → POST /session/name)
7
- * - avatar (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
  *
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import React, { useCallback, useEffect, useRef, useState } from 'react'
29
29
  import { SettingsIcon } from './ThemeIcons'
30
+ import { AvatarPicker } from './AvatarPicker'
30
31
  import {
31
32
  deleteOwnAccount,
32
33
  prefetchSettings,
@@ -154,6 +155,7 @@ export function ConnectedSettings({
154
155
  const [nameDraft, setNameDraft] = useState(resolvedName ?? '')
155
156
  const [editingName, setEditingName] = useState(false)
156
157
  const [nameSaving, setNameSaving] = useState(false)
158
+ const [nameError, setNameError] = useState<string | null>(null)
157
159
  const [displayName, setLocalName] = useState<string | null>(resolvedName)
158
160
 
159
161
  // Key swap. The draft lives only in this state and the input: it is never
@@ -179,6 +181,9 @@ export function ConnectedSettings({
179
181
  const passkeyCtl = useRef<AbortController | null>(null)
180
182
  const cancelPasskey = useCallback((reason?: string) => passkeyCtl.current?.abort(reason), [])
181
183
 
184
+ // Avatar: the picker loads only when opened, never at mount (AvatarPicker).
185
+ const [avatarOpen, setAvatarOpen] = useState(false)
186
+
182
187
  // Content level
183
188
  const [content, setContent] = useState<ContentLevelState | null>(null)
184
189
  const [levelSaving, setLevelSaving] = useState(false)
@@ -279,13 +284,17 @@ export function ConnectedSettings({
279
284
  return
280
285
  }
281
286
  setNameSaving(true)
282
- const stored = await setDisplayName(trimmed)
287
+ const result = await setDisplayName(trimmed)
283
288
  setNameSaving(false)
284
- if (stored !== null) {
285
- setLocalName(stored)
286
- setNameDraft(stored)
287
- onNameChange?.(stored)
289
+ // A refusal keeps the editor open on the draft, saying why.
290
+ if (!result.ok) {
291
+ setNameError(result.error)
292
+ return
288
293
  }
294
+ setNameError(null)
295
+ setLocalName(result.name)
296
+ setNameDraft(result.name ?? '')
297
+ if (result.name !== null) onNameChange?.(result.name)
289
298
  setEditingName(false)
290
299
  }, [nameDraft, displayName, onNameChange])
291
300
 
@@ -461,7 +470,12 @@ export function ConnectedSettings({
461
470
  maxLength={64}
462
471
  autoFocus
463
472
  disabled={nameSaving}
464
- onChange={e => setNameDraft(e.target.value)}
473
+ aria-invalid={nameError !== null}
474
+ aria-describedby={nameError ? 'settings-popout-name-error' : undefined}
475
+ onChange={e => {
476
+ setNameDraft(e.target.value)
477
+ setNameError(null)
478
+ }}
465
479
  onKeyDown={e => {
466
480
  if (e.key === 'Enter') {
467
481
  saveName().catch(reportErr)
@@ -470,6 +484,7 @@ export function ConnectedSettings({
470
484
  // The edit's Escape, not the popout's (window listener).
471
485
  e.stopPropagation()
472
486
  setEditingName(false)
487
+ setNameError(null)
473
488
  setNameDraft(displayName ?? '')
474
489
  }
475
490
  }}
@@ -484,6 +499,15 @@ export function ConnectedSettings({
484
499
  >
485
500
  {nameSaving ? '…' : 'Save'}
486
501
  </button>
502
+ {nameError && (
503
+ <span
504
+ id="settings-popout-name-error"
505
+ className="settings-popout__error"
506
+ role="alert"
507
+ >
508
+ {nameError}
509
+ </span>
510
+ )}
487
511
  </span>
488
512
  ) : (
489
513
  <button
@@ -500,17 +524,24 @@ export function ConnectedSettings({
500
524
  )}
501
525
  </section>
502
526
 
503
- {/* Avatar. Managed on /account (character, Discord, Steam, upload),
504
- so this is only the way there: no thumbnail, because drawing one
505
- would cost every app a request at mount. A new tab, so following
506
- it never takes anyone out of a game mid-match. Hidden for public
507
- callers, like the content pill: /account needs a session. */}
527
+ {/* Avatar: picture and character, chosen in place (AvatarPicker).
528
+ Collapsed until Change, so opening the gear costs no request and no
529
+ page load reads the avatar. Hidden for public callers, like the
530
+ content pill: an avatar needs a session. */}
508
531
  {!resolving && userType !== 'public' && (
509
- <section className="settings-popout__row">
510
- <span className="settings-popout__label">Avatar</span>
511
- <a className="settings-popout__btn" href="/account" target="_blank" rel="noopener">
512
- Change
513
- </a>
532
+ <section className="settings-popout__row settings-popout__row--stack">
533
+ <span className="settings-popout__inline">
534
+ <span className="settings-popout__label settings-popout__label--grow">Avatar</span>
535
+ <button
536
+ type="button"
537
+ className="settings-popout__btn"
538
+ aria-expanded={avatarOpen}
539
+ onClick={() => setAvatarOpen(v => !v)}
540
+ >
541
+ {avatarOpen ? 'Done' : 'Change'}
542
+ </button>
543
+ </span>
544
+ {avatarOpen && <AvatarPicker />}
514
545
  </section>
515
546
  )}
516
547
 
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  export { HadokuThemePicker } from './components/HadokuThemePicker'
21
21
  export { AppHeader } from './components/AppHeader'
22
22
  export { ConnectedSettings } from './components/ConnectedSettings'
23
+ export { AVATAR_EVENT } from './lib/avatarEvent'
23
24
  // Re-exported from @wolffm/themes, which now owns the context AND the provider
24
25
  // that fills it. These are RE-EXPORTS, not re-declarations: every consumer gets
25
26
  // the one context object from themes, which is what makes a duplicated copy of
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Fired on `window` after every avatar change made in the settings popout
3
+ * (AvatarPicker), with the new avatar settings as `detail` (hadoku_site's
4
+ * `AvatarSettings`, @wolffm/prefs-client/avatar). A host page listens to update
5
+ * what it shows (watchparty sends the new character to its room) without
6
+ * polling prefs-api.
7
+ *
8
+ * Its own module, free of imports, so exporting it from the package entry adds
9
+ * no type dependency on prefs-client for consumers.
10
+ */
11
+ export const AVATAR_EVENT = 'hadoku:avatar'
@@ -140,9 +140,15 @@ export async function deleteOwnAccount(): Promise<boolean> {
140
140
  }
141
141
  }
142
142
 
143
- /** POST a new display name for the signed-in user. Returns the stored name
144
- * (may differ from the request for wp-* protected keys) or null on failure. */
145
- export async function setDisplayName(name: string): Promise<string | null> {
143
+ /**
144
+ * POST a new display name for the signed-in user. Answers the stored name (may
145
+ * differ from the request for wp-* protected keys), or why it was refused: the
146
+ * server's own words where it gave any ("That display name is already taken"),
147
+ * so the editor can say them.
148
+ */
149
+ export type RenameResult = { ok: true; name: string | null } | { ok: false; error: string }
150
+
151
+ export async function setDisplayName(name: string): Promise<RenameResult> {
146
152
  try {
147
153
  const res = await fetch(SESSION_NAME, {
148
154
  method: 'POST',
@@ -151,12 +157,16 @@ export async function setDisplayName(name: string): Promise<string | null> {
151
157
  body: JSON.stringify({ name }),
152
158
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
153
159
  })
154
- if (!res.ok) return null
160
+ if (!res.ok) {
161
+ const body = (await res.json().catch(() => null)) as { error?: unknown } | null
162
+ const said = typeof body?.error === 'string' ? body.error : null
163
+ return { ok: false, error: said ?? `Not saved (${res.status})` }
164
+ }
155
165
  const body = (await res.json()) as { ok: boolean; name: string | null }
156
166
  patchSnapshot(prev => ({ ...prev, identity: { ...prev.identity, name: body.name } }))
157
- return body.name
167
+ return { ok: true, name: body.name }
158
168
  } catch {
159
- return null
169
+ return { ok: false, error: 'Not saved: no connection' }
160
170
  }
161
171
  }
162
172