haixunMaster/components/layout/account-switcher.tsx

205 lines
7.4 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
"use client";
import { useEffect, useState } from "react";
import { ChevronDown, Link2, Loader2, Plus, UserRound } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { notify } from "@/lib/notifications/store";
import { cn } from "@/lib/utils";
interface AccountOption {
id: string;
username?: string | null;
displayName?: string | null;
valid: boolean;
apiConnected?: boolean;
browserConnected?: boolean;
}
function isConnected(account: AccountOption) {
return Boolean(account.browserConnected || account.apiConnected);
}
function statusLabel(account: AccountOption) {
const parts: string[] = [];
if (account.browserConnected) parts.push("已同步");
if (account.apiConnected) parts.push("API");
if (!parts.length) return "尚未連線";
return parts.join(" · ");
}
function ConnectionDot({ connected, className }: { connected: boolean; className?: string }) {
return (
<span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
connected ? "bg-primary" : "bg-muted-foreground/40",
className
)}
aria-hidden
/>
);
}
export function AccountSwitcher() {
const [accounts, setAccounts] = useState<AccountOption[]>([]);
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
const [binding, setBinding] = useState(false);
const [creating, setCreating] = useState(false);
async function load() {
const res = await fetch("/api/accounts");
const data = await res.json();
setAccounts(data.accounts ?? []);
setActiveAccountId(data.activeAccountId ?? null);
}
useEffect(() => {
load();
const reload = () => load();
window.addEventListener("haixun:accounts-updated", reload);
window.addEventListener("focus", reload);
return () => {
window.removeEventListener("haixun:accounts-updated", reload);
window.removeEventListener("focus", reload);
};
}, []);
const active = accounts.find((account) => account.id === activeAccountId) ?? null;
const connected = active ? isConnected(active) : false;
async function activate(id: string) {
await fetch(`/api/accounts/${id}/activate`, { method: "POST" });
notify({ type: "success", title: "已切換經營帳號" });
window.dispatchEvent(new Event("haixun:accounts-updated"));
window.location.reload();
}
async function createAccount() {
setCreating(true);
try {
const res = await fetch("/api/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
displayName: `帳號 ${accounts.length + 1}`,
activate: true,
}),
});
const data = await res.json();
if (!res.ok) {
notify({ type: "error", title: "建立帳號失敗", message: data.error });
return;
}
notify({
type: "success",
title: "已建立帳號",
message: "請到連線設定頁用 Chrome 擴充同步 Threads session",
});
window.dispatchEvent(new Event("haixun:accounts-updated"));
load();
} finally {
setCreating(false);
}
}
async function bindAccount() {
setBinding(true);
try {
const res = await fetch("/api/accounts/bind", { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify({ type: "error", title: "無法開始綁定", message: data.error });
setBinding(false);
return;
}
window.location.href = "/api/threads/oauth/authorize";
} catch {
setBinding(false);
notify({ type: "error", title: "無法開始綁定" });
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="account-switcher-trigger flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors hover:bg-accent focus-visible:bg-accent"
>
<div className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<UserRound className="h-4 w-4" />
<ConnectionDot
connected={connected}
className="absolute -bottom-0.5 -right-0.5 border-2 border-sidebar"
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-foreground">
{active?.displayName ?? active?.username ?? "選擇帳號"}
</p>
<p className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
{active && <ConnectionDot connected={connected} />}
<span>{active ? statusLabel(active) : "新增或選擇帳號"}</span>
</p>
</div>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{accounts.length > 0 ? (
accounts.map((account) => {
const accountConnected = isConnected(account);
const isActive = account.id === activeAccountId;
return (
<DropdownMenuItem key={account.id} onClick={() => activate(account.id)}>
<div className="flex w-full items-center gap-2.5">
<div className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<UserRound className="h-3.5 w-3.5" />
<ConnectionDot
connected={accountConnected}
className="absolute -bottom-0.5 -right-0.5 border-2 border-popover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px]">
{account.displayName ?? account.username ?? "未命名帳號"}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{account.username ? `@${account.username} · ` : ""}
{statusLabel(account)}
</p>
</div>
{isActive && (
<span className="shrink-0 rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
使
</span>
)}
</div>
</DropdownMenuItem>
);
})
) : (
<div className="px-2 py-3 text-center text-xs text-muted-foreground"></div>
)}
<DropdownMenuSeparator />
<div className="space-y-2 p-2">
<Button size="sm" className="w-full" variant="secondary" onClick={createAccount} disabled={creating}>
{creating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</Button>
<Button size="sm" className="w-full" variant="outline" onClick={bindAccount} disabled={binding}>
{binding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
Threads API
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}