finance-tools/src/components/Chrome.tsx

257 lines
8.3 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 { 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>
);
}