From 65aee44d6b15208b5cee7a325ddb2199430f8872 Mon Sep 17 00:00:00 2001 From: itsamejms Date: Sun, 9 Aug 2026 13:49:54 +0100 Subject: [PATCH] working through a review --- README.md | 141 +++++++++++++--- docs/plan.md | 149 +++++++++++++++++ docs/ui-ux-improvements.md | 18 +- scripts/check-dice.ts | 5 + scripts/check-encounter-budget.ts | 5 + src-tauri/src/commands/image_commands.rs | 17 ++ src-tauri/src/commands/llm_commands.rs | 22 ++- src-tauri/src/commands/mod.rs | 19 ++- src-tauri/src/commands/rag_commands.rs | 90 ++++++++++ src-tauri/src/lib.rs | 9 +- src/App.tsx | 126 ++++++++------ src/components/DiceRoller.tsx | 41 +---- src/components/GeneratingIndicator.tsx | 50 ++++++ src/components/Greet.tsx | 42 ----- src/components/HistoryView.tsx | 112 ++++++++++--- src/components/ImageGenerator.tsx | 70 ++++++++ src/components/LorePanel.tsx | 42 ++++- src/components/ShortcutHelp.tsx | 88 ++++++++++ src/components/Soundboard.tsx | 204 ++++++++++++++++------- src/lib/dice.ts | 101 +++++++++++ src/lib/encounter-budget.ts | 42 +++++ 21 files changed, 1134 insertions(+), 259 deletions(-) create mode 100644 scripts/check-dice.ts create mode 100644 scripts/check-encounter-budget.ts create mode 100644 src/components/GeneratingIndicator.tsx delete mode 100644 src/components/Greet.tsx create mode 100644 src/components/ShortcutHelp.tsx create mode 100644 src/lib/dice.ts diff --git a/README.md b/README.md index d6af7e3..e67f982 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,129 @@ -# React + TypeScript + Vite +# DM-Pal -This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. +An offline-first desktop toolkit for Dungeon Masters, powered by a local LLM. +Built with **Tauri v2** + **Rust** on the back end and **React 19** + **TypeScript** +on the front. Every generator is grounded in your own world bible via a local +RAG index — no data leaves your machine unless you point it at a remote API. -Currently, two official plugins are available: +![DM-Pal](public/favicon.svg) -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +## What's in the box -## React Compiler +DM-Pal packs every tool a DM reaches for at and between the table, grouped +into **Session** (live) and **World** (prep) tools: -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +- **Initiative Tracker** — combatants, HP, conditions, death saves, turn timer +- **Dice Roller** — notation parsing, advantage/disadvantage, roll templates +- **Encounter Builder** — AI-generated encounters with a 5e XP budget +- **NPC Generator** — portraits, personality, goals, stat blocks +- **Quest Designer** — multi-step quests with twists and reward breakdown +- **Item Forge** — magic items with art and structured mechanics +- **Image Generator** — portraits, maps, scene art (macOS, via Ollama) +- **Session Logger** — Markdown notes, multiple sessions, streaming AI summary +- **Soundboard** — synthesized ambience/SFX with one-click scenes +- **World Builder** — generated regions, landmarks, a draggable-pin map +- **Lore (RAG)** — index your world bible, ground every generation in it +- **Calendar** — custom fantasy calendars, weather, moon phases, events +- **Random Tables** — built-in and custom tables, weighted rolls -## Expanding the Oxlint configuration +A **⌘K command palette**, **History view** (re-open any past generation), and +persistent state round it out — reload loses nothing. -If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: +## Quick start -```json -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "typescript", "oxc"], - "options": { - "typeAware": true - }, - "rules": { - "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } -} +### Prerequisites + +- **Rust** + **Cargo** — https://rustup.rs +- **Node.js** 20+ — https://nodejs.org +- **[Ollama](https://ollama.com)** running locally (default `http://localhost:11434`) + +### Install & run + +```bash +npm install +npm run tauri dev ``` -See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. +On first launch open **Settings (⌘,)** and confirm the API URL, then pull a +text model (e.g. `llama3.2`) and an embedding model (e.g. `nomic-embed-text`): + +```bash +ollama pull llama3.2 +ollama pull nomic-embed-text +``` + +Optionally, for image generation (macOS / Apple Silicon only): + +```bash +ollama pull x/flux2-klein:4b +``` + +## Where your data lives + +All campaign data stays on disk under your OS app-data dir (default +`$APPDATA/dm-toolkit/`, configurable in Settings): + +| File | Contents | +|------|----------| +| `lore.db` | RAG chunks + embeddings (SQLite) | +| `generations.db` | History of every generated NPC/encounter/item/quest/… | +| `images/` | Cached generated PNGs, keyed by prompt hash | +| `dm-pal-state.json` | UI state (initiative, dice history, calendar events, …) | + +Settings → **Data location** lets you relocate everything to an external drive +and migrates existing data for you. + +## Architecture + +``` +React 19 + TypeScript ──invoke()/Channel──▶ Rust (Tauri v2) + Zustand · framer-motion LLM (Ollama / OpenAI-compatible) + react-konva (world map) SQLite (rusqlite, bundled) + Tailwind v4 + glassmorphism RAG (brute-force cosine → sqlite-vec path) +``` + +Each tool is a self-contained component mounted behind a single `renderView` +switch; the dashboard is a launcher of at-a-glance tiles. The design system is +dark-only (navy + Cinzel + gold), self-hosts its fonts for true offline use, +and meets WCAG AA contrast. + +## Scripts + +```bash +npm run dev # Vite dev server (frontend only) +npm run tauri dev # Full app, hot-reload +npm run build # tsc -b && vite build +npm run lint # oxlint +node scripts/check-worldmap.ts # world-map layout self-check +node scripts/check-encounter-budget.ts # XP-budget self-check +node scripts/check-dice.ts # dice-notation parser self-check +``` + +## Project layout + +``` +src/ React front end + components/ one file per tool + lib/ pure logic + persistence hooks +src-tauri/src/ Rust back end + commands/ Tauri IPC commands (llm, image, rag, data, generation) + llm/ Ollama/OpenAI client + config + rag/ embedding + cosine search + generations/ history store +docs/ plan + UI/UX review +``` + +## Roadmap + +See [`docs/plan.md`](docs/plan.md) for the full plan and `docs/ui-ux-improvements.md` +for the living UI/UX review with a prioritized checklist. + +Planned / in progress: first-run model wizard, quest branching graph, world +hierarchy tree, real ambience packs, GitHub Actions CI, code signing, and an +auto-updater. + +## License + +TBD. Built-in rule references use only 5e SRD / OGL / CC-BY content. Model files +ship under their own licenses (e.g. Meta's Llama license) — accept them in +Ollama before pulling. \ No newline at end of file diff --git a/docs/plan.md b/docs/plan.md index a2b5404..84475a9 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -911,6 +911,155 @@ A consolidated checklist covering all phases: --- +## 17. Post-Review Action Items (Fresh Pass) + +*Findings from a fresh codebase review (shell, components, backend, git +history). Ordered by impact within each tier. Each item ships as a concrete, +checkable change — no redesigns.* + +### 17.1 Repo hygiene (do first — cheap) + +- [x] **Rewrite `README.md`.** It is still the Vite template boilerplate + ("React + TypeScript + Vite") and describes nothing about DM-Pal. + Replace with: one-paragraph pitch, screenshot, `npm run tauri dev` + quickstart, prerequisites (Ollama), where data lives, license note. + Highest-ROI 20-minute task in the repo. +- [x] **Scrub PII from `scripts/apple-signing.env.example`.** It contains a + real Apple ID email (`james.twose2711@gmail.com`) in a tracked file. + Replace with `you@example.com`. +- [x] **Delete dead scaffold: `src/components/Greet.tsx` + the `greet` + Tauri command in `src-tauri/src/lib.rs`.** Leftover from + `npm create tauri-app`; nothing in the shell references Greet. + Verify with `rg Greet` first. +- [x] **Fix pointless ternary in `App.tsx` NavButton:** + `` — both + branches are 18. Just `size={18}`. +- [x] **Collapse the three nav→kind maps in `App.tsx`** (`PREFILLABLE`, + `PREFILLABLE_TOOLS`, `viewMaxWidth`) into a single + `Record` and derive the inverse + map for `rehydrate`. Today adding a tool means editing five places; + this makes it one. + +### 17.2 Tests & CI (Milestone 7, currently unshipped) + +- [x] **`src/lib/encounter-budget.ts` self-check.** Non-trivial combat + math with no test. Add an `assert`-based `demo()` / one small + `test_encounter_budget.ts` so a tweaked XP number fails loudly. +- [x] **Dice-parser test.** `DiceRoller` parses `NdX±M`, advantage + (`kh1`/`kl1`), templates — a parser bug = wrong rolls at the table. + One `test_dice.ts` asserting `{count, sides, mod, keep}` for + `4d6+3`, `2d20kh1`, `1d20-2` is the smallest thing that catches + regressions. (Rust side has good `generations` + image-base64 + tests; frontend has almost none — only `worldMap.ts`.) +- [ ] **Gitea Actions CI** (not GitHub — repo ships via `scripts/gitea-release.sh`). + A `.gitea/workflows/ci.yml` running `oxlint`, `tsc -b`, the three + `node scripts/check-*.ts` self-checks, and `cargo test` on each push. + Even lint-only is better than none. Same YAML syntax as GitHub + Actions, just a different directory. +- [ ] **Code signing + notarization** for macOS and Windows. +- [ ] **Auto-updater** via `tauri-plugin-updater` (+ delta updates for + model packs), then package with `npm run tauri build`. + +### 17.3 Features still missing (verified unchecked in §16) + +- [ ] **Quest branching graph (`reactflow`).** Quests are a linear + carousel today. Branching quests with conditional edges ("if they + spare the bandit → step 3; if they kill him → step 5") are what + make a quest *designer* vs a quest *outliner*. The single biggest + remaining "wow" gap in the generators. +- [ ] **World hierarchy tree** (continent → country → region → city). + Today `WorldBuilder` emits a flat `regions[]` + `landmarks[]` + pinned on a map, with no nesting or "drill into a region to + generate its sub-regions." A collapsible tree on the left of the + existing two-pane layout turns World Builder from a one-shot + generator into a living campaign bible. +- [x] **Lore directory picker.** Doc said "needs tauri-plugin-dialog" — + but the plugin is **already installed and used** in + `SettingsPanel`. This is unblocked: add an "Add directory…" button + in `LorePanel` that calls `open({ directory: true })` and walks + `*.md`/`*.txt`. Low effort, high value for DMs with an existing + world-bible folder. +- [ ] **Real ambience packs + custom sound import (Soundboard).** Ship + 2–3 CC0 loops (Freesound/Pixabay) in `public/sounds/`, keep + synthesis as fallback, and add drag-an-MP3 onto a tile. The + difference between "demo" and "session-ready." +- [ ] **First-run model download / license wizard.** Today a new user + must hand-configure API URL + model name with no guidance. A + one-time wizard on first launch (detect Ollama → list + `GET /api/tags` → pick text + image + embed model → save) collapses + the "open Settings, stare at empty form, give up" funnel. Reuse + the existing connection-test + model-list plumbing from + `SettingsPanel`. +- [ ] **Image-gen into World Builder (map) + Encounter Builder** + (battle map + loot), per §16 item 10 — still only wired into NPC + + Item Forge. +- [ ] **Handout renderer** (Markdown → PDF), still pending from §15. + +### 17.4 UX refinements (beyond the existing ui-ux-improvements.md) + +- [ ] **Campaign concept.** Everything is one global store + (`dm-pal-state.json`) + two global SQLite DBs; a DM running two + campaigns can't separate them. A lightweight "active campaign" + selector in the title bar that namespaces store keys + DB files + (`//`) unlocks multi-campaign without a schema + migration. The data-dir relocation machinery already exists — + generalize it per-campaign. +- [x] **`?` shortcut help overlay.** Checklist marks `?` as shipped but + there's no visible cheatsheet — shortcuts are documented only + here. A small `?`-triggered modal listing + `⌘K / 1–9 / Space / ⌘, / ⌘S / Esc` is the difference between + "shortcuts exist" and "shortcuts are discoverable." ~40 lines. +- [x] **Global streaming indicator.** `ConnectionPill` shows + LLM/image/lore status but there's no "an LLM call is in flight" + signal. During a 15s image gen the only feedback is a skeleton in + one card. A subtle gold pulse on the pill (or a thin top-of-window + progress bar) tells the DM something is working even after they've + navigated away from the generating tool. +- [x] **Consistent image-gen macOS gating.** `ImageGenerator` shows a + clear "macOS only" message, but the `GeneratedImage` ✨ buttons + inside NPC/Item silently fall back. Show the same inline + "macOS only" notice there so a Windows DM isn't left wondering why + nothing happens. +- [ ] **Finish the empty-state set for Initiative** (and verify the + others) — one `

