working through a review
This commit is contained in:
@@ -1,15 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { usePersistentState } from "../lib/usePersistentState";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
interface DieResult {
|
||||
notation: string;
|
||||
rolls: number[];
|
||||
total: number;
|
||||
modifier: number;
|
||||
advantage?: "adv" | "dis" | null;
|
||||
keptRoll?: number;
|
||||
}
|
||||
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
|
||||
|
||||
const PRESETS = ["d4", "d6", "d8", "d10", "d12", "d20", "d100"];
|
||||
|
||||
@@ -23,38 +15,13 @@ const TEMPLATES: { label: string; die: string }[] = [
|
||||
{ label: "Damage", die: "d8" },
|
||||
];
|
||||
|
||||
type Mode = "normal" | "adv" | "dis";
|
||||
|
||||
function rollDie(sides: number): number {
|
||||
return Math.floor(Math.random() * sides) + 1;
|
||||
}
|
||||
|
||||
function parseNotation(
|
||||
notation: string,
|
||||
): { count: number; sides: number; modifier: number } | null {
|
||||
const match = notation.trim().toLowerCase().match(/^(\d+)?d(\d+)([+-]\d+)?$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
count: parseInt(match[1] || "1"),
|
||||
sides: parseInt(match[2]),
|
||||
modifier: parseInt(match[3] || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
// ponytail: advantage/disadvantage is a 5e concept. We implement it as
|
||||
// "roll twice, keep the (higher|lower) d20", which matches the PHB. Only
|
||||
// meaningful for d20 rolls; for other dice we just roll normally.
|
||||
function applyMode(rolls: number[], sides: number, mode: Mode): { rolls: number[]; kept: number } {
|
||||
if (mode === "normal" || sides !== 20 || rolls.length !== 2) {
|
||||
return { rolls, kept: rolls[0] ?? 0 };
|
||||
}
|
||||
if (mode === "adv") {
|
||||
const hi = Math.max(rolls[0], rolls[1]);
|
||||
return { rolls, kept: hi };
|
||||
}
|
||||
const lo = Math.min(rolls[0], rolls[1]);
|
||||
return { rolls, kept: lo };
|
||||
}
|
||||
// ponytail: parseNotation + applyMode live in src/lib/dice.ts so they ship
|
||||
// with a runnable self-check (scripts/check-dice.ts). The Mode + DieResult
|
||||
// types are imported from there too.
|
||||
|
||||
export function DiceRoller() {
|
||||
const [input, setInput] = usePersistentState<string>("dice.input", "1d20");
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
interface GenBusyPayload {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
// ponytail: a title-bar "something is generating" signal. The Rust generate
|
||||
// commands emit `gen-busy` true at start / false at end; we keep a counter so
|
||||
// concurrent generations (NPC + image at once) balance to zero only when all
|
||||
// finish. Lets the DM navigate away from a 15s image gen and still see it's
|
||||
// working — the only per-card feedback vanishes the moment they switch views.
|
||||
export function GeneratingIndicator() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let count = 0;
|
||||
let unlisten: (() => void) | undefined;
|
||||
let alive = true;
|
||||
|
||||
listen<GenBusyPayload>("gen-busy", (event) => {
|
||||
if (!alive) return;
|
||||
count += event.payload.busy ? 1 : -1;
|
||||
if (count < 0) count = 0; // guard against a stray false with no matching true
|
||||
setBusy(count > 0);
|
||||
}).then((u) => {
|
||||
unlisten = u;
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!busy) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tauri-no-drag
|
||||
className="ml-2 flex items-center gap-1.5 text-[var(--color-gold-bright)] text-xs select-none"
|
||||
title="A generation is in progress"
|
||||
aria-label="Generation in progress"
|
||||
>
|
||||
<Sparkles size={12} className="thinking-dot" />
|
||||
<span className="hidden sm:inline thinking-dot">Generating…</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export function Greet() {
|
||||
const [greeting, setGreeting] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
|
||||
async function greet() {
|
||||
// Learn more about Tauri commands at https://v2.tauri.app/develop/calling-rust/
|
||||
setGreeting(await invoke("greet", { name }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<form
|
||||
className="flex gap-2 w-full max-w-md"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
greet();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="flex-1 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 font-[var(--font-sans)]"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter a name…"
|
||||
value={name}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Greet
|
||||
</button>
|
||||
</form>
|
||||
{greeting && (
|
||||
<p className="text-[var(--color-text-secondary)] text-sm font-[var(--font-mono)]">
|
||||
{greeting}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
History as HistoryIcon,
|
||||
@@ -44,6 +44,33 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const { addToast } = useToast();
|
||||
|
||||
// ponytail: gallery thumbnails — fetch each image generation's data URL on
|
||||
// demand when the image filter is active. N queries against local SQLite
|
||||
// is fine for a campaign's worth of images; add a list-with-data command
|
||||
// if it ever gets slow. Ref is the source of truth; state mirrors it to
|
||||
// trigger re-renders when a thumbnail lands.
|
||||
const thumbsRef = useRef<Map<number, string>>(new Map());
|
||||
const [thumbs, setThumbs] = useState<Map<number, string>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (activeKind !== "image") return;
|
||||
const missing = summaries
|
||||
.filter((s) => s.kind === "image" && !thumbsRef.current.has(s.id))
|
||||
.map((s) => s.id);
|
||||
if (missing.length === 0) return;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
for (const id of missing) {
|
||||
try {
|
||||
const g = await getGeneration(id);
|
||||
if (g && g.kind === "image" && g.data) thumbsRef.current.set(id, g.data);
|
||||
} catch {}
|
||||
}
|
||||
if (alive) setThumbs(new Map(thumbsRef.current));
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [activeKind, summaries]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -101,6 +128,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
try {
|
||||
await deleteGeneration(id);
|
||||
setSummaries((prev) => prev.filter((s) => s.id !== id));
|
||||
thumbsRef.current.delete(id);
|
||||
setThumbs(new Map(thumbsRef.current));
|
||||
if (selected?.id === id) setSelected(null);
|
||||
} catch (e) {
|
||||
addToast(`Delete failed: ${e}`, "error");
|
||||
@@ -114,6 +143,8 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
const n = await clearAllGenerations();
|
||||
addToast(`Cleared ${n} generation${n === 1 ? "" : "s"}`, "success");
|
||||
setSummaries([]);
|
||||
thumbsRef.current.clear();
|
||||
setThumbs(new Map(thumbsRef.current));
|
||||
setSelected(null);
|
||||
setConfirmClear(false);
|
||||
} catch (e) {
|
||||
@@ -232,29 +263,62 @@ export function HistoryView({ onRehydrate }: HistoryViewProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSelect(s.id)}
|
||||
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
|
||||
selected?.id === s.id
|
||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||
: "hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
||||
{KIND_LABELS[s.kind]}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
|
||||
{relativeTime(s.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-primary)] truncate">
|
||||
{s.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{activeKind === "image" ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{filtered.map((s) => {
|
||||
const src = thumbs.get(s.id);
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSelect(s.id)}
|
||||
className={`group relative rounded-lg overflow-hidden border bg-[var(--color-bg-deep)] aspect-square cursor-pointer transition-colors ${
|
||||
selected?.id === s.id
|
||||
? "border-[var(--color-gold-bright)]"
|
||||
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
|
||||
}`}
|
||||
title={s.title}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt={s.title} className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Loader2 size={16} className="animate-spin text-[var(--color-text-dim)]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-1.5 py-1">
|
||||
<span className="text-[10px] text-white/90 truncate block">{s.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filtered.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => onSelect(s.id)}
|
||||
className={`w-full text-left rounded-lg px-2.5 py-1.5 mb-1 cursor-pointer transition-colors ${
|
||||
selected?.id === s.id
|
||||
? "bg-[var(--color-bg-card)] text-[var(--color-gold-bright)]"
|
||||
: "hover:bg-[var(--color-bg-card)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)] shrink-0">
|
||||
{KIND_LABELS[s.kind]}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-dim)] shrink-0">
|
||||
{relativeTime(s.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-[var(--color-text-primary)] truncate">
|
||||
{s.title}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
const [variants, setVariants] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [variant, setVariant] = useState(0);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
@@ -31,6 +32,7 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
if (!prompt.trim()) return;
|
||||
setLoading(true);
|
||||
setDataUrl(null);
|
||||
setVariants([]);
|
||||
try {
|
||||
// ponytail: append a variant tag to bust the backend's prompt-hash cache
|
||||
// so "regenerate" actually produces a new image instead of the cached one.
|
||||
@@ -51,6 +53,44 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// ponytail: batch = 4 concurrent generates with distinct variation tags so
|
||||
// the backend cache yields 4 different images. Ollama serializes them
|
||||
// server-side anyway, so concurrency is just cleaner code than a loop with
|
||||
// awaits. Each variant is persisted so the gallery gets all four.
|
||||
async function generateBatch() {
|
||||
if (!prompt.trim()) return;
|
||||
setLoading(true);
|
||||
setVariants([]);
|
||||
setDataUrl(null);
|
||||
setUnsupported(false);
|
||||
const tags = [1, 2, 3, 4];
|
||||
const results = await Promise.allSettled(
|
||||
tags.map((t) =>
|
||||
invoke<string>("generate_image", {
|
||||
req: { prompt: `${prompt}\n\n(variation ${t})`, model: model.trim() || null },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const ok: string[] = [];
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled" && r.value) {
|
||||
ok.push(r.value);
|
||||
void addGeneration({ kind: "image", title: prompt, data: r.value, source: model.trim() || DEFAULT_MODEL });
|
||||
}
|
||||
}
|
||||
if (ok.length === 0) {
|
||||
const firstErr = results.find((r) => r.status === "rejected");
|
||||
const msg = firstErr ? String((firstErr as PromiseRejectedResult).reason) : "";
|
||||
if (msg.toLowerCase().includes("macos-only")) setUnsupported(true);
|
||||
addToast(`Batch failed: ${msg || "no images returned"}`, "error");
|
||||
} else {
|
||||
setVariants(ok);
|
||||
setDataUrl(ok[0]);
|
||||
addToast(`Generated ${ok.length} variants`, "success");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function regenerate() {
|
||||
setVariant((v) => v + 1);
|
||||
// run after state update flushes
|
||||
@@ -90,6 +130,14 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
>
|
||||
{loading ? "✨ Generating…" : "✨ Generate Image"}
|
||||
</button>
|
||||
<button
|
||||
onClick={generateBatch}
|
||||
disabled={loading || !prompt.trim()}
|
||||
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-text-secondary)] px-3 py-2 text-sm hover:border-[var(--color-gold-bright)] hover:text-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="Generate 4 variants and pick the best"
|
||||
>
|
||||
{loading ? "…" : "4×"}
|
||||
</button>
|
||||
{dataUrl && !loading && (
|
||||
<button
|
||||
onClick={regenerate}
|
||||
@@ -115,6 +163,28 @@ export function ImageGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
||||
)}
|
||||
|
||||
|
||||
{variants.length > 1 && !loading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium">Variants — click to select</span>
|
||||
<div className="grid grid-cols-2 gap-2 w-full max-w-md mx-auto">
|
||||
{variants.map((v, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setDataUrl(v)}
|
||||
className={`rounded-lg overflow-hidden border aspect-square cursor-pointer transition-colors ${
|
||||
dataUrl === v
|
||||
? "border-[var(--color-gold-bright)]"
|
||||
: "border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)]"
|
||||
}`}
|
||||
title={`Variant ${i + 1}`}
|
||||
>
|
||||
<img src={v} alt={`Variant ${i + 1}`} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dataUrl && !loading && (
|
||||
<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)]">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Trash2, ChevronDown, ChevronRight, FileUp } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Trash2, ChevronDown, ChevronRight, FileUp, FolderOpen } from "lucide-react";
|
||||
import { useToast } from "./Toast";
|
||||
import { addToLore } from "../lib/lore";
|
||||
|
||||
@@ -25,6 +26,7 @@ export function LorePanel() {
|
||||
const [text, setText] = useState("");
|
||||
const [sources, setSources] = useState<RagSource[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [addingDir, setAddingDir] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -117,6 +119,34 @@ export function LorePanel() {
|
||||
if (files.length) addToast(`Indexing ${files.length} file${files.length === 1 ? "" : "s"}…`, "info");
|
||||
}
|
||||
|
||||
// ponytail: pick a folder and let Rust walk it (recursive, .md/.txt only).
|
||||
// Doing the walk in Rust sidesteps the webview fs scope — an external
|
||||
// world-bible folder needs no fs permission grant this way.
|
||||
async function addDirectory() {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (!selected || typeof selected !== "string") return;
|
||||
setAddingDir(true);
|
||||
try {
|
||||
const report = await invoke<{ files: number; chunks: number; skipped: string[] }>(
|
||||
"rag_add_directory",
|
||||
{ req: { path: selected } },
|
||||
);
|
||||
await loadSources();
|
||||
if (report.files === 0) {
|
||||
addToast("No .md / .txt files found in that folder", "info");
|
||||
} else {
|
||||
addToast(
|
||||
`Indexed ${report.files} file${report.files === 1 ? "" : "s"} · ${report.chunks} chunks`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
for (const s of report.skipped) console.warn("lore dir import skipped:", s);
|
||||
} catch (e) {
|
||||
addToast(`Folder import failed: ${e}`, "error");
|
||||
}
|
||||
setAddingDir(false);
|
||||
}
|
||||
|
||||
async function search() {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
@@ -168,6 +198,16 @@ export function LorePanel() {
|
||||
onChange={onFiles}
|
||||
/>
|
||||
</label>
|
||||
{/* ponytail: directory import — walks every .md/.txt recursively in
|
||||
Rust (no webview fs-scope needed for an external folder). */}
|
||||
<button
|
||||
onClick={addDirectory}
|
||||
disabled={addingDir}
|
||||
className="flex items-center gap-2 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-sm text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{addingDir ? "Indexing…" : "Add folder…"}
|
||||
</button>
|
||||
{msg && <span className="text-xs text-[var(--color-text-secondary)]">{msg}</span>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { navItems } from "../App";
|
||||
|
||||
interface ShortcutHelpProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ponytail: a single source of truth for the global shortcuts, shown via the
|
||||
// `?` key. Tool-local shortcuts (space-to-roll in Dice) live in their tool's
|
||||
// own help text — this is only the app-shell set the DM can't otherwise
|
||||
// discover because the rail is icon-only.
|
||||
const GLOBAL: { keys: string; label: string }[] = [
|
||||
{ keys: "⌘K", label: "Command palette — jump to any tool" },
|
||||
{ keys: "⌘H", label: "History — re-open a past generation" },
|
||||
{ keys: "⌘,", label: "Settings" },
|
||||
{ keys: "Esc", label: "Close palette / overlay" },
|
||||
{ keys: "?", label: "This help" },
|
||||
];
|
||||
|
||||
export function ShortcutHelp({ open, onClose }: ShortcutHelpProps) {
|
||||
if (!open) return null;
|
||||
|
||||
// ponytail: digit shortcuts come from navItems (single source of truth), so
|
||||
// this list can't drift from the rail. Only items with a shortcut show.
|
||||
const digits = navItems.filter((n) => n.shortcut);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-start justify-center pt-24 px-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Keyboard shortcuts"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40" aria-hidden="true" />
|
||||
<div
|
||||
className="relative w-full max-w-md glass-card p-4 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Keyboard size={16} className="text-[var(--color-gold-bright)]" />
|
||||
<h2 className="font-heading text-[var(--color-gold-bright)] text-sm font-semibold tracking-wide">
|
||||
Keyboard shortcuts
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Global shortcuts */}
|
||||
<ul className="flex flex-col gap-1.5 mb-3">
|
||||
{GLOBAL.map((s) => (
|
||||
<li key={s.keys} className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-[var(--color-text-secondary)]">{s.label}</span>
|
||||
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)] shrink-0">
|
||||
{s.keys}
|
||||
</kbd>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Digit shortcuts — derived from the rail so they can't drift. */}
|
||||
{digits.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-[var(--color-border-subtle)] pt-2 mb-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-dim)]">
|
||||
Jump to tool
|
||||
</span>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
{digits.map((n) => (
|
||||
<li key={n.view} className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-[var(--color-text-secondary)] truncate">{n.label}</span>
|
||||
<kbd className="px-1.5 py-0.5 rounded border border-[var(--color-border-subtle)] bg-[var(--color-bg-deep)] text-[10px] font-mono text-[var(--color-text-dim)] shrink-0">
|
||||
{n.shortcut}
|
||||
</kbd>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-2 border-t border-[var(--color-border-subtle)] text-[10px] text-[var(--color-text-dim)]">
|
||||
<span>In a tool? Each one lists its own shortcuts inline.</span>
|
||||
<span>esc to close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+141
-63
@@ -27,10 +27,16 @@ const SFX_SOUNDS = [
|
||||
{ id: "footsteps", label: "Steps", icon: "👣" },
|
||||
];
|
||||
|
||||
type Scene = { name: string; ambients: string[] };
|
||||
|
||||
export function Soundboard() {
|
||||
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
|
||||
const [volume, setVolume] = usePersistentState<number>("sound.volume", 0.5);
|
||||
const [muted, setMuted] = usePersistentState<boolean>("sound.muted", false);
|
||||
// ponytail: scenes store only the set of ambient ids + a name; per-scene
|
||||
// volume is deferred (global volume is enough until a DM actually asks).
|
||||
const [scenes, setScenes] = usePersistentState<Scene[]>("sound.scenes", []);
|
||||
const [newSceneName, setNewSceneName] = useState("");
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
||||
const { addToast } = useToast();
|
||||
@@ -81,69 +87,89 @@ export function Soundboard() {
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
// Shared start/stop so scenes and the toggle button route through one path.
|
||||
const startAmbient = useCallback((id: string) => {
|
||||
const sound = AMBIENT_SOUNDS.find((s) => s.id === id);
|
||||
if (!sound) return;
|
||||
const ctx = getAudioCtx();
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.value = 0; // Start silent, fade in
|
||||
gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1);
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = sound.filterFreq;
|
||||
|
||||
let source: OscillatorNode | AudioBufferSourceNode;
|
||||
if (sound.type === "brown") {
|
||||
// Brown noise via buffer
|
||||
const bufferSize = ctx.sampleRate * 2;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
let last = 0;
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
data[i] = (last + 0.02 * white) / 1.02;
|
||||
last = data[i];
|
||||
data[i] *= 3.5; // Normalize
|
||||
}
|
||||
source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = true;
|
||||
} else {
|
||||
source = ctx.createOscillator();
|
||||
source.type = sound.type;
|
||||
source.frequency.value = sound.freq;
|
||||
}
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
source.start();
|
||||
nodesRef.current.set(id, { source, gain: gainNode, filter });
|
||||
}, [volume, getAudioCtx]);
|
||||
|
||||
const stopAmbient = useCallback((id: string) => {
|
||||
const nodes = nodesRef.current.get(id);
|
||||
if (!nodes) return;
|
||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||
setTimeout(() => {
|
||||
try { nodes.source.stop(); } catch {}
|
||||
nodesRef.current.delete(id);
|
||||
}, 600);
|
||||
}, []);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
nodesRef.current.forEach((_, id) => stopAmbient(id));
|
||||
nodesRef.current.clear();
|
||||
setActiveAmbients(new Set());
|
||||
}, [stopAmbient]);
|
||||
|
||||
const toggleAmbient = useCallback((id: string) => {
|
||||
setActiveAmbients((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
// Stop the sound
|
||||
const nodes = nodesRef.current.get(id);
|
||||
if (nodes) {
|
||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||
setTimeout(() => {
|
||||
nodes.source.stop();
|
||||
nodesRef.current.delete(id);
|
||||
}, 600);
|
||||
}
|
||||
stopAmbient(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
// Start the sound
|
||||
const sound = AMBIENT_SOUNDS.find((s) => s.id === id);
|
||||
if (!sound) return next;
|
||||
|
||||
const ctx = getAudioCtx();
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.value = 0; // Start silent, fade in
|
||||
gainNode.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1);
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = sound.filterFreq;
|
||||
|
||||
let source: OscillatorNode | AudioBufferSourceNode;
|
||||
|
||||
if (sound.type === "brown") {
|
||||
// Brown noise via buffer
|
||||
const bufferSize = ctx.sampleRate * 2;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
let last = 0;
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
data[i] = (last + 0.02 * white) / 1.02;
|
||||
last = data[i];
|
||||
data[i] *= 3.5; // Normalize
|
||||
}
|
||||
source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.loop = true;
|
||||
} else {
|
||||
// Oscillator
|
||||
source = ctx.createOscillator();
|
||||
source.type = sound.type;
|
||||
source.frequency.value = sound.freq;
|
||||
}
|
||||
|
||||
source.connect(filter);
|
||||
filter.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
source.start();
|
||||
|
||||
nodesRef.current.set(id, { source, gain: gainNode, filter });
|
||||
startAmbient(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [volume, getAudioCtx]);
|
||||
}, [startAmbient, stopAmbient]);
|
||||
|
||||
// Apply a saved scene: stop everything, then start the scene's ambients.
|
||||
const applyScene = useCallback((scene: Scene) => {
|
||||
stopAll();
|
||||
// Defer starts one tick so the stop ramps clear first; ambients are
|
||||
// independent ids so a small delay keeps the fade clean.
|
||||
setTimeout(() => {
|
||||
setActiveAmbients(new Set(scene.ambients));
|
||||
scene.ambients.forEach((id) => startAmbient(id));
|
||||
}, 50);
|
||||
addToast(`Scene: ${scene.name}`, "info");
|
||||
}, [stopAll, startAmbient, addToast]);
|
||||
|
||||
const playSfx = useCallback((id: string) => {
|
||||
if (muted) return; // ponytail: master mute gates one-shot SFX too.
|
||||
@@ -359,18 +385,70 @@ export function Soundboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenes — one-click named combinations of the ambients above. */}
|
||||
<div>
|
||||
<h3 className="text-[var(--color-text-secondary)] text-xs font-medium mb-2 uppercase tracking-wider">
|
||||
🎭 Scenes
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{scenes.map((scene, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex items-center gap-1 rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)] pl-2.5 pr-1 py-1 transition-colors"
|
||||
>
|
||||
<button
|
||||
onClick={() => applyScene(scene)}
|
||||
className="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-gold-bright)] cursor-pointer"
|
||||
title={`Play: ${scene.ambients.map((id) => AMBIENT_SOUNDS.find((s) => s.id === id)?.label).filter(Boolean).join(", ")}`}
|
||||
>
|
||||
{scene.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setScenes((prev) => prev.filter((_, j) => j !== i))}
|
||||
className="text-[var(--color-text-dim)] hover:text-[var(--color-danger)] text-xs px-1 cursor-pointer"
|
||||
aria-label={`Delete scene ${scene.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{scenes.length === 0 && (
|
||||
<span className="text-[10px] text-[var(--color-text-dim)]">No scenes yet — turn some ambients on and save the combo.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-2">
|
||||
<input
|
||||
className="flex-1 min-w-0 rounded bg-[var(--color-bg-deep)] border border-[var(--color-border-subtle)] px-2 py-1 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs"
|
||||
value={newSceneName}
|
||||
onChange={(e) => setNewSceneName(e.target.value)}
|
||||
placeholder={activeAmbients.size ? "Scene name (e.g. Tavern)" : "Turn ambients on first"}
|
||||
disabled={activeAmbients.size === 0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newSceneName.trim() && activeAmbients.size) {
|
||||
setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]);
|
||||
setNewSceneName("");
|
||||
addToast(`Saved scene: ${newSceneName.trim()}`, "success");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!newSceneName.trim() || activeAmbients.size === 0) return;
|
||||
setScenes((prev) => [...prev, { name: newSceneName.trim(), ambients: [...activeAmbients] }]);
|
||||
setNewSceneName("");
|
||||
addToast(`Saved scene: ${newSceneName.trim()}`, "success");
|
||||
}}
|
||||
disabled={!newSceneName.trim() || activeAmbients.size === 0}
|
||||
className="rounded bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-3 py-1 text-xs font-semibold cursor-pointer hover:bg-[var(--color-gold-muted)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save scene
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop all — always visible so the DM never hunts for it. */}
|
||||
<button
|
||||
onClick={() => {
|
||||
nodesRef.current.forEach((nodes) => {
|
||||
try {
|
||||
nodes.gain.gain.linearRampToValueAtTime(0, audioCtxRef.current!.currentTime + 0.5);
|
||||
setTimeout(() => { try { nodes.source.stop(); } catch {} }, 600);
|
||||
} catch {}
|
||||
});
|
||||
nodesRef.current.clear();
|
||||
setActiveAmbients(new Set());
|
||||
}}
|
||||
onClick={stopAll}
|
||||
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 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user