import { Map, User, Swords, Dice5, Volume2, Settings, ChevronLeft, Timer, Calendar, Wand2, BookOpen, ImagePlus, Shuffle, Flag, History, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Dashboard } from "./components/Dashboard"; import { SettingsPanel } from "./components/SettingsPanel"; import { NpcGenerator } from "./components/NpcGenerator"; import { DiceRoller } from "./components/DiceRoller"; import { SessionLogger } from "./components/SessionLogger"; import { EncounterBuilder } from "./components/EncounterBuilder"; import { InitiativeTracker } from "./components/InitiativeTracker"; import { RandomTables } from "./components/RandomTables"; import { CalendarWidget } from "./components/CalendarWidget"; import { WorldBuilder } from "./components/WorldBuilder"; import { ItemForge } from "./components/ItemForge"; import { QuestDesigner } from "./components/QuestDesigner"; import { Soundboard } from "./components/Soundboard"; import { LorePanel } from "./components/LorePanel"; import { ImageGenerator } from "./components/ImageGenerator"; import { ToastContainer } from "./components/Toast"; import { CommandPalette } from "./components/CommandPalette"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { ConnectionPill } from "./components/ConnectionPill"; import { HistoryView } from "./components/HistoryView"; import type { Generation, GenerationKind } from "./lib/generations"; export type View = | "dashboard" | "world" | "npcs" | "encounter" | "dice" | "session" | "sound" | "initiative" | "tables" | "calendar" | "quest" | "items" | "lore" | "image" | "settings" | "history"; // ponytail: tools that accept a prefill from history. Mirrors the kinds. const PREFILLABLE: Partial> = { npcs: "npc", encounter: "encounter", world: "world", items: "item", quest: "quest", session: "session", image: "image", }; type NavGroup = "session" | "world"; interface NavItem { icon: typeof Map; label: string; view: View; group: NavGroup; shortcut?: string; } // ponytail: single source of truth for nav; groups drive the rail layout. export const navItems: NavItem[] = [ { icon: Timer, label: "Initiative", view: "initiative", group: "session", shortcut: "1" }, { icon: Dice5, label: "Dice", view: "dice", group: "session", shortcut: "2" }, { icon: Swords, label: "Encounter", view: "encounter", group: "session", shortcut: "3" }, { icon: User, label: "NPCs", view: "npcs", group: "session", shortcut: "4" }, { icon: Flag, label: "Quest", view: "quest", group: "session", shortcut: "5" }, { icon: Volume2, label: "Sound", view: "sound", group: "session", shortcut: "6" }, { icon: Map, label: "World", view: "world", group: "world", shortcut: "7" }, { icon: BookOpen, label: "Lore", view: "lore", group: "world" }, { icon: Wand2, label: "Items", view: "items", group: "world" }, { icon: Calendar, label: "Calendar", view: "calendar", group: "world" }, { icon: Shuffle, label: "Tables", view: "tables", group: "world" }, { icon: ImagePlus, label: "Image", view: "image", group: "world" }, { icon: History, label: "History", view: "history", group: "world" }, ]; function renderView( view: View, prefill: Generation | null, onPrefillConsumed: () => void, rehydrate: (kind: GenerationKind, data: Generation) => void, ): React.ReactNode { // ponytail: history view renders its own list/detail panes, so it doesn't // use the prefill or wrapper pattern. if (view === "history") { return ; } // Tools that accept prefill. if (view === "npcs") return ; if (view === "encounter") return ; if (view === "world") return ; if (view === "items") return ; if (view === "quest") return ; if (view === "session") return ; if (view === "image") return ; switch (view) { case "dice": return ; case "initiative": return ; case "tables": return ; case "calendar": return ; case "settings": return ; case "lore": return ; case "sound": return ; default: return null; } } // ponytail: per-tool preferred max-width. Wider tools stop fighting the rail. const viewMaxWidth: Partial> = { dice: "max-w-2xl", settings: "max-w-2xl", npcs: "max-w-4xl", encounter: "max-w-4xl", quest: "max-w-4xl", items: "max-w-4xl", world: "max-w-6xl", lore: "max-w-6xl", image: "max-w-6xl", sound: "max-w-6xl", session: "max-w-6xl", initiative: "max-w-2xl", tables: "max-w-2xl", calendar: "max-w-2xl", }; // ponytail: tools that accept a prefill. The map mirrors `PREFILLABLE` above // and exists so rehydrate() can find the right View for a given kind. const PREFILLABLE_TOOLS = { npcs: NpcGenerator, encounter: EncounterBuilder, world: WorldBuilder, items: ItemForge, quest: QuestDesigner, session: SessionLogger, image: ImageGenerator, } as const; export default function App() { const [view, setView] = useState("dashboard"); const [paletteOpen, setPaletteOpen] = useState(false); // ponytail: prefill is set when the user clicks "Open in tool" in History. // The targeted tool reads it via usePrefillEffect and calls onConsumed // to clear it. State lives here so the rehydrate→switch→consume dance // doesn't depend on a shared store. const [prefill, setPrefill] = useState(null); function rehydrate(kind: GenerationKind, data: Generation) { const target = (Object.keys(PREFILLABLE_TOOLS) as View[]).find( (v) => PREFILLABLE[v] === kind, ); if (!target) return; setPrefill(data); setView(target); } // ⌘K / Ctrl-K opens the command palette; ⌘H opens the History page; Esc closes the palette. useEffect(() => { function onKey(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setPaletteOpen((o) => !o); return; } if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "h") { e.preventDefault(); setView("history"); return; } if (e.key === "Escape" && paletteOpen) { setPaletteOpen(false); return; } // Digit shortcuts jump to nav items, but not while typing in an input. if ( !paletteOpen && !e.metaKey && !e.ctrlKey && !e.altKey && /^[1-9]$/.test(e.key) && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) ) { const target = navItems.find((n) => n.shortcut === e.key); if (target) { e.preventDefault(); setView(target.view); } } // ⌘, opens settings. if ((e.metaKey || e.ctrlKey) && e.key === ",") { e.preventDefault(); setView("settings"); } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [paletteOpen]); const isOnDashboard = view === "dashboard"; const groupedNav = useMemo( () => ({ session: navItems.filter((n) => n.group === "session"), world: navItems.filter((n) => n.group === "world"), }), [], ); function NavButton({ item }: { item: NavItem }) { const active = view === item.view; return ( ); } return (
{/* ponytail: skip-to-content for keyboard users — jumps past the rail. */} Skip to content {/* Left Rail */} {/* Main Content Area */}
{/* Title Bar */}
{!isOnDashboard && ( )} DM-Pal
{/* Main Content */}
{isOnDashboard ? ( ) : view === "history" ? ( rehydrate(k, d)} /> ) : (
{view === "settings" ? (

Settings

) : (
{renderView(view, prefill, () => setPrefill(null), rehydrate)}
)}
)}
setPaletteOpen(false)} onSelect={(v) => { setView(v); setPaletteOpen(false); }} /> {/* Toast notifications */}
); } function GroupLabel({ children }: { children: React.ReactNode }) { return ( ); }