finance-tools/src/components/Chrome.tsx

257 lines
8.3 KiB
TypeScript
Raw Normal View History

2026-06-21 20:28:06 +00:00
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import GuideMascot from "./GuideMascot";
import AIDebugObservatory from "./AIDebugObservatory";
import { usePlayerProgress } from "../hooks/usePlayerProgress";
import { api } from "../lib/api";
import { AppIcon, RouteIcon, type IconName } from "./PixelIcons";
function fmtAssets(v: number | null | undefined) {
if (v == null || Number.isNaN(v)) return "—";
const sign = v < 0 ? "-" : "";
return `${sign}$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
}
const NAV: { section: string; items: { to: string; icon: IconName; label: string }[] }[] = [
{
section: "觀測",
items: [
{ to: "/", icon: "castle", label: "今日 · 基地" },
{ to: "/market", icon: "world", label: "市場 · 世界" },
],
},
{
section: "研究",
items: [{ to: "/research", icon: "folder", label: "個股 · 背包" }],
},
{
section: "修練",
items: [
{ to: "/skills", icon: "scroll", label: "心法 · 技能樹" },
{ to: "/patterns", icon: "cards", label: "線型 · 圖鑑" },
2026-06-22 09:16:20 +00:00
{ to: "/library", icon: "book", label: "知識 · 圖書館" },
],
},
{
section: "內容",
items: [
{ to: "/content", icon: "folder", label: "內容 · 管理" },
2026-06-21 20:28:06 +00:00
],
},
{
section: "我的",
items: [
{ to: "/journal", icon: "folder", label: "復盤 · 戰績" },
{ to: "/profile", icon: "wizard", label: "角色 · 養成" },
{ to: "/settings", icon: "gear", label: "設定" },
],
},
];
const BOTTOM_NAV: { to: string; icon: IconName; label: string }[] = [
{ to: "/", icon: "castle", label: "基地" },
{ to: "/market", icon: "world", label: "市場" },
{ to: "/research", icon: "folder", label: "背包" },
{ to: "/skills", icon: "scroll", label: "心法" },
2026-06-22 09:16:20 +00:00
{ to: "/library", icon: "book", label: "圖書館" },
2026-06-21 20:28:06 +00:00
];
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
return (
<nav className="nav">
{NAV.map((g) => (
<div key={g.section}>
<div className="nav-section">{g.section}</div>
{g.items.map((it) => (
<NavLink key={it.to} to={it.to} end={it.to === "/"} onClick={onNavigate}>
<span className="ico">
<RouteIcon to={it.to} size={22} />
</span>
{it.label}
</NavLink>
))}
</div>
))}
</nav>
);
}
function useMobileNav() {
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.matchMedia("(max-width: 920px)").matches : false
);
useEffect(() => {
const mq = window.matchMedia("(max-width: 920px)");
const sync = () => setIsMobile(mq.matches);
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
return isMobile;
}
export default function Chrome() {
const [navOpen, setNavOpen] = useState(false);
const isMobile = useMobileNav();
const location = useLocation();
const { player } = usePlayerProgress();
const portfolioQ = useQuery({
queryKey: ["portfolio-total"],
queryFn: api.portfolioTotal,
staleTime: 45_000,
refetchOnWindowFocus: true,
});
const xpPct = player?.xpToNext ? Math.round((player.xpInLevel / player.xpToNext) * 100) : 0;
const portfolio = portfolioQ.data;
useEffect(() => {
setNavOpen(false);
}, [location.pathname]);
useEffect(() => {
document.body.classList.toggle("nav-open", navOpen);
return () => document.body.classList.remove("nav-open");
}, [navOpen]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setNavOpen(false);
};
const onResize = () => {
if (!window.matchMedia("(max-width: 920px)").matches) setNavOpen(false);
};
window.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<div className="layout">
<button
type="button"
className={"nav-backdrop" + (navOpen ? " open" : "")}
aria-label="關閉選單"
onClick={() => setNavOpen(false)}
/>
<aside className={"sidebar" + (navOpen ? " open" : "")} aria-hidden={isMobile && !navOpen}>
<div className="brand">
<span className="logo">
<AppIcon name="compass" size={28} framed glow variant="hero" />
</span>
<div>
<div className="title"></div>
<div className="sub">OBSERVATORY</div>
</div>
<button type="button" className="nav-close" aria-label="關閉選單" onClick={() => setNavOpen(false)}>
</button>
</div>
<NavLinks onNavigate={() => setNavOpen(false)} />
<div className="sidebar-foot">
<br />
</div>
</aside>
<div className="main">
<header className="topbar">
<button
type="button"
className="nav-toggle"
aria-label="開啟選單"
aria-expanded={navOpen}
onClick={() => setNavOpen((v) => !v)}
>
<span />
<span />
<span />
</button>
<div className="hero-chip">
<div className="avatar">
<AppIcon name="wizard" size={32} framed variant="nav" />
</div>
<div className="hero-meta">
<div className="name">{player?.displayName || "投資冒險者"}</div>
<div className="lvl">
{player ? (
<>
<span>Lv.{player.level} · {player.title}</span>
{player.epithet ? <span className="hero-epithet">{player.epithet}</span> : null}
</>
) : (
"載入中…"
)}
</div>
</div>
</div>
<div className="xp-wrap">
<div className="xp-label">
<span>EXP</span>
<span>
{player
? `${player.xpInLevel.toLocaleString()} / ${player.xpToNext.toLocaleString()}`
: "—"}
</span>
</div>
<div className="bar">
<span style={{ width: `${xpPct}%` }} />
</div>
</div>
<div className="topbar-right">
<Link
to="/journal"
className="coin topbar-assets"
title={
portfolio
? `現金 ${fmtAssets(portfolio.totalCash)} 持倉 ${fmtAssets(portfolio.totalStock)}${
portfolio.accountNames?.length
? `${portfolio.accountNames.join("、")}`
: ""
}`
: "前往查看帳戶資產"
}
>
<AppIcon name="coin" size={18} framed variant="nav" />
<span className="topbar-assets-col">
<span className="topbar-assets-label"></span>
<span className="topbar-assets-val">
{portfolioQ.isLoading ? "…" : fmtAssets(portfolio?.totalAssets)}
</span>
</span>
</Link>
</div>
</header>
<nav className="bottom-nav" aria-label="主要導覽">
{BOTTOM_NAV.map((it) => (
<NavLink key={it.to} to={it.to} end={it.to === "/"}>
<span className="ic">
<AppIcon name={it.icon} size={22} framed variant="nav" />
</span>
<span className="lb">{it.label}</span>
</NavLink>
))}
<button type="button" className="bottom-nav-more" onClick={() => setNavOpen(true)}>
<span className="ic">
<AppIcon name="menu" size={20} framed={false} />
</span>
<span className="lb"></span>
</button>
</nav>
<main className="content">
<Outlet />
</main>
<GuideMascot />
<AIDebugObservatory />
</div>
</div>
);
}