working through the plan + UI/ UX

This commit is contained in:
itsamejms
2026-07-12 22:13:43 +01:00
parent 5b256242be
commit a24f3615e0
38 changed files with 5295 additions and 328 deletions
+278 -114
View File
@@ -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>
);
}