216 lines
7.4 KiB
TypeScript
216 lines
7.4 KiB
TypeScript
|
|
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 { hasAccounts, refresh: refreshOnboarding } = useOnboarding()
|
|||
|
|
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">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => {
|
|||
|
|
setOpen((v) => !v)
|
|||
|
|
if (open) setPanel('list')
|
|||
|
|
}}
|
|||
|
|
className={`ac-account-trigger ${!loading && !hasAccounts ? 'ac-account-trigger--prompt' : ''}`}
|
|||
|
|
aria-expanded={open}
|
|||
|
|
aria-haspopup="listbox"
|
|||
|
|
aria-label={`經營帳號:${label}`}
|
|||
|
|
>
|
|||
|
|
<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>
|
|||
|
|
)
|
|||
|
|
}
|