@akshatbuilds/sonix 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/app/globals.css +110 -0
  4. package/app/layout.tsx +54 -0
  5. package/app/page.tsx +40 -0
  6. package/app/usage/page.tsx +9 -0
  7. package/cli/index.mjs +151 -0
  8. package/components/sound-card.tsx +595 -0
  9. package/components/sounds-gallery.tsx +137 -0
  10. package/components/theme-provider.tsx +11 -0
  11. package/components/theme-toggle.tsx +82 -0
  12. package/components/ui/button.tsx +57 -0
  13. package/components/ui/checkbox.tsx +30 -0
  14. package/components/ui/slider.tsx +28 -0
  15. package/components/ui/switch.tsx +29 -0
  16. package/components/ui/tooltip.tsx +30 -0
  17. package/components/usage-guide.tsx +155 -0
  18. package/components.json +21 -0
  19. package/lib/sounds.ts +329 -0
  20. package/lib/utils.ts +6 -0
  21. package/next-env.d.ts +6 -0
  22. package/next.config.mjs +5 -0
  23. package/package.json +96 -0
  24. package/postcss.config.mjs +8 -0
  25. package/public/click1.mp3 +0 -0
  26. package/public/click2.mp3 +0 -0
  27. package/public/registry/index.json +92 -0
  28. package/public/registry/sounds/button-click-secondary.json +13 -0
  29. package/public/registry/sounds/button-click.json +13 -0
  30. package/public/registry/sounds/error-beep.json +10 -0
  31. package/public/registry/sounds/error-buzz.json +10 -0
  32. package/public/registry/sounds/hover-blip.json +10 -0
  33. package/public/registry/sounds/hover-soft.json +10 -0
  34. package/public/registry/sounds/key-press.json +10 -0
  35. package/public/registry/sounds/notification-ping.json +10 -0
  36. package/public/registry/sounds/notification-subtle.json +10 -0
  37. package/public/registry/sounds/pop.json +10 -0
  38. package/public/registry/sounds/slider-tick.json +10 -0
  39. package/public/registry/sounds/success-bell.json +10 -0
  40. package/public/registry/sounds/success-chime.json +10 -0
  41. package/public/registry/sounds/swoosh.json +10 -0
  42. package/scripts/build-registry.mjs +293 -0
  43. package/tailwind.config.ts +100 -0
  44. package/tsconfig.json +33 -0
