feat: soundboard, toast notifications, robust JSON parsing
Soundboard (Web Audio API): - 8 ambient loops: rain, wind, fire, ocean, forest, tavern, thunder, cave - 8 one-shot SFX: sword, bow, spell, door, coins, horn, dice, footsteps - Brown noise generation for ambient textures - Oscillator-based SFX with frequency sweeps - Volume slider, active state indicators with pulsing dot - Stop All button when ambients are playing - Toast notifications on SFX trigger Toast notification system: - Zustand-based store with auto-dismiss (4s) - 4 types: success, error, info, warning - Slide-in animation from top-right - Dismissible with × button - Integrated into App shell JSON parsing (all AI components): - Shared lib/llm-parse.ts with extractJson, parseLlmJson, flattenValue, ensureArray - Strips markdown code fences before parsing - Handles nested objects where strings expected - All 5 AI components use parseLlmJson instead of raw regex - All prompts explicitly request raw JSON with no fences - ItemForge shows raw response in <details> on parse failure Full production build passing
This commit is contained in:
Generated
+33
-3
@@ -13,7 +13,8 @@
|
||||
"framer-motion": "^12",
|
||||
"lucide-react": "^0.525",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
"react-dom": "^19.2.7",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4",
|
||||
@@ -1330,7 +1331,7 @@
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1376,7 +1377,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
@@ -2117,6 +2118,35 @@
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@
|
||||
"framer-motion": "^12",
|
||||
"lucide-react": "^0.525",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
"react-dom": "^19.2.7",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4",
|
||||
|
||||
+6
-10
@@ -26,6 +26,8 @@ import { CalendarWidget } from "./components/CalendarWidget";
|
||||
import { WorldBuilder } from "./components/WorldBuilder";
|
||||
import { ItemForge } from "./components/ItemForge";
|
||||
import { QuestDesigner } from "./components/QuestDesigner";
|
||||
import { Soundboard } from "./components/Soundboard";
|
||||
import { ToastContainer } from "./components/Toast";
|
||||
|
||||
export type View =
|
||||
| "dashboard"
|
||||
@@ -77,16 +79,7 @@ function renderView(view: View): React.ReactNode {
|
||||
case "items":
|
||||
return <ItemForge />;
|
||||
case "sound":
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-[var(--color-text-dim)]">
|
||||
<div className="text-center">
|
||||
<Volume2 size={64} className="mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-heading">Soundboard</p>
|
||||
<p className="text-sm mt-2">Local audio clips for ambience</p>
|
||||
<p className="text-xs mt-1 opacity-60">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Soundboard />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -239,6 +232,9 @@ export default function App() {
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -158,9 +158,16 @@ export function Dashboard({ onNavigate }: DashboardProps) {
|
||||
|
||||
{/* Soundboard — 1×1 */}
|
||||
<BentoCard title="Soundboard" icon={<Volume2 size={16} />} span="col-span-1 row-span-1">
|
||||
<div className="flex flex-col items-center justify-center h-full text-[var(--color-text-dim)] text-sm gap-2">
|
||||
<Volume2 size={32} className="opacity-30" />
|
||||
<p>Soundboard coming soon</p>
|
||||
<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>
|
||||
|
||||
@@ -192,4 +199,5 @@ import { RandomTables } from "./RandomTables";
|
||||
import { CalendarWidget } from "./CalendarWidget";
|
||||
import { WorldBuilder } from "./WorldBuilder";
|
||||
import { QuestDesigner } from "./QuestDesigner";
|
||||
import { ItemForge } from "./ItemForge";
|
||||
import { ItemForge } from "./ItemForge";
|
||||
import { Soundboard } from "./Soundboard";
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
// Ambient sound types with their oscillator configs
|
||||
const AMBIENT_SOUNDS = [
|
||||
{ id: "rain", label: "Rain", icon: "🌧", type: "brown" as const, freq: 0, filterFreq: 800 },
|
||||
{ id: "wind", label: "Wind", icon: "💨", type: "brown" as const, freq: 0, filterFreq: 400 },
|
||||
{ id: "fire", label: "Fire", icon: "🔥", type: "brown" as const, freq: 0, filterFreq: 200 },
|
||||
{ id: "ocean", label: "Ocean", icon: "🌊", type: "sine" as const, freq: 80, filterFreq: 1200 },
|
||||
{ id: "forest", label: "Forest", icon: "🌲", type: "brown" as const, freq: 0, filterFreq: 3000 },
|
||||
{ id: "tavern", label: "Tavern", icon: "🍺", type: "brown" as const, freq: 0, filterFreq: 600 },
|
||||
{ id: "thunder", label: "Thunder", icon: "⛈", type: "sawtooth" as const, freq: 40, filterFreq: 200 },
|
||||
{ id: "cave", label: "Cave", icon: "🪨", type: "sine" as const, freq: 55, filterFreq: 150 },
|
||||
];
|
||||
|
||||
// Sound effect one-shots
|
||||
const SFX_SOUNDS = [
|
||||
{ id: "sword", label: "Sword", icon: "⚔" },
|
||||
{ id: "bow", label: "Bow", icon: "🏹" },
|
||||
{ id: "spell", label: "Spell", icon: "✨" },
|
||||
{ id: "door", label: "Door", icon: "🚪" },
|
||||
{ id: "coins", label: "Coins", icon: "💰" },
|
||||
{ id: "horn", label: "Horn", icon: "📯" },
|
||||
{ id: "dice", label: "Dice", icon: "🎲" },
|
||||
{ id: "footsteps", label: "Steps", icon: "👣" },
|
||||
];
|
||||
|
||||
export function Soundboard() {
|
||||
const [activeAmbients, setActiveAmbients] = useState<Set<string>>(new Set());
|
||||
const [volume, setVolume] = useState(0.5);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const nodesRef = useRef<Map<string, { source: OscillatorNode | AudioBufferSourceNode; gain: GainNode; filter: BiquadFilterNode }>>(new Map());
|
||||
const { addToast } = useToast();
|
||||
|
||||
const getAudioCtx = useCallback(() => {
|
||||
if (!audioCtxRef.current) {
|
||||
audioCtxRef.current = new AudioContext();
|
||||
}
|
||||
return audioCtxRef.current;
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
} 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 });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [volume, getAudioCtx]);
|
||||
|
||||
const playSfx = useCallback((id: string) => {
|
||||
const ctx = getAudioCtx();
|
||||
const sfx = SFX_SOUNDS.find((s) => s.id === id);
|
||||
if (!sfx) return;
|
||||
|
||||
// Create simple synthesized sound effects
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = volume;
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
switch (id) {
|
||||
case "sword": {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sawtooth";
|
||||
osc.frequency.setValueAtTime(800, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.15);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||
osc.connect(gain);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.2);
|
||||
break;
|
||||
}
|
||||
case "bow": {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(1200, ctx.currentTime);
|
||||
osc.frequency.exponentialRampToValueAtTime(100, ctx.currentTime + 0.3);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
osc.connect(gain);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
}
|
||||
case "spell": {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(300, ctx.currentTime);
|
||||
osc.frequency.linearRampToValueAtTime(900, ctx.currentTime + 0.4);
|
||||
osc.frequency.linearRampToValueAtTime(300, ctx.currentTime + 0.8);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.8);
|
||||
osc.connect(gain);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.8);
|
||||
break;
|
||||
}
|
||||
case "door": {
|
||||
const bufferSize = ctx.sampleRate * 0.3;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.05));
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = 500;
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
source.start(ctx.currentTime);
|
||||
break;
|
||||
}
|
||||
case "coins": {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const osc = ctx.createOscillator();
|
||||
const coinGain = ctx.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.value = 2000 + Math.random() * 2000;
|
||||
coinGain.gain.setValueAtTime(volume * 0.3, ctx.currentTime + i * 0.08);
|
||||
coinGain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + i * 0.08 + 0.15);
|
||||
osc.connect(coinGain);
|
||||
coinGain.connect(ctx.destination);
|
||||
osc.start(ctx.currentTime + i * 0.08);
|
||||
osc.stop(ctx.currentTime + i * 0.08 + 0.15);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "horn": {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sawtooth";
|
||||
osc.frequency.value = 220;
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(400, ctx.currentTime);
|
||||
filter.frequency.linearRampToValueAtTime(2000, ctx.currentTime + 0.1);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.6);
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
osc.start(ctx.currentTime);
|
||||
osc.stop(ctx.currentTime + 0.6);
|
||||
break;
|
||||
}
|
||||
case "dice": {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const bufferSize = ctx.sampleRate * 0.04;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let j = 0; j < bufferSize; j++) {
|
||||
data[j] = (Math.random() * 2 - 1) * Math.exp(-j / (ctx.sampleRate * 0.01));
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const hitGain = ctx.createGain();
|
||||
hitGain.gain.value = volume * 0.3;
|
||||
source.connect(hitGain);
|
||||
hitGain.connect(ctx.destination);
|
||||
source.start(ctx.currentTime + i * 0.03 + Math.random() * 0.02);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "footsteps": {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const bufferSize = ctx.sampleRate * 0.05;
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let j = 0; j < bufferSize; j++) {
|
||||
data[j] = (Math.random() * 2 - 1) * Math.exp(-j / (ctx.sampleRate * 0.015));
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const stepGain = ctx.createGain();
|
||||
stepGain.gain.value = volume * 0.2;
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.value = 300;
|
||||
source.connect(filter);
|
||||
filter.connect(stepGain);
|
||||
stepGain.connect(ctx.destination);
|
||||
source.start(ctx.currentTime + i * 0.4);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
addToast(`${sfx.icon} ${sfx.label}`, "info");
|
||||
}, [volume, getAudioCtx, addToast]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[var(--color-text-secondary)] text-xs font-medium w-16">Volume</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="flex-1 accent-[var(--color-gold-bright)]"
|
||||
/>
|
||||
<span className="text-[var(--color-text-dim)] text-xs font-mono w-8 text-right">
|
||||
{Math.round(volume * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Ambient sounds */}
|
||||
<div>
|
||||
<h3 className="text-[var(--color-text-secondary)] text-xs font-medium mb-2 uppercase tracking-wider">
|
||||
🎧 Ambient Loops
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{AMBIENT_SOUNDS.map((sound) => (
|
||||
<button
|
||||
key={sound.id}
|
||||
onClick={() => toggleAmbient(sound.id)}
|
||||
className={`rounded-xl p-3 flex flex-col items-center gap-1.5 cursor-pointer transition-all ${
|
||||
activeAmbients.has(sound.id)
|
||||
? "glass-card-active scale-[1.02]"
|
||||
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)] hover:scale-[1.02]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{sound.icon}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-secondary)]">{sound.label}</span>
|
||||
{activeAmbients.has(sound.id) && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound effects */}
|
||||
<div>
|
||||
<h3 className="text-[var(--color-text-secondary)] text-xs font-medium mb-2 uppercase tracking-wider">
|
||||
⚔ Sound Effects
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{SFX_SOUNDS.map((sfx) => (
|
||||
<button
|
||||
key={sfx.id}
|
||||
onClick={() => playSfx(sfx.id)}
|
||||
className="rounded-xl p-3 flex flex-col items-center gap-1.5 bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] hover:border-[var(--color-gold-bright)] hover:scale-[1.02] transition-all cursor-pointer active:scale-95"
|
||||
>
|
||||
<span className="text-2xl">{sfx.icon}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-secondary)]">{sfx.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stop all */}
|
||||
{activeAmbients.size > 0 && (
|
||||
<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());
|
||||
}}
|
||||
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"
|
||||
>
|
||||
⏹ Stop All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type ToastType = "success" | "error" | "info" | "warning";
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastStore {
|
||||
toasts: Toast[];
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export const useToast = create<ToastStore>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (message, type = "info") => {
|
||||
const id = String(nextId++);
|
||||
set((state) => ({ toasts: [...state.toasts, { id, message, type }] }));
|
||||
// Auto-dismiss after 4 seconds
|
||||
setTimeout(() => {
|
||||
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
||||
}, 4000);
|
||||
},
|
||||
removeToast: (id) => {
|
||||
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
|
||||
},
|
||||
}));
|
||||
|
||||
const typeStyles: Record<ToastType, { bg: string; border: string; icon: string }> = {
|
||||
success: {
|
||||
bg: "bg-[var(--color-success)]/10",
|
||||
border: "border-[var(--color-success)]/40",
|
||||
icon: "✓",
|
||||
},
|
||||
error: {
|
||||
bg: "bg-[var(--color-danger)]/10",
|
||||
border: "border-[var(--color-danger)]/40",
|
||||
icon: "✕",
|
||||
},
|
||||
info: {
|
||||
bg: "bg-[var(--color-gold-bright)]/10",
|
||||
border: "border-[var(--color-gold-bright)]/40",
|
||||
icon: "ℹ",
|
||||
},
|
||||
warning: {
|
||||
bg: "bg-[var(--color-gold-muted)]/10",
|
||||
border: "border-[var(--color-gold-muted)]/40",
|
||||
icon: "⚠",
|
||||
},
|
||||
};
|
||||
|
||||
export function ToastContainer() {
|
||||
const { toasts, removeToast } = useToast();
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||
{toasts.map((toast) => {
|
||||
const style = typeStyles[toast.type];
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto glass-card px-4 py-3 flex items-center gap-2 text-sm ${style.bg} border ${style.border} animate-[slideIn_0.2s_ease-out]`}
|
||||
>
|
||||
<span className="text-base">{style.icon}</span>
|
||||
<span className="text-[var(--color-text-primary)]">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="ml-2 text-[var(--color-text-dim)] hover:text-[var(--color-text-primary)] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -179,4 +179,22 @@ body,
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--color-gold-bright);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ─── Animations ──────────────────────────────────────────────── */
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
Reference in New Issue
Block a user