feat: menu and ponters
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import frameTerraSvg from "@/assets/frame.themed.svg";
|
||||
import frameAzureSvg from "@/assets/frame.azure.svg";
|
||||
import NavMenu from "./NavMenu";
|
||||
|
||||
const TITLE = `
|
||||
|
||||
@@ -82,7 +83,9 @@ function RomanClock() {
|
||||
|
||||
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");
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -103,7 +106,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-screen bg-background text-foreground">
|
||||
<main className="flex-1 overflow-auto">
|
||||
<main className="flex-1 overflow-auto" data-scroll-root>
|
||||
<div className="relative max-w-5xl min-w-5xl mx-auto min-h-full">
|
||||
{/* Frame SVG — background */}
|
||||
<img
|
||||
@@ -137,6 +140,17 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Nav menu — anchored below the frame, left side (hidden on editor) */}
|
||||
{!isEditor && (
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={{ top: 200, left: -210 }}
|
||||
>
|
||||
<NavMenu />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
||||
|
||||
87
frontend/src/components/shared/NavMenu.tsx
Normal file
87
frontend/src/components/shared/NavMenu.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import menuSvg from "@/assets/menu.min.svg";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Browse", path: "/browse" },
|
||||
{ label: "Compose", path: "/" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Smooth scroll-following: tracks the scroll container and lerps
|
||||
* the menu's vertical offset so it glides into place with eased acceleration.
|
||||
*/
|
||||
function useSmoothScroll(scrollSelector: string, ease = 0.08) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const targetRef = useRef(0);
|
||||
const currentRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.querySelector(scrollSelector);
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
targetRef.current = container.scrollTop;
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
const tick = () => {
|
||||
const diff = targetRef.current - currentRef.current;
|
||||
if (Math.abs(diff) < 0.5) {
|
||||
currentRef.current = targetRef.current;
|
||||
} else {
|
||||
currentRef.current += diff * ease;
|
||||
}
|
||||
setOffset(currentRef.current);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [scrollSelector, ease]);
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
export default function NavMenu() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const scrollY = useSmoothScroll("[data-scroll-root]", 0.07);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="nav-menu-root"
|
||||
style={{ transform: `translateY(${scrollY}px)` }}
|
||||
>
|
||||
{/* Frame — menu.min.svg as mask, colored by theme */}
|
||||
<div
|
||||
className="nav-menu-frame"
|
||||
style={{
|
||||
WebkitMaskImage: `url(${menuSvg})`,
|
||||
maskImage: `url(${menuSvg})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navigation links */}
|
||||
<nav className="nav-menu-links">
|
||||
{NAV_ITEMS.map(({ label, path }) => {
|
||||
const active = location.pathname === path;
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
onClick={() => navigate(path)}
|
||||
className="nav-menu-item"
|
||||
data-active={active || undefined}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user