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

199 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
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">
{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";
}