haixunMaster/haixun-backend/web/src/components/AccountSwitcher.tsx

222 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useOnboarding } from '../onboarding/OnboardingContext'
import { useThreadsAccount } from '../threads/ThreadsAccountContext'
import {
threadsAccountConnected,
threadsAccountLabel,
threadsAccountStatusLabel,
} from '../lib/threadsAccount'
import { ApiError } from '../api/client'
import { Button, ErrorText, Field, Input } from './ui'
import { AcIcon } from './AcIcon'
function AccountAvatar({
connected,
size = 'md',
}: {
connected: boolean
size?: 'sm' | 'md'
}) {
const iconWrap = size === 'sm' ? '!h-7 !w-7' : '!h-8 !w-8'
return (
<span
className={`ac-account-avatar ac-account-avatar--${size} ${connected ? 'ac-account-avatar--online' : ''}`}
>
<AcIcon app="threads" size="sm" className={`${iconWrap} !border-0 !bg-transparent`} />
</span>
)
}
export function AccountSwitcher() {
const navigate = useNavigate()
const { accounts, activeAccountId, activeAccount, loading, switchAccount, createAccount } =
useThreadsAccount()
const { nextStep, refresh: refreshOnboarding } = useOnboarding()
const accountGuideActive = !loading && nextStep === 'account'
const [open, setOpen] = useState(false)
const [panel, setPanel] = useState<'list' | 'create'>('list')
const [creating, setCreating] = useState(false)
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState('')
const rootRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointer = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false)
setPanel('list')
}
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (panel === 'create') {
setPanel('list')
setError('')
return
}
setOpen(false)
}
}
document.addEventListener('mousedown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [open, panel])
const connected = threadsAccountConnected(activeAccount)
const label = loading ? '載入中' : threadsAccountLabel(activeAccount)
function openCreatePanel() {
setPanel('create')
setDisplayName('')
setError('')
}
async function pickAccount(id: string) {
setOpen(false)
setPanel('list')
await switchAccount(id)
const snapshot = await refreshOnboarding()
navigate(snapshot.isComplete ? `/threads/${id}/publish` : '/settings')
}
async function handleCreate() {
setCreating(true)
setError('')
try {
const created = await createAccount({
displayName: displayName.trim() || undefined,
})
setOpen(false)
setPanel('list')
const snapshot = await refreshOnboarding()
navigate(snapshot.isComplete ? `/threads/${created.id}/publish` : '/settings')
} catch (e) {
setError(e instanceof ApiError ? e.message : '建立帳號失敗')
} finally {
setCreating(false)
}
}
return (
<div ref={rootRef} className="ac-account-switcher relative">
{accountGuideActive && !open ? (
<span className="hx-guide-badge" aria-hidden>
</span>
) : null}
<button
type="button"
onClick={() => {
setOpen((v) => !v)
if (open) setPanel('list')
}}
className={`ac-account-trigger ${accountGuideActive ? 'hx-guide-glow ac-account-trigger--prompt' : ''}`}
aria-expanded={open}
aria-haspopup="listbox"
aria-label={`經營帳號:${label}${accountGuideActive ? '(入門下一步:建立經營帳號)' : ''}`}
>
<AccountAvatar connected={connected} />
<span className="ac-account-trigger-text hidden min-w-0 max-w-[7.5rem] truncate sm:inline">
{label}
</span>
<span className="ac-account-trigger-chevron" aria-hidden>
{open ? '▴' : '▾'}
</span>
</button>
{open ? (
<div role="listbox" className="ac-account-menu">
{panel === 'list' ? (
<>
<p className="ac-account-menu-title"></p>
<div className="ac-account-menu-list">
{accounts.length ? (
accounts.map((account) => {
const isActive = account.id === activeAccountId
const itemConnected = threadsAccountConnected(account)
return (
<button
key={account.id}
type="button"
role="option"
aria-selected={isActive}
onClick={() => pickAccount(account.id)}
className={`ac-account-menu-item ${isActive ? 'ac-account-menu-item--active' : ''}`}
>
<AccountAvatar connected={itemConnected} size="sm" />
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold">
{threadsAccountLabel(account)}
</span>
<span className="block truncate text-xs text-muted">
{threadsAccountStatusLabel(account)}
</span>
</span>
{isActive ? (
<span className="ac-account-menu-pill">使</span>
) : null}
</button>
)
})
) : (
<p className="px-3 py-5 text-center text-sm text-muted"> Threads </p>
)}
</div>
<div className="ac-account-menu-footer">
<Button
type="button"
variant="soft"
className="w-full rounded-[var(--radius-pill)]"
onClick={openCreatePanel}
>
</Button>
</div>
</>
) : (
<div className="ac-account-create-panel">
<div className="ac-account-create-head">
<button
type="button"
className="ac-account-create-back"
onClick={() => {
setPanel('list')
setError('')
}}
>
</button>
<p className="ac-account-menu-title !px-0 !pt-0"></p>
</div>
<p className="ac-account-create-hint"></p>
<div className="ac-account-create-form">
<Field label="顯示名稱(選填)">
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="側欄顯示用,不填則自動命名"
autoFocus
/>
</Field>
<ErrorText message={error} />
<Button
type="button"
className="w-full rounded-[var(--radius-pill)]"
disabled={creating}
onClick={handleCreate}
>
{creating ? '建立中…' : '建立並設定連線'}
</Button>
</div>
</div>
)}
</div>
) : null}
</div>
)
}