feat: final graph
This commit is contained in:
@@ -3,7 +3,9 @@ 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 = `
|
||||
|
||||
@@ -80,11 +82,47 @@ function RomanClock() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
@@ -104,17 +142,17 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-screen bg-background text-foreground">
|
||||
<main className="flex-1 overflow-auto" data-scroll-root>
|
||||
<div className="relative max-w-5xl min-w-5xl mx-auto min-h-full">
|
||||
<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: "1315px",
|
||||
height: `${layout.frameHeight}px`,
|
||||
background: "var(--primary)",
|
||||
WebkitMaskImage: `url(${frameSvg})`,
|
||||
maskImage: `url(${frameSvg})`,
|
||||
WebkitMaskImage: `url(${layout.svg})`,
|
||||
maskImage: `url(${layout.svg})`,
|
||||
WebkitMaskSize: "cover",
|
||||
maskSize: "cover",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
@@ -123,15 +161,26 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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={{ paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }}
|
||||
onClick={() => navigate("/")}
|
||||
style={layout.title}
|
||||
>
|
||||
<pre
|
||||
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center"
|
||||
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>
|
||||
@@ -141,7 +190,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
{/* Bottom hole — main content */}
|
||||
<div
|
||||
className="relative z-10 overflow-auto"
|
||||
style={{ marginLeft: 12, marginRight: 68, width: 843, height: 1030, paddingTop: 8, paddingBottom: 20 }}
|
||||
style={layout.content}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -150,7 +199,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
{!isEditor && (
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={{ top: 150, left: -210 }}
|
||||
style={layout.nav}
|
||||
>
|
||||
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||
</div>
|
||||
@@ -159,7 +208,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
</main>
|
||||
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
||||
<span>Becoming with hubris · MMXXVI</span>
|
||||
<span>Created with hubris · MMXXVI</span>
|
||||
</footer>
|
||||
</div>
|
||||
<Toaster />
|
||||
|
||||
79
frontend/src/components/shared/LazyEyes.tsx
Normal file
79
frontend/src/components/shared/LazyEyes.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
||||
|
||||
interface EyeSpec {
|
||||
top: number;
|
||||
left: number;
|
||||
size?: number;
|
||||
irisSize?: number;
|
||||
}
|
||||
|
||||
interface LazyEyesProps {
|
||||
eyes: EyeSpec[];
|
||||
/** Viewport-relative anchor the eyes "live" at. If omitted, uses the component's own position. */
|
||||
anchor?: { x: number; y: number };
|
||||
maxShift?: number;
|
||||
ease?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: LazyEyesProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
|
||||
|
||||
// Keep anchorRef in sync with prop or auto-compute from DOM
|
||||
useEffect(() => {
|
||||
if (anchor) {
|
||||
anchorRef.current = anchor;
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
anchorRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", update, true);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", update, true);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [anchor]);
|
||||
|
||||
const offset = useLazyEyes({ anchorRef, maxShift, ease });
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
|
||||
{eyes.map((eye, i) => {
|
||||
const size = eye.size ?? 5;
|
||||
const irisSize = eye.irisSize ?? 2;
|
||||
// Clamp iris within eye circle
|
||||
const maxR = (size - irisSize) / 2;
|
||||
const dist = Math.sqrt(offset.x * offset.x + offset.y * offset.y) || 0;
|
||||
const scale = dist > maxR && dist > 0 ? maxR / dist : 1;
|
||||
const cx = offset.x * scale;
|
||||
const cy = offset.y * scale;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="editor-pointer-eye"
|
||||
style={{ top: eye.top, left: eye.left, width: size, height: size }}
|
||||
>
|
||||
<div
|
||||
className="editor-pointer-iris"
|
||||
style={{
|
||||
width: irisSize,
|
||||
height: irisSize,
|
||||
marginTop: -(irisSize / 2) - 0.5,
|
||||
marginLeft: -(irisSize / 2) - 0.5,
|
||||
transform: `translate(${cx}px, ${cy}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user