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

222 lines
7.7 KiB
TypeScript
Raw Normal View History

2026-06-23 16:55:10 +00:00
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()
2026-06-24 06:04:54 +00:00
const { nextStep, refresh: refreshOnboarding } = useOnboarding()
const accountGuideActive = !loading && nextStep === 'account'
2026-06-23 16:55:10 +00:00
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">
2026-06-24 06:04:54 +00:00
{accountGuideActive && !open ? (
<span className="hx-guide-badge" aria-hidden>
</span>
) : null}
2026-06-23 16:55:10 +00:00
<button
type="button"
onClick={() => {
setOpen((v) => !v)
if (open) setPanel('list')
}}
2026-06-24 06:04:54 +00:00
className={`ac-account-trigger ${accountGuideActive ? 'hx-guide-glow ac-account-trigger--prompt' : ''}`}
2026-06-23 16:55:10 +00:00
aria-expanded={open}
aria-haspopup="listbox"
2026-06-24 06:04:54 +00:00
aria-label={`經營帳號:${label}${accountGuideActive ? '(入門下一步:建立經營帳號)' : ''}`}
2026-06-23 16:55:10 +00:00
>
<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>
)
}