414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
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<Record<View, GenerationKind>> = {
|
|
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 <HistoryView onRehydrate={rehydrate} />;
|
|
}
|
|
// Tools that accept prefill.
|
|
if (view === "npcs") return <NpcGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
if (view === "encounter") return <EncounterBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
if (view === "world") return <WorldBuilder prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
if (view === "items") return <ItemForge prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
if (view === "quest") return <QuestDesigner prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
if (view === "session") return <SessionLogger prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
if (view === "image") return <ImageGenerator prefill={prefill} onPrefillConsumed={onPrefillConsumed} />;
|
|
switch (view) {
|
|
case "dice":
|
|
return <DiceRoller />;
|
|
case "initiative":
|
|
return <InitiativeTracker />;
|
|
case "tables":
|
|
return <RandomTables />;
|
|
case "calendar":
|
|
return <CalendarWidget />;
|
|
case "settings":
|
|
return <SettingsPanel />;
|
|
case "lore":
|
|
return <LorePanel />;
|
|
case "sound":
|
|
return <Soundboard />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ponytail: per-tool preferred max-width. Wider tools stop fighting the rail.
|
|
const viewMaxWidth: Partial<Record<View, string>> = {
|
|
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<View>("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<Generation | null>(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 (
|
|
<button
|
|
onClick={() => setView(item.view)}
|
|
aria-label={item.label + (item.shortcut ? ` (${item.shortcut})` : "")}
|
|
data-tooltip={item.label}
|
|
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
|
active
|
|
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
|
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
|
}`}
|
|
>
|
|
<item.icon size={item.view === "tables" ? 18 : 18} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]">
|
|
{/* ponytail: skip-to-content for keyboard users — jumps past the rail. */}
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-2 focus:left-2 focus:px-3 focus:py-1.5 focus:rounded-lg focus:bg-[var(--color-gold-bright)] focus:text-[var(--color-bg-deep)] focus:text-xs focus:font-semibold"
|
|
>
|
|
Skip to content
|
|
</a>
|
|
{/* Left Rail */}
|
|
<nav
|
|
aria-label="Primary"
|
|
className="flex flex-col items-center w-14 bg-[var(--color-bg-surface)] border-r border-[var(--color-border-subtle)] py-3 gap-1 shrink-0"
|
|
>
|
|
{/* App Logo — goes to dashboard */}
|
|
<button
|
|
onClick={() => setView("dashboard")}
|
|
aria-label="Dashboard"
|
|
data-tooltip="Dashboard"
|
|
className={`w-8 h-8 rounded-lg flex items-center justify-center mb-4 font-heading font-bold text-lg cursor-pointer transition-colors ${
|
|
isOnDashboard
|
|
? "bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)]"
|
|
: "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)] hover:bg-[var(--color-gold-bright)] hover:text-[var(--color-bg-deep)]"
|
|
}`}
|
|
>
|
|
⚔
|
|
</button>
|
|
|
|
{/* SESSION group — in-combat tools */}
|
|
<GroupLabel>Session</GroupLabel>
|
|
{groupedNav.session.map((item) => (
|
|
<NavButton key={item.view} item={item} />
|
|
))}
|
|
|
|
<div className="w-6 h-px bg-[var(--color-border-subtle)] my-1.5" />
|
|
|
|
{/* WORLD group — prep / worldbuilding tools */}
|
|
<GroupLabel>World</GroupLabel>
|
|
{groupedNav.world.map((item) => (
|
|
<NavButton key={item.view} item={item} />
|
|
))}
|
|
|
|
{/* Spacer */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Settings */}
|
|
<button
|
|
onClick={() => setView("settings")}
|
|
aria-label="Settings (⌘,)"
|
|
data-tooltip="Settings"
|
|
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
|
view === "settings"
|
|
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
|
: "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]"
|
|
}`}
|
|
>
|
|
<Settings size={18} />
|
|
</button>
|
|
</nav>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="flex flex-col flex-1 overflow-hidden">
|
|
{/* Title Bar */}
|
|
<header
|
|
className="flex items-center h-10 px-4 bg-[var(--color-bg-surface)] border-b border-[var(--color-border-subtle)] shrink-0"
|
|
data-tauri-drag-region
|
|
>
|
|
{!isOnDashboard && (
|
|
<button
|
|
onClick={() => setView("dashboard")}
|
|
data-tauri-no-drag
|
|
className="mr-3 text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
|
title="Back to dashboard"
|
|
aria-label="Back to dashboard"
|
|
>
|
|
<ChevronLeft size={18} />
|
|
</button>
|
|
)}
|
|
<span className="font-heading text-[var(--color-gold-bright)] text-base font-bold tracking-wider">
|
|
DM-Pal
|
|
</span>
|
|
<button
|
|
onClick={() => setPaletteOpen(true)}
|
|
data-tauri-no-drag
|
|
className="ml-4 flex items-center gap-2 text-[var(--color-text-dim)] text-xs hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
|
|
aria-label="Open command palette (⌘K)"
|
|
>
|
|
<span>Jump to…</span>
|
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono">
|
|
⌘K
|
|
</kbd>
|
|
</button>
|
|
<button
|
|
onClick={() => setView("history")}
|
|
data-tauri-no-drag
|
|
className="ml-2 flex items-center gap-1.5 text-[var(--color-text-dim)] text-xs hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
|
aria-label="Open history (⌘H)"
|
|
title="History (⌘H)"
|
|
>
|
|
<History size={12} />
|
|
<span className="hidden sm:inline">History</span>
|
|
<kbd className="hidden md:inline-flex items-center px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono">
|
|
⌘H
|
|
</kbd>
|
|
</button>
|
|
<div className="ml-auto" data-tauri-no-drag />
|
|
<ConnectionPill />
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 overflow-auto" id="main-content">
|
|
<ErrorBoundary>
|
|
{isOnDashboard ? (
|
|
<Dashboard onNavigate={setView} />
|
|
) : view === "history" ? (
|
|
<HistoryView onRehydrate={(k, d) => rehydrate(k, d)} />
|
|
) : (
|
|
<div className={`${viewMaxWidth[view] ?? "max-w-2xl"} mx-auto p-6`}>
|
|
{view === "settings" ? (
|
|
<div className="glass-card p-6">
|
|
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
|
Settings
|
|
</h2>
|
|
<SettingsPanel />
|
|
</div>
|
|
) : (
|
|
<div className="glass-card p-6">
|
|
{renderView(view, prefill, () => setPrefill(null), rehydrate)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</ErrorBoundary>
|
|
</main>
|
|
</div>
|
|
|
|
<CommandPalette
|
|
open={paletteOpen}
|
|
onClose={() => setPaletteOpen(false)}
|
|
onSelect={(v) => {
|
|
setView(v);
|
|
setPaletteOpen(false);
|
|
}}
|
|
/>
|
|
|
|
{/* Toast notifications */}
|
|
<ToastContainer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GroupLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<span
|
|
className="text-[9px] uppercase tracking-widest text-[var(--color-text-dim)] font-medium mb-1 mt-1"
|
|
aria-hidden="true"
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|