165 lines
5.6 KiB
TypeScript
165 lines
5.6 KiB
TypeScript
import { useState, useRef, useMemo } from 'react';
|
|
import { Search, Play, Square, AudioLines } from 'lucide-react';
|
|
import { Widget } from 'widgets/Widget';
|
|
import { Input } from '@/components/ui/input';
|
|
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)} · {sound.duration.toFixed(2)}s
|
|
</span>
|
|
</button>
|
|
);
|
|
|
|
export const Catalog = () => {
|
|
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 (
|
|
<Widget title="Sound Library">
|
|
<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 · 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>
|
|
</Widget>
|
|
);
|
|
};
|