thread-master/backend/web/src/components/Layout.tsx

199 lines
7.6 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useState } from "react";
import { api, ThreadsAccount } from "../api/haixun";
import { navApps, mobileNavKeys } from "../lib/acAssets";
import { persistActiveThreadsAccountId } from "../lib/activeAccount";
import { useAuth } from "../auth/AuthContext";
import { AcIcon } from "./AcIcon";
import { AuthTicketIcon, SceneDecor } from "./AuthDecor";
import { ThemeToggle } from "./ThemeToggle";
import { Button } from "./ui";
import { JobMonitor } from "./JobMonitor";
export function Layout({
active,
onNavigate,
children
}: {
active: string;
onNavigate: (key: string) => void;
children: React.ReactNode;
}) {
const { member, logout } = useAuth();
2026-07-07 14:02:41 +00:00
const isAdmin = member?.roles?.includes("admin") || false;
const visibleNavApps = navApps.filter((app) => app.key !== "register" || isAdmin);
const activeApp = visibleNavApps.find((app) => app.key === active) || visibleNavApps[0];
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [activeAccountId, setActiveAccountId] = useState("");
const [switching, setSwitching] = useState(false);
const loadAccounts = useCallback(async () => {
try {
const data = await api.threadsAccounts();
const nextActiveId = data.active_account_id || data.list?.[0]?.id || "";
setAccounts(data.list || []);
setActiveAccountId(nextActiveId);
persistActiveThreadsAccountId(nextActiveId);
} catch {
setAccounts([]);
setActiveAccountId("");
persistActiveThreadsAccountId("");
}
}, []);
useEffect(() => {
let alive = true;
async function load() {
try {
const data = await api.threadsAccounts();
if (!alive) return;
const nextActiveId = data.active_account_id || data.list?.[0]?.id || "";
setAccounts(data.list || []);
setActiveAccountId(nextActiveId);
persistActiveThreadsAccountId(nextActiveId);
} catch {
if (alive) {
setAccounts([]);
setActiveAccountId("");
persistActiveThreadsAccountId("");
}
}
}
void load();
return () => {
alive = false;
};
}, []);
useEffect(() => {
const reload = () => void loadAccounts();
window.addEventListener("haixun:active-account-changed", reload);
return () => window.removeEventListener("haixun:active-account-changed", reload);
}, [loadAccounts]);
async function switchAccount(accountId: string) {
if (!accountId || accountId === activeAccountId) return;
setSwitching(true);
try {
await api.activateThreadsAccount(accountId);
setActiveAccountId(accountId);
persistActiveThreadsAccountId(accountId);
window.dispatchEvent(new CustomEvent("haixun:active-account-changed", { detail: { accountId } }));
} finally {
setSwitching(false);
}
}
function navigateApp(key: string) {
if (accounts.length === 0 && requiresThreadsAccount(key)) {
onNavigate("accounts");
return;
}
onNavigate(key);
}
return (
<div className="hx-scene">
<SceneDecor />
<div className="layout-main ac-app-shell-root">
<div className="app-shell">
{/* 目錄區塊直接貼最左邊、頂天立地tiles 字體/圖示/排版完全保留 */}
<aside className="ac-pocket-device ac-pocket-full">
<div className="ac-pocket-screen">
<div className="display-en muted ac-sidebar-pad">PATROL PAD</div>
<nav className="ac-pocket-scroll">
2026-07-07 14:02:41 +00:00
{visibleNavApps.map((app) => (
(() => {
const locked = accounts.length === 0 && requiresThreadsAccount(app.key);
return (
<button
key={app.key}
className={`ac-app-tile ${app.key === active ? "ac-app-tile--active" : ""} ${locked ? "ac-app-tile--locked" : ""}`}
onClick={() => navigateApp(app.key)}
>
<AcIcon name={app.icon} />
<span>
<strong>{app.label}</strong>
<span className="display-en subtle ac-nav-sublabel">{app.sublabel}</span>
{locked ? <span className="subtle ac-nav-hint"></span> : null}
</span>
</button>
);
})()
))}
</nav>
</div>
</aside>
{/* 右側主區域header 只在右邊,內容盡量寬 */}
<div className="app-main-col">
<header className="ac-app-header">
<div className="button-row">
<AuthTicketIcon />
<div className="ac-header-brand">
<strong> Console</strong>
<div className="display-en subtle ac-header-sub">PATROL PAD</div>
</div>
</div>
<div className="button-row">
{accounts.length > 0 ? (
<label className="button-row" style={{ gap: "0.35rem" }}>
<span className="display-en subtle ac-header-sub">ACCOUNT</span>
<select className="select" value={activeAccountId} disabled={switching} onChange={(e) => void switchAccount(e.target.value)} style={{ width: "13rem" }}>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.display_name || account.username || account.id}
</option>
))}
</select>
</label>
) : (
<button className="badge badge-warning" onClick={() => onNavigate("accounts")}></button>
)}
<span className="badge badge-neutral">{member?.display_name || member?.email || "島民"}</span>
<ThemeToggle />
<Button variant="ghost" onClick={() => void logout()}></Button>
</div>
</header>
<section className="ac-app-main-inner">
<div className="display-en subtle ac-page-sublabel">{activeApp.sublabel}</div>
{children}
</section>
</div>
</div>
</div>
<MobileBottomNav active={active} onNavigate={navigateApp} hasAccounts={accounts.length > 0} />
<JobMonitor />
</div>
);
}
function MobileBottomNav({ active, onNavigate, hasAccounts }: { active: string; onNavigate: (key: string) => void; hasAccounts: boolean }) {
const apps = navApps.filter((app) => mobileNavKeys.includes(app.key));
return (
<nav className="ac-dock">
{apps.map((app) => (
<button key={app.key} className={`${app.key === active ? "active" : ""} ${!hasAccounts && requiresThreadsAccount(app.key) ? "locked" : ""}`} onClick={() => onNavigate(app.key)}>
<AcIcon name={app.icon} size={19} />
<span>{app.label}</span>
</button>
))}
<button className={active === "settings" ? "active" : ""} onClick={() => onNavigate("settings")}>
<AcIcon name="settings" size={19} />
<span style={{ fontSize: "0.75rem", fontWeight: 800 }}></span>
</button>
<button
className={!mobileNavKeys.includes(active) && active !== "settings" ? "active" : ""}
onClick={() => onNavigate("accounts")}
>
<AcIcon name="more" size={19} />
<span style={{ fontSize: "0.75rem", fontWeight: 800 }}></span>
</button>
</nav>
);
}
function requiresThreadsAccount(key: string) {
return key === "publish" || key === "personas" || key === "missions" || key === "patrol";
}