205 lines
7.4 KiB
TypeScript
205 lines
7.4 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|