218 lines
9.1 KiB
TypeScript
218 lines
9.1 KiB
TypeScript
import { useEffect, useState, type ReactNode } from "react";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
import frameSvg from "@/assets/frame.themed.svg";
|
|
import browserSvg from "@/assets/browser.min.svg";
|
|
import NavMenu from "./NavMenu";
|
|
import LazyEyes from "./LazyEyes";
|
|
|
|
const TITLE = `
|
|
|
|
▄▄▄▄███▄▄▄▄ ▄█ ▄████████ ▄████████ ▄██████▄ ███▄▄▄▄ ▄██████▄ ▄▄▄▄███▄▄▄▄ ▄█ ▄████████ ▄██████▄ ███▄▄▄▄
|
|
▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███ ███ ███▀▀▀██▄ ███ ███ ▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███▀▀▀██▄
|
|
███ ███ ███ ███▌ ███ █▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ █▀ ███ ███ ███ ███
|
|
███ ███ ███ ███▌ ███ ▄███▄▄▄▄██▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ ███ ███ ███ ███
|
|
███ ███ ███ ███▌ ███ ▀▀███▀▀▀▀▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ ███ ███ ███ ███
|
|
███ ███ ███ ███ ███ █▄ ▀███████████ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄ ███ ███ ███ ███
|
|
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
|
|
▀█ ███ █▀ █▀ ████████▀ ███ ███ ▀██████▀ ▀█ █▀ ▀██████▀ ▀█ ███ █▀ █▀ ████████▀ ▀██████▀ ▀█ █▀
|
|
███ ███
|
|
|
|
`;
|
|
|
|
type Theme = "terra" | "azure";
|
|
|
|
function getStoredTheme(): Theme {
|
|
try { return (localStorage.getItem("micronomicon-theme") as Theme) || "terra"; }
|
|
catch { return "terra"; }
|
|
}
|
|
|
|
function toRoman(n: number): string {
|
|
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
|
|
const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
|
|
let r = "";
|
|
for (let i = 0; i < vals.length; i++) {
|
|
while (n >= vals[i]) { r += syms[i]; n -= vals[i]; }
|
|
}
|
|
return r;
|
|
}
|
|
|
|
function RomanDrum({ value }: { value: string }) {
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-block",
|
|
overflow: "hidden",
|
|
height: "1em",
|
|
lineHeight: "1em",
|
|
verticalAlign: "bottom",
|
|
}}
|
|
>
|
|
<span
|
|
key={value}
|
|
style={{
|
|
display: "inline-block",
|
|
animation: "romanSlideIn 0.25s cubic-bezier(0.22, 1, 0.36, 1)",
|
|
}}
|
|
>
|
|
{value}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function RomanClock() {
|
|
const [time, setTime] = useState(new Date());
|
|
useEffect(() => {
|
|
const id = setInterval(() => setTime(new Date()), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
const h = time.getHours();
|
|
const m = time.getMinutes();
|
|
const s = time.getSeconds();
|
|
return (
|
|
<span style={{ display: "inline-flex", alignItems: "baseline", gap: 0 }}>
|
|
<RomanDrum value={toRoman(h || 12)} />
|
|
<span>:</span>
|
|
<RomanDrum value={toRoman(m || 1)} />
|
|
<span>:</span>
|
|
<RomanDrum value={toRoman(s || 1)} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-frame layout configs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FrameLayout {
|
|
svg: string;
|
|
frameHeight: number;
|
|
containerMaxW: string;
|
|
containerMinW: string;
|
|
title: { paddingTop: number; height: number; paddingLeft: number; paddingRight: number; paddingBottom: number };
|
|
content: { marginLeft: number; marginTop: number; marginRight: number; width: number; height: number; paddingTop: number; paddingBottom: number };
|
|
nav: { top: number; left: number };
|
|
}
|
|
|
|
const defaultLayout: FrameLayout = {
|
|
svg: frameSvg,
|
|
frameHeight: 1315,
|
|
containerMaxW: "max-w-5xl",
|
|
containerMinW: "min-w-5xl",
|
|
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
|
|
content: { marginLeft: 12, marginRight: 68, width: 843, height: 1030, paddingTop: 8, paddingBottom: 20 },
|
|
nav: { top: 150, left: -210 },
|
|
};
|
|
|
|
const browseLayout: FrameLayout = {
|
|
svg: browserSvg,
|
|
frameHeight: 884,
|
|
containerMaxW: "max-w-5xl",
|
|
containerMinW: "min-w-5xl",
|
|
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
|
|
content: { marginLeft: 268, marginTop: 63, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 },
|
|
nav: { top: 150, left: -210 },
|
|
};
|
|
|
|
export default function AppShell({ children }: { children: ReactNode }) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
|
const isEditor = location.pathname.startsWith("/editor");
|
|
const isBrowse = location.pathname === "/browse";
|
|
const layout = isBrowse ? browseLayout : defaultLayout;
|
|
|
|
useEffect(() => {
|
|
const root = document.documentElement;
|
|
// Remove all theme classes, then apply
|
|
root.classList.remove("dark", "theme-azure");
|
|
if (theme === "terra") {
|
|
root.classList.add("dark");
|
|
} else {
|
|
root.classList.add("theme-azure");
|
|
}
|
|
localStorage.setItem("micronomicon-theme", theme);
|
|
}, [theme]);
|
|
|
|
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<div className="flex flex-col h-screen bg-background text-foreground">
|
|
<main className="flex-1 overflow-auto" data-scroll-root>
|
|
<div className={`relative ${layout.containerMaxW} ${layout.containerMinW} mx-auto min-h-full`}>
|
|
{/* Frame SVG — background */}
|
|
<div
|
|
aria-hidden
|
|
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
|
|
style={{
|
|
zIndex: 0,
|
|
height: `${layout.frameHeight}px`,
|
|
background: "var(--primary)",
|
|
WebkitMaskImage: `url(${layout.svg})`,
|
|
maskImage: `url(${layout.svg})`,
|
|
WebkitMaskSize: "cover",
|
|
maskSize: "cover",
|
|
WebkitMaskRepeat: "no-repeat",
|
|
maskRepeat: "no-repeat",
|
|
objectFit: "fill"
|
|
}}
|
|
/>
|
|
|
|
{/* Lazy eyes on the browse frame figure */}
|
|
{isBrowse && (
|
|
<LazyEyes
|
|
eyes={[
|
|
{ top: 81, left: 554, size: 5, irisSize: 2 },
|
|
{ top: 85, left: 562, size: 5, irisSize: 2 },
|
|
]}
|
|
maxShift={2}
|
|
/>
|
|
)}
|
|
|
|
{/* Top hole — ASCII title */}
|
|
<div
|
|
className="relative z-10 flex flex-col cursor-pointer overflow-hidden"
|
|
style={layout.title}
|
|
>
|
|
<pre
|
|
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center bg-background"
|
|
style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }}
|
|
onClick={() => navigate("/")}
|
|
>
|
|
{TITLE}
|
|
</pre>
|
|
<span className="font-mono text-[10px] text-muted-foreground"><RomanClock /></span>
|
|
</div>
|
|
|
|
{/* Bottom hole — main content */}
|
|
<div
|
|
className="relative z-10 overflow-auto"
|
|
style={layout.content}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{/* Nav menu — anchored below the frame, left side (hidden on editor) */}
|
|
{!isEditor && (
|
|
<div
|
|
className="absolute z-20"
|
|
style={layout.nav}
|
|
>
|
|
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</main>
|
|
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
|
<span>Created with hubris · MMXXVI</span>
|
|
</footer>
|
|
</div>
|
|
<Toaster />
|
|
</TooltipProvider>
|
|
);
|
|
}
|