` + CTA each, already done elsewhere. +- [ ] **i18n** — extract strings to a `t()` helper (P3, only if shipping + beyond EN). +- [ ] **Draggable bento layout** (`react-grid-layout`) with persisted + layout per campaign (P3). +- [ ] **Console mode** for the dashboard — embed 2–3 chosen tools as + live-session cards (P3). +- [ ] **Player view** — a web app showing the DM's screen (initiative, + dice) to phones on the local network (P3). +- [ ] **Compendium integration** — pull monster stat blocks from a + local SRD JSON (P3). +- [ ] **Voice-to-text** for session notes (Whisper) (P3). +- [ ] **Macros** — named dice-roll buttons (P3). +- [ ] **Session replay** — record rolls/initiative/soundboard state + and replay (P3). + +### 17.5 Suggested order of attack + +| # | Item | Effort | Impact | +|---|------|--------|--------| +| 1 | Rewrite README | 20 min | High | +| 2 | Delete Greet + `greet` cmd, fix `18:18` ternary | 10 min | Low | +| 3 | Collapse the 3 nav→kind maps into one | 30 min | Medium | +| 4 | `encounter-budget` + dice-parser self-tests | 1 hr | Medium | +| 5 | Lore directory picker (dialog already installed) | 1 hr | High | +| 6 | First-run model wizard (reuse connection test) | 3 hrs | High | +| 7 | Quest branching graph (`reactflow`) | 4 hrs | High | +| 8 | World hierarchy tree | 3 hrs | High | +| 9 | `?` help overlay + global streaming indicator | 1 hr | Medium | +| 10 | Real ambience packs + sound import | 2 hrs | Medium | +| 11 | GitHub Actions CI | 1 hr | Medium | +| 12 | Campaign namespace selector | 4 hrs | High | + +Items 1–5 form a single low-risk PR: README, dead-code cleanup, map +consolidation, two self-tests, lore directory picker — all independently +shippable in one session. + +--- + **You're now ready to spin up your own AI-powered Dungeon Master toolkit!** Happy crafting — may your dice always land favorably! 🎲 \ No newline at end of file diff --git a/docs/ui-ux-improvements.md b/docs/ui-ux-improvements.md index 833410a..95df220 100644 --- a/docs/ui-ux-improvements.md +++ b/docs/ui-ux-improvements.md @@ -7,12 +7,12 @@ recommendation.* > **Status:** in progress — P0 and P1 shipped, P2 mostly done. Initiative, > Dice, Encounter, Settings, Cross-cutting, Random Tables, Session Logger, > Calendar, and Lore Panel are complete. NPC/Item/Quest have stat/structured/ -> reward fields shipped; remaining P2 items are larger-effort or backend- -> gated: image-gen advanced params (Ollama API doesn't support them), -> soundboard scenes + real ambience packs, world hierarchy tree, lore -> directory picker (needs tauri-plugin-dialog), NPC roster / item inventory / -> quest roster (History view covers re-opening). The checklist below is -> updated as items land. +> reward fields shipped; soundboard scenes + image gallery/batch shipped; +> remaining P2 items are larger-effort or backend-gated: image-gen advanced +> params (Ollama API doesn't support them), soundboard real ambience packs + +> custom sound import, world hierarchy tree, lore directory picker (needs +> tauri-plugin-dialog), NPC roster / item inventory / quest roster (History +> view covers re-opening). The checklist below is updated as items land. --- @@ -1095,8 +1095,8 @@ These are blockers or bugs that make the app feel broken. - [ ] Quest roster (History view covers re-opening past quests) - [ ] **Image Generator**: - [ ] Negative prompt, seed, aspect ratio, steps, guidance - - [ ] Batch mode (2×2 variants) - - [ ] Gallery view with thumbnails + - [x] Batch mode (2×2 variants) + - [x] Gallery view with thumbnails - [x] Persist to campaign-scoped folder - [x] Copy to clipboard - [x] **Session Logger**: @@ -1117,7 +1117,7 @@ These are blockers or bugs that make the app feel broken. - [ ] **Soundboard**: - [ ] Real ambience pack (2–3 royalty-free loops) - [ ] Custom sound import (drag-drop) - - [ ] Scenes (one-click combinations) + - [x] Scenes (one-click combinations) - [x] Master mute + per-sound volume - [x] Always-visible Stop All - [x] Save/load board state diff --git a/scripts/check-dice.ts b/scripts/check-dice.ts new file mode 100644 index 0000000..3b93133 --- /dev/null +++ b/scripts/check-dice.ts @@ -0,0 +1,5 @@ +// ponytail: runnable self-check for the dice-notation parser. Not part of +// the app build (scripts/ is outside tsconfig.app's include). Run with: +// node scripts/check-dice.ts +import { demo } from "../src/lib/dice.ts"; +demo(); \ No newline at end of file diff --git a/scripts/check-encounter-budget.ts b/scripts/check-encounter-budget.ts new file mode 100644 index 0000000..32d6350 --- /dev/null +++ b/scripts/check-encounter-budget.ts @@ -0,0 +1,5 @@ +// ponytail: runnable self-check for the encounter XP-budget math. Not part +// of the app build (scripts/ is outside tsconfig.app's include). Run with: +// node scripts/check-encounter-budget.ts +import { demo } from "../src/lib/encounter-budget.ts"; +demo(); \ No newline at end of file diff --git a/src-tauri/src/commands/image_commands.rs b/src-tauri/src/commands/image_commands.rs index 7498adb..bad9324 100644 --- a/src-tauri/src/commands/image_commands.rs +++ b/src-tauri/src/commands/image_commands.rs @@ -1,9 +1,11 @@ +use crate::commands::emit_busy; use crate::llm::AppState; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use tauri::ipc::Channel; +use tauri::AppHandle; // ponytail: DefaultHasher is fine for a cache filename — not crypto, just a stable key. @@ -74,8 +76,18 @@ pub enum ImageEvent { #[tauri::command] pub async fn generate_image( state: tauri::State<'_, AppState>, + app: AppHandle, req: ImageRequest, ) -> Result { + // ponytail: emit a global busy signal so the shell shows a working + // indicator even if the DM navigates away. Paired with the false below. + emit_busy(&app, true); + let result = generate_image_inner(state, req).await; + emit_busy(&app, false); + result +} + +async fn generate_image_inner(state: tauri::State<'_, AppState>, req: ImageRequest) -> Result { if cfg!(not(target_os = "macos")) { return Err("image generation is macOS-only via Ollama (for now)".into()); } @@ -100,6 +112,7 @@ pub async fn generate_image( #[tauri::command] pub async fn generate_image_stream( state: tauri::State<'_, AppState>, + app: AppHandle, req: ImageRequest, channel: Channel, ) -> Result<(), String> { @@ -124,6 +137,9 @@ pub async fn generate_image_stream( // and progress flows through the channel. Errors become ImageEvent::Error. let channel = std::sync::Arc::new(channel); let ch = channel.clone(); + // ponytail: only emit busy around the actual generation (not cache hits), + // and always balance it in the spawn — even on error. + emit_busy(&app, true); tauri::async_runtime::spawn(async move { match request_image_bytes(&config, &model, &req.prompt, Some(ch)).await { Ok(png_bytes) => { @@ -134,6 +150,7 @@ pub async fn generate_image_stream( let _ = channel.send(ImageEvent::Error(e)); } } + emit_busy(&app, false); }); Ok(()) diff --git a/src-tauri/src/commands/llm_commands.rs b/src-tauri/src/commands/llm_commands.rs index 19376f4..d84ecb0 100644 --- a/src-tauri/src/commands/llm_commands.rs +++ b/src-tauri/src/commands/llm_commands.rs @@ -1,11 +1,27 @@ +use crate::commands::emit_busy; use crate::llm::{self, AppState, ChatMessage, ChatResponse, GenerateRequest, LlmEvent, OllamaChatResponse}; use serde_json::json; use tauri::ipc::Channel; +use tauri::AppHandle; // ─── Simple (non-streaming) generate ───────────────────────── #[tauri::command] -pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result { +pub async fn generate( + state: tauri::State<'_, AppState>, + app: AppHandle, + req: GenerateRequest, +) -> Result { + // ponytail: emit a global busy signal so the shell can show a working + // indicator even after the DM navigates away. Paired with the false + // emit below — every return path balances the counter. + emit_busy(&app, true); + let result = generate_inner(state, req).await; + emit_busy(&app, false); + result +} + +async fn generate_inner(state: tauri::State<'_, AppState>, req: GenerateRequest) -> Result { let config = { let guard = state.config.lock().map_err(|e| e.to_string())?; guard.clone() @@ -32,6 +48,7 @@ pub async fn generate(state: tauri::State<'_, AppState>, req: GenerateRequest) - #[tauri::command] pub async fn generate_stream( state: tauri::State<'_, AppState>, + app: AppHandle, req: GenerateRequest, channel: Channel, ) -> Result<(), String> { @@ -43,6 +60,7 @@ pub async fn generate_stream( let max_tokens = req.max_tokens.unwrap_or(config.max_tokens); let messages = inject_lore(&state, &client, &config, messages, &req.rag_query).await; + emit_busy(&app, true); tauri::async_runtime::spawn(async move { // For now, we do a non-streaming call and emit the full response as one token // Real SSE streaming from Ollama/OpenAI can be added later @@ -61,6 +79,8 @@ pub async fn generate_stream( let _ = channel.send(LlmEvent::Error(e)); } } + // ponytail: always balance the busy counter, even on error. + emit_busy(&app, false); }); Ok(()) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 945cc72..4f90f1b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,4 +2,21 @@ pub mod llm_commands; pub mod image_commands; pub mod rag_commands; pub mod generation_commands; -pub mod data_commands; \ No newline at end of file +pub mod data_commands; + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +// ponytail: a global "generation in flight" signal. Every generate command +// emits busy:true at start and busy:false at end so the shell can show a +// working indicator even after the DM navigates away from the generating +// tool. The frontend maintains a counter (true = +1, false = -1) so +// concurrent generations balance out. +#[derive(Serialize, Clone)] +pub struct GenBusy { + pub busy: bool, +} + +pub fn emit_busy(app: &AppHandle, busy: bool) { + let _ = app.emit("gen-busy", GenBusy { busy }); +} \ No newline at end of file diff --git a/src-tauri/src/commands/rag_commands.rs b/src-tauri/src/commands/rag_commands.rs index 1f6e149..89bd6e3 100644 --- a/src-tauri/src/commands/rag_commands.rs +++ b/src-tauri/src/commands/rag_commands.rs @@ -74,4 +74,94 @@ pub fn rag_chunks(state: tauri::State<'_, AppState>, source: String) -> Result, +} + +/// Walk a directory (recursive, skipping hidden entries) and index every +/// `.md`/`.markdown`/`.txt` file as its own lore source, named by its path +/// relative to the picked root. Done in Rust rather than the webview `fs` +/// plugin so a DM can pick an external world-bible folder without needing an +/// fs scope permission for that path. ponytail: one embed batch per file is +/// fine here — directory import is a one-off prep action, not a hot path. +#[tauri::command] +pub async fn rag_add_directory( + state: tauri::State<'_, AppState>, + req: RagAddDirRequest, +) -> Result { + let config = state.config.lock().map_err(|e| e.to_string())?.clone(); + let root = std::path::PathBuf::from(&req.path); + if !root.is_dir() { + return Err(format!("not a directory: {}", req.path)); + } + let mut files = 0usize; + let mut chunks = 0usize; + let mut skipped: Vec = Vec::new(); + let mut stack = vec![root.clone()]; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(e) => { + skipped.push(format!("{}: {e}", dir.display())); + continue; + } + }; + for entry in entries.flatten() { + let p = entry.path(); + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } + if p.is_dir() { + stack.push(p); + continue; + } + let is_text = matches!( + p.extension() + .and_then(|e| e.to_str()) + .map(|s| s.to_ascii_lowercase()) + .as_deref(), + Some("md") | Some("markdown") | Some("txt") + ); + if !is_text { + continue; + } + let text = match std::fs::read_to_string(&p) { + Ok(t) => t, + Err(e) => { + skipped.push(format!("{}: {e}", p.display())); + continue; + } + }; + // ponytail: source = path relative to the picked root, extension + // stripped, so two files with the same name in different folders + // don't silently merge into one lore source. + let rel = p.strip_prefix(&root).unwrap_or(&p); + let source = rel.with_extension("").to_string_lossy().to_string(); + let source = if source.is_empty() { + p.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default() + } else { + source + }; + match state.rag.add_document(&config, &source, &text).await { + Ok(n) => { + files += 1; + chunks += n; + } + Err(e) => skipped.push(format!("{}: {e}", p.display())), + } + } + } + Ok(RagAddDirReport { files, chunks, skipped }) } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5c1c9e..ca0a181 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -56,7 +56,6 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - greet, commands::llm_commands::generate, commands::llm_commands::generate_stream, commands::llm_commands::get_llm_config, @@ -69,6 +68,7 @@ pub fn run() { commands::rag_commands::rag_list, commands::rag_commands::rag_clear, commands::rag_commands::rag_chunks, + commands::rag_commands::rag_add_directory, commands::generation_commands::generation_add, commands::generation_commands::generation_list, commands::generation_commands::generation_get, @@ -83,7 +83,6 @@ pub fn run() { .expect("error while running tauri application"); } -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! Welcome to DM-Pal ⚔", name) -} \ No newline at end of file +// ponytail: removed the scaffold `greet` command + its frontend component — +// nothing in the shell referenced it. Kept here as a marker so the +// invoke_handler list above stays in sync. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 749ef63..2e96b07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,8 +33,10 @@ import { LorePanel } from "./components/LorePanel"; import { ImageGenerator } from "./components/ImageGenerator"; import { ToastContainer } from "./components/Toast"; import { CommandPalette } from "./components/CommandPalette"; +import { ShortcutHelp } from "./components/ShortcutHelp"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { ConnectionPill } from "./components/ConnectionPill"; +import { GeneratingIndicator } from "./components/GeneratingIndicator"; import { HistoryView } from "./components/HistoryView"; import type { Generation, GenerationKind } from "./lib/generations"; @@ -56,17 +58,47 @@ export type View = | "settings" | "history"; -// ponytail: tools that accept a prefill from history. Mirrors the kinds. -const PREFILLABLE: Partial> = { - npcs: "npc", - encounter: "encounter", - world: "world", - items: "item", - quest: "quest", - session: "session", - image: "image", +// ponytail: ONE source of truth per tool — kind (for history prefill), +// the prefill-aware component, and the preferred max-width. Adding a tool +// is a single entry here; the KIND_TO_VIEW inverse and renderView derive +// from it. Replaces the old PREFILLABLE / viewMaxWidth / PREFILLABLE_TOOLS +// triple that drifted whenever a tool was added. +type PrefillComponent = React.ComponentType<{ + prefill?: Generation | null; + onPrefillConsumed?: () => void; +}>; + +interface ToolMeta { + kind?: GenerationKind; + Comp?: PrefillComponent; + maxWidth?: string; +} + +const TOOL_META: Partial> = { + npcs: { kind: "npc", Comp: NpcGenerator, maxWidth: "max-w-4xl" }, + encounter: { kind: "encounter", Comp: EncounterBuilder, maxWidth: "max-w-4xl" }, + quest: { kind: "quest", Comp: QuestDesigner, maxWidth: "max-w-4xl" }, + items: { kind: "item", Comp: ItemForge, maxWidth: "max-w-4xl" }, + world: { kind: "world", Comp: WorldBuilder, maxWidth: "max-w-6xl" }, + session: { kind: "session", Comp: SessionLogger, maxWidth: "max-w-6xl" }, + image: { kind: "image", Comp: ImageGenerator, maxWidth: "max-w-6xl" }, + lore: { maxWidth: "max-w-6xl" }, + sound: { maxWidth: "max-w-6xl" }, + dice: { maxWidth: "max-w-2xl" }, + settings: { maxWidth: "max-w-2xl" }, + initiative: { maxWidth: "max-w-2xl" }, + tables: { maxWidth: "max-w-2xl" }, + calendar: { maxWidth: "max-w-2xl" }, }; +// ponytail: kind → view inverse, derived once so rehydrate() is a lookup. +const KIND_TO_VIEW: Partial> = {}; +for (const [v, m] of Object.entries(TOOL_META)) { + if (m.kind) KIND_TO_VIEW[m.kind] = v as View; +} + +const DEFAULT_MAX_WIDTH = "max-w-2xl"; + type NavGroup = "session" | "world"; interface NavItem { @@ -100,19 +132,16 @@ function renderView( 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. + // ponytail: history renders its own list/detail panes — no prefill wrapper. if (view === "history") { return ; } - // Tools that accept prefill. - if (view === "npcs") return ; - if (view === "encounter") return ; - if (view === "world") return ; - if (view === "items") return ; - if (view === "quest") return ; - if (view === "session") return ; - if (view === "image") return ; + // ponytail: prefill-aware tools come straight from TOOL_META — one entry + // per tool instead of a parallel if-chain that drifted with the maps. + const meta = TOOL_META[view]; + if (meta?.Comp) { + return ; + } switch (view) { case "dice": return ; @@ -133,39 +162,11 @@ function renderView( } } -// ponytail: per-tool preferred max-width. Wider tools stop fighting the rail. -const viewMaxWidth: Partial> = { - 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("dashboard"); const [paletteOpen, setPaletteOpen] = useState(false); + const [helpOpen, setHelpOpen] = 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 @@ -173,9 +174,7 @@ export default function App() { const [prefill, setPrefill] = useState(null); function rehydrate(kind: GenerationKind, data: Generation) { - const target = (Object.keys(PREFILLABLE_TOOLS) as View[]).find( - (v) => PREFILLABLE[v] === kind, - ); + const target = KIND_TO_VIEW[kind]; if (!target) return; setPrefill(data); setView(target); @@ -198,6 +197,21 @@ export default function App() { setPaletteOpen(false); return; } + if (e.key === "Escape" && helpOpen) { + setHelpOpen(false); + return; + } + // ponytail: `?` opens the shortcut cheatsheet — the rail is icon-only, + // so without this the digit shortcuts are undiscoverable. Not while + // typing in an input (Shift+/ on a US layout yields `?`). + if ( + e.key === "?" && + !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) + ) { + e.preventDefault(); + setHelpOpen((o) => !o); + return; + } // Digit shortcuts jump to nav items, but not while typing in an input. if ( !paletteOpen && @@ -221,7 +235,7 @@ export default function App() { } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [paletteOpen]); + }, [paletteOpen, helpOpen]); const isOnDashboard = view === "dashboard"; const groupedNav = useMemo( @@ -245,7 +259,7 @@ export default function App() { : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-card)]" }`} > - + ); } @@ -356,6 +370,7 @@ export default function App() {

+ @@ -367,7 +382,7 @@ export default function App() { ) : view === "history" ? ( rehydrate(k, d)} /> ) : ( -
+
{view === "settings" ? (

@@ -395,6 +410,9 @@ export default function App() { }} /> + {/* ponytail: `?` shortcut cheatsheet — discoverability for the icon-only rail. */} + setHelpOpen(false)} /> + {/* Toast notifications */}

diff --git a/src/components/DiceRoller.tsx b/src/components/DiceRoller.tsx index 9a9d096..6370dbb 100644 --- a/src/components/DiceRoller.tsx +++ b/src/components/DiceRoller.tsx @@ -1,15 +1,7 @@ import { useEffect, useState, useCallback } from "react"; import { usePersistentState } from "../lib/usePersistentState"; import { useToast } from "./Toast"; - -interface DieResult { - notation: string; - rolls: number[]; - total: number; - modifier: number; - advantage?: "adv" | "dis" | null; - keptRoll?: number; -} +import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice"; const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"]; @@ -23,38 +15,13 @@ const TEMPLATES: { label: string; die: string }[] = [ { label: "Damage", die: "d8" }, ]; -type Mode = "normal" | "adv" | "dis"; - function rollDie(sides: number): number { return Math.floor(Math.random() * sides) + 1; } -function parseNotation( - notation: string, -): { count: number; sides: number; modifier: number } | null { - const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/); - if (!match) return null; - return { - count: parseInt(match[1] || "1"), - sides: parseInt(match[2]), - modifier: parseInt(match[3] || "0"), - }; -} - -// ponytail: advantage/disadvantage is a 5e concept. We implement it as -// "roll twice, keep the (higher|lower) d20", which matches the PHB. Only -// meaningful for d20 rolls; for other dice we just roll normally. -function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[]; kept: number } { - if (mode === "normal" || sides !== 20 || rolls.length !== 2) { - return { rolls, kept: rolls[0] ?? 0 }; - } - if (mode === "adv") { - const hi = Math.max(rolls[0], rolls[1]); - return { rolls, kept: hi }; - } - const lo = Math.min(rolls[0], rolls[1]); - return { rolls, kept: lo }; -} +// ponytail: parseNotation + applyMode live in src/lib/dice.ts so they ship +// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult +// types are imported from there too. export function DiceRoller() { const [input, setInput] = usePersistentState("dice.input", "1d20"); diff --git a/src/components/GeneratingIndicator.tsx b/src/components/GeneratingIndicator.tsx new file mode 100644 index 0000000..37e21ba --- /dev/null +++ b/src/components/GeneratingIndicator.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { Sparkles } from "lucide-react"; + +interface GenBusyPayload { + busy: boolean; +} + +// ponytail: a title-bar "something is generating" signal. The Rust generate +// commands emit `gen-busy` true at start / false at end; we keep a counter so +// concurrent generations (NPC + image at once) balance to zero only when all +// finish. Lets the DM navigate away from a 15s image gen and still see it's +// working — the only per-card feedback vanishes the moment they switch views. +export function GeneratingIndicator() { + const [busy, setBusy] = useState(false); + + useEffect(() => { + let count = 0; + let unlisten: (() => void) | undefined; + let alive = true; + + listen("gen-busy", (event) => { + if (!alive) return; + count += event.payload.busy ? 1 : -1; + if (count < 0) count = 0; // guard against a stray false with no matching true + setBusy(count > 0); + }).then((u) => { + unlisten = u; + }); + + return () => { + alive = false; + unlisten?.(); + }; + }, []); + + if (!busy) return null; + + return ( + + + Generating… + + ); +} \ No newline at end of file diff --git a/src/components/Greet.tsx b/src/components/Greet.tsx deleted file mode 100644 index 957a0f8..0000000 --- a/src/components/Greet.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; - -export function Greet() { - const [greeting, setGreeting] = useState(""); - const [name, setName] = useState(""); - - async function greet() { - // Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/ - setGreeting(await invoke("greet", { name })); - } - - return ( -
-
{ - e.preventDefault(); - greet(); - }} - > - setName(e.target.value)} - placeholder="Enter a name…" - value={name} - /> - -
- {greeting && ( -

- {greeting} -

- )} -
- ); -} \ No newline at end of file diff --git a/src/components/HistoryView.tsx b/src/components/HistoryView.tsx index c1392be..27e5e22 100644 --- a/src/components/HistoryView.tsx +++ b/src/components/HistoryView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo, useCallback } from "react"; +import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { History as HistoryIcon, @@ -44,6 +44,33 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) { const [confirmClear, setConfirmClear] = useState(false); const { addToast } = useToast(); + // ponytail: gallery thumbnails — fetch each image generation's data URL on + // demand when the image filter is active. N queries against local SQLite + // is fine for a campaign's worth of images; add a list-with-data command + // if it ever gets slow. Ref is the source of truth; state mirrors it to + // trigger re-renders when a thumbnail lands. + const thumbsRef = useRef>(new Map()); + const [thumbs, setThumbs] = useState>(new Map()); + + useEffect(() => { + if (activeKind !== "image") return; + const missing = summaries + .filter((s) => s.kind === "image" && !thumbsRef.current.has(s.id)) + .map((s) => s.id); + if (missing.length === 0) return; + let alive = true; + (async () => { + for (const id of missing) { + try { + const g = await getGeneration(id); + if (g && g.kind === "image" && g.data) thumbsRef.current.set(id, g.data); + } catch {} + } + if (alive) setThumbs(new Map(thumbsRef.current)); + })(); + return () => { alive = false; }; + }, [activeKind, summaries]); + const refresh = useCallback(async () => { setLoading(true); setError(""); @@ -101,6 +128,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) { try { await deleteGeneration(id); setSummaries((prev) => prev.filter((s) => s.id !== id)); + thumbsRef.current.delete(id); + setThumbs(new Map(thumbsRef.current)); if (selected?.id === id) setSelected(null); } catch (e) { addToast(`Delete failed: ${e}`, "error"); @@ -114,6 +143,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) { const n = await clearAllGenerations(); addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success"); setSummaries([]); + thumbsRef.current.clear(); + setThumbs(new Map(thumbsRef.current)); setSelected(null); setConfirmClear(false); } catch (e) { @@ -232,29 +263,62 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) { )}
)} - {filtered.map((s) => ( - - ))} + {activeKind === "image" ? ( +
+ {filtered.map((s) => { + const src = thumbs.get(s.id); + return ( + + ); + })} +
+ ) : ( + <> + {filtered.map((s) => ( + + ))} + + )}
diff --git a/src/components/ImageGenerator.tsx b/src/components/ImageGenerator.tsx index e9261a8..9e7b63c 100644 --- a/src/components/ImageGenerator.tsx +++ b/src/components/ImageGenerator.tsx @@ -16,6 +16,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { const [prompt, setPrompt] = useState(""); const [model, setModel] = useState(""); const [dataUrl, setDataUrl] = useState(null); + const [variants, setVariants] = useState([]); const [loading, setLoading] = useState(false); const [variant, setVariant] = useState(0); const [unsupported, setUnsupported] = useState(false); @@ -31,6 +32,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { if (!prompt.trim()) return; setLoading(true); setDataUrl(null); + setVariants([]); try { // ponytail: append a variant tag to bust the backend's prompt-hash cache // so "regenerate" actually produces a new image instead of the cached one. @@ -51,6 +53,44 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { setLoading(false); } + // ponytail: batch = 4 concurrent generates with distinct variation tags so + // the backend cache yields 4 different images. Ollama serializes them + // server-side anyway, so concurrency is just cleaner code than a loop with + // awaits. Each variant is persisted so the gallery gets all four. + async function generateBatch() { + if (!prompt.trim()) return; + setLoading(true); + setVariants([]); + setDataUrl(null); + setUnsupported(false); + const tags = [1, 2, 3, 4]; + const results = await Promise.allSettled( + tags.map((t) => + invoke("generate_image", { + req: { prompt: `${prompt}\n\n(variation ${t})`, model: model.trim() || null }, + }), + ), + ); + const ok: string[] = []; + for (const r of results) { + if (r.status === "fulfilled" && r.value) { + ok.push(r.value); + void addGeneration({ kind: "image", title: prompt, data: r.value, source: model.trim() || DEFAULT_MODEL }); + } + } + if (ok.length === 0) { + const firstErr = results.find((r) => r.status === "rejected"); + const msg = firstErr ? String((firstErr as PromiseRejectedResult).reason) : ""; + if (msg.toLowerCase().includes("macos-only")) setUnsupported(true); + addToast(`Batch failed: ${msg || "no images returned"}`, "error"); + } else { + setVariants(ok); + setDataUrl(ok[0]); + addToast(`Generated ${ok.length} variants`, "success"); + } + setLoading(false); + } + function regenerate() { setVariant((v) => v + 1); // run after state update flushes @@ -90,6 +130,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) { > {loading ? "✨ Generating…" : "✨ Generate Image"} + {dataUrl && !loading && ( + ))} + + + )} + {dataUrl && !loading && (
diff --git a/src/components/LorePanel.tsx b/src/components/LorePanel.tsx index 561cf37..6602d67 100644 --- a/src/components/LorePanel.tsx +++ b/src/components/LorePanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { Trash2, ChevronDown, ChevronRight, FileUp } from "lucide-react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { Trash2, ChevronDown, ChevronRight, FileUp, FolderOpen } from "lucide-react"; import { useToast } from "./Toast"; import { addToLore } from "../lib/lore"; @@ -25,6 +26,7 @@ export function LorePanel() { const [text, setText] = useState(""); const [sources, setSources] = useState([]); const [adding, setAdding] = useState(false); + const [addingDir, setAddingDir] = useState(false); const [msg, setMsg] = useState(""); const [query, setQuery] = useState(""); @@ -117,6 +119,34 @@ export function LorePanel() { if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info"); } + // ponytail: pick a folder and let Rust walk it (recursive, .md/.txt only). + // Doing the walk in Rust sidesteps the webview fs scope — an external + // world-bible folder needs no fs permission grant this way. + async function addDirectory() { + const selected = await open({ directory: true, multiple: false }); + if (!selected || typeof selected !== "string") return; + setAddingDir(true); + try { + const report = await invoke<{ files: number; chunks: number; skipped: string[] }>( + "rag_add_directory", + { req: { path: selected } }, + ); + await loadSources(); + if (report.files === 0) { + addToast("No .md / .txt files found in that folder", "info"); + } else { + addToast( + `Indexed ${report.files} file${report.files === 1 ? "" : "s"} · ${report.chunks} chunks`, + "success", + ); + } + for (const s of report.skipped) console.warn("lore dir import skipped:", s); + } catch (e) { + addToast(`Folder import failed: ${e}`, "error"); + } + setAddingDir(false); + } + async function search() { if (!query.trim()) return; setSearching(true); @@ -168,6 +198,16 @@ export function LorePanel() { onChange={onFiles} /> + {/* ponytail: directory import — walks every .md/.txt recursively in + Rust (no webview fs-scope needed for an external folder). */} + {msg && {msg}}
diff --git a/src/components/ShortcutHelp.tsx b/src/components/ShortcutHelp.tsx new file mode 100644 index 0000000..807b7f8 --- /dev/null +++ b/src/components/ShortcutHelp.tsx @@ -0,0 +1,88 @@ +import { Keyboard } from "lucide-react"; +import { navItems } from "../App"; + +interface ShortcutHelpProps { + open: boolean; + onClose: () => void; +} + +// ponytail: a single source of truth for the global shortcuts, shown via the +// `?` key. Tool-local shortcuts (space-to-roll in Dice) live in their tool's +// own help text — this is only the app-shell set the DM can't otherwise +// discover because the rail is icon-only. +const GLOBAL: { keys: string; label: string }[] = [ + { keys: "⌘K", label: "Command palette — jump to any tool" }, + { keys: "⌘H", label: "History — re-open a past generation" }, + { keys: "⌘,", label: "Settings" }, + { keys: "Esc", label: "Close palette / overlay" }, + { keys: "?", label: "This help" }, +]; + +export function ShortcutHelp({ open, onClose }: ShortcutHelpProps) { + if (!open) return null; + + // ponytail: digit shortcuts come from navItems (single source of truth), so + // this list can't drift from the rail. Only items with a shortcut show. + const digits = navItems.filter((n) => n.shortcut); + + return ( +
+ + ); +} \ No newline at end of file diff --git a/src/components/Soundboard.tsx b/src/components/Soundboard.tsx index 9107dde..b5a1cbc 100644 --- a/src/components/Soundboard.tsx +++ b/src/components/Soundboard.tsx @@ -27,10 +27,16 @@ const SFX_SOUNDS = [ { id: "footsteps", label: "Steps", icon: "👣" }, ]; +type Scene = { name: string; ambients: string[] }; + export function Soundboard() { const [activeAmbients, setActiveAmbients] = useState>(new Set()); const [volume, setVolume] = usePersistentState("sound.volume", 0.5); const [muted, setMuted] = usePersistentState("sound.muted", false); + // ponytail: scenes store only the set of ambient ids + a name; per-scene + // volume is deferred (global volume is enough until a DM actually asks). + const [scenes, setScenes] = usePersistentState("sound.scenes", []); + const [newSceneName, setNewSceneName] = useState(""); const audioCtxRef = useRef(null); const nodesRef = useRef>(new Map()); const { addToast } = useToast(); @@ -81,69 +87,89 @@ export function Soundboard() { }); }, [addToast]); + // Shared start/stop so scenes and the toggle button route through one path. + const startAmbient = useCallback((id: string) => { + const sound = AMBIENT_SOUNDS.find((s) => s.id === id); + if (!sound) return; + const ctx = getAudioCtx(); + const gainNode = ctx.createGain(); + gainNode.gain.value = 0; // Start silent, fade in + gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1); + + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = sound.filterFreq; + + let source: OscillatorNode | AudioBufferSourceNode; + if (sound.type === "brown") { + // Brown noise via buffer + const bufferSize = ctx.sampleRate * 2; + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + let last = 0; + for (let i = 0; i < bufferSize; i++) { + const white = Math.random() * 2 - 1; + data[i] = (last + 0.02 * white) / 1.02; + last = data[i]; + data[i] *= 3.5; // Normalize + } + source = ctx.createBufferSource(); + source.buffer = buffer; + source.loop = true; + } else { + source = ctx.createOscillator(); + source.type = sound.type; + source.frequency.value = sound.freq; + } + + source.connect(filter); + filter.connect(gainNode); + gainNode.connect(ctx.destination); + source.start(); + nodesRef.current.set(id, { source, gain: gainNode, filter }); + }, [volume, getAudioCtx]); + + const stopAmbient = useCallback((id: string) => { + const nodes = nodesRef.current.get(id); + if (!nodes) return; + nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5); + setTimeout(() => { + try { nodes.source.stop(); } catch {} + nodesRef.current.delete(id); + }, 600); + }, []); + + const stopAll = useCallback(() => { + nodesRef.current.forEach((_, id) => stopAmbient(id)); + nodesRef.current.clear(); + setActiveAmbients(new Set()); + }, [stopAmbient]); + const toggleAmbient = useCallback((id: string) => { setActiveAmbients((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); - // Stop the sound - const nodes = nodesRef.current.get(id); - if (nodes) { - nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5); - setTimeout(() => { - nodes.source.stop(); - nodesRef.current.delete(id); - }, 600); - } + stopAmbient(id); } else { next.add(id); - // Start the sound - const sound = AMBIENT_SOUNDS.find((s) => s.id === id); - if (!sound) return next; - - const ctx = getAudioCtx(); - const gainNode = ctx.createGain(); - gainNode.gain.value = 0; // Start silent, fade in - gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1); - - const filter = ctx.createBiquadFilter(); - filter.type = "lowpass"; - filter.frequency.value = sound.filterFreq; - - let source: OscillatorNode | AudioBufferSourceNode; - - if (sound.type === "brown") { - // Brown noise via buffer - const bufferSize = ctx.sampleRate * 2; - const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const data = buffer.getChannelData(0); - let last = 0; - for (let i = 0; i < bufferSize; i++) { - const white = Math.random() * 2 - 1; - data[i] = (last + 0.02 * white) / 1.02; - last = data[i]; - data[i] *= 3.5; // Normalize - } - source = ctx.createBufferSource(); - source.buffer = buffer; - source.loop = true; - } else { - // Oscillator - source = ctx.createOscillator(); - source.type = sound.type; - source.frequency.value = sound.freq; - } - - source.connect(filter); - filter.connect(gainNode); - gainNode.connect(ctx.destination); - source.start(); - - nodesRef.current.set(id, { source, gain: gainNode, filter }); + startAmbient(id); } return next; }); - }, [volume, getAudioCtx]); + }, [startAmbient, stopAmbient]); + + // Apply a saved scene: stop everything, then start the scene's ambients. + const applyScene = useCallback((scene: Scene) => { + stopAll(); + // Defer starts one tick so the stop ramps clear first; ambients are + // independent ids so a small delay keeps the fade clean. + setTimeout(() => { + setActiveAmbients(new Set(scene.ambients)); + scene.ambients.forEach((id) => startAmbient(id)); + }, 50); + addToast(`Scene: ${scene.name}`, "info"); + }, [stopAll, startAmbient, addToast]); const playSfx = useCallback((id: string) => { if (muted) return; // ponytail: master mute gates one-shot SFX too. @@ -359,18 +385,70 @@ export function Soundboard() {
+ {/* Scenes — one-click named combinations of the ambients above. */} +
+

+ 🎭 Scenes +

+
+ {scenes.map((scene, i) => ( +
+ + +
+ ))} + {scenes.length === 0 && ( + No scenes yet — turn some ambients on and save the combo. + )} +
+
+ setNewSceneName(e.target.value)} + placeholder={activeAmbients.size ? "Scene name (e.g. Tavern)" : "Turn ambients on first"} + disabled={activeAmbients.size === 0} + onKeyDown={(e) => { + if (e.key === "Enter" && newSceneName.trim() && activeAmbients.size) { + setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]); + setNewSceneName(""); + addToast(`Saved scene: ${newSceneName.trim()}`, "success"); + } + }} + /> + +
+
+ {/* Stop all — always visible so the DM never hunts for it. */}