@@ -0,0 +1,595 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Slider } from '@/components/ui/slider';
6
+ import { Switch } from '@/components/ui/switch';
7
+ import { Checkbox } from '@/components/ui/checkbox';
8
+ import {
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipProvider,
12
+ TooltipTrigger,
13
+ } from '@/components/ui/tooltip';
14
+ import type { SoundName } from '@/lib/sounds';
15
+ import { playSound } from '@/lib/sounds';
16
+
17
+ // Standalone copy-paste code for each sound
18
+ const soundSnippets: Record<SoundName, string> = {
19
+ 'button-click': `// Requires click1.mp3 in your public folder
20
+ const audio = new Audio('/click1.mp3');
21
+ audio.volume = 0.5;
22
+ audio.play();`,
23
+
24
+ 'button-click-secondary': `// Requires click2.mp3 in your public folder
25
+ const audio = new Audio('/click2.mp3');
26
+ audio.volume = 0.5;
27
+ audio.play();`,
28
+
29
+ 'hover-blip': `const ctx = new AudioContext();
30
+ const now = ctx.currentTime;
31
+ const osc = ctx.createOscillator();
32
+ const gain = ctx.createGain();
33
+ osc.type = 'sine';
34
+ osc.frequency.setValueAtTime(1200, now);
35
+ osc.frequency.exponentialRampToValueAtTime(2400, now + 0.05);
36
+ gain.gain.setValueAtTime(0.15, now);
37
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
38
+ osc.connect(gain).connect(ctx.destination);
39
+ osc.start(now);
40
+ osc.stop(now + 0.05);`,
41
+
42
+ 'hover-soft': `const ctx = new AudioContext();
43
+ const now = ctx.currentTime;
44
+ const osc = ctx.createOscillator();
45
+ const gain = ctx.createGain();
46
+ osc.type = 'sine';
47
+ osc.frequency.setValueAtTime(900, now);
48
+ gain.gain.setValueAtTime(0.08, now);
49
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
50
+ osc.connect(gain).connect(ctx.destination);
51
+ osc.start(now);
52
+ osc.stop(now + 0.06);`,
53
+
54
+ 'success-chime': `const ctx = new AudioContext();
55
+ const now = ctx.currentTime;
56
+ // Note 1
57
+ const osc1 = ctx.createOscillator();
58
+ const gain1 = ctx.createGain();
59
+ osc1.type = 'sine';
60
+ osc1.frequency.setValueAtTime(800, now);
61
+ gain1.gain.setValueAtTime(0.25, now);
62
+ gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
63
+ osc1.connect(gain1).connect(ctx.destination);
64
+ osc1.start(now);
65
+ osc1.stop(now + 0.15);
66
+ // Note 2
67
+ const osc2 = ctx.createOscillator();
68
+ const gain2 = ctx.createGain();
69
+ osc2.type = 'sine';
70
+ osc2.frequency.setValueAtTime(1200, now + 0.1);
71
+ gain2.gain.setValueAtTime(0.001, now);
72
+ gain2.gain.setValueAtTime(0.25, now + 0.1);
73
+ gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
74
+ osc2.connect(gain2).connect(ctx.destination);
75
+ osc2.start(now + 0.1);
76
+ osc2.stop(now + 0.3);`,
77
+
78
+ 'success-bell': `const ctx = new AudioContext();
79
+ const now = ctx.currentTime;
80
+ [1000, 1250, 1500].forEach((freq, i) => {
81
+ const osc = ctx.createOscillator();
82
+ const gain = ctx.createGain();
83
+ osc.type = 'sine';
84
+ osc.frequency.setValueAtTime(freq, now + i * 0.08);
85
+ gain.gain.setValueAtTime(0.001, now);
86
+ gain.gain.setValueAtTime(0.2, now + i * 0.08);
87
+ gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.15);
88
+ osc.connect(gain).connect(ctx.destination);
89
+ osc.start(now + i * 0.08);
90
+ osc.stop(now + i * 0.08 + 0.15);
91
+ });`,
92
+
93
+ 'error-buzz': `const ctx = new AudioContext();
94
+ const now = ctx.currentTime;
95
+ const osc = ctx.createOscillator();
96
+ const gain = ctx.createGain();
97
+ osc.type = 'sawtooth';
98
+ osc.frequency.setValueAtTime(150, now);
99
+ gain.gain.setValueAtTime(0.2, now);
100
+ gain.gain.setValueAtTime(0.001, now + 0.08);
101
+ gain.gain.setValueAtTime(0.2, now + 0.1);
102
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
103
+ osc.connect(gain).connect(ctx.destination);
104
+ osc.start(now);
105
+ osc.stop(now + 0.2);`,
106
+
107
+ 'error-beep': `const ctx = new AudioContext();
108
+ const now = ctx.currentTime;
109
+ [500, 350].forEach((freq, i) => {
110
+ const osc = ctx.createOscillator();
111
+ const gain = ctx.createGain();
112
+ osc.type = 'square';
113
+ osc.frequency.setValueAtTime(freq, now + i * 0.12);
114
+ gain.gain.setValueAtTime(0.001, now);
115
+ gain.gain.setValueAtTime(0.15, now + i * 0.12);
116
+ gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.12 + 0.1);
117
+ osc.connect(gain).connect(ctx.destination);
118
+ osc.start(now + i * 0.12);
119
+ osc.stop(now + i * 0.12 + 0.1);
120
+ });`,
121
+
122
+ 'notification-ping': `const ctx = new AudioContext();
123
+ const now = ctx.currentTime;
124
+ const osc = ctx.createOscillator();
125
+ const gain = ctx.createGain();
126
+ osc.type = 'sine';
127
+ osc.frequency.setValueAtTime(1100, now);
128
+ osc.frequency.exponentialRampToValueAtTime(1800, now + 0.05);
129
+ osc.frequency.exponentialRampToValueAtTime(1100, now + 0.1);
130
+ gain.gain.setValueAtTime(0.25, now);
131
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
132
+ osc.connect(gain).connect(ctx.destination);
133
+ osc.start(now);
134
+ osc.stop(now + 0.15);`,
135
+
136
+ 'notification-subtle': `const ctx = new AudioContext();
137
+ const now = ctx.currentTime;
138
+ const osc = ctx.createOscillator();
139
+ const gain = ctx.createGain();
140
+ osc.type = 'sine';
141
+ osc.frequency.setValueAtTime(700, now);
142
+ gain.gain.setValueAtTime(0.1, now);
143
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
144
+ osc.connect(gain).connect(ctx.destination);
145
+ osc.start(now);
146
+ osc.stop(now + 0.12);`,
147
+
148
+ 'swoosh': `const ctx = new AudioContext();
149
+ const now = ctx.currentTime;
150
+ const bufferSize = Math.floor(ctx.sampleRate * 0.4);
151
+ const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
152
+ const data = buffer.getChannelData(0);
153
+ for (let i = 0; i < bufferSize; i++) {
154
+ data[i] = Math.random() * 2 - 1;
155
+ }
156
+ const noise = ctx.createBufferSource();
157
+ noise.buffer = buffer;
158
+ const lp = ctx.createBiquadFilter();
159
+ lp.type = 'lowpass';
160
+ lp.frequency.setValueAtTime(350, now);
161
+ lp.frequency.linearRampToValueAtTime(500, now + 0.15);
162
+ lp.frequency.linearRampToValueAtTime(250, now + 0.38);
163
+ lp.Q.setValueAtTime(0.5, now);
164
+ const noiseGain = ctx.createGain();
165
+ noiseGain.gain.setValueAtTime(0.001, now);
166
+ noiseGain.gain.linearRampToValueAtTime(0.06, now + 0.16);
167
+ noiseGain.gain.linearRampToValueAtTime(0.05, now + 0.24);
168
+ noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.38);
169
+ noise.connect(lp).connect(noiseGain).connect(ctx.destination);
170
+ noise.start(now);
171
+ noise.stop(now + 0.4);`,
172
+
173
+ 'pop': `const ctx = new AudioContext();
174
+ const now = ctx.currentTime;
175
+ const osc = ctx.createOscillator();
176
+ const gain = ctx.createGain();
177
+ osc.type = 'sine';
178
+ osc.frequency.setValueAtTime(950, now);
179
+ osc.frequency.exponentialRampToValueAtTime(150, now + 0.08);
180
+ gain.gain.setValueAtTime(0.35, now);
181
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
182
+ osc.connect(gain).connect(ctx.destination);
183
+ osc.start(now);
184
+ osc.stop(now + 0.08);`,
185
+
186
+ 'slider-tick': `const ctx = new AudioContext();
187
+ const now = ctx.currentTime;
188
+ const osc = ctx.createOscillator();
189
+ const gain = ctx.createGain();
190
+ osc.type = 'square';
191
+ osc.frequency.setValueAtTime(5000, now);
192
+ osc.frequency.exponentialRampToValueAtTime(1200, now + 0.005);
193
+ gain.gain.setValueAtTime(0.08, now);
194
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.01);
195
+ osc.connect(gain).connect(ctx.destination);
196
+ osc.start(now);
197
+ osc.stop(now + 0.01);`,
198
+
199
+ 'key-press': `// Usage: element.onkeydown = (e) => { if (!e.repeat) playKey(); }
200
+ function playKey() {
201
+ const ctx = new AudioContext();
202
+ const now = ctx.currentTime;
203
+ // Lubed Cherry MX Brown — deep muted thock, no click
204
+ const v = () => 0.94 + Math.random() * 0.12;
205
+ const drift = Math.random() * 0.0015;
206
+ // Layer 1: bottom-out thock (main sound)
207
+ const thud = ctx.createOscillator();
208
+ const thudGain = ctx.createGain();
209
+ const thudFilter = ctx.createBiquadFilter();
210
+ thud.type = 'sine';
211
+ thud.frequency.setValueAtTime(95 * v(), now + drift);
212
+ thud.frequency.exponentialRampToValueAtTime(38, now + drift + 0.04);
213
+ thudFilter.type = 'lowpass';
214
+ thudFilter.frequency.setValueAtTime(300, now + drift);
215
+ thudFilter.Q.setValueAtTime(0.5, now + drift);
216
+ thudGain.gain.setValueAtTime(0.22, now + drift);
217
+ thudGain.gain.exponentialRampToValueAtTime(0.001, now + drift + 0.055);
218
+ thud.connect(thudFilter).connect(thudGain).connect(ctx.destination);
219
+ thud.start(now + drift); thud.stop(now + drift + 0.06);
220
+ // Layer 2: tactile bump (barely audible)
221
+ const bump = ctx.createOscillator();
222
+ const bumpGain = ctx.createGain();
223
+ const bumpFilter = ctx.createBiquadFilter();
224
+ bump.type = 'triangle';
225
+ bump.frequency.setValueAtTime(420 * v(), now);
226
+ bump.frequency.exponentialRampToValueAtTime(180, now + 0.008);
227
+ bumpFilter.type = 'lowpass';
228
+ bumpFilter.frequency.setValueAtTime(600, now);
229
+ bumpGain.gain.setValueAtTime(0.03, now);
230
+ bumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.012);
231
+ bump.connect(bumpFilter).connect(bumpGain).connect(ctx.destination);
232
+ bump.start(now); bump.stop(now + 0.012);
233
+ // Layer 3: dampened noise (soft low puff, lube kills scratchiness)
234
+ const bufLen = Math.floor(ctx.sampleRate * 0.03);
235
+ const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate);
236
+ const d = buf.getChannelData(0);
237
+ for (let i = 0; i < bufLen; i++) d[i] = Math.random() * 2 - 1;
238
+ const noise = ctx.createBufferSource();
239
+ noise.buffer = buf;
240
+ const noiseLp = ctx.createBiquadFilter();
241
+ noiseLp.type = 'lowpass';
242
+ noiseLp.frequency.setValueAtTime(400 * v(), now + drift);
243
+ noiseLp.Q.setValueAtTime(0.3, now + drift);
244
+ const noiseGain = ctx.createGain();
245
+ noiseGain.gain.setValueAtTime(0.025, now + drift);
246
+ noiseGain.gain.exponentialRampToValueAtTime(0.001, now + drift + 0.025);
247
+ noise.connect(noiseLp).connect(noiseGain).connect(ctx.destination);
248
+ noise.start(now + drift); noise.stop(now + drift + 0.03);
249
+ }`,
250
+ };
251
+
252
+ interface SoundCardProps {
253
+ name: SoundName;
254
+ label: string;
255
+ category: string;
256
+ description: string;
257
+ }
258
+
259
+ function InteractivePreview({ name }: { name: SoundName }) {
260
+ const [switchOn, setSwitchOn] = useState(false);
261
+ const [checked, setChecked] = useState(false);
262
+ const [sliderVal, setSliderVal] = useState([50]);
263
+ const [notifVisible, setNotifVisible] = useState(false);
264
+ const [successVisible, setSuccessVisible] = useState(false);
265
+ const [errorVisible, setErrorVisible] = useState(false);
266
+
267
+ switch (name) {
268
+ case 'button-click':
269
+ return (
270
+ <Button
271
+ size="sm"
272
+ onClick={() => playSound('button-click')}
273
+ className="w-full"
274
+ >
275
+ Click Me
276
+ </Button>
277
+ );
278
+
279
+ case 'button-click-secondary':
280
+ return (
281
+ <Button
282
+ size="sm"
283
+ variant="secondary"
284
+ onClick={() => playSound('button-click-secondary')}
285
+ className="w-full"
286
+ >
287
+ Secondary Action
288
+ </Button>
289
+ );
290
+
291
+ case 'hover-blip':
292
+ return (
293
+ <div
294
+ onMouseEnter={() => playSound('hover-blip')}
295
+ className="flex w-full cursor-pointer items-center justify-center rounded-md border border-dashed border-border py-3 text-xs text-muted-foreground transition-colors hover:border-primary hover:bg-accent hover:text-foreground"
296
+ >
297
+ Hover over me
298
+ </div>
299
+ );
300
+
301
+ case 'hover-soft':
302
+ return (
303
+ <div className="flex w-full gap-2">
304
+ {['Link 1', 'Link 2', 'Link 3'].map((label) => (
305
+ <span
306
+ key={label}
307
+ onMouseEnter={() => playSound('hover-soft')}
308
+ className="flex-1 cursor-pointer rounded-md py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
309
+ >
310
+ {label}
311
+ </span>
312
+ ))}
313
+ </div>
314
+ );
315
+
316
+ case 'success-chime':
317
+ return (
318
+ <div className="flex w-full flex-col gap-2">
319
+ <Button
320
+ size="sm"
321
+ variant="outline"
322
+ onClick={() => {
323
+ playSound('success-chime');
324
+ setSuccessVisible(true);
325
+ setTimeout(() => setSuccessVisible(false), 1500);
326
+ }}
327
+ className="w-full"
328
+ >
329
+ Save Changes
330
+ </Button>
331
+ {successVisible && (
332
+ <p className="text-center text-xs text-green-600 dark:text-green-400" aria-live="polite">✓ Saved successfully!</p>
333
+ )}
334
+ </div>
335
+ );
336
+
337
+ case 'success-bell':
338
+ return (
339
+ <div className="flex w-full flex-col gap-2">
340
+ <div className="flex items-center gap-2">
341
+ <Checkbox
342
+ checked={checked}
343
+ onCheckedChange={() => {
344
+ setChecked(!checked);
345
+ if (!checked) playSound('success-bell');
346
+ }}
347
+ />
348
+ <label className="text-xs text-card-foreground cursor-pointer" onClick={() => {
349
+ setChecked(!checked);
350
+ if (!checked) playSound('success-bell');
351
+ }}>
352
+ Mark as complete
353
+ </label>
354
+ </div>
355
+ </div>
356
+ );
357
+
358
+ case 'error-buzz':
359
+ return (
360
+ <div className="flex w-full flex-col gap-2">
361
+ <Button
362
+ size="sm"
363
+ variant="destructive"
364
+ onClick={() => {
365
+ playSound('error-buzz');
366
+ setErrorVisible(true);
367
+ setTimeout(() => setErrorVisible(false), 1500);
368
+ }}
369
+ className="w-full"
370
+ >
371
+ Delete Item
372
+ </Button>
373
+ {errorVisible && (
374
+ <p className="text-center text-xs text-red-600 dark:text-red-400" aria-live="polite">✗ Action failed!</p>
375
+ )}
376
+ </div>
377
+ );
378
+
379
+ case 'error-beep':
380
+ return (
381
+ <Button
382
+ size="sm"
383
+ variant="outline"
384
+ onClick={() => playSound('error-beep')}
385
+ className="w-full border-destructive text-destructive hover:bg-destructive/10"
386
+ >
387
+ Invalid Input ✗
388
+ </Button>
389
+ );
390
+
391
+ case 'notification-ping':
392
+ return (
393
+ <div className="flex w-full flex-col gap-2">
394
+ <Button
395
+ size="sm"
396
+ variant="outline"
397
+ onClick={() => {
398
+ playSound('notification-ping');
399
+ setNotifVisible(true);
400
+ setTimeout(() => setNotifVisible(false), 2000);
401
+ }}
402
+ className="w-full"
403
+ >
404
+ Send Notification
405
+ </Button>
406
+ {notifVisible && (
407
+ <div className="rounded-md bg-primary px-3 py-2 text-xs text-primary-foreground text-center animate-in fade-in slide-in-from-top-1" aria-live="polite">
408
+ New message received
409
+ </div>
410
+ )}
411
+ </div>
412
+ );
413
+
414
+ case 'notification-subtle':
415
+ return (
416
+ <div className="flex w-full items-center justify-between">
417
+ <span className="text-xs text-card-foreground">Enable notifications</span>
418
+ <Switch
419
+ checked={switchOn}
420
+ onCheckedChange={(val) => {
421
+ setSwitchOn(val);
422
+ playSound('notification-subtle');
423
+ }}
424
+ />
425
+ </div>
426
+ );
427
+
428
+ case 'swoosh':
429
+ return (
430
+ <div className="flex w-full gap-2">
431
+ {['Tab 1', 'Tab 2', 'Tab 3'].map((label, i) => (
432
+ <button
433
+ key={label}
434
+ onClick={() => playSound('swoosh')}
435
+ className={`flex-1 rounded-md py-2 text-xs font-medium transition-colors ${
436
+ i === 0
437
+ ? 'bg-primary text-primary-foreground'
438
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
439
+ }`}
440
+ >
441
+ {label}
442
+ </button>
443
+ ))}
444
+ </div>
445
+ );
446
+
447
+ case 'pop':
448
+ return (
449
+ <TooltipProvider>
450
+ <Tooltip onOpenChange={(open) => open && playSound('pop')}>
451
+ <TooltipTrigger asChild>
452
+ <Button
453
+ size="sm"
454
+ variant="outline"
455
+ className="w-full"
456
+ >
457
+ Hover for Tooltip 💬
458
+ </Button>
459
+ </TooltipTrigger>
460
+ <TooltipContent>
461
+ <p className="text-xs">This is a tooltip!</p>
462
+ </TooltipContent>
463
+ </Tooltip>
464
+ </TooltipProvider>
465
+ );
466
+
467
+ case 'slider-tick':
468
+ return (
469
+ <div className="flex w-full flex-col gap-2 px-1">
470
+ <Slider
471
+ value={sliderVal}
472
+ onValueChange={(val) => {
473
+ setSliderVal(val);
474
+ playSound('slider-tick');
475
+ }}
476
+ max={100}
477
+ step={5}
478
+ />
479
+ <p className="text-center text-xs text-muted-foreground">
480
+ Volume: {sliderVal[0]}%
481
+ </p>
482
+ </div>
483
+ );
484
+
485
+ case 'key-press':
486
+ return (
487
+ <input
488
+ type="text"
489
+ placeholder="Type something…"
490
+ onKeyDown={(e) => { if (!e.repeat) playSound('key-press'); }}
491
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:ring-1 focus:ring-ring"
492
+ />
493
+ );
494
+
495
+ default:
496
+ return (
497
+ <Button
498
+ size="sm"
499
+ variant="outline"
500
+ onClick={() => playSound(name)}
501
+ className="w-full"
502
+ >
503
+ Play Sound
504
+ </Button>
505
+ );
506
+ }
507
+ }
508
+
509
+ function CopyIcon({ className }: { className?: string }) {
510
+ return (
511
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
512
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
513
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
514
+ </svg>
515
+ );
516
+ }
517
+
518
+ function CheckIcon({ className }: { className?: string }) {
519
+ return (
520
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
521
+ <polyline points="20 6 9 17 4 12" />
522
+ </svg>
523
+ );
524
+ }
525
+
526
+ function DownloadIcon({ className }: { className?: string }) {
527
+ return (
528
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
529
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
530
+ <polyline points="7 10 12 15 17 10" />
531
+ <line x1="12" y1="15" x2="12" y2="3" />
532
+ </svg>
533
+ );
534
+ }
535
+
536
+ const mp3Map: Partial<Record<SoundName, string>> = {
537
+ 'button-click': '/click1.mp3',
538
+ 'button-click-secondary': '/click2.mp3',
539
+ };
540
+
541
+ export function SoundCard({
542
+ name,
543
+ label,
544
+ category,
545
+ description,
546
+ }: SoundCardProps) {
547
+ const [copied, setCopied] = useState(false);
548
+ const isMp3 = name === 'button-click' || name === 'button-click-secondary';
549
+
550
+ const handleCopyCode = () => {
551
+ if (isMp3) return;
552
+ playSound('notification-subtle');
553
+ navigator.clipboard.writeText(soundSnippets[name]).then(() => {
554
+ setCopied(true);
555
+ setTimeout(() => setCopied(false), 2000);
556
+ });
557
+ };
558
+
559
+ const handleDownload = () => {
560
+ playSound('notification-subtle');
561
+ };
562
+
563
+ return (
564
+ <div className="relative flex flex-col gap-3 rounded-lg border border-border bg-card p-4 transition-colors">
565
+ {isMp3 ? (
566
+ <a
567
+ href={mp3Map[name]}
568
+ download
569
+ aria-label="Download mp3"
570
+ onClick={handleDownload}
571
+ className="absolute right-3 top-3 rounded-md border border-border bg-muted p-1.5 text-muted-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
572
+ >
573
+ <DownloadIcon />
574
+ </a>
575
+ ) : (
576
+ <button
577
+ onClick={handleCopyCode}
578
+ aria-label={copied ? 'Copied' : 'Copy code'}
579
+ className="absolute right-3 top-3 rounded-md border border-border bg-muted p-1.5 text-muted-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
580
+ >
581
+ {copied ? <CheckIcon /> : <CopyIcon />}
582
+ </button>
583
+ )}
584
+
585
+ <div className="flex flex-col gap-1 pr-10">
586
+ <h3 className="font-sans text-sm font-semibold text-card-foreground">{label}</h3>
587
+ <p className="text-xs text-muted-foreground">{category}</p>
588
+ </div>
589
+
590
+ <p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
591
+
592
+ <InteractivePreview name={name} />
593
+ </div>
594
+ );
595
+ }