working through the plan + UI/ UX
This commit is contained in:
+278
-114
@@ -3,17 +3,19 @@ import {
|
||||
User,
|
||||
Swords,
|
||||
Dice5,
|
||||
ScrollText,
|
||||
Volume2,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
Sparkles,
|
||||
Timer,
|
||||
Calendar,
|
||||
Wand2,
|
||||
BookOpen,
|
||||
ImagePlus,
|
||||
Shuffle,
|
||||
Flag,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { SettingsPanel } from "./components/SettingsPanel";
|
||||
import { NpcGenerator } from "./components/NpcGenerator";
|
||||
@@ -27,7 +29,13 @@ 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 { HistoryView } from "./components/HistoryView";
|
||||
import type { Generation, GenerationKind } from "./lib/generations";
|
||||
|
||||
export type View =
|
||||
| "dashboard"
|
||||
@@ -42,28 +50,71 @@ export type View =
|
||||
| "calendar"
|
||||
| "quest"
|
||||
| "items"
|
||||
| "settings";
|
||||
| "lore"
|
||||
| "image"
|
||||
| "settings"
|
||||
| "history";
|
||||
|
||||
const navItems: { icon: typeof Map; label: string; view: View }[] = [
|
||||
{ icon: Map, label: "World", view: "world" },
|
||||
{ icon: User, label: "NPCs", view: "npcs" },
|
||||
{ icon: Wand2, label: "Items", view: "items" },
|
||||
{ icon: Swords, label: "Encounter", view: "encounter" },
|
||||
{ icon: Dice5, label: "Dice", view: "dice" },
|
||||
{ icon: ScrollText, label: "Session", view: "session" },
|
||||
{ icon: Volume2, label: "Sound", view: "sound" },
|
||||
// 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): React.ReactNode {
|
||||
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 "npcs":
|
||||
return <NpcGenerator />;
|
||||
case "dice":
|
||||
return <DiceRoller />;
|
||||
case "session":
|
||||
return <SessionLogger />;
|
||||
case "encounter":
|
||||
return <EncounterBuilder />;
|
||||
case "initiative":
|
||||
return <InitiativeTracker />;
|
||||
case "tables":
|
||||
@@ -72,12 +123,8 @@ function renderView(view: View): React.ReactNode {
|
||||
return <CalendarWidget />;
|
||||
case "settings":
|
||||
return <SettingsPanel />;
|
||||
case "world":
|
||||
return <WorldBuilder />;
|
||||
case "quest":
|
||||
return <QuestDesigner />;
|
||||
case "items":
|
||||
return <ItemForge />;
|
||||
case "lore":
|
||||
return <LorePanel />;
|
||||
case "sound":
|
||||
return <Soundboard />;
|
||||
default:
|
||||
@@ -85,103 +132,171 @@ function renderView(view: View): React.ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
const isDetailView = view !== "dashboard" && view !== "settings";
|
||||
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)]">
|
||||
{/* Left Rail */}
|
||||
<nav 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">
|
||||
<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 ${
|
||||
view === "dashboard"
|
||||
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)]"
|
||||
}`}
|
||||
title="Dashboard"
|
||||
>
|
||||
⚔
|
||||
</button>
|
||||
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => setView(item.view)}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
||||
view === item.view
|
||||
? "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)]"
|
||||
}`}
|
||||
title={item.label}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
</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" />
|
||||
|
||||
{/* Extra nav items */}
|
||||
<button
|
||||
onClick={() => setView("initiative")}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
||||
view === "initiative"
|
||||
? "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)]"
|
||||
}`}
|
||||
title="Initiative"
|
||||
>
|
||||
<Timer size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("tables")}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
||||
view === "tables"
|
||||
? "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)]"
|
||||
}`}
|
||||
title="Random Tables"
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("quest")}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
||||
view === "quest"
|
||||
? "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)]"
|
||||
}`}
|
||||
title="Quest Designer"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("calendar")}
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors cursor-pointer ${
|
||||
view === "calendar"
|
||||
? "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)]"
|
||||
}`}
|
||||
title="Calendar"
|
||||
>
|
||||
<Calendar size={18} />
|
||||
</button>
|
||||
|
||||
<div className="w-6 h-px bg-[var(--color-border-subtle)] my-1" />
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => setView(view === "settings" ? "dashboard" : "settings")}
|
||||
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)]"
|
||||
}`}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
@@ -194,11 +309,13 @@ export default function App() {
|
||||
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
|
||||
>
|
||||
{isDetailView && (
|
||||
{!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>
|
||||
@@ -206,35 +323,82 @@ export default function App() {
|
||||
<span className="font-heading text-[var(--color-gold-bright)] text-base font-bold tracking-wider">
|
||||
DM-Pal
|
||||
</span>
|
||||
<div className="ml-4 flex items-center gap-1 text-[var(--color-text-secondary)] text-xs cursor-pointer hover:text-[var(--color-text-primary)] transition-colors">
|
||||
<ChevronDown size={12} />
|
||||
<span>My Campaign</span>
|
||||
</div>
|
||||
<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 />
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
{view === "dashboard" ? (
|
||||
<Dashboard onNavigate={setView} />
|
||||
) : view === "settings" ? (
|
||||
<div className="max-w-lg mx-auto p-6">
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-lg font-semibold mb-4">
|
||||
Settings
|
||||
</h2>
|
||||
<SettingsPanel />
|
||||
</div>
|
||||
) : isDetailView ? (
|
||||
<div className="max-w-2xl mx-auto p-6">
|
||||
<div className="glass-card p-6">
|
||||
{renderView(view)}
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user