diff --git a/src/components/CalendarWidget.tsx b/src/components/CalendarWidget.tsx new file mode 100644 index 0000000..66bb909 --- /dev/null +++ b/src/components/CalendarWidget.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; + +const MONTHS = [ + "Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn", + "Flamerule", "Eleasis", "Eleint", "Marpenoth", "Uktar", "Nightal", +]; + +interface CalendarEvent { + day: number; + month: number; + text: string; + color: string; +} + +const COLORS = ["var(--color-gold-bright)", "var(--color-info)", "var(--color-success)", "var(--color-danger)"]; + +export function CalendarWidget() { + const [month, setMonth] = useState(0); + const [year, setYear] = useState(1492); + const [events, setEvents] = useState([]); + const [selectedDay, setSelectedDay] = useState(null); + const [newEvent, setNewEvent] = useState(""); + + const today = { day: 15, month: 5 }; // 15th of Kythorn (arbitrary "today") + + function addEvent() { + if (!newEvent.trim() || selectedDay === null) return; + setEvents((prev) => [ + ...prev, + { day: selectedDay, month, text: newEvent.trim(), color: COLORS[events.length % COLORS.length] }, + ]); + setNewEvent(""); + } + + function removeEvent(idx: number) { + setEvents((prev) => prev.filter((_, i) => i !== idx)); + } + + const dayEvents = events.filter((e) => e.day === selectedDay && e.month === month); + + return ( +
+ {/* Month navigation */} +
+ +
+
+ {MONTHS[month]} +
+
{year} DR
+
+ +
+ + {/* Day grid */} +
+ {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => ( +
+ {d} +
+ ))} + {/* Start offset - Faerûn calendar starts on first of month */} + {Array.from({ length: 30 }, (_, i) => { + const dayNum = i + 1; + const isToday = dayNum === today.day && month === today.month; + const hasEvent = events.some((e) => e.day === dayNum && e.month === month); + const isSelected = dayNum === selectedDay; + + return ( + + ); + })} +
+ + {/* Events for selected day */} + {selectedDay !== null && ( +
+
+ {MONTHS[month]} {selectedDay} +
+ {dayEvents.length === 0 && ( +
No events
+ )} + {dayEvents.map((e, i) => ( +
+ + {e.text} + +
+ ))} +
+ setNewEvent(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addEvent()} + placeholder="Add event…" + /> + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index affcc19..3afeccd 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -13,6 +13,10 @@ import { BentoCard } from "./BentoCard"; import { NpcGenerator } from "./NpcGenerator"; import { DiceRoller } from "./DiceRoller"; import { SessionLogger } from "./SessionLogger"; +import { EncounterBuilder } from "./EncounterBuilder"; +import { InitiativeTracker } from "./InitiativeTracker"; +import { RandomTables } from "./RandomTables"; +import { CalendarWidget } from "./CalendarWidget"; export function Dashboard() { return ( @@ -39,71 +43,39 @@ export function Dashboard() { {/* Encounter Builder — 1×1 */} - } - span="col-span-1 row-span-1" - > -
- Encounter builder coming soon -
+ } span="col-span-1 row-span-1"> + {/* Session Log — 2×1 */} - } - span="col-span-2 row-span-1" - > + } span="col-span-2 row-span-1"> {/* Quest Designer — 2×1 */} - } - span="col-span-2 row-span-1" - > + } span="col-span-2 row-span-1">
- Quest flowchart coming soon + Quest flowchart coming soon — react-flow integration pending
{/* Initiative Tracker — 1×1 */} } span="col-span-1 row-span-1"> -
- Combat tracker coming soon -
+
{/* Random Tables — 1×1 */} - } - span="col-span-1 row-span-1" - > -
- Random tables coming soon -
+ } span="col-span-1 row-span-1"> + {/* Calendar — 1×1 */} - } - span="col-span-1 row-span-1" - > -
- Calendar coming soon -
+ } span="col-span-1 row-span-1"> + {/* Soundboard — 1×1 */} - } - span="col-span-1 row-span-1" - > + } span="col-span-1 row-span-1">
Soundboard coming soon
diff --git a/src/components/EncounterBuilder.tsx b/src/components/EncounterBuilder.tsx new file mode 100644 index 0000000..269bc05 --- /dev/null +++ b/src/components/EncounterBuilder.tsx @@ -0,0 +1,136 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface Encounter { + monsters: string[]; + terrain: string; + difficulty: string; + loot: string; +} + +export function EncounterBuilder() { + const [partyLevel, setPartyLevel] = useState(5); + const [partySize, setPartySize] = useState(4); + const [terrain, setTerrain] = useState("Forest"); + const [encounter, setEncounter] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function generate() { + setLoading(true); + setError(""); + setEncounter(null); + try { + const result = await invoke("generate", { + req: { + prompt: `Generate a D&D 5e encounter for a party of ${partySize} level-${partyLevel} characters in a ${terrain.toLowerCase()} setting. + +Provide the response as a JSON object with exactly these keys: +- "monsters": an array of 2-4 monster descriptions with quantities (e.g. "3x Goblin Scouts") +- "terrain": a brief terrain description with environmental features +- "difficulty": one of Easy/Medium/Hard/Deadly +- "loot": a brief treasure description`, + system: "You are a D&D encounter designer. Always respond with valid JSON only.", + temperature: 0.9, + max_tokens: 400, + }, + }); + const jsonMatch = result.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]) as Encounter; + setEncounter(parsed); + } else { + setError("LLM did not return valid JSON. Raw:\n" + result); + } + } catch (e) { + setError(String(e)); + } + setLoading(false); + } + + return ( +
+
+
+ + setPartyLevel(parseInt(e.target.value) || 1)} + className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono" + /> +
+
+ + setPartySize(parseInt(e.target.value) || 1)} + className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono" + /> +
+
+ +
+ + +
+ + + + {error && ( +
+ {error} +
+ )} + + {encounter && ( +
+
+ + {encounter.difficulty} Encounter + + + Level {partyLevel} × {partySize} + +
+ +
+ Monsters +
    + {encounter.monsters?.map((m, i) =>
  • {m}
  • )} +
+
+ +
+ Terrain Features +

{encounter.terrain}

+
+ +
+ Loot +

{encounter.loot}

+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/InitiativeTracker.tsx b/src/components/InitiativeTracker.tsx new file mode 100644 index 0000000..e7a704c --- /dev/null +++ b/src/components/InitiativeTracker.tsx @@ -0,0 +1,233 @@ +import { useState, useCallback } from "react"; + +interface Combatant { + id: string; + name: string; + initiative: number; + hp: number; + maxHp: number; + conditions: string[]; +} + +const CONDITIONS = [ + "Blinded", "Charmed", "Deafened", "Frightened", "Grappled", + "Incapacitated", "Invisible", "Paralyzed", "Poisoned", "Prone", + "Restrained", "Stunned", "Unconscious", +]; + +let nextId = 1; + +export function InitiativeTracker() { + const [combatants, setCombatants] = useState([]); + const [name, setName] = useState(""); + const [initBonus, setInitBonus] = useState(0); + const [hp, setHp] = useState(10); + const [activeId, setActiveId] = useState(null); + const [round, setRound] = useState(1); + + const addCombatant = useCallback(() => { + if (!name.trim()) return; + const roll = Math.floor(Math.random() * 20) + 1 + initBonus; + const c: Combatant = { + id: String(nextId++), + name: name.trim(), + initiative: roll, + hp, + maxHp: hp, + conditions: [], + }; + setCombatants((prev) => [...prev, c].sort((a, b) => b.initiative - a.initiative)); + setName(""); + }, [name, initBonus, hp]); + + const removeCombatant = useCallback((id: string) => { + setCombatants((prev) => prev.filter((c) => c.id !== id)); + setActiveId((prev) => (prev === id ? null : prev)); + }, []); + + const toggleCondition = useCallback((id: string, condition: string) => { + setCombatants((prev) => + prev.map((c) => + c.id === id + ? { + ...c, + conditions: c.conditions.includes(condition) + ? c.conditions.filter((cn) => cn !== condition) + : [...c.conditions, condition], + } + : c + ) + ); + }, []); + + const changeHp = useCallback((id: string, delta: number) => { + setCombatants((prev) => + prev.map((c) => + c.id === id ? { ...c, hp: Math.max(0, Math.min(c.maxHp, c.hp + delta)) } : c + ) + ); + }, []); + + const nextTurn = useCallback(() => { + if (combatants.length === 0) return; + if (!activeId) { + setActiveId(combatants[0].id); + return; + } + const idx = combatants.findIndex((c) => c.id === activeId); + if (idx === combatants.length - 1) { + setRound((r) => r + 1); + setActiveId(combatants[0].id); + } else { + setActiveId(combatants[idx + 1].id); + } + }, [combatants, activeId]); + + const reset = useCallback(() => { + setCombatants([]); + setActiveId(null); + setRound(1); + }, []); + + return ( +
+ {/* Round indicator */} +
+ + Round {round} + +
+ + +
+
+ + {/* Add combatant */} +
+ setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && addCombatant()} + placeholder="Name" + /> + setInitBonus(parseInt(e.target.value) || 0)} + title="Initiative bonus" + /> + setHp(parseInt(e.target.value) || 1)} + title="Max HP" + /> + +
+ + {/* Combatant list */} +
+ {combatants.map((c) => { + const isActive = c.id === activeId; + const hpPct = c.maxHp > 0 ? c.hp / c.maxHp : 0; + const hpColor = + hpPct > 0.5 + ? "var(--color-success)" + : hpPct > 0.25 + ? "var(--color-gold-bright)" + : "var(--color-danger)"; + + return ( +
+
+
+ + {c.initiative} + + + {c.name} + +
+ +
+ + {/* HP bar */} +
+
+
+
+ + {c.hp}/{c.maxHp} + + + +
+ + {/* Conditions */} + {c.conditions.length > 0 && ( +
+ {c.conditions.map((cn) => ( + toggleCondition(c.id, cn)} + className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-1.5 py-0 text-[10px] cursor-pointer hover:opacity-80" + > + {cn} + + ))} +
+ )} + + {/* Add condition */} +
+ {CONDITIONS.filter((cn) => !c.conditions.includes(cn)).slice(0, 5).map((cn) => ( + + ))} +
+
+ ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/RandomTables.tsx b/src/components/RandomTables.tsx new file mode 100644 index 0000000..66947f3 --- /dev/null +++ b/src/components/RandomTables.tsx @@ -0,0 +1,180 @@ +import { useState, useCallback } from "react"; + +interface TableEntry { + min: number; + max: number; + result: string; +} + +interface RandomTable { + name: string; + dice: string; + entries: TableEntry[]; +} + +const BUILTIN_TABLES: RandomTable[] = [ + { + name: "Tavern Names", + dice: "1d20", + entries: [ + { min: 1, max: 2, result: "The Rusty Tankard" }, + { min: 3, max: 4, result: "The Sleeping Dragon" }, + { min: 5, max: 6, result: "The Gilded Flask" }, + { min: 7, max: 8, result: "The Blind Beholder" }, + { min: 9, max: 10, result: "The Copper Kettle" }, + { min: 11, max: 12, result: "The Prancing Pony" }, + { min: 13, max: 14, result: "The Wandering Wyvern" }, + { min: 15, max: 16, result: "The Blacksmith's Rest" }, + { min: 17, max: 18, result: "The Tipsy Goblin" }, + { min: 19, max: 20, result: "The Moonlit Hearth" }, + ], + }, + { + name: "Weather", + dice: "1d12", + entries: [ + { min: 1, max: 2, result: "Clear skies, warm breeze" }, + { min: 3, max: 4, result: "Overcast, cool" }, + { min: 5, max: 6, result: "Light rain, fog in the morning" }, + { min: 7, max: 8, result: "Thunderstorm, heavy rain" }, + { min: 9, max: 10, result: "Strong winds, debris flying" }, + { min: 11, max: 12, result: "Unnatural: green-tinted fog or blood rain" }, + ], + }, + { + name: "NPC Quirks", + dice: "1d10", + entries: [ + { min: 1, max: 1, result: "Speaks in rhyming couplets" }, + { min: 2, max: 2, result: "Constantly checks over their shoulder" }, + { min: 3, max: 3, result: "Has a pet mouse on their shoulder" }, + { min: 4, max: 4, result: "Insists on being called 'Your Eminence'" }, + { min: 5, max: 5, result: "Smells strongly of cinnamon" }, + { min: 6, max: 6, result: "Never makes eye contact" }, + { min: 7, max: 7, result: "Finishes everyone else's sentences" }, + { min: 8, max: 8, result: "Carries a broken sword and won't say why" }, + { min: 9, max: 9, result: "Laughs at inappropriate moments" }, + { min: 10, max: 10, result: "Writes everything down in a tiny notebook" }, + ], + }, + { + name: "Treasure (Low)", + dice: "1d6", + entries: [ + { min: 1, max: 1, result: "5d6 copper pieces" }, + { min: 2, max: 2, result: "2d6 silver pieces and a garnet ring" }, + { min: 3, max: 3, result: "1d6 gold pieces and a potion of healing" }, + { min: 4, max: 4, result: "A scroll with a cantrip and 3d6 silver" }, + { min: 5, max: 5, result: "A mundane item of unusual craftsmanship" }, + { min: 6, max: 6, result: "A map fragment leading to a nearby ruin" }, + ], + }, +]; + +function rollDice(notation: string): number { + const match = notation.toLowerCase().match(/^(\d+)d(\d+)([+-]\d+)?$/); + if (!match) return Math.floor(Math.random() * 20) + 1; + const count = parseInt(match[1]); + const sides = parseInt(match[2]); + const modifier = parseInt(match[3] || "0"); + let total = 0; + for (let i = 0; i < count; i++) { + total += Math.floor(Math.random() * sides) + 1; + } + return total + modifier; +} + +interface RollLog { + tableName: string; + roll: number; + result: string; +} + +export function RandomTables() { + const [selected, setSelected] = useState(0); + const [log, setLog] = useState([]); + const [customRoll, setCustomRoll] = useState(null); + + const table = BUILTIN_TABLES[selected]; + + const roll = useCallback(() => { + const rollVal = rollDice(table.dice); + const entry = table.entries.find((e) => rollVal >= e.min && rollVal <= e.max); + const result = entry?.result ?? "No result found"; + setLog((prev) => [{ tableName: table.name, roll: rollVal, result }, ...prev].slice(0, 30)); + setCustomRoll(rollVal); + }, [table]); + + return ( +
+ {/* Table selector */} + + + {/* Roll button */} + + + {/* Result */} + {customRoll !== null && ( +
+
+ {customRoll} +
+
+ {table.entries.find((e) => customRoll >= e.min && customRoll <= e.max)?.result} +
+
+ )} + + {/* Table preview */} +
+
+ {table.entries.map((e) => ( +
= e.min && customRoll <= e.max + ? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)]" + : "text-[var(--color-text-secondary)]" + }`} + > + {e.min === e.max ? e.min : `${e.min}-${e.max}`} + {e.result} +
+ ))} +
+
+ + {/* Roll history */} + {log.length > 0 && ( +
+
History
+ {log.map((l, i) => ( +
+ {l.tableName} + {l.roll} + {l.result} +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file