diff --git a/src/App.tsx b/src/App.tsx index 9d387ca..1d69ea4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { Sparkles, Timer, Calendar, + Wand2, } from "lucide-react"; import { useState } from "react"; import { Dashboard } from "./components/Dashboard"; @@ -22,6 +23,9 @@ 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"; export type View = | "dashboard" @@ -35,11 +39,13 @@ export type View = | "tables" | "calendar" | "quest" + | "items" | "settings"; 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" }, @@ -65,27 +71,11 @@ function renderView(view: View): React.ReactNode { case "settings": return ; case "world": - return ( -
-
- -

World Map

-

Interactive map canvas coming soon

-

react-konva integration pending

-
-
- ); + return ; case "quest": - return ( -
-
- -

Quest Designer

-

Branching flowchart coming soon

-

react-flow integration pending

-
-
- ); + return ; + case "items": + return ; case "sound": return (
@@ -165,6 +155,17 @@ export default function App() { > +
@@ -96,14 +98,15 @@ export function Dashboard({ onNavigate }: DashboardProps) { {/* Quest Designer — 2×1 */} } span="col-span-2 row-span-1"> -
- -

Quest flowchart coming soon

+
+
+ +
@@ -160,6 +163,21 @@ export function Dashboard({ onNavigate }: DashboardProps) {

Soundboard coming soon

+ + {/* Item Forge — 2×1 */} + } span="col-span-2 row-span-1"> +
+
+ +
+ +
+
); } @@ -171,4 +189,7 @@ import { SessionLogger } from "./SessionLogger"; import { EncounterBuilder } from "./EncounterBuilder"; import { InitiativeTracker } from "./InitiativeTracker"; import { RandomTables } from "./RandomTables"; -import { CalendarWidget } from "./CalendarWidget"; \ No newline at end of file +import { CalendarWidget } from "./CalendarWidget"; +import { WorldBuilder } from "./WorldBuilder"; +import { QuestDesigner } from "./QuestDesigner"; +import { ItemForge } from "./ItemForge"; \ No newline at end of file diff --git a/src/components/ItemForge.tsx b/src/components/ItemForge.tsx new file mode 100644 index 0000000..8fda2e3 --- /dev/null +++ b/src/components/ItemForge.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface ItemResponse { + name: string; + rarity: string; + type: string; + description: string; + mechanical: string; + lore: string; +} + +const RARITIES = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"]; +const TYPES = ["Weapon", "Armor", "Potion", "Scroll", "Wondrous Item", "Ring", "Wand", "Staff"]; + +export function ItemForge() { + const [rarity, setRarity] = useState("Rare"); + const [itemType, setItemType] = useState("Wondrous Item"); + const [prompt, setPrompt] = useState(""); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function generate() { + setLoading(true); + setError(""); + setItem(null); + try { + const result = await invoke("generate", { + req: { + prompt: `Create a D&D 5e magic item with the following specifications: +Rarity: ${rarity} +Type: ${itemType} +${prompt ? `Additional details: ${prompt}` : ""} + +Provide the response as a JSON object with exactly these keys: +- "name": the item's evocative name +- "rarity": "${rarity}" +- "type": "${itemType}" +- "description": a vivid 2-3 sentence physical description +- "mechanical": the item's game mechanics (what it does, charges, etc.) +- "lore": a 1-2 sentence piece of lore or history about the item`, + system: "You are a creative D&D item designer. Always respond with valid JSON only.", + temperature: 0.85, + max_tokens: 500, + }, + }); + const jsonMatch = result.match(/\{[\s\S]*\}/); + if (jsonMatch) { + setItem(JSON.parse(jsonMatch[0]) as ItemResponse); + } else { + setError("LLM did not return valid JSON.\n" + result); + } + } catch (e) { + setError(String(e)); + } + setLoading(false); + } + + const rarityColor: Record = { + Common: "var(--color-text-secondary)", + Uncommon: "#1eff00", + Rare: "#0070dd", + "Very Rare": "#a335ee", + Legendary: "#ff8000", + Artifact: "#e6cc80", + }; + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+ + setPrompt(e.target.value)} + placeholder="e.g. a flame dagger that grants fire resistance..." + onKeyDown={(e) => e.key === "Enter" && generate()} + /> +
+ + + + {error && ( +
+ {error} +
+ )} + + {item && ( +
+
+
+

+ {item.name} +

+
+ {item.rarity} + + {item.type} +
+
+
+ +
+ Description +

{item.description}

+
+ +
+ Mechanics +

{item.mechanical}

+
+ +
+ Lore +

{item.lore}

+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/QuestDesigner.tsx b/src/components/QuestDesigner.tsx new file mode 100644 index 0000000..bbe1d55 --- /dev/null +++ b/src/components/QuestDesigner.tsx @@ -0,0 +1,191 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface QuestStep { + title: string; + description: string; + choice?: string; +} + +interface QuestResponse { + title: string; + hook: string; + steps: QuestStep[]; + twist: string; + reward: string; +} + +export function QuestDesigner() { + const [theme, setTheme] = useState(""); + const [level, setLevel] = useState(5); + const [quest, setQuest] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function generate() { + setLoading(true); + setError(""); + setQuest(null); + setCurrentStep(0); + try { + const result = await invoke("generate", { + req: { + prompt: `Design a D&D quest for level ${level} characters. +${theme ? `Theme: ${theme}` : ""} + +Provide the response as a JSON object with exactly these keys: +- "title": a catchy quest name +- "hook": 1-2 sentences describing how the party gets involved +- "steps": an array of 3-5 quest steps, each with "title" and "description" (2-3 sentences each), and optionally "choice" (a meaningful decision the party faces) +- "twist": a surprise revelation or complication +- "reward": what the party gains on completion`, + system: "You are a creative D&D quest designer. Always respond with valid JSON only.", + temperature: 0.85, + max_tokens: 700, + }, + }); + const jsonMatch = result.match(/\{[\s\S]*\}/); + if (jsonMatch) { + setQuest(JSON.parse(jsonMatch[0]) as QuestResponse); + } else { + setError("LLM did not return valid JSON.\n" + result); + } + } catch (e) { + setError(String(e)); + } + setLoading(false); + } + + return ( +
+
+
+
+ + setTheme(e.target.value)} + placeholder="e.g. haunted forest, political intrigue, dragon's lair..." + onKeyDown={(e) => e.key === "Enter" && generate()} + /> +
+
+ + setLevel(parseInt(e.target.value) || 1)} + className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm font-mono" + /> +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {quest && ( +
+ {/* Quest title & hook */} +
+

+ {quest.title} +

+

+ {quest.hook} +

+
+ + {/* Steps - step-through view */} + {quest.steps?.length > 0 && ( +
+
+

+ Quest Steps +

+
+ {quest.steps.map((_, i) => ( + + ))} +
+
+ +
+
+ Step {currentStep + 1}: {quest.steps[currentStep].title} +
+

+ {quest.steps[currentStep].description} +

+ {quest.steps[currentStep].choice && ( +
+ Choice +

{quest.steps[currentStep].choice}

+
+ )} +
+ + +
+
+
+ )} + + {/* Twist */} + {quest.twist && ( +
+ Twist +

{quest.twist}

+
+ )} + + {/* Reward */} + {quest.reward && ( +
+ Reward +

{quest.reward}

+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/WorldBuilder.tsx b/src/components/WorldBuilder.tsx new file mode 100644 index 0000000..bc89595 --- /dev/null +++ b/src/components/WorldBuilder.tsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface WorldResponse { + name: string; + description: string; + regions: string[]; + landmarks: string[]; + conflicts: string[]; + cultures: string[]; +} + +export function WorldBuilder() { + const [theme, setTheme] = useState("high fantasy"); + const [world, setWorld] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function generate() { + setLoading(true); + setError(""); + setWorld(null); + try { + const result = await invoke("generate", { + req: { + prompt: `Create a detailed fantasy world with the theme: "${theme}". + +Provide the response as a JSON object with exactly these keys: +- "name": the world's name +- "description": a 2-3 sentence overview +- "regions": an array of 3-4 region names with brief descriptions +- "landmarks": an array of 2-3 notable landmarks +- "conflicts": an array of 2-3 major conflicts or tensions +- "cultures": an array of 2-3 distinct cultures or peoples`, + system: "You are a creative world-building DM. Always respond with valid JSON only.", + temperature: 0.9, + max_tokens: 600, + }, + }); + const jsonMatch = result.match(/\{[\s\S]*\}/); + if (jsonMatch) { + setWorld(JSON.parse(jsonMatch[0]) as WorldResponse); + } else { + setError("LLM did not return valid JSON.\n" + result); + } + } catch (e) { + setError(String(e)); + } + setLoading(false); + } + + return ( +
+
+ + setTheme(e.target.value)} + placeholder="e.g. high fantasy, dark post-apocalyptic, steampunk..." + onKeyDown={(e) => e.key === "Enter" && generate()} + /> + +
+ + {error && ( +
+ {error} +
+ )} + + {world && ( +
+ {/* World name and description */} +
+

+ {world.name} +

+

+ {world.description} +

+
+ + {/* Regions */} + {world.regions?.length > 0 && ( +
+

+ 🏔 Regions +

+
+ {world.regions.map((r, i) => ( +
+ {r} +
+ ))} +
+
+ )} + + {/* Landmarks */} + {world.landmarks?.length > 0 && ( +
+

+ 🏛 Landmarks +

+
+ {world.landmarks.map((l, i) => ( + + {l} + + ))} +
+
+ )} + + {/* Conflicts */} + {world.conflicts?.length > 0 && ( +
+

+ ⚔ Conflicts +

+
+ {world.conflicts.map((c, i) => ( +
+ {c} +
+ ))} +
+
+ )} + + {/* Cultures */} + {world.cultures?.length > 0 && ( +
+

+ 👥 Cultures +

+
+ {world.cultures.map((c, i) => ( + + {c} + + ))} +
+
+ )} +
+ )} +
+ ); +} \ No newline at end of file