257 lines
8.3 KiB
TypeScript
257 lines
8.3 KiB
TypeScript
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: "線型 · 圖鑑" },
|
||
{ to: "/library", icon: "book", label: "知識 · 圖書館" },
|
||
],
|
||
},
|
||
{
|
||
section: "內容",
|
||
items: [
|
||
{ to: "/content", icon: "folder", label: "內容 · 管理" },
|
||
],
|
||
},
|
||
{
|
||
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: "心法" },
|
||
{ to: "/library", icon: "book", label: "圖書館" },
|
||
];
|
||
|
||
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>
|
||
);
|
||
}
|