first
This commit is contained in:
@@ -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)} · {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 · 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user