minor UI fixes
This commit is contained in:
+90
-90
@@ -4,8 +4,8 @@
|
|||||||
ordered by impact, not file order. Each item ships with a concrete, checkable
|
ordered by impact, not file order. Each item ships with a concrete, checkable
|
||||||
recommendation.*
|
recommendation.*
|
||||||
|
|
||||||
> **Status:** audit only — no code changes yet. Treat the checklist at the
|
> **Status:** in progress — P0 and P1 shipped, P2 largely done. The
|
||||||
> bottom as the punch list for the next sprint.
|
> checklist below is updated as items land.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,16 +32,16 @@ the wins are layout, IA, and copy — not a redesign.
|
|||||||
|
|
||||||
### Top 5 wins (do these first)
|
### Top 5 wins (do these first)
|
||||||
|
|
||||||
1. **Kill the "expand to use it" pattern.** Make every bento card a *summary*
|
1. ✓ **Kill the "expand to use it" pattern.** Make every bento card a *summary*
|
||||||
with a clear `Open` button. Tools render full-width in the detail view,
|
with a clear `Open` button. Tools render full-width in the detail view,
|
||||||
not at 1/4 size on the dashboard.
|
not at 1/4 size on the dashboard.
|
||||||
2. **Reorganize the left rail into 2 visual groups** (Session vs World) with
|
2. ✓ **Reorganize the left rail into 2 visual groups** (Session vs World) with
|
||||||
separators and tooltips that show on hover for icon-only nav.
|
separators and tooltips that show on hover for icon-only nav.
|
||||||
3. **Add a real loading/streaming UI** for LLM and image generation. The
|
3. ✓ **Add a real loading/streaming UI** for LLM and image generation. The
|
||||||
"Generating…" text in a button is not enough for 5–20 s waits.
|
"Generating…" text in a button is not enough for 5–20 s waits.
|
||||||
4. **Ship a real Settings page** (load on mount, group endpoints/models/gen
|
4. ✓ **Ship a real Settings page** (load on mount, group endpoints/models/gen
|
||||||
params, show connection status).
|
params, show connection status).
|
||||||
5. **Fix the world/nav mismatch**: `isDetailView` in `App.tsx` only shows
|
5. ✓ **Fix the world/nav mismatch**: `isDetailView` in `App.tsx` only shows
|
||||||
the back button for some views, and `view === "settings"` is treated
|
the back button for some views, and `view === "settings"` is treated
|
||||||
separately so a back arrow is missing.
|
separately so a back arrow is missing.
|
||||||
|
|
||||||
@@ -997,113 +997,113 @@ familiarity).
|
|||||||
|
|
||||||
These are blockers or bugs that make the app feel broken.
|
These are blockers or bugs that make the app feel broken.
|
||||||
|
|
||||||
- [ ] **Fix `WorldBuilder.tsx` syntax bug** (lines 132, 147, 161:
|
- [x] **Fix `WorldBuilder.tsx` syntax bug** (lines 132, 147, 161:
|
||||||
`})` should be `}`). Currently renders broken JSX.
|
`})` should be `}`). Currently renders broken JSX.
|
||||||
- [ ] **Auto-load Settings on mount.** Remove the "Load Settings"
|
- [x] **Auto-load Settings on mount.** Remove the "Load Settings"
|
||||||
gate.
|
gate.
|
||||||
- [ ] **Add `data-tauri-no-drag` to interactive elements in the
|
- [x] **Add `data-tauri-no-drag` to interactive elements in the
|
||||||
header** (back button, status pill).
|
header** (back button, status pill).
|
||||||
- [ ] **Show back button on Settings** (drop the
|
- [x] **Show back button on Settings** (drop the
|
||||||
`view !== "settings"` exclusion in `isDetailView`).
|
`view !== "settings"` exclusion in `isDetailView`).
|
||||||
- [ ] **Add a React error boundary** around the main content
|
- [x] **Add a React error boundary** around the main content
|
||||||
area.
|
area.
|
||||||
- [ ] **Self-host the fonts** in `public/fonts/` (offline-first).
|
- [x] **Self-host the fonts** in `public/fonts/` (offline-first).
|
||||||
- [ ] **Standardize errors via the Toast system** — delete inline
|
- [x] **Standardize errors via the Toast system** — delete inline
|
||||||
error boxes from NPC, Encounter, Quest, World, Item, Image.
|
error boxes from NPC, Encounter, Quest, World, Item, Image.
|
||||||
- [ ] **Replace native `title=""` tooltips** with a styled
|
- [x] **Replace native `title=""` tooltips** with a styled
|
||||||
`data-tooltip` (visible on hover and focus).
|
`data-tooltip` (visible on hover and focus).
|
||||||
- [ ] **Add `aria-label`** to every icon-only button.
|
- [x] **Add `aria-label`** to every icon-only button.
|
||||||
|
|
||||||
### P1 — High impact (next sprint)
|
### P1 — High impact (next sprint)
|
||||||
|
|
||||||
- [ ] **Reorganize the left rail** into 2 visual groups
|
- [x] **Reorganize the left rail** into 2 visual groups
|
||||||
(Session / World) with a thin gold separator and small
|
(Session / World) with a thin gold separator and small
|
||||||
uppercase labels.
|
uppercase labels.
|
||||||
- [ ] **Replace the duplicate `Sparkles` icon** on Random Tables
|
- [x] **Replace the duplicate `Sparkles` icon** on Random Tables
|
||||||
(use `Shuffle` or `Dices`).
|
(use `Shuffle` or `Dices`).
|
||||||
- [ ] **Add a global ⌘K command palette** for navigation.
|
- [x] **Add a global ⌘K command palette** for navigation.
|
||||||
- [ ] **Implement persistence** for Initiative, Dice history,
|
- [x] **Implement persistence** for Initiative, Dice history,
|
||||||
Calendar events, Generated NPCs/Encounters/Items/Quests.
|
Calendar events, Generated NPCs/Encounters/Items/Quests.
|
||||||
Use `tauri-plugin-store` for small state, `tauri-plugin-fs`
|
Use `tauri-plugin-store` for small state, `tauri-plugin-fs`
|
||||||
for generated content.
|
for generated content.
|
||||||
- [ ] **Ship a loading skeleton + streaming UI** for LLM and
|
- [x] **Ship a loading skeleton + streaming UI** for LLM and
|
||||||
image generation. Use the existing Tauri Channel
|
image generation. Use the existing Tauri Channel
|
||||||
infrastructure.
|
infrastructure.
|
||||||
- [ ] **Add roll breakdown** to Dice Roller for any roll with
|
- [x] **Add roll breakdown** to Dice Roller for any roll with
|
||||||
>1 die or any modifier.
|
>1 die or any modifier.
|
||||||
- [ ] **Add Advantage/Disadvantage buttons** to Dice Roller.
|
- [x] **Add Advantage/Disadvantage buttons** to Dice Roller.
|
||||||
- [ ] **Make the dashboard a launcher** — replace embedded
|
- [x] **Make the dashboard a launcher** — replace embedded
|
||||||
mini-tools with at-a-glance summaries + primary CTA.
|
mini-tools with at-a-glance summaries + primary CTA.
|
||||||
- [ ] **Drop the universal `max-w-2xl glass-card` wrapper** in
|
- [x] **Drop the universal `max-w-2xl glass-card` wrapper** in
|
||||||
the detail view. Let each tool set its own max-width.
|
the detail view. Let each tool set its own max-width.
|
||||||
- [ ] **Add a real loading state** to Settings: connection test
|
- [x] **Add a real loading state** to Settings: connection test
|
||||||
with model list.
|
with model list.
|
||||||
- [ ] **Replace "My Campaign" placeholder** with a real status
|
- [x] **Replace "My Campaign" placeholder** with a real status
|
||||||
pill (LLM connection, image model, lore chunk count).
|
pill (LLM connection, image model, lore chunk count).
|
||||||
- [ ] **Lighten `--color-text-dim`** to meet WCAG AA (4.5:1)
|
- [x] **Lighten `--color-text-dim`** to meet WCAG AA (4.5:1)
|
||||||
against `--color-bg-deep`.
|
against `--color-bg-deep`.
|
||||||
|
|
||||||
### P2 — Important (next month)
|
### P2 — Important (next month)
|
||||||
|
|
||||||
- [ ] **Initiative Tracker**:
|
- [ ] **Initiative Tracker**:
|
||||||
- [ ] Click-to-edit initiative number
|
- [x] Click-to-edit initiative number
|
||||||
- [ ] Custom conditions
|
- [x] Custom conditions
|
||||||
- [ ] Damage input (numeric, not +/- only)
|
- [x] Damage input (numeric, not +/- only)
|
||||||
- [ ] Death saves
|
- [x] Death saves
|
||||||
- [ ] Turn timer
|
- [x] Turn timer
|
||||||
- [ ] Notes per combatant
|
- [x] Notes per combatant
|
||||||
- [ ] Highlight next-up combatant
|
- [x] Highlight next-up combatant
|
||||||
- [ ] Confirm before Reset
|
- [x] Confirm before Reset
|
||||||
- [ ] "Import from Encounter" button
|
- [x] "Import from Encounter" button
|
||||||
- [ ] **Dice Roller**:
|
- [ ] **Dice Roller**:
|
||||||
- [ ] Modifier input (saved per session)
|
- [x] Modifier input (saved per session)
|
||||||
- [ ] Spacebar rolls
|
- [x] Spacebar rolls
|
||||||
- [ ] Roll templates (Attack, Save, Check, Damage)
|
- [x] Roll templates (Attack, Save, Check, Damage)
|
||||||
- [ ] Clear/copy history
|
- [x] Clear/copy history
|
||||||
- [ ] **Encounter Builder**:
|
- [ ] **Encounter Builder**:
|
||||||
- [ ] XP budget math (party size, level, difficulty)
|
- [x] XP budget math (party size, level, difficulty)
|
||||||
- [ ] Color-coded difficulty
|
- [x] Color-coded difficulty
|
||||||
- [ ] Send to Initiative Tracker
|
- [x] Send to Initiative Tracker
|
||||||
- [ ] Send to Soundboard (terrain-based)
|
- [x] Send to Soundboard (terrain-based)
|
||||||
- [ ] Save to lore
|
- [x] Save to lore
|
||||||
- [ ] 4 more terrain presets (planar, haunted, ship, siege)
|
- [x] 4 more terrain presets (planar, haunted, ship, siege)
|
||||||
- [ ] **NPC Generator**:
|
- [ ] **NPC Generator**:
|
||||||
- [ ] Rename "Class" to "Background" with curated list
|
- [x] Rename "Class" to "Background" with curated list
|
||||||
- [ ] Expand races to 9 (Half-Orc, Goliath)
|
- [x] Expand races to 9 (Half-Orc, Goliath)
|
||||||
- [ ] NPC Roster view
|
- [ ] NPC Roster view
|
||||||
- [ ] Stat block section (AC, HP, ability scores)
|
- [ ] Stat block section (AC, HP, ability scores)
|
||||||
- [ ] Random name button
|
- [x] Random name button
|
||||||
- [ ] Save portrait (right-click)
|
- [ ] Save portrait (right-click)
|
||||||
- [ ] Edit personality/goals in-place
|
- [x] Edit personality/goals in-place
|
||||||
- [ ] **Item Forge**:
|
- [ ] **Item Forge**:
|
||||||
- [ ] Click art to expand
|
- [x] Click art to expand
|
||||||
- [ ] Structured mechanics (attunement, charges, value, weight)
|
- [ ] Structured mechanics (attunement, charges, value, weight)
|
||||||
- [ ] Save to inventory
|
- [ ] Save to inventory
|
||||||
- [ ] Random item button
|
- [x] Random item button
|
||||||
- [ ] **Quest Designer**:
|
- [ ] **Quest Designer**:
|
||||||
- [ ] Branching quest graph (react-flow)
|
- [ ] Branching quest graph (react-flow)
|
||||||
- [ ] Step status (Pending/Active/Done)
|
- [x] Step status (Pending/Active/Done)
|
||||||
- [ ] Player-facing view toggle (hide Twist)
|
- [x] Player-facing view toggle (hide Twist)
|
||||||
- [ ] Reward breakdown (XP per character, gold, items)
|
- [ ] Reward breakdown (XP per character, gold, items)
|
||||||
- [ ] Quest roster
|
- [ ] Quest roster
|
||||||
- [ ] **Image Generator**:
|
- [ ] **Image Generator**:
|
||||||
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
- [ ] Negative prompt, seed, aspect ratio, steps, guidance
|
||||||
- [ ] Batch mode (2×2 variants)
|
- [ ] Batch mode (2×2 variants)
|
||||||
- [ ] Gallery view with thumbnails
|
- [ ] Gallery view with thumbnails
|
||||||
- [ ] Persist to campaign-scoped folder
|
- [x] Persist to campaign-scoped folder
|
||||||
- [ ] Copy to clipboard
|
- [x] Copy to clipboard
|
||||||
- [ ] **Session Logger**:
|
- [ ] **Session Logger**:
|
||||||
- [ ] Markdown editor + live preview
|
- [ ] Markdown editor + live preview
|
||||||
- [ ] Sessions as first-class concept (multiple sessions)
|
- [ ] Sessions as first-class concept (multiple sessions)
|
||||||
- [ ] Streaming AI summary
|
- [x] Streaming AI summary
|
||||||
- [ ] Export to Markdown
|
- [x] Export to Markdown
|
||||||
- [ ] Per-entry actions (edit, delete, highlight)
|
- [x] Per-entry actions (edit, delete, highlight)
|
||||||
- [ ] Tags
|
- [ ] Tags
|
||||||
- [ ] **Calendar**:
|
- [ ] **Calendar**:
|
||||||
- [ ] Custom calendar editor
|
- [ ] Custom calendar editor
|
||||||
- [ ] Current campaign date with "Next day" button
|
- [x] Current campaign date with "Next day" button
|
||||||
- [ ] Weather generator
|
- [x] Weather generator
|
||||||
- [ ] Moon phases
|
- [x] Moon phases
|
||||||
- [ ] Event categories with colors
|
- [ ] Event categories with colors
|
||||||
- [ ] Recurring events
|
- [ ] Recurring events
|
||||||
- [ ] Agenda view
|
- [ ] Agenda view
|
||||||
@@ -1111,43 +1111,43 @@ These are blockers or bugs that make the app feel broken.
|
|||||||
- [ ] Real ambience pack (2–3 royalty-free loops)
|
- [ ] Real ambience pack (2–3 royalty-free loops)
|
||||||
- [ ] Custom sound import (drag-drop)
|
- [ ] Custom sound import (drag-drop)
|
||||||
- [ ] Scenes (one-click combinations)
|
- [ ] Scenes (one-click combinations)
|
||||||
- [ ] Master mute + per-sound volume
|
- [x] Master mute + per-sound volume
|
||||||
- [ ] Always-visible Stop All
|
- [x] Always-visible Stop All
|
||||||
- [ ] Save/load board state
|
- [x] Save/load board state
|
||||||
- [ ] Pre-warm AudioContext
|
- [x] Pre-warm AudioContext
|
||||||
- [ ] **Random Tables**:
|
- [ ] **Random Tables**:
|
||||||
- [ ] 10+ more built-in tables
|
- [x] 10+ more built-in tables
|
||||||
- [ ] User-defined tables (create/edit/delete)
|
- [ ] User-defined tables (create/edit/delete)
|
||||||
- [ ] Table import (paste Markdown)
|
- [ ] Table import (paste Markdown)
|
||||||
- [ ] Weighted tables
|
- [ ] Weighted tables
|
||||||
- [ ] Export table
|
- [x] Export table
|
||||||
- [ ] **World Builder**:
|
- [ ] **World Builder**:
|
||||||
- [ ] Map canvas (react-konva)
|
- [ ] Map canvas (react-konva)
|
||||||
- [ ] Theme presets
|
- [x] Theme presets
|
||||||
- [ ] Hierarchy tree
|
- [ ] Hierarchy tree
|
||||||
- [ ] Persist world
|
- [x] Persist world
|
||||||
- [ ] **Lore Panel**:
|
- [ ] **Lore Panel**:
|
||||||
- [ ] Two-pane layout
|
- [ ] Two-pane layout
|
||||||
- [ ] Drag-drop file upload
|
- [ ] Drag-drop file upload
|
||||||
- [ ] Directory picker
|
- [ ] Directory picker
|
||||||
- [ ] Chunk preview
|
- [ ] Chunk preview
|
||||||
- [ ] Source filter
|
- [ ] Source filter
|
||||||
- [ ] ★ relevance (not raw cosine)
|
- [x] ★ relevance (not raw cosine)
|
||||||
- [ ] Confirm before "Clear all"
|
- [x] Confirm before "Clear all"
|
||||||
- [ ] **Settings**:
|
- [ ] **Settings**:
|
||||||
- [ ] Group into fieldsets (Connection, Text, Image, Embedding, Danger)
|
- [x] Group into fieldsets (Connection, Text, Image, Embedding, Danger)
|
||||||
- [ ] Connection test with model list
|
- [x] Connection test with model list
|
||||||
- [ ] Model picker with autocomplete (`GET /api/tags`)
|
- [x] Model picker with autocomplete (`GET /api/tags`)
|
||||||
- [ ] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom)
|
- [x] Provider presets (Ollama, LM Studio, OpenAI, Anthropic, Custom)
|
||||||
- [ ] Reset to defaults
|
- [x] Reset to defaults
|
||||||
- [ ] Export/import config
|
- [ ] Export/import config
|
||||||
- [ ] **Cross-cutting**:
|
- [ ] **Cross-cutting**:
|
||||||
- [ ] Keyboard shortcuts: ⌘K, 1–9, Space, ⌘,, ⌘S, Esc, ?
|
- [x] Keyboard shortcuts: ⌘K, 1–9, Space, ⌘,, ⌘S, Esc, ?
|
||||||
- [ ] Skeleton loaders for all async states
|
- [x] Skeleton loaders for all async states
|
||||||
- [ ] Empty states for all tools
|
- [x] Empty states for all tools
|
||||||
- [ ] `prefers-reduced-motion` respect on framer-motion
|
- [x] `prefers-reduced-motion` respect on framer-motion
|
||||||
- [ ] Skip-to-content link
|
- [x] Skip-to-content link
|
||||||
- [ ] Color + label for state (not color alone)
|
- [x] Color + label for state (not color alone)
|
||||||
|
|
||||||
### P3 — Nice to have
|
### P3 — Nice to have
|
||||||
|
|
||||||
@@ -1173,7 +1173,7 @@ These are blockers or bugs that make the app feel broken.
|
|||||||
|
|
||||||
If you want a single PR that lands a lot of value, here are 8
|
If you want a single PR that lands a lot of value, here are 8
|
||||||
items that take <1 day combined and touch mostly `App.tsx` and
|
items that take <1 day combined and touch mostly `App.tsx` and
|
||||||
small CSS:
|
small CSS. **All 10 below are now shipped.**
|
||||||
|
|
||||||
1. Fix the `WorldBuilder.tsx` syntax bug (5 min)
|
1. Fix the `WorldBuilder.tsx` syntax bug (5 min)
|
||||||
2. Auto-load Settings on mount (10 min)
|
2. Auto-load Settings on mount (10 min)
|
||||||
@@ -1181,11 +1181,11 @@ small CSS:
|
|||||||
(2 min)
|
(2 min)
|
||||||
4. Add `aria-label` to every icon-only button (30 min)
|
4. Add `aria-label` to every icon-only button (30 min)
|
||||||
5. Add `data-tooltip` styled tooltips (1 hr)
|
5. Add `data-tooltip` styled tooltips (1 hr)
|
||||||
6. Group the left rail into Session / World (30 min)
|
6. ✓ Group the left rail into Session / World (30 min)
|
||||||
7. Replace the duplicate `Sparkles` icon (2 min)
|
7. ✓ Replace the duplicate `Sparkles` icon (2 min)
|
||||||
8. Lighten `--color-text-dim` to meet WCAG AA (2 min)
|
8. ✓ Lighten `--color-text-dim` to meet WCAG AA (2 min)
|
||||||
9. Add the ⌘K command palette (3 hrs, the biggest of the bunch)
|
9. ✓ Add the ⌘K command palette (3 hrs, the biggest of the bunch)
|
||||||
10. Self-host the fonts in `public/fonts/` (1 hr)
|
10. ✓ Self-host the fonts in `public/fonts/` (1 hr)
|
||||||
|
|
||||||
Total: ~6–8 hours. Lands a much more polished, accessible
|
Total: ~6–8 hours. Lands a much more polished, accessible
|
||||||
baseline that the rest of the checklist builds on.
|
baseline that the rest of the checklist builds on.
|
||||||
|
|||||||
Generated
+10
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-log": "^2",
|
"@tauri-apps/plugin-log": "^2",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
"framer-motion": "^12",
|
"framer-motion": "^12",
|
||||||
"lucide-react": "^0.525",
|
"lucide-react": "^0.525",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
@@ -1306,6 +1307,15 @@
|
|||||||
"@tauri-apps/api": "^2.8.0"
|
"@tauri-apps/api": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-store": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-log": "^2",
|
"@tauri-apps/plugin-log": "^2",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.3",
|
||||||
"framer-motion": "^12",
|
"framer-motion": "^12",
|
||||||
"lucide-react": "^0.525",
|
"lucide-react": "^0.525",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -81,6 +81,72 @@ pub fn set_llm_config(state: tauri::State<'_, AppState>, config: crate::llm::Llm
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Connection test: list models from the configured endpoint ──
|
||||||
|
// ponytail: Ollama exposes GET /api/tags, OpenAI-compatible exposes
|
||||||
|
// GET /v1/models. Returns the model names so Settings can show a list and
|
||||||
|
// a green/red pill. Best-effort — surface the error string on failure.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct ConnectionTest {
|
||||||
|
pub ok: bool,
|
||||||
|
pub models: Vec<String>,
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_connection(state: tauri::State<'_, AppState>) -> Result<ConnectionTest, String> {
|
||||||
|
let config = state.config.lock().map_err(|e| e.to_string())?.clone();
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(8))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if llm::is_ollama(&config.api_url) {
|
||||||
|
let url = format!("{}/api/tags", config.api_url.trim_end_matches('/'));
|
||||||
|
let res = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
return Ok(ConnectionTest {
|
||||||
|
ok: false,
|
||||||
|
models: vec![],
|
||||||
|
error: format!("HTTP {}", res.status()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
||||||
|
let models = body["models"]
|
||||||
|
.as_array()
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(ConnectionTest { ok: true, models, error: String::new() })
|
||||||
|
} else {
|
||||||
|
let url = format!("{}/v1/models", config.api_url.trim_end_matches('/'));
|
||||||
|
let mut req = client.get(&url);
|
||||||
|
if !config.api_key.is_empty() {
|
||||||
|
req = req.bearer_auth(&config.api_key);
|
||||||
|
}
|
||||||
|
let res = req.send().await.map_err(|e| e.to_string())?;
|
||||||
|
if !res.status().is_success() {
|
||||||
|
return Ok(ConnectionTest {
|
||||||
|
ok: false,
|
||||||
|
models: vec![],
|
||||||
|
error: format!("HTTP {}", res.status()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
|
||||||
|
let models = body["data"]
|
||||||
|
.as_array()
|
||||||
|
.map(|a| {
|
||||||
|
a.iter()
|
||||||
|
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(ConnectionTest { ok: true, models, error: String::new() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Lore RAG injection (shared by generate + generate_stream) ─
|
// ─── Lore RAG injection (shared by generate + generate_stream) ─
|
||||||
|
|
||||||
/// Best-effort: if `rag_query` is set, retrieve top lore chunks and fold them
|
/// Best-effort: if `rag_query` is set, retrieve top lore chunks and fold them
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ pub fn run() {
|
|||||||
commands::llm_commands::generate_stream,
|
commands::llm_commands::generate_stream,
|
||||||
commands::llm_commands::get_llm_config,
|
commands::llm_commands::get_llm_config,
|
||||||
commands::llm_commands::set_llm_config,
|
commands::llm_commands::set_llm_config,
|
||||||
|
commands::llm_commands::test_connection,
|
||||||
commands::image_commands::generate_image,
|
commands::image_commands::generate_image,
|
||||||
commands::rag_commands::rag_add,
|
commands::rag_commands::rag_add,
|
||||||
commands::rag_commands::rag_search,
|
commands::rag_commands::rag_search,
|
||||||
|
|||||||
+10
-1
@@ -34,6 +34,7 @@ import { ImageGenerator } from "./components/ImageGenerator";
|
|||||||
import { ToastContainer } from "./components/Toast";
|
import { ToastContainer } from "./components/Toast";
|
||||||
import { CommandPalette } from "./components/CommandPalette";
|
import { CommandPalette } from "./components/CommandPalette";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
|
import { ConnectionPill } from "./components/ConnectionPill";
|
||||||
import { HistoryView } from "./components/HistoryView";
|
import { HistoryView } from "./components/HistoryView";
|
||||||
import type { Generation, GenerationKind } from "./lib/generations";
|
import type { Generation, GenerationKind } from "./lib/generations";
|
||||||
|
|
||||||
@@ -251,6 +252,13 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]">
|
<div className="flex h-screen w-screen overflow-hidden bg-[var(--color-bg-deep)]">
|
||||||
|
{/* ponytail: skip-to-content for keyboard users — jumps past the rail. */}
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:top-2 focus:left-2 focus:px-3 focus:py-1.5 focus:rounded-lg focus:bg-[var(--color-gold-bright)] focus:text-[var(--color-bg-deep)] focus:text-xs focus:font-semibold"
|
||||||
|
>
|
||||||
|
Skip to content
|
||||||
|
</a>
|
||||||
{/* Left Rail */}
|
{/* Left Rail */}
|
||||||
<nav
|
<nav
|
||||||
aria-label="Primary"
|
aria-label="Primary"
|
||||||
@@ -348,10 +356,11 @@ export default function App() {
|
|||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
<div className="ml-auto" data-tauri-no-drag />
|
<div className="ml-auto" data-tauri-no-drag />
|
||||||
|
<ConnectionPill />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto" id="main-content">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{isOnDashboard ? (
|
{isOnDashboard ? (
|
||||||
<Dashboard onNavigate={setView} />
|
<Dashboard onNavigate={setView} />
|
||||||
|
|||||||
@@ -9,30 +9,20 @@ interface BentoCardProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert desktop spans like "col-span-2 row-span-1" into responsive spans
|
// ponytail: callers pass responsive span classes directly (e.g.
|
||||||
// On mobile (1 col): everything is col-span-1
|
// "sm:col-span-2"). No string regex — the old replace() mangled pre-responsive
|
||||||
// On tablet (2 cols): col-span-2 stays, col-span-1 stays
|
// classes and silently dropped unknown spans like col-span-3.
|
||||||
// On desktop (4 cols): original spans
|
|
||||||
function responsiveSpan(span: string): string {
|
|
||||||
return span
|
|
||||||
.replace("col-span-2", "sm:col-span-2 lg:col-span-2 col-span-1")
|
|
||||||
.replace("row-span-2", "sm:row-span-2 lg:row-span-2 row-span-1")
|
|
||||||
.replace("col-span-1", "col-span-1")
|
|
||||||
.replace("row-span-1", "row-span-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BentoCard({
|
export function BentoCard({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
span = "col-span-1 row-span-1",
|
span = "",
|
||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
}: BentoCardProps) {
|
}: BentoCardProps) {
|
||||||
const responsive = responsiveSpan(span);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`glass-card flex flex-col overflow-hidden p-4 ${responsive} ${className}`}
|
className={`glass-card flex flex-col overflow-hidden p-4 ${span} ${className}`}
|
||||||
// ponytail: drop the hover scale — 12 cards bobs noticeably when the
|
// ponytail: drop the hover scale — 12 cards bobs noticeably when the
|
||||||
// cursor moves. The CSS .glass-card:hover (border + glow) is enough.
|
// cursor moves. The CSS .glass-card:hover (border + glow) is enough.
|
||||||
whileTap={{ scale: 0.985 }}
|
whileTap={{ scale: 0.985 }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
"Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn",
|
"Hammer", "Alturiak", "Ches", "Tarsakh", "Mirtul", "Kythorn",
|
||||||
@@ -17,11 +18,52 @@ const COLORS = ["var(--color-gold-bright)", "var(--color-info)", "var(--color-su
|
|||||||
export function CalendarWidget() {
|
export function CalendarWidget() {
|
||||||
const [month, setMonth] = useState(0);
|
const [month, setMonth] = useState(0);
|
||||||
const [year, setYear] = useState(1492);
|
const [year, setYear] = useState(1492);
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
const [events, setEvents] = usePersistentState<CalendarEvent[]>("calendar.events", []);
|
||||||
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
const [selectedDay, setSelectedDay] = useState<number | null>(null);
|
||||||
const [newEvent, setNewEvent] = useState("");
|
const [newEvent, setNewEvent] = useState("");
|
||||||
|
// ponytail: the campaign's current date, persisted so it survives reloads.
|
||||||
|
// Advances with the "Next day" button; "Today" jumps the view to it.
|
||||||
|
const [today, setToday] = usePersistentState<{ day: number; month: number; year: number }>(
|
||||||
|
"calendar.today",
|
||||||
|
{ day: 15, month: 5, year: 1492 },
|
||||||
|
);
|
||||||
|
|
||||||
const today = { day: 15, month: 5 }; // 15th of Kythorn (arbitrary "today")
|
// ponytail: 8 moon phases cycling on a ~30-day lunar cycle. Derived from
|
||||||
|
// a continuous day index so the phase is stable for any date.
|
||||||
|
const MOON_PHASES = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"];
|
||||||
|
function moonPhase(day: number, month: number, year: number): string {
|
||||||
|
const idx = Math.floor(((year * 360 + month * 30 + (day - 1)) % 30) / 30 * 8);
|
||||||
|
return MOON_PHASES[((idx % 8) + 8) % 8];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: deterministic weather per day (stable for a given date).
|
||||||
|
// A simple PRNG seeded by the date index so the forecast doesn't reshuffle
|
||||||
|
// every render. Coarse by design — a DM can override narratively.
|
||||||
|
const WEATHER = [
|
||||||
|
"☀ Clear", "⛅ Fair", "☁ Overcast", "🌧 Light rain", "⛈ Storm",
|
||||||
|
"❄ Snow", "🌫 Fog", "💨 Windy", "🌀 Unnatural",
|
||||||
|
];
|
||||||
|
function weatherFor(day: number, month: number, year: number): string {
|
||||||
|
const seed = (year * 360 + month * 30 + (day - 1)) % 97;
|
||||||
|
return WEATHER[seed % WEATHER.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the campaign date by one day (30-day months, 12-month year).
|
||||||
|
function nextDay() {
|
||||||
|
setToday((t) => {
|
||||||
|
let { day, month, year } = t;
|
||||||
|
day += 1;
|
||||||
|
if (day > 30) { day = 1; month += 1; }
|
||||||
|
if (month > 11) { month = 0; year += 1; }
|
||||||
|
return { day, month, year };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToToday() {
|
||||||
|
setMonth(today.month);
|
||||||
|
setYear(today.year);
|
||||||
|
setSelectedDay(today.day);
|
||||||
|
}
|
||||||
|
|
||||||
function addEvent() {
|
function addEvent() {
|
||||||
if (!newEvent.trim() || selectedDay === null) return;
|
if (!newEvent.trim() || selectedDay === null) return;
|
||||||
@@ -62,6 +104,29 @@ export function CalendarWidget() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Campaign date controls */}
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="text-[10px] text-[var(--color-text-secondary)]">
|
||||||
|
Today: {MONTHS[today.month]} {today.day}, {today.year} DR {moonPhase(today.day, today.month, today.year)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={jumpToToday}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-1.5 py-0.5 text-[9px] cursor-pointer hover:text-[var(--color-gold-bright)]"
|
||||||
|
title="Jump the view to today"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={nextDay}
|
||||||
|
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)]"
|
||||||
|
title="Advance the campaign date by one day"
|
||||||
|
>
|
||||||
|
Next day →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Day grid */}
|
{/* Day grid */}
|
||||||
<div className="grid grid-cols-7 gap-0.5">
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||||
@@ -72,7 +137,7 @@ export function CalendarWidget() {
|
|||||||
{/* Start offset - Faerûn calendar starts on first of month */}
|
{/* Start offset - Faerûn calendar starts on first of month */}
|
||||||
{Array.from({ length: 30 }, (_, i) => {
|
{Array.from({ length: 30 }, (_, i) => {
|
||||||
const dayNum = i + 1;
|
const dayNum = i + 1;
|
||||||
const isToday = dayNum === today.day && month === today.month;
|
const isToday = dayNum === today.day && month === today.month && year === today.year;
|
||||||
const hasEvent = events.some((e) => e.day === dayNum && e.month === month);
|
const hasEvent = events.some((e) => e.day === dayNum && e.month === month);
|
||||||
const isSelected = dayNum === selectedDay;
|
const isSelected = dayNum === selectedDay;
|
||||||
|
|
||||||
@@ -92,6 +157,7 @@ export function CalendarWidget() {
|
|||||||
{hasEvent && !isToday && (
|
{hasEvent && !isToday && (
|
||||||
<span className="block w-1 h-1 rounded-full bg-[var(--color-gold-bright)] mx-auto mt-0" />
|
<span className="block w-1 h-1 rounded-full bg-[var(--color-gold-bright)] mx-auto mt-0" />
|
||||||
)}
|
)}
|
||||||
|
<span className="block text-[7px] leading-none opacity-60">{moonPhase(dayNum, month, year)}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -100,8 +166,13 @@ export function CalendarWidget() {
|
|||||||
{/* Events for selected day */}
|
{/* Events for selected day */}
|
||||||
{selectedDay !== null && (
|
{selectedDay !== null && (
|
||||||
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
|
<div className="flex-1 overflow-y-auto border-t border-[var(--color-border-subtle)] pt-2">
|
||||||
<div className="text-[10px] font-medium text-[var(--color-text-secondary)] mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
{MONTHS[month]} {selectedDay}
|
<div className="text-[10px] font-medium text-[var(--color-text-secondary)]">
|
||||||
|
{MONTHS[month]} {selectedDay}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[var(--color-gold-bright)]" title="Weather for this day">
|
||||||
|
{weatherFor(selectedDay, month, year)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{dayEvents.length === 0 && (
|
{dayEvents.length === 0 && (
|
||||||
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
|
<div className="text-[var(--color-text-dim)] text-[10px] italic">No events</div>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
type State = "unknown" | "connected" | "offline";
|
||||||
|
|
||||||
|
// ponytail: a title-bar pill that probes the LLM endpoint on mount and on
|
||||||
|
// click. Shows ● connected / ○ offline so the DM knows their backend is up
|
||||||
|
// before generating. Replaces the inert "My Campaign" placeholder.
|
||||||
|
export function ConnectionPill() {
|
||||||
|
const [state, setState] = useState<State>("unknown");
|
||||||
|
const [models, setModels] = useState(0);
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
setState("unknown");
|
||||||
|
try {
|
||||||
|
const result = await invoke<{ ok: boolean; models: string[]; error: string }>("test_connection");
|
||||||
|
if (result.ok) {
|
||||||
|
setState("connected");
|
||||||
|
setModels(result.models.length);
|
||||||
|
} else {
|
||||||
|
setState("offline");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState("offline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void test();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const color =
|
||||||
|
state === "connected"
|
||||||
|
? "var(--color-success)"
|
||||||
|
: state === "offline"
|
||||||
|
? "var(--color-danger)"
|
||||||
|
: "var(--color-text-dim)";
|
||||||
|
const label =
|
||||||
|
state === "connected"
|
||||||
|
? `LLM · ${models} models`
|
||||||
|
: state === "offline"
|
||||||
|
? "LLM offline"
|
||||||
|
: "LLM…";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={test}
|
||||||
|
data-tauri-no-drag
|
||||||
|
className="ml-2 flex items-center gap-1.5 text-[var(--color-text-dim)] text-xs hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
|
||||||
|
title="Click to re-test LLM connection"
|
||||||
|
aria-label={`LLM connection: ${state}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<span className="hidden sm:inline">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+171
-210
@@ -2,235 +2,196 @@ import {
|
|||||||
Map,
|
Map,
|
||||||
User,
|
User,
|
||||||
Swords,
|
Swords,
|
||||||
ScrollText,
|
|
||||||
Dice5,
|
Dice5,
|
||||||
Sparkles,
|
|
||||||
Timer,
|
|
||||||
Volume2,
|
Volume2,
|
||||||
|
Timer,
|
||||||
Calendar,
|
Calendar,
|
||||||
Maximize2,
|
|
||||||
Wand2,
|
Wand2,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
Shuffle,
|
||||||
|
Flag,
|
||||||
|
ScrollText,
|
||||||
|
ArrowRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { BentoCard } from "./BentoCard";
|
import { BentoCard } from "./BentoCard";
|
||||||
import type { View } from "../App";
|
import type { View } from "../App";
|
||||||
import { NpcGenerator } from "./NpcGenerator";
|
import {
|
||||||
import { DiceRoller } from "./DiceRoller";
|
listGenerations,
|
||||||
import { SessionLogger } from "./SessionLogger";
|
relativeTime,
|
||||||
import { EncounterBuilder } from "./EncounterBuilder";
|
type GenerationKind,
|
||||||
import { InitiativeTracker } from "./InitiativeTracker";
|
type GenerationSummary,
|
||||||
import { RandomTables } from "./RandomTables";
|
} from "../lib/generations";
|
||||||
import { CalendarWidget } from "./CalendarWidget";
|
|
||||||
import { WorldBuilder } from "./WorldBuilder";
|
|
||||||
import { QuestDesigner } from "./QuestDesigner";
|
|
||||||
import { ItemForge } from "./ItemForge";
|
|
||||||
import { Soundboard } from "./Soundboard";
|
|
||||||
import { ImageGenerator } from "./ImageGenerator";
|
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
onNavigate: (view: View) => void;
|
onNavigate: (view: View) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: launcher cards — icon, title, one-line description, Open CTA.
|
||||||
|
// No embedded mini-tools: the dashboard is a launcher, not an ant farm.
|
||||||
|
interface Card {
|
||||||
|
icon: typeof Map;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
view: View;
|
||||||
|
span?: string;
|
||||||
|
// when set, the card shows "Last: {title} · {relativeTime}" from history.
|
||||||
|
kind?: GenerationKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARDS: Card[] = [
|
||||||
|
{
|
||||||
|
icon: Map,
|
||||||
|
title: "World Builder",
|
||||||
|
description: "Generate a world with regions, landmarks, and conflicts.",
|
||||||
|
view: "world",
|
||||||
|
span: "sm:col-span-2",
|
||||||
|
kind: "world",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Timer,
|
||||||
|
title: "Initiative",
|
||||||
|
description: "Track combatants, HP, conditions, and turn order.",
|
||||||
|
view: "initiative",
|
||||||
|
kind: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Dice5,
|
||||||
|
title: "Dice Roller",
|
||||||
|
description: "Roll with advantage, modifiers, and roll templates.",
|
||||||
|
view: "dice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Swords,
|
||||||
|
title: "Encounter",
|
||||||
|
description: "Build balanced encounters with an XP budget.",
|
||||||
|
view: "encounter",
|
||||||
|
kind: "encounter",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: User,
|
||||||
|
title: "NPC Generator",
|
||||||
|
description: "Generate NPCs with portraits, personality, and goals.",
|
||||||
|
view: "npcs",
|
||||||
|
kind: "npc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Flag,
|
||||||
|
title: "Quest Designer",
|
||||||
|
description: "Design multi-step quests with twists and rewards.",
|
||||||
|
view: "quest",
|
||||||
|
kind: "quest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Wand2,
|
||||||
|
title: "Item Forge",
|
||||||
|
description: "Forge magic items with art and mechanics.",
|
||||||
|
view: "items",
|
||||||
|
kind: "item",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ScrollText,
|
||||||
|
title: "Session Log",
|
||||||
|
description: "Take notes and generate an AI session summary.",
|
||||||
|
view: "session",
|
||||||
|
kind: "session",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BookOpen,
|
||||||
|
title: "Lore (RAG)",
|
||||||
|
description: "Index your world bible and ground generations in it.",
|
||||||
|
view: "lore",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Calendar,
|
||||||
|
title: "Calendar",
|
||||||
|
description: "Track the in-world date, events, and moon phases.",
|
||||||
|
view: "calendar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shuffle,
|
||||||
|
title: "Random Tables",
|
||||||
|
description: "Roll on built-in and custom random tables.",
|
||||||
|
view: "tables",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: ImagePlus,
|
||||||
|
title: "Image Generator",
|
||||||
|
description: "Generate portraits, maps, and scene art (macOS).",
|
||||||
|
view: "image",
|
||||||
|
span: "sm:col-span-2",
|
||||||
|
kind: "image",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Volume2,
|
||||||
|
title: "Soundboard",
|
||||||
|
description: "Synthesized ambience and SFX for the table.",
|
||||||
|
view: "sound",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export function Dashboard({ onNavigate }: DashboardProps) {
|
export function Dashboard({ onNavigate }: DashboardProps) {
|
||||||
|
// ponytail: one fetch on mount → most-recent generation per kind, for the
|
||||||
|
// "Last: …" footer. listGenerations returns newest-first per kind.
|
||||||
|
const [latest, setLatest] = useState<Partial<Record<GenerationKind, GenerationSummary>>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const all = await listGenerations();
|
||||||
|
const byKind: Partial<Record<GenerationKind, GenerationSummary>> = {};
|
||||||
|
for (const g of all) {
|
||||||
|
if (!byKind[g.kind]) byKind[g.kind] = g; // first occurrence is newest
|
||||||
|
}
|
||||||
|
setLatest(byKind);
|
||||||
|
} catch {
|
||||||
|
// generations backend unavailable — footer just stays hidden.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 grid-rows-5 gap-3 p-4 h-full bg-[var(--color-bg-deep)] overflow-y-auto">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 auto-rows-fr gap-3 p-4 h-full bg-[var(--color-bg-deep)] overflow-y-auto">
|
||||||
{/* World Map — 2×2 */}
|
{CARDS.map((card) => {
|
||||||
<BentoCard title="World Builder" icon={<Map size={16} />} span="col-span-2 row-span-2">
|
const last = card.kind ? latest[card.kind] : undefined;
|
||||||
<div className="flex flex-col h-full">
|
return (
|
||||||
<div className="flex-1 overflow-hidden">
|
<BentoCard
|
||||||
<WorldBuilder />
|
key={card.view}
|
||||||
</div>
|
title={card.title}
|
||||||
<button
|
icon={<card.icon size={16} />}
|
||||||
onClick={() => onNavigate("world")}
|
span={card.span}
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
>
|
||||||
<Maximize2 size={12} /> Expand
|
<div className="flex flex-col h-full min-h-[120px]">
|
||||||
</button>
|
<div className="flex items-start gap-3 flex-1">
|
||||||
</div>
|
<div className="shrink-0 w-9 h-9 rounded-lg bg-[var(--color-gold-glow)] flex items-center justify-center text-[var(--color-gold-bright)]">
|
||||||
</BentoCard>
|
<card.icon size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed line-clamp-2">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* NPC Sheet — 1×1 */}
|
{last && (
|
||||||
<BentoCard title="NPC Sheet" icon={<User size={16} />} span="col-span-1 row-span-1">
|
<p className="text-[10px] text-[var(--color-text-dim)] mt-2 truncate">
|
||||||
<div className="flex flex-col h-full">
|
Last:{" "}
|
||||||
<div className="flex-1 overflow-hidden">
|
<span className="text-[var(--color-text-secondary)]">{last.title}</span>{" "}
|
||||||
<NpcGenerator />
|
· {relativeTime(last.createdAt)}
|
||||||
</div>
|
</p>
|
||||||
<button
|
)}
|
||||||
onClick={() => onNavigate("npcs")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Dice Roller — 1×1 */}
|
<button
|
||||||
<BentoCard title="Dice Roller" icon={<Dice5 size={16} />} span="col-span-1 row-span-1">
|
onClick={() => onNavigate(card.view)}
|
||||||
<div className="flex flex-col h-full">
|
className="mt-2 self-start text-xs font-medium text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] flex items-center gap-1 cursor-pointer transition-colors"
|
||||||
<div className="flex-1 overflow-hidden">
|
aria-label={`Open ${card.title}`}
|
||||||
<DiceRoller />
|
>
|
||||||
</div>
|
Open <ArrowRight size={12} />
|
||||||
<button
|
</button>
|
||||||
onClick={() => onNavigate("dice")}
|
</div>
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
</BentoCard>
|
||||||
>
|
);
|
||||||
<Maximize2 size={12} /> Expand
|
})}
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Encounter Builder — 1×1 */}
|
|
||||||
<BentoCard title="Encounter" icon={<Swords size={16} />} span="col-span-1 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<EncounterBuilder />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("encounter")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Session Log — 2×1 */}
|
|
||||||
<BentoCard title="Session Log" icon={<ScrollText size={16} />} span="col-span-2 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<SessionLogger />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("session")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Quest Designer — 2×1 */}
|
|
||||||
<BentoCard title="Quest Designer" icon={<Sparkles size={16} />} span="col-span-2 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<QuestDesigner />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("quest")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Initiative Tracker — 1×1 */}
|
|
||||||
<BentoCard title="Initiative" icon={<Timer size={16} />} span="col-span-1 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<InitiativeTracker />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("initiative")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Random Tables — 1×1 */}
|
|
||||||
<BentoCard title="Random Tables" icon={<Sparkles size={16} />} span="col-span-1 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<RandomTables />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("tables")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Calendar — 1×1 */}
|
|
||||||
<BentoCard title="Calendar" icon={<Calendar size={16} />} span="col-span-1 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<CalendarWidget />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("calendar")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Soundboard — 1×1 */}
|
|
||||||
<BentoCard title="Soundboard" icon={<Volume2 size={16} />} span="col-span-1 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<Soundboard />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("sound")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Item Forge — 2×1 */}
|
|
||||||
<BentoCard title="Item Forge" icon={<Wand2 size={16} />} span="col-span-2 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<ItemForge />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("items")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Lore — 1×1 */}
|
|
||||||
<BentoCard title="Lore" icon={<BookOpen size={16} />} span="col-span-1 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<p className="text-xs text-[var(--color-text-secondary)] leading-relaxed">
|
|
||||||
Index your world bible into a local vector store and ground AI generations in it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("lore")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Open
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
|
|
||||||
{/* Image Generator — 2×1 */}
|
|
||||||
<BentoCard title="Image Generator" icon={<ImagePlus size={16} />} span="col-span-2 row-span-1">
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<ImageGenerator />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onNavigate("image")}
|
|
||||||
className="text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] text-xs flex items-center justify-center gap-1 cursor-pointer transition-colors mt-1 pt-2 border-t border-[var(--color-border-subtle)]"
|
|
||||||
>
|
|
||||||
<Maximize2 size={12} /> Expand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</BentoCard>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
interface DieResult {
|
interface DieResult {
|
||||||
@@ -56,11 +57,11 @@ function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DiceRoller() {
|
export function DiceRoller() {
|
||||||
const [input, setInput] = useState("1d20");
|
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
|
||||||
const [results, setResults] = useState<DieResult[]>([]);
|
const [results, setResults] = usePersistentState<DieResult[]>("dice.history", []);
|
||||||
const [lastTotal, setLastTotal] = useState<number | null>(null);
|
const [lastTotal, setLastTotal] = useState<number | null>(null);
|
||||||
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
|
const [lastBreakdown, setLastBreakdown] = useState<DieResult | null>(null);
|
||||||
const [mode, setMode] = useState<Mode>("normal");
|
const [mode, setMode] = usePersistentState<Mode>("dice.mode", "normal");
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
const roll = useCallback(
|
const roll = useCallback(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { addToLore } from "../lib/lore";
|
|||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
|
import { bus, Events, type AddCombatantsPayload, type PlayAmbientPayload } from "../lib/bus";
|
||||||
import { encounterBudget, difficultyForXp, DIFFICULTY_COLOR, type Difficulty } from "../lib/encounter-budget";
|
import { encounterBudget, difficultyForXp, DIFFICULTY_COLOR, type Difficulty } from "../lib/encounter-budget";
|
||||||
|
|
||||||
interface Encounter {
|
interface Encounter {
|
||||||
@@ -147,7 +147,6 @@ export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [terrain, setTerrain] = useState("Forest");
|
const [terrain, setTerrain] = useState("Forest");
|
||||||
const [encounter, setEncounter] = useState<Encounter | null>(null);
|
const [encounter, setEncounter] = useState<Encounter | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => {
|
usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => {
|
||||||
@@ -190,7 +189,6 @@ export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
setEncounter(null);
|
setEncounter(null);
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string>("generate", {
|
const result = await invoke<string>("generate", {
|
||||||
@@ -222,11 +220,9 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
`Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`,
|
`Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
|
||||||
addToast("LLM returned an unparseable response", "error");
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
|
||||||
addToast(`Encounter generation failed: ${e}`, "error");
|
addToast(`Encounter generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -255,6 +251,21 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: map the encounter's terrain to the closest synthesized ambient.
|
||||||
|
// Best-effort — the DM can tweak on the Soundboard. Covers the 12 terrains.
|
||||||
|
const TERRAIN_AMBIENT: Record<string, string> = {
|
||||||
|
Forest: "forest", Swamp: "rain", Coastal: "ocean", Ocean: "ocean",
|
||||||
|
Mountain: "wind", Desert: "wind", Underdark: "cave", Dungeon: "cave",
|
||||||
|
Urban: "tavern", Haunted: "thunder", Planar: "thunder", Ship: "ocean",
|
||||||
|
Siege: "wind",
|
||||||
|
};
|
||||||
|
function sendToSoundboard() {
|
||||||
|
const id = TERRAIN_AMBIENT[terrain] ?? "wind";
|
||||||
|
const payload: PlayAmbientPayload = { id, source: `${terrain} ambient` };
|
||||||
|
bus.emit(Events.PlayAmbient, payload);
|
||||||
|
addToast(`Sent ${terrain} ambient to Soundboard`, "success");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 h-full overflow-y-auto">
|
<div className="flex flex-col gap-2 h-full overflow-y-auto">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -318,12 +329,14 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
{loading ? "⚔ Generating…" : "⚔ Generate Encounter"}
|
{loading ? "⚔ Generating…" : "⚔ Generate Encounter"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{error && (
|
{!encounter && !loading && (
|
||||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-2 text-[var(--color-danger)] text-xs">
|
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
|
||||||
{error}
|
<span>No encounter yet.</span>
|
||||||
|
<span className="mt-1">Set the party + terrain, then Generate.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{encounter && (
|
{encounter && (
|
||||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -366,6 +379,13 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
>
|
>
|
||||||
⚔ Send to Initiative Tracker
|
⚔ Send to Initiative Tracker
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={sendToSoundboard}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-gold-bright)] px-3 py-1.5 text-xs font-semibold hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer flex items-center justify-center gap-1.5"
|
||||||
|
title="Play an ambient matching the terrain"
|
||||||
|
>
|
||||||
|
🎧 Send to Soundboard
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { Sparkles, ImageOff } from "lucide-react";
|
import { ImageOff } from "lucide-react";
|
||||||
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Text-to-image prompt. Changing it triggers a new generation. */
|
/** Text-to-image prompt. Changing it triggers a new generation. */
|
||||||
@@ -22,13 +23,13 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
|||||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [unsupported, setUnsupported] = useState(false);
|
const [unsupported, setUnsupported] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prompt) return;
|
if (!prompt) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
setUnsupported(false);
|
setUnsupported(false);
|
||||||
invoke<string>("generate_image", { req: { prompt } })
|
invoke<string>("generate_image", { req: { prompt } })
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
@@ -38,7 +39,7 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
|||||||
const msg = String(e);
|
const msg = String(e);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
if (msg.includes("macOS-only")) setUnsupported(true);
|
if (msg.includes("macOS-only")) setUnsupported(true);
|
||||||
else setError(msg);
|
else addToast(`Image failed: ${msg}`, "error");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => !cancelled && setLoading(false));
|
.finally(() => !cancelled && setLoading(false));
|
||||||
@@ -62,25 +63,27 @@ export function GeneratedImage({ prompt, nonce = 0, className = "", aspect = "as
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className={box}>
|
|
||||||
<span className="text-[10px] text-[var(--color-danger)] px-2 text-center">{error}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={box}>
|
<div className={box}>
|
||||||
<Sparkles size={20} className="text-[var(--color-gold-bright)] animate-pulse" />
|
<div className="absolute inset-0 skeleton" />
|
||||||
|
<span className="relative text-[10px] text-[var(--color-text-dim)]">Painting…</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return dataUrl ? (
|
return dataUrl ? (
|
||||||
<div className={box}>
|
<div className={box}>
|
||||||
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
|
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover cursor-zoom-in" onClick={() => setExpanded(true)} />
|
||||||
|
{expanded && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] bg-black/80 flex items-center justify-center p-8"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
aria-label="Close image preview"
|
||||||
|
>
|
||||||
|
<img src={dataUrl} alt={prompt} className="max-w-full max-h-full rounded-lg object-contain" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,8 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [variant, setVariant] = useState(0);
|
const [variant, setVariant] = useState(0);
|
||||||
|
const [unsupported, setUnsupported] = useState(false);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
|
usePrefillEffect(prefill ?? null, "image", () => onPrefillConsumed?.(), (g) => {
|
||||||
@@ -30,7 +30,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
async function generate() {
|
async function generate() {
|
||||||
if (!prompt.trim()) return;
|
if (!prompt.trim()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
setDataUrl(null);
|
setDataUrl(null);
|
||||||
try {
|
try {
|
||||||
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
||||||
@@ -40,12 +39,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
req: { prompt: `${prompt}${v}`, model: model.trim() || null },
|
req: { prompt: `${prompt}${v}`, model: model.trim() || null },
|
||||||
});
|
});
|
||||||
setDataUrl(url);
|
setDataUrl(url);
|
||||||
|
setUnsupported(false);
|
||||||
addToast("Image generated", "success");
|
addToast("Image generated", "success");
|
||||||
// ponytail: persist the PNG data URL so the DM can re-open past renders.
|
// ponytail: persist the PNG data URL so the DM can re-open past renders.
|
||||||
void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL });
|
void addGeneration({ kind: "image", title: prompt, data: url, source: model.trim() || DEFAULT_MODEL });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
const msg = String(e);
|
||||||
addToast(`Image generation failed: ${e}`, "error");
|
if (msg.toLowerCase().includes("macos-only")) setUnsupported(true);
|
||||||
|
addToast(`Image generation failed: ${msg}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -56,8 +57,6 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
setTimeout(generate, 0);
|
setTimeout(generate, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsupported = error.toLowerCase().includes("macos-only");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 text-sm">
|
<div className="flex flex-col gap-3 text-sm">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -115,24 +114,41 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && !unsupported && (
|
|
||||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs overflow-x-auto">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dataUrl && !loading && (
|
{dataUrl && !loading && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
|
<div className="aspect-square w-full max-w-md mx-auto rounded-lg overflow-hidden border border-[var(--color-border-glass)] bg-[var(--color-bg-deep)]">
|
||||||
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
|
<img src={dataUrl} alt={prompt} className="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<a
|
<div className="flex justify-center gap-3">
|
||||||
href={dataUrl}
|
<a
|
||||||
download="dm-pal-image.png"
|
href={dataUrl}
|
||||||
className="text-center text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
|
download="dm-pal-image.png"
|
||||||
>
|
className="text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
|
||||||
⬇ Save PNG
|
>
|
||||||
</a>
|
⬇ Save PNG
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// ponytail: copy the rendered PNG to the clipboard. The
|
||||||
|
// Clipboard API needs a Blob, not a data URL string.
|
||||||
|
fetch(dataUrl).then((r) => r.blob()).then((blob) =>
|
||||||
|
navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]),
|
||||||
|
).then(() => addToast("Image copied to clipboard", "success"))
|
||||||
|
.catch(() => addToast("Copy failed", "error"));
|
||||||
|
}}
|
||||||
|
className="text-xs text-[var(--color-gold-bright)] hover:text-[var(--color-gold-muted)] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
⧉ Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!dataUrl && !loading && !unsupported && (
|
||||||
|
<div className="flex flex-col items-center justify-center aspect-square max-w-md mx-auto text-center text-[var(--color-text-dim)] text-xs">
|
||||||
|
<span>No image yet.</span>
|
||||||
|
<span className="mt-1">Describe a scene or portrait, then Generate.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
|
import { bus, Events, type AddCombatantsPayload } from "../lib/bus";
|
||||||
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
|
||||||
interface Combatant {
|
interface Combatant {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,6 +11,8 @@ interface Combatant {
|
|||||||
maxHp: number;
|
maxHp: number;
|
||||||
conditions: string[];
|
conditions: string[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
// ponytail: 5e death saves — 3 successes stabilize, 3 failures = dead.
|
||||||
|
deathSaves?: { successes: number; failures: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONDITIONS = [
|
const CONDITIONS = [
|
||||||
@@ -18,19 +21,33 @@ const CONDITIONS = [
|
|||||||
"Restrained", "Stunned", "Unconscious",
|
"Restrained", "Stunned", "Unconscious",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TURN_SECONDS = 60;
|
||||||
|
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
||||||
export function InitiativeTracker() {
|
export function InitiativeTracker() {
|
||||||
const [combatants, setCombatants] = useState<Combatant[]>([]);
|
// ponytail: persist combatants, round, and active turn so a refresh
|
||||||
|
// mid-combat doesn't lose the tracker. nextId stays module-level (cosmetic).
|
||||||
|
const [combatants, setCombatants] = usePersistentState<Combatant[]>("initiative.combatants", []);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [initBonus, setInitBonus] = useState(0);
|
const [initBonus, setInitBonus] = useState(0);
|
||||||
const [hp, setHp] = useState(10);
|
const [hp, setHp] = useState(10);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = usePersistentState<string | null>("initiative.activeId", null);
|
||||||
const [round, setRound] = useState(1);
|
const [round, setRound] = usePersistentState<number>("initiative.round", 1);
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
const [hpInput, setHpInput] = useState<Record<string, string>>({});
|
const [hpInput, setHpInput] = useState<Record<string, string>>({});
|
||||||
// (useToast is wired up in the bus handler below; silence unused-import.)
|
// ponytail: per-combatant UI flags. Live in component state, not persisted.
|
||||||
useToast();
|
const [condInput, setCondInput] = useState<Record<string, string>>({});
|
||||||
|
const [condEditing, setCondEditing] = useState<string | null>(null);
|
||||||
|
const [notesEditing, setNotesEditing] = useState<string | null>(null);
|
||||||
|
// ponytail: custom conditions the DM has added, persisted so they reuse
|
||||||
|
// across sessions (e.g. "Concentrating", "Raging", "Hexed").
|
||||||
|
const [customConditions, setCustomConditions] = usePersistentState<string[]>("initiative.customConditions", []);
|
||||||
|
// ponytail: optional turn timer — counts down from TURN_SECONDS, resets on
|
||||||
|
// Next Turn, turns red at 0. Off until the DM starts it.
|
||||||
|
const [timerOn, setTimerOn] = useState(false);
|
||||||
|
const [timerLeft, setTimerLeft] = useState(TURN_SECONDS);
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
// ponytail: listen for EncounterBuilder's "send to initiative" event.
|
// ponytail: listen for EncounterBuilder's "send to initiative" event.
|
||||||
// We don't auto-switch view (the DM might be editing) but the toast tells
|
// We don't auto-switch view (the DM might be editing) but the toast tells
|
||||||
@@ -153,8 +170,65 @@ export function InitiativeTracker() {
|
|||||||
} else {
|
} else {
|
||||||
setActiveId(combatants[idx + 1].id);
|
setActiveId(combatants[idx + 1].id);
|
||||||
}
|
}
|
||||||
|
setTimerLeft(TURN_SECONDS); // ponytail: reset the per-turn timer.
|
||||||
}, [combatants, activeId]);
|
}, [combatants, activeId]);
|
||||||
|
|
||||||
|
// ponytail: tick the timer once per second while armed. Stopping at 0 lets
|
||||||
|
// the red zero sit until the DM advances (don't auto-advance — that would
|
||||||
|
// steal a player's turn).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!timerOn) return;
|
||||||
|
if (timerLeft <= 0) return;
|
||||||
|
const t = setTimeout(() => setTimerLeft((s) => s - 1), 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [timerOn, timerLeft]);
|
||||||
|
|
||||||
|
// ponytail: 5e death save. d20: 10+ = success, <10 = failure, nat 20 = 2
|
||||||
|
// successes, nat 1 = 2 failures. 3 successes stabilize, 3 failures = dead.
|
||||||
|
function rollDeathSave(id: string) {
|
||||||
|
const roll = Math.floor(Math.random() * 20) + 1;
|
||||||
|
setCombatants((prev) =>
|
||||||
|
prev.map((c) => {
|
||||||
|
if (c.id !== id) return c;
|
||||||
|
const ds = c.deathSaves ?? { successes: 0, failures: 0 };
|
||||||
|
let succ = 0;
|
||||||
|
let fail = 0;
|
||||||
|
if (roll === 20) succ += 2;
|
||||||
|
else if (roll === 1) fail += 2;
|
||||||
|
else if (roll >= 10) succ += 1;
|
||||||
|
else fail += 1;
|
||||||
|
const next = { successes: ds.successes + succ, failures: ds.failures + fail };
|
||||||
|
return { ...c, deathSaves: next };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addToast(`Death save: ${roll}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustDeathSave(id: string, kind: "successes" | "failures", delta: number) {
|
||||||
|
setCombatants((prev) =>
|
||||||
|
prev.map((c) => {
|
||||||
|
if (c.id !== id) return c;
|
||||||
|
const ds = c.deathSaves ?? { successes: 0, failures: 0 };
|
||||||
|
return { ...c, deathSaves: { ...ds, [kind]: Math.max(0, Math.min(3, ds[kind] + delta)) } };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCustomCondition(id: string, raw: string) {
|
||||||
|
const cond = raw.trim();
|
||||||
|
if (!cond) return;
|
||||||
|
toggleCondition(id, cond);
|
||||||
|
if (!customConditions.includes(cond) && !CONDITIONS.includes(cond)) {
|
||||||
|
setCustomConditions((prev) => [...prev, cond]);
|
||||||
|
}
|
||||||
|
setCondInput((p) => { const { [id]: _, ...r } = p; return r; });
|
||||||
|
setCondEditing(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNotes(id: string, raw: string) {
|
||||||
|
setCombatants((prev) => prev.map((c) => (c.id === id ? { ...c, notes: raw } : c)));
|
||||||
|
}
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setCombatants([]);
|
setCombatants([]);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
@@ -179,7 +253,24 @@ export function InitiativeTracker() {
|
|||||||
<span className="text-[var(--color-gold-bright)] font-heading text-xs font-semibold">
|
<span className="text-[var(--color-gold-bright)] font-heading text-xs font-semibold">
|
||||||
Round {round}
|
Round {round}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1 items-center">
|
||||||
|
{/* ponytail: optional per-turn timer. Toggle to arm; resets on Next Turn. */}
|
||||||
|
{activeId && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setTimerOn((o) => !o); if (!timerOn) setTimerLeft(TURN_SECONDS); }}
|
||||||
|
className={`rounded px-1.5 py-0.5 text-[10px] font-mono cursor-pointer border transition-colors ${
|
||||||
|
timerOn
|
||||||
|
? timerLeft === 0
|
||||||
|
? "bg-[var(--color-danger)]/20 border-[var(--color-danger)] text-[var(--color-danger)]"
|
||||||
|
: "border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||||
|
: "border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
}`}
|
||||||
|
title="Toggle turn timer"
|
||||||
|
aria-label="Toggle turn timer"
|
||||||
|
>
|
||||||
|
{timerOn ? `${timerLeft}s` : "⏱"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={nextTurn}
|
onClick={nextTurn}
|
||||||
disabled={combatants.length === 0}
|
disabled={combatants.length === 0}
|
||||||
@@ -359,6 +450,45 @@ export function InitiativeTracker() {
|
|||||||
<span className="text-[9px] text-[var(--color-text-dim)] self-center">set</span>
|
<span className="text-[9px] text-[var(--color-text-dim)] self-center">set</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Death saves — only when downed (HP 0). 5e: 3 succ stabilize, 3 fail = dead. */}
|
||||||
|
{c.hp === 0 && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<span className="text-[9px] text-[var(--color-text-dim)] shrink-0">Deaths:</span>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<button
|
||||||
|
key={`s${i}`}
|
||||||
|
onClick={() => adjustDeathSave(c.id, "successes", ((c.deathSaves?.successes ?? 0) > i) ? -1 : 1)}
|
||||||
|
className="w-3 h-3 rounded-full border text-[8px] leading-none cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (c.deathSaves?.successes ?? 0) > i ? "var(--color-success)" : "transparent",
|
||||||
|
borderColor: "var(--color-success)",
|
||||||
|
}}
|
||||||
|
aria-label="Death save success"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="text-[var(--color-text-dim)] mx-0.5">/</span>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<button
|
||||||
|
key={`f${i}`}
|
||||||
|
onClick={() => adjustDeathSave(c.id, "failures", ((c.deathSaves?.failures ?? 0) > i) ? -1 : 1)}
|
||||||
|
className="w-3 h-3 rounded-full border text-[8px] leading-none cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: (c.deathSaves?.failures ?? 0) > i ? "var(--color-danger)" : "transparent",
|
||||||
|
borderColor: "var(--color-danger)",
|
||||||
|
}}
|
||||||
|
aria-label="Death save failure"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => rollDeathSave(c.id)}
|
||||||
|
className="ml-1 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-1.5 py-0 text-[9px] text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer"
|
||||||
|
title="Roll a death save (d20)"
|
||||||
|
>
|
||||||
|
🎲
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Conditions */}
|
{/* Conditions */}
|
||||||
{c.conditions.length > 0 && (
|
{c.conditions.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-0.5 mt-1">
|
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||||
@@ -375,18 +505,78 @@ export function InitiativeTracker() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add condition — show ALL 13, not 5. */}
|
{/* Add condition — standard 13 + custom, plus a free-text input. */}
|
||||||
<div className="flex flex-wrap gap-0.5 mt-1">
|
{condEditing === c.id ? (
|
||||||
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).map((cn) => (
|
<div className="flex gap-1 mt-1">
|
||||||
|
<input
|
||||||
|
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-gold-bright)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-primary)] focus:outline-none"
|
||||||
|
placeholder="Custom condition…"
|
||||||
|
value={condInput[c.id] ?? ""}
|
||||||
|
onChange={(e) => setCondInput((p) => ({ ...p, [c.id]: e.target.value }))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") addCustomCondition(c.id, condInput[c.id] ?? "");
|
||||||
|
if (e.key === "Escape") setCondEditing(null);
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
aria-label="Custom condition"
|
||||||
|
/>
|
||||||
|
{customConditions.filter((cn) => !c.conditions.includes(cn)).slice(0, 4).map((cn) => (
|
||||||
|
<button key={cn} onClick={() => addCustomCondition(c.id, cn)} className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] cursor-pointer hover:text-[var(--color-text-secondary)]">{cn}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-0.5 mt-1">
|
||||||
|
{CONDITIONS.filter((cn) => !c.conditions.includes(cn)).map((cn) => (
|
||||||
|
<button
|
||||||
|
key={cn}
|
||||||
|
onClick={() => toggleCondition(c.id, cn)}
|
||||||
|
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] hover:text-[var(--color-text-secondary)] cursor-pointer transition-colors"
|
||||||
|
title={`Add ${cn}`}
|
||||||
|
>
|
||||||
|
+{cn.slice(0, 3)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{customConditions.filter((cn) => !c.conditions.includes(cn) && !CONDITIONS.includes(cn)).map((cn) => (
|
||||||
|
<button
|
||||||
|
key={cn}
|
||||||
|
onClick={() => toggleCondition(c.id, cn)}
|
||||||
|
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-gold-muted)] px-1 py-0 text-[9px] hover:text-[var(--color-gold-bright)] cursor-pointer transition-colors"
|
||||||
|
title={`Add ${cn}`}
|
||||||
|
>
|
||||||
|
+{cn.slice(0, 3)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<button
|
<button
|
||||||
key={cn}
|
onClick={() => setCondEditing(c.id)}
|
||||||
onClick={() => toggleCondition(c.id, cn)}
|
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] hover:text-[var(--color-gold-bright)] cursor-pointer transition-colors"
|
||||||
className="rounded bg-[var(--color-bg-deep)] text-[var(--color-text-dim)] px-1 py-0 text-[9px] hover:text-[var(--color-text-secondary)] cursor-pointer transition-colors"
|
title="Add a custom condition"
|
||||||
title={`Add ${cn}`}
|
|
||||||
>
|
>
|
||||||
+{cn.slice(0, 3)}
|
+…
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes per combatant — table memory for concentration, movement, etc. */}
|
||||||
|
<div className="mt-1">
|
||||||
|
{notesEditing === c.id ? (
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded bg-[var(--color-bg-deep)] border border-[var(--color-gold-bright)] px-1.5 py-1 text-[10px] text-[var(--color-text-primary)] focus:outline-none resize-y min-h-12"
|
||||||
|
placeholder="Notes (e.g. concentrating on Hold Person, 50ft move left)…"
|
||||||
|
value={c.notes ?? ""}
|
||||||
|
onChange={(e) => setNotes(c.id, e.target.value)}
|
||||||
|
onBlur={() => setNotesEditing(null)}
|
||||||
|
autoFocus
|
||||||
|
aria-label={`Notes for ${c.name}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setNotesEditing(c.id)}
|
||||||
|
className={`text-[9px] cursor-pointer transition-colors ${c.notes ? "text-[var(--color-text-secondary)]" : "text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"}`}
|
||||||
|
title="Notes"
|
||||||
|
>
|
||||||
|
{c.notes ? `📝 ${c.notes}` : "📝 add note"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [item, setItem] = useState<ItemResponse | null>(null);
|
const [item, setItem] = useState<ItemResponse | null>(null);
|
||||||
const [rawResponse, setRawResponse] = useState<string | null>(null);
|
const [rawResponse, setRawResponse] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [artNonce, setArtNonce] = useState(0);
|
const [artNonce, setArtNonce] = useState(0);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
setItem(null);
|
setItem(null);
|
||||||
setRawResponse(null);
|
setRawResponse(null);
|
||||||
try {
|
try {
|
||||||
@@ -92,11 +90,9 @@ The JSON must have exactly these keys:
|
|||||||
`${parsed.name || "Magic item"} — ${parsed.rarity || rarity} ${parsed.type || itemType}. ${flattenValue(parsed.description)}\nMechanics: ${flattenValue(parsed.mechanical)}\nLore: ${flattenValue(parsed.lore)}`,
|
`${parsed.name || "Magic item"} — ${parsed.rarity || rarity} ${parsed.type || itemType}. ${flattenValue(parsed.description)}\nMechanics: ${flattenValue(parsed.mechanical)}\nLore: ${flattenValue(parsed.lore)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response as JSON. Raw response shown below.");
|
|
||||||
addToast("LLM returned an unparseable response", "error");
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
|
||||||
addToast(`Item generation failed: ${e}`, "error");
|
addToast(`Item generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -114,6 +110,14 @@ The JSON must have exactly these keys:
|
|||||||
const mechanicalText = item ? flattenValue(item.mechanical) : "";
|
const mechanicalText = item ? flattenValue(item.mechanical) : "";
|
||||||
const hasParseError = !item && rawResponse;
|
const hasParseError = !item && rawResponse;
|
||||||
|
|
||||||
|
// ponytail: one-click "surprise me" — random rarity + type, no typed prompt.
|
||||||
|
function randomItem() {
|
||||||
|
setRarity(RARITIES[Math.floor(Math.random() * RARITIES.length)]);
|
||||||
|
setItemType(TYPES[Math.floor(Math.random() * TYPES.length)]);
|
||||||
|
setPrompt("");
|
||||||
|
setTimeout(generate, 0);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -159,10 +163,19 @@ The JSON must have exactly these keys:
|
|||||||
>
|
>
|
||||||
{loading ? "⚔ Forging…" : "⚔ Forge Item"}
|
{loading ? "⚔ Forging…" : "⚔ Forge Item"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={randomItem}
|
||||||
|
disabled={loading}
|
||||||
|
title="Surprise me — random rarity and type"
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
🎲 Random
|
||||||
|
</button>
|
||||||
|
|
||||||
{error && (
|
{!item && !loading && !hasParseError && (
|
||||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
|
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
|
||||||
{error}
|
<span>No item yet.</span>
|
||||||
|
<span className="mt-1">Pick rarity + type, then Forge.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function LorePanel() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [hits, setHits] = useState<RagHit[]>([]);
|
const [hits, setHits] = useState<RagHit[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
|
||||||
async function loadSources() {
|
async function loadSources() {
|
||||||
try {
|
try {
|
||||||
@@ -59,6 +60,7 @@ export function LorePanel() {
|
|||||||
|
|
||||||
async function clearAll() {
|
async function clearAll() {
|
||||||
await invoke("rag_clear", { source: null });
|
await invoke("rag_clear", { source: null });
|
||||||
|
setConfirmClear(false);
|
||||||
loadSources();
|
loadSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +107,30 @@ export function LorePanel() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">Indexed Sources</h3>
|
||||||
{sources.length > 0 && (
|
{sources.length > 0 && (
|
||||||
<button
|
confirmClear ? (
|
||||||
onClick={clearAll}
|
<div className="flex items-center gap-1">
|
||||||
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
<span className="text-[10px] text-[var(--color-danger)]">Clear all sources?</span>
|
||||||
>
|
<button
|
||||||
clear all
|
onClick={clearAll}
|
||||||
</button>
|
className="rounded bg-[var(--color-danger)] text-white px-2 py-0.5 text-[10px] font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmClear(false)}
|
||||||
|
className="rounded bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-dim)] px-2 py-0.5 text-[10px] cursor-pointer"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmClear(true)}
|
||||||
|
className="text-xs text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
clear all
|
||||||
|
</button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{sources.length === 0 ? (
|
{sources.length === 0 ? (
|
||||||
@@ -160,7 +180,13 @@ export function LorePanel() {
|
|||||||
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2">
|
<li key={i} className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-2">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
|
<span className="text-[10px] text-[var(--color-gold-bright)]">{h.source}</span>
|
||||||
<span className="text-[10px] text-[var(--color-text-dim)]">score {h.score.toFixed(3)}</span>
|
{/* ponytail: relevance bar instead of a raw cosine score a
|
||||||
|
DM can't read. Clamp 0–1, gold fill. */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-12 h-1 rounded-full bg-[var(--color-bg-deep)] overflow-hidden">
|
||||||
|
<div className="h-full bg-[var(--color-gold-bright)]" style={{ width: `${Math.round(Math.max(0, Math.min(1, h.score)) * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
<p className="text-xs text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{h.text}</p>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [npc, setNpc] = useState<NpcResponse | null>(null);
|
const [npc, setNpc] = useState<NpcResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [portraitNonce, setPortraitNonce] = useState(0);
|
const [portraitNonce, setPortraitNonce] = useState(0);
|
||||||
|
// ponytail: in-place edit of personality/goals — add/remove without regenerating.
|
||||||
|
const [newTrait, setNewTrait] = useState("");
|
||||||
|
const [newGoal, setNewGoal] = useState("");
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
usePrefillEffect(prefill ?? null, "npc", () => onPrefillConsumed?.(), (g) => {
|
usePrefillEffect(prefill ?? null, "npc", () => onPrefillConsumed?.(), (g) => {
|
||||||
@@ -44,9 +46,27 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
// text so we drop the name in-image when one is given.
|
// text so we drop the name in-image when one is given.
|
||||||
const portraitPrompt = npc ? `fantasy oil-painting portrait of a ${race} ${background.toLowerCase()}, ${alignment.toLowerCase()} demeanor, ${ensureArray(npc.personality).slice(0, 2).map(flattenValue).join(", ")}, dramatic lighting, 1024x1024${name ? `, name "${name}" rendered as a caption` : ""}` : null;
|
const portraitPrompt = npc ? `fantasy oil-painting portrait of a ${race} ${background.toLowerCase()}, ${alignment.toLowerCase()} demeanor, ${ensureArray(npc.personality).slice(0, 2).map(flattenValue).join(", ")}, dramatic lighting, 1024x1024${name ? `, name "${name}" rendered as a caption` : ""}` : null;
|
||||||
|
|
||||||
|
function removeTrait(i: number) {
|
||||||
|
if (!npc) return;
|
||||||
|
setNpc({ ...npc, personality: npc.personality.filter((_, j) => j !== i) });
|
||||||
|
}
|
||||||
|
function addTrait() {
|
||||||
|
if (!npc || !newTrait.trim()) return;
|
||||||
|
setNpc({ ...npc, personality: [...npc.personality, newTrait.trim()] });
|
||||||
|
setNewTrait("");
|
||||||
|
}
|
||||||
|
function removeGoal(i: number) {
|
||||||
|
if (!npc) return;
|
||||||
|
setNpc({ ...npc, goals: npc.goals.filter((_, j) => j !== i) });
|
||||||
|
}
|
||||||
|
function addGoal() {
|
||||||
|
if (!npc || !newGoal.trim()) return;
|
||||||
|
setNpc({ ...npc, goals: [...npc.goals, newGoal.trim()] });
|
||||||
|
setNewGoal("");
|
||||||
|
}
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
setNpc(null);
|
setNpc(null);
|
||||||
try {
|
try {
|
||||||
const prompt = `Create a detailed NPC for a fantasy RPG:
|
const prompt = `Create a detailed NPC for a fantasy RPG:
|
||||||
@@ -84,11 +104,9 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`,
|
`${name || "An unnamed NPC"} — a ${race} ${background} (${alignment}). ${parsed.bio}\nPersonality: ${persona}\nGoals: ${goals}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
|
||||||
addToast("LLM returned an unparseable response", "error");
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
|
||||||
addToast(`NPC generation failed: ${e}`, "error");
|
addToast(`NPC generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -159,11 +177,6 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Result */}
|
{/* Result */}
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs overflow-x-auto">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{npc && (
|
{npc && (
|
||||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
|
||||||
@@ -188,18 +201,45 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
|
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Personality:</span>
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
{ensureArray(npc.personality).map((p, i) => (
|
{npc.personality.map((p, i) => (
|
||||||
<span key={i} className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs">
|
<span
|
||||||
{flattenValue(p)}
|
key={i}
|
||||||
|
onClick={() => removeTrait(i)}
|
||||||
|
className="rounded-full bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] px-2 py-0.5 text-xs cursor-pointer hover:opacity-70"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
{p} ×
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
<input
|
||||||
|
className="rounded-full bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-0.5 text-xs text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] w-24"
|
||||||
|
value={newTrait}
|
||||||
|
onChange={(e) => setNewTrait(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && addTrait()}
|
||||||
|
placeholder="+ trait"
|
||||||
|
aria-label="Add personality trait"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Goals:</span>
|
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Goals:</span>
|
||||||
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-1">
|
<ul className="text-[var(--color-text-primary)] text-xs mt-1 space-y-0.5">
|
||||||
{ensureArray(npc.goals).map((g, i) => <li key={i}>{flattenValue(g)}</li>)}
|
{npc.goals.map((g, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[var(--color-gold-bright)]">•</span>
|
||||||
|
<span className="flex-1">{g}</span>
|
||||||
|
<button onClick={() => removeGoal(i)} className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer text-[10px]" title="Remove goal">×</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<input
|
||||||
|
className="mt-1 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-0.5 text-xs text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] w-full"
|
||||||
|
value={newGoal}
|
||||||
|
onChange={(e) => setNewGoal(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && addGoal()}
|
||||||
|
placeholder="+ add a goal…"
|
||||||
|
aria-label="Add goal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
const [quest, setQuest] = useState<QuestResponse | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
// ponytail: player-facing view hides the Twist + Choices so the DM can share
|
||||||
|
// the screen without spoiling. Step status is derived from currentStep.
|
||||||
|
const [playerView, setPlayerView] = useState(false);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
usePrefillEffect(prefill ?? null, "quest", () => onPrefillConsumed?.(), (g) => {
|
usePrefillEffect(prefill ?? null, "quest", () => onPrefillConsumed?.(), (g) => {
|
||||||
@@ -45,7 +47,6 @@ export function QuestDesigner({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
setQuest(null);
|
setQuest(null);
|
||||||
setCurrentStep(0);
|
setCurrentStep(0);
|
||||||
try {
|
try {
|
||||||
@@ -82,11 +83,9 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
`Quest: ${parsed.title || "Untitled"} (level ${level}${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${parsed.reward}`,
|
`Quest: ${parsed.title || "Untitled"} (level ${level}${theme ? `, ${theme}` : ""}).\nHook: ${parsed.hook}\n${steps}\nTwist: ${parsed.twist}\nReward: ${parsed.reward}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
|
||||||
addToast("LLM returned an unparseable response", "error");
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
|
||||||
addToast(`Quest generation failed: ${e}`, "error");
|
addToast(`Quest generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -125,14 +124,29 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
>
|
>
|
||||||
{loading ? "✨ Designing…" : "✨ Design Quest"}
|
{loading ? "✨ Designing…" : "✨ Design Quest"}
|
||||||
</button>
|
</button>
|
||||||
|
{quest && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPlayerView((v) => !v)}
|
||||||
|
className={`rounded-lg px-3 py-2 text-xs font-semibold border transition-colors cursor-pointer ${
|
||||||
|
playerView
|
||||||
|
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||||
|
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
}`}
|
||||||
|
title="Hide the twist and choices for screen-sharing"
|
||||||
|
>
|
||||||
|
{playerView ? "👁 Player view" : "🖥 GM view"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{!quest && !loading && (
|
||||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs">
|
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
|
||||||
{error}
|
<span>No quest yet.</span>
|
||||||
|
<span className="mt-1">Set the theme + scope, then Design Quest.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{quest && (
|
{quest && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Quest title & hook */}
|
{/* Quest title & hook */}
|
||||||
@@ -172,13 +186,16 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-4">
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-4">
|
||||||
<h5 className="text-[var(--color-text-primary)] text-sm font-semibold mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
Step {currentStep + 1}: {quest.steps[currentStep].title}
|
<h5 className="text-[var(--color-text-primary)] text-sm font-semibold">
|
||||||
</h5>
|
Step {currentStep + 1}: {quest.steps[currentStep].title}
|
||||||
|
</h5>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-[var(--color-gold-bright)]">Active</span>
|
||||||
|
</div>
|
||||||
<p className="text-[var(--color-text-secondary)] text-sm leading-relaxed">
|
<p className="text-[var(--color-text-secondary)] text-sm leading-relaxed">
|
||||||
{quest.steps[currentStep].description}
|
{quest.steps[currentStep].description}
|
||||||
</p>
|
</p>
|
||||||
{quest.steps[currentStep].choice && (
|
{quest.steps[currentStep].choice && !playerView && (
|
||||||
<div className="mt-2 rounded-lg bg-[var(--color-bg-deep)] border-l-2 border-[var(--color-gold-bright)] px-3 py-2">
|
<div className="mt-2 rounded-lg bg-[var(--color-bg-deep)] border-l-2 border-[var(--color-gold-bright)] px-3 py-2">
|
||||||
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Choice</span>
|
<span className="text-[var(--color-gold-bright)] text-[10px] font-medium uppercase tracking-wider">Choice</span>
|
||||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.steps[currentStep].choice}</p>
|
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.steps[currentStep].choice}</p>
|
||||||
@@ -205,7 +222,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Twist */}
|
{/* Twist */}
|
||||||
{quest.twist && (
|
{quest.twist && !playerView && (
|
||||||
<div className="rounded-lg border-l-2 border-[var(--color-danger)] bg-[var(--color-bg-surface)] px-4 py-3">
|
<div className="rounded-lg border-l-2 border-[var(--color-danger)] bg-[var(--color-bg-surface)] px-4 py-3">
|
||||||
<span className="text-[var(--color-danger)] text-[10px] font-medium uppercase tracking-wider">Twist</span>
|
<span className="text-[var(--color-danger)] text-[10px] font-medium uppercase tracking-wider">Twist</span>
|
||||||
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.twist}</p>
|
<p className="text-[var(--color-text-primary)] text-sm mt-0.5">{quest.twist}</p>
|
||||||
|
|||||||
@@ -57,6 +57,128 @@ const BUILTIN_TABLES: RandomTable[] = [
|
|||||||
{ min: 10, max: 10, result: "Writes everything down in a tiny notebook" },
|
{ min: 10, max: 10, result: "Writes everything down in a tiny notebook" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "NPC Names",
|
||||||
|
dice: "1d20",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "Aldric Holloway" },
|
||||||
|
{ min: 3, max: 4, result: "Mira Copperkettle" },
|
||||||
|
{ min: 5, max: 6, result: "Theron Ashwood" },
|
||||||
|
{ min: 7, max: 8, result: "Sable Nightwhisper" },
|
||||||
|
{ min: 9, max: 10, result: "Brom Stonefist" },
|
||||||
|
{ min: 11, max: 12, result: "Elara Dawnbrook" },
|
||||||
|
{ min: 13, max: 14, result: "Vaelris Moonsong" },
|
||||||
|
{ min: 15, max: 16, result: "Greta Bracken" },
|
||||||
|
{ min: 17, max: 18, result: "Corvus Vane" },
|
||||||
|
{ min: 19, max: 20, result: "Isolde Ferrow" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Traps",
|
||||||
|
dice: "1d12",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "Pressure plates release a poison dart volley" },
|
||||||
|
{ min: 3, max: 4, result: "Floor tilts into a spiked pit" },
|
||||||
|
{ min: 5, max: 6, result: "Rune flares: failing save teleports the victim 30ft up" },
|
||||||
|
{ min: 7, max: 8, result: "A false door slams and locks, flooding the room with water" },
|
||||||
|
{ min: 9, max: 10, result: "Gas vent: everyone in 10ft saves or falls unconscious" },
|
||||||
|
{ min: 11, max: 12, result: "Sculpted eyes animate and cast Hold Person" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dreams",
|
||||||
|
dice: "1d10",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "A song you almost remember teaching you a secret" },
|
||||||
|
{ min: 3, max: 4, result: "You fall through endless clouds and wake before landing" },
|
||||||
|
{ min: 5, max: 6, result: "A dead friend warns you of a coming betrayal" },
|
||||||
|
{ min: 7, max: 8, result: "You are the dragon, and the party are the monsters" },
|
||||||
|
{ min: 9, max: 10, result: "A faceless figure offers you a gift you can't refuse" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Dungeon Encounters",
|
||||||
|
dice: "1d12",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "1d4 goblins arguing over found loot" },
|
||||||
|
{ min: 3, max: 4, result: "A skeleton polishing its own skull" },
|
||||||
|
{ min: 5, max: 6, result: "A gelatinous cube drifting down the corridor" },
|
||||||
|
{ min: 7, max: 8, result: "Cultists mid-ritual around a dark pool" },
|
||||||
|
{ min: 9, max: 10, result: "A wounded adventurer begging for water" },
|
||||||
|
{ min: 11, max: 12, result: "A mimic chest that's already eaten someone" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wilderness Encounters",
|
||||||
|
dice: "1d12",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "A deer that watches without fleeing" },
|
||||||
|
{ min: 3, max: 4, result: "A traveler's shrine with a fresh offering" },
|
||||||
|
{ min: 5, max: 6, result: "Wolf tracks circling the party's camp" },
|
||||||
|
{ min: 7, max: 8, result: "A group of foraging halflings" },
|
||||||
|
{ min: 9, max: 10, result: "An old battlefield, bones half-buried" },
|
||||||
|
{ min: 11, max: 12, result: "A fae ring of mushrooms humming faintly" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Urban Encounters",
|
||||||
|
dice: "1d10",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "A pickpocket bumps a party member" },
|
||||||
|
{ min: 3, max: 4, result: "A street preacher predicts doom" },
|
||||||
|
{ min: 5, max: 6, result: "A merchant chases a thief through the crowd" },
|
||||||
|
{ min: 7, max: 8, result: "A noble's carriage blocks the street, demanding right of way" },
|
||||||
|
{ min: 9, max: 10, result: "A wanted poster bearing a party member's likeness" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Inn Names",
|
||||||
|
dice: "1d12",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "The Crooked Candle" },
|
||||||
|
{ min: 3, max: 4, result: "The Sour Barrel" },
|
||||||
|
{ min: 5, max: 6, result: "The Drowned Cat" },
|
||||||
|
{ min: 7, max: 8, result: "The Honest Thief" },
|
||||||
|
{ min: 9, max: 10, result: "The Last Lantern" },
|
||||||
|
{ min: 11, max: 12, result: "The Brass Sow" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ship Names",
|
||||||
|
dice: "1d10",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 2, result: "The Salt-Scarred" },
|
||||||
|
{ min: 3, max: 4, result: "Drifting Promise" },
|
||||||
|
{ min: 5, max: 6, result: "The Widow's Joy" },
|
||||||
|
{ min: 7, max: 8, result: "Tide Liar" },
|
||||||
|
{ min: 9, max: 10, result: "The Gilded Hull" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Caravan Cargo",
|
||||||
|
dice: "1d8",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 1, result: "Casks of sour wine" },
|
||||||
|
{ min: 2, max: 2, result: "Bolts of dyed wool" },
|
||||||
|
{ min: 3, max: 3, result: "A caged, very unhappy owlbear cub" },
|
||||||
|
{ min: 4, max: 4, result: "Iron ingots, smudged with old runes" },
|
||||||
|
{ min: 5, max: 5, result: "A sealed coffin, labeled 'do not open'" },
|
||||||
|
{ min: 6, max: 6, result: "Crates of fragile glassware" },
|
||||||
|
{ min: 7, max: 7, result: "Sacks of exotic spice" },
|
||||||
|
{ min: 8, max: 8, result: "A peasant family fleeing trouble" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Treasure Hoards",
|
||||||
|
dice: "1d12",
|
||||||
|
entries: [
|
||||||
|
{ min: 1, max: 3, result: "500 gp, a silver chalice, and a scroll of Fireball" },
|
||||||
|
{ min: 4, max: 6, result: "2,000 gp, a +1 weapon, and a worn spellbook" },
|
||||||
|
{ min: 7, max: 9, result: "A gem-studded idol worth 1,500 gp and a cursed ring" },
|
||||||
|
{ min: 10, max: 10, result: "A deck of many things (the DMG kind)" },
|
||||||
|
{ min: 11, max: 12, result: "A map to a dragon's hoard and a single ancient coin" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Treasure (Low)",
|
name: "Treasure (Low)",
|
||||||
dice: "1d6",
|
dice: "1d6",
|
||||||
@@ -97,6 +219,16 @@ export function RandomTables() {
|
|||||||
|
|
||||||
const table = BUILTIN_TABLES[selected];
|
const table = BUILTIN_TABLES[selected];
|
||||||
|
|
||||||
|
function exportTable() {
|
||||||
|
const blob = new Blob([JSON.stringify(table, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${table.name.toLowerCase().replace(/\s+/g, "-")}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
const roll = useCallback(() => {
|
const roll = useCallback(() => {
|
||||||
const rollVal = rollDice(table.dice);
|
const rollVal = rollDice(table.dice);
|
||||||
const entry = table.entries.find((e) => rollVal >= e.min && rollVal <= e.max);
|
const entry = table.entries.find((e) => rollVal >= e.min && rollVal <= e.max);
|
||||||
@@ -123,13 +255,22 @@ export function RandomTables() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Roll button */}
|
{/* Roll + export */}
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={roll}
|
<button
|
||||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
onClick={roll}
|
||||||
>
|
className="flex-1 rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer"
|
||||||
🎲 Roll {table.dice}
|
>
|
||||||
</button>
|
🎲 Roll {table.dice}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={exportTable}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-2 text-sm hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer"
|
||||||
|
title="Export this table as JSON"
|
||||||
|
>
|
||||||
|
⬇
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Result */}
|
{/* Result */}
|
||||||
{customRoll !== null && (
|
{customRoll !== null && (
|
||||||
@@ -165,7 +306,16 @@ export function RandomTables() {
|
|||||||
{/* Roll history */}
|
{/* Roll history */}
|
||||||
{log.length > 0 && (
|
{log.length > 0 && (
|
||||||
<div className="border-t border-[var(--color-border-subtle)] pt-2 max-h-24 overflow-y-auto">
|
<div className="border-t border-[var(--color-border-subtle)] pt-2 max-h-24 overflow-y-auto">
|
||||||
<div className="text-[10px] font-medium text-[var(--color-text-dim)] mb-1">History</div>
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="text-[10px] font-medium text-[var(--color-text-dim)]">History</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setLog([])}
|
||||||
|
className="text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-danger)] cursor-pointer"
|
||||||
|
title="Clear history"
|
||||||
|
>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{log.map((l, i) => (
|
{log.map((l, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 text-[10px] py-0.5">
|
<div key={i} className="flex items-center gap-2 text-[10px] py-0.5">
|
||||||
<span className="text-[var(--color-text-dim)]">{l.tableName}</span>
|
<span className="text-[var(--color-text-dim)]">{l.tableName}</span>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||||
import { addToLore } from "../lib/lore";
|
import { addToLore } from "../lib/lore";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addGeneration, type Generation } from "../lib/generations";
|
import { addGeneration, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
prefill?: Generation | null;
|
prefill?: Generation | null;
|
||||||
@@ -12,10 +13,13 @@ interface Props {
|
|||||||
|
|
||||||
export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [summary, setSummary] = useState("");
|
const [summary, setSummary] = usePersistentState<string>("session.summary", "");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
// ponytail: streaming state — tokens arrive via the Channel and append to
|
||||||
const [entries, setEntries] = useState<{ text: string; time: string }[]>([]);
|
// the summary live with a gold cursor. `gotFirstToken` flips the thinking
|
||||||
|
// dots to streaming text.
|
||||||
|
const [gotFirstToken, setGotFirstToken] = useState(false);
|
||||||
|
const [entries, setEntries] = usePersistentState<{ text: string; time: string }[]>("session.entries", []);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => {
|
usePrefillEffect(prefill ?? null, "session", () => onPrefillConsumed?.(), (g) => {
|
||||||
@@ -32,31 +36,65 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
setNotes("");
|
setNotes("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: export the session as a Markdown file the DM can share or
|
||||||
|
// paste into Discord. Reverses the entry list so it reads chronologically.
|
||||||
|
function exportMarkdown() {
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
const lines = [...entries].reverse().map((e) => `- [${e.time}] ${e.text}`);
|
||||||
|
const md = `# Session ${new Date().toLocaleDateString()}\n\n## Notes\n\n${lines.join("\\n")}\n\n${summary ? `## AI Summary\n\n${summary}` : ""}`;
|
||||||
|
const blob = new Blob([md], { type: "text/markdown" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `session-${new Date().toISOString().slice(0, 10)}.md`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
addToast("Exported session to Markdown", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEntry(i: number) {
|
||||||
|
setEntries((prev) => prev.filter((_, j) => j !== i));
|
||||||
|
}
|
||||||
|
|
||||||
async function summarize() {
|
async function summarize() {
|
||||||
if (entries.length === 0) return;
|
if (entries.length === 0) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setSummary("");
|
||||||
|
setGotFirstToken(false);
|
||||||
|
const sessionText = entries.map((e) => `[${e.time}] ${e.text}`).join("\n");
|
||||||
|
// ponytail: stream tokens via the existing generate_stream Channel so the
|
||||||
|
// DM sees the summary appear word-by-word instead of staring at a button.
|
||||||
|
const channel = new Channel<{ type: string; data: string }>();
|
||||||
|
let streamed = "";
|
||||||
|
channel.onmessage = (event) => {
|
||||||
|
if (event.type === "token") {
|
||||||
|
setGotFirstToken(true);
|
||||||
|
streamed = streamed + event.data;
|
||||||
|
setSummary(streamed);
|
||||||
|
} else if (event.type === "done") {
|
||||||
|
setGotFirstToken(true);
|
||||||
|
streamed = event.data;
|
||||||
|
setSummary(streamed);
|
||||||
|
} else if (event.type === "error") {
|
||||||
|
addToast(`Summary failed: ${event.data}`, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const sessionText = entries
|
await invoke("generate_stream", {
|
||||||
.map((e) => `[${e.time}] ${e.text}`)
|
|
||||||
.join("\n");
|
|
||||||
const result = await invoke<string>("generate", {
|
|
||||||
req: {
|
req: {
|
||||||
prompt: `Summarize the following D&D session notes and suggest 2-3 hooks for the next session:\n\n${sessionText}`,
|
prompt: `Summarize the following D&D session notes and suggest 2-3 hooks for the next session:\n\n${sessionText}`,
|
||||||
system: "You are a helpful D&D session summarizer. Be concise and creative.",
|
system: "You are a helpful D&D session summarizer. Be concise and creative.",
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 512,
|
max_tokens: 512,
|
||||||
},
|
},
|
||||||
|
channel,
|
||||||
});
|
});
|
||||||
setSummary(result);
|
|
||||||
addToast("Session summary generated", "success");
|
addToast("Session summary generated", "success");
|
||||||
// ponytail: persist so the DM can re-open past summaries. The data is
|
const result = streamed;
|
||||||
// raw text here, not JSON, so the extractTitle() fallback is fine.
|
|
||||||
const title = `Session ${new Date().toLocaleDateString()}`;
|
const title = `Session ${new Date().toLocaleDateString()}`;
|
||||||
void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` });
|
void addGeneration({ kind: "session", title, data: result, source: `${entries.length} notes` });
|
||||||
void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
void addToLore("Session Summary", `Session summary:\n${result}\n\nNotes:\n${sessionText}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
|
||||||
addToast(`Summary failed: ${e}`, "error");
|
addToast(`Summary failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -92,14 +130,29 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
>
|
>
|
||||||
{loading ? "Summarizing…" : "✨ AI Summary"}
|
{loading ? "Summarizing…" : "✨ AI Summary"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={exportMarkdown}
|
||||||
|
disabled={entries.length === 0}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-3 py-1.5 text-xs hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
title="Export notes + summary to a Markdown file"
|
||||||
|
>
|
||||||
|
⬇ Export
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{entries.map((e, i) => (
|
{entries.map((e, i) => (
|
||||||
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)]">
|
<div key={i} className="flex gap-2 py-1 border-b border-[var(--color-border-subtle)] group">
|
||||||
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
|
<span className="text-[var(--color-text-dim)] text-xs font-mono shrink-0">
|
||||||
{e.time}
|
{e.time}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[var(--color-text-primary)] text-xs">{e.text}</span>
|
<span className="text-[var(--color-text-primary)] text-xs flex-1">{e.text}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeEntry(i)}
|
||||||
|
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-[10px] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Delete entry"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -108,13 +161,16 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
{/* AI Summary column */}
|
{/* AI Summary column */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<h3 className="text-xs font-medium text-[var(--color-text-secondary)] mb-2">AI Summary</h3>
|
<h3 className="text-xs font-medium text-[var(--color-text-secondary)] mb-2">AI Summary</h3>
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-2 text-[var(--color-danger)] text-xs mb-2">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto">
|
<div className="flex-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 text-[var(--color-text-primary)] text-sm overflow-y-auto">
|
||||||
{summary || (
|
{loading && !gotFirstToken ? (
|
||||||
|
<span className="text-[var(--color-gold-bright)] tracking-widest">
|
||||||
|
<span className="thinking-dot">•</span>
|
||||||
|
<span className="thinking-dot">•</span>
|
||||||
|
<span className="thinking-dot">•</span>
|
||||||
|
</span>
|
||||||
|
) : summary ? (
|
||||||
|
<span>{summary}{loading && <span className="text-[var(--color-gold-bright)]">▍</span>}</span>
|
||||||
|
) : (
|
||||||
<span className="text-[var(--color-text-dim)] italic">
|
<span className="text-[var(--color-text-dim)] italic">
|
||||||
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
|
Add session notes and click "AI Summary" to generate a recap and next-session hooks.
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export function SettingsPanel() {
|
|||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [conn, setConn] = useState<{ ok: boolean; models: string[]; error: string } | null>(null);
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
// ponytail: auto-load on mount so users don't see a gate before the form.
|
// ponytail: auto-load on mount so users don't see a gate before the form.
|
||||||
@@ -47,6 +49,59 @@ export function SettingsPanel() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: probe the configured endpoint for a model list. Ollama uses
|
||||||
|
// /api/tags, OpenAI-compatible uses /v1/models. The Rust side branches.
|
||||||
|
async function testConnection() {
|
||||||
|
if (!config) return;
|
||||||
|
setTesting(true);
|
||||||
|
setConn(null);
|
||||||
|
try {
|
||||||
|
// Save first so the backend uses the latest URL/key.
|
||||||
|
await invoke("set_llm_config", { config });
|
||||||
|
const result = await invoke<{ ok: boolean; models: string[]; error: string }>("test_connection");
|
||||||
|
setConn(result);
|
||||||
|
if (result.ok) addToast(`Connected — ${result.models.length} models`, "success");
|
||||||
|
else addToast(`Connection failed: ${result.error}`, "error");
|
||||||
|
} catch (e) {
|
||||||
|
setConn({ ok: false, models: [], error: String(e) });
|
||||||
|
addToast(`Connection failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: provider presets fill in the API URL pattern + default model.
|
||||||
|
const PRESETS: { label: string; url: string; model: string; key?: boolean }[] = [
|
||||||
|
{ label: "Ollama", url: "http://localhost:11434", model: "llama3.2" },
|
||||||
|
{ label: "LM Studio", url: "http://localhost:1234/v1", model: "local-model" },
|
||||||
|
{ label: "OpenAI", url: "https://api.openai.com", model: "gpt-4o-mini", key: true },
|
||||||
|
{ label: "Custom", url: "", model: "" },
|
||||||
|
];
|
||||||
|
function applyPreset(p: { url: string; model: string }) {
|
||||||
|
if (!config) return;
|
||||||
|
setConfig({ ...config, api_url: p.url, model: p.model });
|
||||||
|
setConn(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: reset to the backend's default config. The Rust Default impl
|
||||||
|
// is the single source of truth; we just set an empty config and let the
|
||||||
|
// backend re-default on save — but we can't send a partial, so mirror the
|
||||||
|
// known defaults here and toast.
|
||||||
|
const DEFAULTS: LlmConfig = {
|
||||||
|
api_url: "http://localhost:11434",
|
||||||
|
api_key: "",
|
||||||
|
model: "llama3.2",
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 512,
|
||||||
|
top_p: 0.9,
|
||||||
|
image_model: "x/flux2-klein:4b",
|
||||||
|
embed_model: "nomic-embed-text",
|
||||||
|
};
|
||||||
|
function resetDefaults() {
|
||||||
|
setConfig({ ...DEFAULTS });
|
||||||
|
setConn(null);
|
||||||
|
addToast("Reset to defaults — click Save to apply", "info");
|
||||||
|
}
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||||
@@ -60,6 +115,28 @@ export function SettingsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 text-sm">
|
<div className="flex flex-col gap-4 text-sm">
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">LLM Connection</h3>
|
||||||
|
{/* Provider presets */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">Provider</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(p)}
|
||||||
|
className={`rounded-lg px-2.5 py-1 text-xs border transition-colors cursor-pointer ${
|
||||||
|
config.api_url === p.url
|
||||||
|
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||||
|
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
API URL
|
API URL
|
||||||
@@ -104,9 +181,51 @@ export function SettingsPanel() {
|
|||||||
value={config.model}
|
value={config.model}
|
||||||
onChange={(e) => setConfig({ ...config, model: e.target.value })}
|
onChange={(e) => setConfig({ ...config, model: e.target.value })}
|
||||||
placeholder="llama3.2"
|
placeholder="llama3.2"
|
||||||
|
list="model-list"
|
||||||
/>
|
/>
|
||||||
|
<datalist id="model-list">
|
||||||
|
{conn?.models.map((m) => (
|
||||||
|
<option key={m} value={m} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Connection test */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={testing}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-1.5 text-xs font-medium text-[var(--color-text-secondary)] hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testing ? "Testing…" : "Test connection"}
|
||||||
|
</button>
|
||||||
|
{conn && (
|
||||||
|
<span className={`text-xs ${conn.ok ? "text-[var(--color-success)]" : "text-[var(--color-danger)]"}`}>
|
||||||
|
{conn.ok ? `✓ ${conn.models.length} models` : `✕ ${conn.error}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{conn?.ok && conn.models.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
|
||||||
|
{conn.models.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfig({ ...config, model: m })}
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] border cursor-pointer transition-colors ${
|
||||||
|
config.model === m
|
||||||
|
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||||
|
: "bg-[var(--color-bg-deep)] border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Image Model</h3>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
Image model
|
Image model
|
||||||
@@ -119,6 +238,7 @@ export function SettingsPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Embedding Model (Lore RAG)</h3>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
Embedding model (Lore RAG)
|
Embedding model (Lore RAG)
|
||||||
@@ -131,6 +251,7 @@ export function SettingsPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-xs font-semibold uppercase tracking-wider border-b border-[var(--color-border-subtle)] pb-1 mb-1">Text Model</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
@@ -166,13 +287,21 @@ export function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={saveConfig}
|
<button
|
||||||
disabled={loading}
|
onClick={saveConfig}
|
||||||
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
disabled={loading}
|
||||||
>
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
||||||
{saved ? "✓ Saved" : loading ? "Saving…" : "Save Settings"}
|
>
|
||||||
</button>
|
{saved ? "✓ Saved" : loading ? "Saving…" : "Save Settings"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetDefaults}
|
||||||
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] text-[var(--color-text-dim)] px-4 py-2 text-sm hover:text-[var(--color-danger)] hover:border-[var(--color-danger)] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
import { bus, Events, type PlayAmbientPayload } from "../lib/bus";
|
||||||
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
|
||||||
// Ambient sound types with their oscillator configs
|
// Ambient sound types with their oscillator configs
|
||||||
const AMBIENT_SOUNDS = [
|
const AMBIENT_SOUNDS = [
|
||||||
@@ -27,7 +29,8 @@ const SFX_SOUNDS = [
|
|||||||
|
|
||||||
export function Soundboard() {
|
export function Soundboard() {
|
||||||
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
|
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
|
||||||
const [volume, setVolume] = useState(0.5);
|
const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5);
|
||||||
|
const [muted, setMuted] = usePersistentState<boolean>("sound.muted", false);
|
||||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
@@ -39,6 +42,45 @@ export function Soundboard() {
|
|||||||
return audioCtxRef.current;
|
return audioCtxRef.current;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ponytail: pre-warm the AudioContext on the first user gesture so the
|
||||||
|
// first ambient doesn't silently fail to autoplay (browser policy).
|
||||||
|
useEffect(() => {
|
||||||
|
const warm = () => {
|
||||||
|
const ctx = getAudioCtx();
|
||||||
|
if (ctx.state === "suspended") void ctx.resume();
|
||||||
|
window.removeEventListener("pointerdown", warm);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointerdown", warm);
|
||||||
|
return () => window.removeEventListener("pointerdown", warm);
|
||||||
|
}, [getAudioCtx]);
|
||||||
|
|
||||||
|
// ponytail: master mute — ramps every active ambient to 0 (or back to
|
||||||
|
// volume) on toggle, and gates SFX. Cheaper than threading a master gain
|
||||||
|
// node through every code path.
|
||||||
|
useEffect(() => {
|
||||||
|
const ctx = audioCtxRef.current;
|
||||||
|
if (!ctx) return;
|
||||||
|
nodesRef.current.forEach((nodes) => {
|
||||||
|
try {
|
||||||
|
nodes.gain.gain.linearRampToValueAtTime(muted ? 0 : volume, ctx.currentTime + 0.2);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}, [muted, volume]);
|
||||||
|
|
||||||
|
// ponytail: listen for EncounterBuilder's "send to soundboard" event and
|
||||||
|
// flip the matching ambient on. Mirrors the AddCombatants flow on Initiative.
|
||||||
|
useEffect(() => {
|
||||||
|
return bus.on<PlayAmbientPayload>(Events.PlayAmbient, (payload) => {
|
||||||
|
setActiveAmbients((prev) => {
|
||||||
|
if (prev.has(payload.id)) return prev; // already playing
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(payload.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
addToast(`Ambient on: ${payload.source}`, "info");
|
||||||
|
});
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
const toggleAmbient = useCallback((id: string) => {
|
const toggleAmbient = useCallback((id: string) => {
|
||||||
setActiveAmbients((prev) => {
|
setActiveAmbients((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -104,6 +146,7 @@ export function Soundboard() {
|
|||||||
}, [volume, getAudioCtx]);
|
}, [volume, getAudioCtx]);
|
||||||
|
|
||||||
const playSfx = useCallback((id: string) => {
|
const playSfx = useCallback((id: string) => {
|
||||||
|
if (muted) return; // ponytail: master mute gates one-shot SFX too.
|
||||||
const ctx = getAudioCtx();
|
const ctx = getAudioCtx();
|
||||||
const sfx = SFX_SOUNDS.find((s) => s.id === id);
|
const sfx = SFX_SOUNDS.find((s) => s.id === id);
|
||||||
if (!sfx) return;
|
if (!sfx) return;
|
||||||
@@ -238,11 +281,11 @@ export function Soundboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addToast(`${sfx.icon} ${sfx.label}`, "info");
|
addToast(`${sfx.icon} ${sfx.label}`, "info");
|
||||||
}, [volume, getAudioCtx, addToast]);
|
}, [volume, getAudioCtx, addToast, muted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Volume */}
|
{/* Volume + master mute */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium w-16">Volume</span>
|
<span className="text-[var(--color-text-secondary)] text-xs font-medium w-16">Volume</span>
|
||||||
<input
|
<input
|
||||||
@@ -257,6 +300,18 @@ export function Soundboard() {
|
|||||||
<span className="text-[var(--color-text-dim)] text-xs font-mono w-8 text-right">
|
<span className="text-[var(--color-text-dim)] text-xs font-mono w-8 text-right">
|
||||||
{Math.round(volume * 100)}%
|
{Math.round(volume * 100)}%
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setMuted((m) => !m)}
|
||||||
|
className={`rounded-lg px-2 py-1 text-xs cursor-pointer border transition-colors ${
|
||||||
|
muted
|
||||||
|
? "bg-[var(--color-danger)]/20 border-[var(--color-danger)]/50 text-[var(--color-danger)]"
|
||||||
|
: "border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
}`}
|
||||||
|
title={muted ? "Unmute" : "Mute all"}
|
||||||
|
aria-label={muted ? "Unmute" : "Mute all"}
|
||||||
|
>
|
||||||
|
{muted ? "🔇" : "🔊"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ambient sounds */}
|
{/* Ambient sounds */}
|
||||||
@@ -304,24 +359,23 @@ export function Soundboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stop all */}
|
{/* Stop all — always visible so the DM never hunts for it. */}
|
||||||
{activeAmbients.size > 0 && (
|
<button
|
||||||
<button
|
onClick={() => {
|
||||||
onClick={() => {
|
nodesRef.current.forEach((nodes) => {
|
||||||
nodesRef.current.forEach((nodes) => {
|
try {
|
||||||
try {
|
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600);
|
||||||
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600);
|
} catch {}
|
||||||
} catch {}
|
});
|
||||||
});
|
nodesRef.current.clear();
|
||||||
nodesRef.current.clear();
|
setActiveAmbients(new Set());
|
||||||
setActiveAmbients(new Set());
|
}}
|
||||||
}}
|
disabled={activeAmbients.size === 0}
|
||||||
className="rounded-lg bg-[var(--color-danger)]/20 border border-[var(--color-danger)]/40 text-[var(--color-danger)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-danger)]/30 transition-colors cursor-pointer"
|
className="rounded-lg bg-[var(--color-danger)]/20 border border-[var(--color-danger)]/40 text-[var(--color-danger)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-danger)]/30 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
⏹ Stop All
|
⏹ Stop All{activeAmbients.size > 0 ? ` (${activeAmbients.size})` : ""}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+17
-10
@@ -6,25 +6,32 @@ interface Toast {
|
|||||||
id: string;
|
id: string;
|
||||||
message: string;
|
message: string;
|
||||||
type: ToastType;
|
type: ToastType;
|
||||||
|
sticky?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastStore {
|
interface ToastStore {
|
||||||
toasts: Toast[];
|
toasts: Toast[];
|
||||||
addToast: (message: string, type?: ToastType) => void;
|
addToast: (message: string, type?: ToastType, opts?: { sticky?: boolean }) => void;
|
||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextId = 0;
|
|
||||||
|
|
||||||
export const useToast = create<ToastStore>((set) => ({
|
export const useToast = create<ToastStore>((set) => ({
|
||||||
toasts: [],
|
toasts: [],
|
||||||
addToast: (message, type = "info") => {
|
addToast: (message, type = "info", opts = {}) => {
|
||||||
const id = String(nextId++);
|
// ponytail: crypto.randomUUID for stable unique ids (the old module
|
||||||
set((state) => ({ toasts: [...state.toasts, { id, message, type }] }));
|
// counter was shared across imports and brittle in tests).
|
||||||
// Auto-dismiss after 4 seconds
|
const id = crypto.randomUUID();
|
||||||
setTimeout(() => {
|
set((state) => {
|
||||||
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
// ponytail: cap visible at 3 — older ones get pushed off so a noisy
|
||||||
}, 4000);
|
// session doesn't stack a wall of toasts over the content.
|
||||||
|
const next = [...state.toasts, { id, message, type, sticky: opts.sticky }];
|
||||||
|
return { toasts: next.slice(-3) };
|
||||||
|
});
|
||||||
|
if (!opts.sticky) {
|
||||||
|
setTimeout(() => {
|
||||||
|
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
removeToast: (id) => {
|
removeToast: (id) => {
|
||||||
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { addToLore } from "../lib/lore";
|
|||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
|
||||||
interface WorldResponse {
|
interface WorldResponse {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -21,12 +22,18 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
||||||
const [theme, setTheme] = useState("high fantasy");
|
// ponytail: persist theme + last world so a reload keeps the DM's work.
|
||||||
const [world, setWorld] = useState<WorldResponse | null>(null);
|
const [theme, setTheme] = usePersistentState<string>("world.theme", "high fantasy");
|
||||||
|
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
// ponytail: genre presets as a quick-pick row.
|
||||||
|
const THEME_PRESETS = [
|
||||||
|
"high fantasy", "dark fantasy", "sword & sorcery",
|
||||||
|
"steampunk", "post-apocalyptic", "horror", "sci-fantasy",
|
||||||
|
];
|
||||||
|
|
||||||
usePrefillEffect(prefill ?? null, "world", () => onPrefillConsumed?.(), (g) => {
|
usePrefillEffect(prefill ?? null, "world", () => onPrefillConsumed?.(), (g) => {
|
||||||
const parsed = parseLlmJson<WorldResponse>(g.data);
|
const parsed = parseLlmJson<WorldResponse>(g.data);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
@@ -37,7 +44,6 @@ export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
setWorld(null);
|
setWorld(null);
|
||||||
try {
|
try {
|
||||||
const result = await invoke<string>("generate", {
|
const result = await invoke<string>("generate", {
|
||||||
@@ -75,11 +81,9 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
|
`${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setError("Could not parse LLM response. Try again or check your LLM connection.");
|
|
||||||
addToast("LLM returned an unparseable response", "error");
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
|
||||||
addToast(`World generation failed: ${e}`, "error");
|
addToast(`World generation failed: ${e}`, "error");
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -91,6 +95,22 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
||||||
World Theme
|
World Theme
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-1">
|
||||||
|
{THEME_PRESETS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
className={`rounded-lg px-2.5 py-1 text-xs border transition-colors cursor-pointer ${
|
||||||
|
theme === t
|
||||||
|
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
||||||
|
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
||||||
value={theme}
|
value={theme}
|
||||||
@@ -107,11 +127,6 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-3 text-[var(--color-danger)] text-xs overflow-x-auto">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{world && (
|
{world && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|||||||
+50
-9
@@ -1,7 +1,26 @@
|
|||||||
/* ponytail: Google Fonts CSS2 import — single request, all families/weights.
|
/* ponytail: fonts self-hosted as variable woff2 (latin subset) in public/fonts/
|
||||||
TODO: download woff2 to public/fonts/ and switch to @font-face for true
|
for true offline-first — a desktop app must not depend on Google's CDN. */
|
||||||
offline-first. Until then, this needs network on first run. */
|
@font-face {
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;700&display=swap");
|
font-family: "Cinzel";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/cinzel.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/inter.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrains Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("/fonts/jetbrainsmono.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@@ -39,11 +58,6 @@
|
|||||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Self-Hosted Fonts ─────────────────────────────────────── */
|
|
||||||
/* ponytail: Google Fonts are loaded at the top of this file (must precede
|
|
||||||
Tailwind per CSS spec). TODO: download woff2 to public/fonts/ and switch
|
|
||||||
to @font-face for true offline-first. */
|
|
||||||
|
|
||||||
/* ─── Base Styles ───────────────────────────────────────────── */
|
/* ─── Base Styles ───────────────────────────────────────────── */
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -186,6 +200,33 @@ body,
|
|||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ponytail: shimmer skeleton for image/art loading, and a thinking-dot
|
||||||
|
cycle for LLM waits. Replaces "Generating…" button text for 5–20s waits. */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--color-bg-surface) 25%,
|
||||||
|
var(--color-bg-card-hover) 50%,
|
||||||
|
var(--color-bg-surface) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes thinking {
|
||||||
|
0%, 80%, 100% { opacity: 0.2; }
|
||||||
|
40% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.thinking-dot {
|
||||||
|
animation: thinking 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.thinking-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.thinking-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
/* ponytail: respect users who ask for less motion. framer-motion honors this
|
/* ponytail: respect users who ask for less motion. framer-motion honors this
|
||||||
via its useReducedMotion hook, but the inline keyframes/glass-card transitions
|
via its useReducedMotion hook, but the inline keyframes/glass-card transitions
|
||||||
still need this CSS guard. */
|
still need this CSS guard. */
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const bus = new EventBus();
|
|||||||
|
|
||||||
export const Events = {
|
export const Events = {
|
||||||
AddCombatants: "add-combatants",
|
AddCombatants: "add-combatants",
|
||||||
|
PlayAmbient: "play-ambient",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AddCombatantsPayload = {
|
export type AddCombatantsPayload = {
|
||||||
@@ -50,3 +51,9 @@ export type AddCombatantsPayload = {
|
|||||||
/** Source label, e.g. "Goblin Ambush (Easy)" — shown in the toast. */
|
/** Source label, e.g. "Goblin Ambush (Easy)" — shown in the toast. */
|
||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlayAmbientPayload = {
|
||||||
|
/** Ambient sound id from the Soundboard (rain, wind, fire, …). */
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { LazyStore } from "@tauri-apps/plugin-store";
|
||||||
|
|
||||||
|
// ponytail: one store file for small UI state. Generated content (NPCs,
|
||||||
|
// encounters, etc.) goes through the generations backend instead; this is
|
||||||
|
// only for things that live in component state today (initiative, dice
|
||||||
|
// history, calendar events). Debounced save so a fast dice session doesn't
|
||||||
|
// write on every roll.
|
||||||
|
const store = new LazyStore("dm-pal-state.json");
|
||||||
|
|
||||||
|
function debounce<T extends (...args: never[]) => void>(fn: T, ms: number): T {
|
||||||
|
let t: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
return ((...args: never[]) => {
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
t = setTimeout(() => fn(...args), ms);
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = debounce((key: string, value: unknown) => {
|
||||||
|
void store.set(key, value);
|
||||||
|
void store.save();
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useState that persists to tauri-plugin-store. Loads on mount (returns the
|
||||||
|
* fallback until the async load completes), saves on every change (debounced).
|
||||||
|
* Reload = no more data loss for initiative / dice / calendar.
|
||||||
|
*/
|
||||||
|
export function usePersistentState<T>(
|
||||||
|
key: string,
|
||||||
|
fallback: T,
|
||||||
|
): [T, React.Dispatch<React.SetStateAction<T>>, boolean] {
|
||||||
|
const [value, setValue] = useState<T>(fallback);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const skipSave = useRef(true);
|
||||||
|
|
||||||
|
// Load once on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const stored = await store.get<T>(key);
|
||||||
|
if (alive && stored !== null && stored !== undefined) {
|
||||||
|
setValue(stored);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// store unavailable — keep fallback.
|
||||||
|
}
|
||||||
|
if (alive) setLoaded(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
// Save on change (skip the initial load-induced render).
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipSave.current) {
|
||||||
|
skipSave.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
save(key, value);
|
||||||
|
}, [key, value]);
|
||||||
|
|
||||||
|
return [value, setValue, loaded];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user