This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
import { useState, useRef, useMemo } from 'react';
import { Search, Play, Square, AudioLines, ChevronDown, ChevronUp } from 'lucide-react';
import { Card } from '@/components/Card';
import { Input } from '@/components/ui/input';
import { useUserState } from '@/state/useUserState';
import { categories, allSounds } from './catalog-data';
import type { SoundAsset } from './types';
type ActiveCategory = 'all' | string;
const formatName = (name: string) =>
name
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
const getCategoryLabel = (sound: SoundAsset): string => {
const cat = categories.find((c) => c.sounds.includes(sound));
return cat?.label ?? '';
};
const SoundCard = ({
sound,
isPlaying,
onPlay,
onStop,
}: {
sound: SoundAsset;
isPlaying: boolean;
onPlay: () => void;
onStop: () => void;
}) => (
<button
type="button"
onClick={isPlaying ? onStop : onPlay}
className={`flex flex-col items-center gap-2 rounded-lg border px-3 py-4 text-center transition-all cursor-pointer ${
isPlaying
? 'border-duck-teal bg-duck-teal/5'
: 'border-duck-dark/10 hover:border-duck-dark/25 hover:bg-duck-dark/[0.02]'
}`}
>
<div className="h-8 flex items-center justify-center text-duck-dark/30">
{isPlaying ? <Square className="h-5 w-5 text-duck-teal fill-duck-teal" /> : <Play className="h-5 w-5" />}
</div>
<span className="text-sm font-medium text-duck-dark leading-tight">{formatName(sound.name)}</span>
<span className="text-xs text-duck-dark/40">
{getCategoryLabel(sound)} &middot; {sound.duration.toFixed(2)}s
</span>
</button>
);
export const Catalog = () => {
const [collapsed, setCollapsed] = useUserState('widget:soundLibrary:collapsed', true);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState<ActiveCategory>('all');
const [playingId, setPlayingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const filtered = useMemo(() => {
const source =
activeCategory === 'all' ? allSounds : (categories.find((c) => c.id === activeCategory)?.sounds ?? []);
if (!search) return source;
const q = search.toLowerCase();
return source.filter((s) => s.name.toLowerCase().includes(q));
}, [search, activeCategory]);
const play = (sound: SoundAsset) => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
const audio = new Audio(sound.dataUri);
audio.addEventListener('ended', () => setPlayingId(null));
audio.play();
audioRef.current = audio;
setPlayingId(sound.name);
};
const stop = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPlayingId(null);
};
const tabs = [
{ id: 'all' as const, label: 'All', count: allSounds.length },
...categories.map((c) => ({ id: c.id, label: c.label, count: c.sounds.length })),
];
return (
<div className="w-full">
<Card className="overflow-hidden">
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
<span className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide">Sound Library</span>
<button
onClick={() => setCollapsed((c) => !c)}
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
</div>
{!collapsed && (
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="relative w-full sm:w-56">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-duck-dark/40" />
<Input
placeholder="Search sounds..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="pl-9"
/>
</div>
<div className="flex flex-wrap gap-1.5">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveCategory(tab.id)}
className={`px-2.5 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors border ${
activeCategory === tab.id
? 'border-duck-teal bg-duck-teal/10 text-duck-teal'
: 'border-duck-dark/15 text-duck-dark/50 hover:border-duck-dark/30'
}`}
>
{tab.label} <span className="opacity-60">{tab.count}</span>
</button>
))}
</div>
<span className="text-xs text-duck-dark/40 ml-auto hidden sm:block">{filtered.length} sounds</span>
</div>
{filtered.length === 0 && <p className="text-sm text-duck-dark/40 py-8 text-center">No sounds found.</p>}
<div className="max-h-[400px] overflow-y-auto">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{filtered.map((sound) => (
<SoundCard
key={sound.name}
sound={sound}
isPlaying={playingId === sound.name}
onPlay={() => play(sound)}
onStop={stop}
/>
))}
</div>
</div>
<div className="flex items-center justify-between pt-1">
<span className="text-[11px] text-duck-dark/70">
CC0 licensed &middot; Original audio by{' '}
<a
href="https://kenney.nl/"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-duck-dark"
>
Kenney
</a>
</span>
<a
href="https://www.soundcn.xyz/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<span className="text-xs text-duck-dark/40">powered by</span>
<AudioLines className="h-5 w-5" style={{ color: '#F5A32F' }} />
<span className="text-sm font-bold text-duck-dark">soundcn</span>
</a>
</div>
</div>
)}
</Card>
</div>
);
};