Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
8.0 kB · 270 lines
TSX
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271import { BookOpen, CircleHelp, Files, Home, Library, Menu, Network, PanelLeftClose, Search, Settings, Shapes, X,} from "lucide-react";import { useEffect, useRef, useState } from "react";
export type Section = | "home" | "notes" | "learning" | "problems" | "shelf" | "graph" | "whiteboards" | "settings";
const PRIMARY_NAV: Array<{ id: Section; label: string; icon: React.ReactNode;}> = [ { id: "home", label: "Overview", icon: <Home /> }, { id: "notes", label: "Notes", icon: <Files /> }, { id: "learning", label: "Learning", icon: <BookOpen /> }, { id: "problems", label: "Problems", icon: <CircleHelp /> }, { id: "shelf", label: "Library", icon: <Library /> },];
const SPATIAL_NAV: Array<{ id: Section; label: string; icon: React.ReactNode;}> = [ { id: "graph", label: "Graph", icon: <Network /> }, { id: "whiteboards", label: "Whiteboards", icon: <Shapes /> },];
const SECTION_LABELS: Record<Section, string> = { home: "Overview", notes: "Notes", learning: "Learning", problems: "Problems", shelf: "Library", graph: "Graph", whiteboards: "Whiteboards", settings: "Settings",};
export function AppShell({ section, onNavigate, onSearchNotes, boardEditor = false, children,}: { section: Section; onNavigate: (section: Section) => void; onSearchNotes?: () => void; boardEditor?: boolean; children: React.ReactNode;}) { const [mobileOpen, setMobileOpen] = useState(false); const mobileNavigationTriggerRef = useRef<HTMLButtonElement>(null); const mobileNavigationCloseRef = useRef<HTMLButtonElement>(null); const [shortcutLabel] = useState(() => { if (typeof navigator === "undefined") return "Ctrl K"; return /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘ K" : "Ctrl K"; }); const [collapsed, setCollapsed] = useState( () => localStorage.getItem("forge.sidebar.collapsed") === "true", );
useEffect(() => { localStorage.setItem("forge.sidebar.collapsed", String(collapsed)); }, [collapsed]);
useEffect(() => { if (!onSearchNotes) return; const handleShortcut = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setMobileOpen(false); onSearchNotes(); } }; document.addEventListener("keydown", handleShortcut); return () => document.removeEventListener("keydown", handleShortcut); }, [onSearchNotes]);
useEffect(() => { if (!mobileOpen) return; const sidebar = document.getElementById("forge-navigation"); if (!sidebar) return; const getFocusable = () => Array.from( sidebar.querySelectorAll<HTMLElement>( 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [contenteditable="true"], [tabindex]:not([tabindex="-1"])', ), ).filter((element) => { const style = window.getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; }); window.requestAnimationFrame(() => mobileNavigationCloseRef.current?.focus()); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); setMobileOpen(false); window.requestAnimationFrame(() => mobileNavigationTriggerRef.current?.focus()); return; } if (event.key !== "Tab") return; const focusable = getFocusable(); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [mobileOpen]);
const compact = boardEditor || collapsed;
const navigate = (next: Section) => { onNavigate(next); setMobileOpen(false); if (window.matchMedia("(max-width: 1100px)").matches) { window.requestAnimationFrame(() => mobileNavigationTriggerRef.current?.focus()); } };
const navGroup = ( label: string, entries: typeof PRIMARY_NAV, ) => ( <div className="nav-group"> <span className="nav-group-label">{label}</span> {entries.map((item) => ( <button type="button" key={item.id} className={section === item.id ? "active" : ""} onClick={() => navigate(item.id)} aria-label={item.label} title={compact ? item.label : undefined} > {item.icon} <span>{item.label}</span> </button> ))} </div> );
return ( <div className={`app-shell ${compact ? "sidebar-collapsed" : ""} ${section === "whiteboards" ? "whiteboards-shell" : ""} ${boardEditor ? "whiteboard-editor-shell" : ""} ${ mobileOpen ? "navigation-open" : "" }`} > <aside id="forge-navigation" className={`app-sidebar ${mobileOpen ? "mobile-open" : ""}`} > <header className="sidebar-header"> <button type="button" className="brand-button" onClick={() => navigate("home")} aria-label="Open Overview" > <span className="forge-glyph">O</span> <span className="brand-copy"> <strong>Odre</strong> </span> </button> <button type="button" className="sidebar-close-mobile" ref={mobileNavigationCloseRef} onClick={() => { setMobileOpen(false); window.requestAnimationFrame(() => mobileNavigationTriggerRef.current?.focus()); }} aria-label="Close navigation" title="Close navigation" > <X /> </button> </header>
<button type="button" className="command-search" onClick={() => { setMobileOpen(false); if (onSearchNotes) onSearchNotes(); else navigate("notes"); }} aria-label="Search notes" > <Search /> <span>Search notes</span> <kbd>{shortcutLabel}</kbd> </button>
<nav aria-label="Odre navigation"> {navGroup("Work", PRIMARY_NAV)} {navGroup("Organize", SPATIAL_NAV)} </nav>
<footer className="sidebar-footer"> <button type="button" className={section === "settings" ? "active" : ""} onClick={() => navigate("settings")} aria-label="Settings" > <Settings /> <span>Settings</span> </button> </footer>
<button type="button" className="sidebar-collapse" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} > <PanelLeftClose /> </button> </aside>
<div className="app-main-frame"> <div className="mobile-shell-bar"> <button type="button" className="mobile-navigation-button" onClick={() => setMobileOpen(true)} ref={mobileNavigationTriggerRef} aria-label="Open navigation" aria-controls="forge-navigation" aria-expanded={mobileOpen} title="Open navigation" > <Menu /> </button> <strong>{SECTION_LABELS[section]}</strong> </div> <main className="app-content">{children}</main> </div> </div> );}