feat: implement favorites functionality with SoundCard integration and FavoritesService

This commit is contained in:
JSC
2025-08-16 21:16:13 +02:00
parent ecb17e9f94
commit 2e41d5b695
5 changed files with 228 additions and 16 deletions

View File

@@ -4,43 +4,83 @@ import { cn } from '@/lib/utils'
import { formatDuration } from '@/utils/format-duration'
import { formatSize } from '@/utils/format-size'
import NumberFlow from '@number-flow/react'
import { Clock, Play, Weight } from 'lucide-react'
import { Clock, Heart, Play, Weight } from 'lucide-react'
interface SoundCardProps {
sound: Sound
playSound: (sound: Sound) => void
onFavoriteToggle: (soundId: number, isFavorited: boolean) => void
colorClasses: string
}
export function SoundCard({ sound, playSound, colorClasses }: SoundCardProps) {
const handlePlaySound = () => {
export function SoundCard({ sound, playSound, onFavoriteToggle, colorClasses }: SoundCardProps) {
const handlePlaySound = (e: React.MouseEvent) => {
// Don't play sound if clicking on favorite button
if ((e.target as HTMLElement).closest('[data-favorite-button]')) {
return
}
playSound(sound)
}
const handleFavoriteToggle = (e: React.MouseEvent) => {
e.stopPropagation()
onFavoriteToggle(sound.id, !sound.is_favorited)
}
return (
<Card
onClick={handlePlaySound}
className={cn(
'py-2 transition-all duration-100 shadow-sm cursor-pointer active:scale-95',
'py-2 transition-all duration-100 shadow-sm cursor-pointer active:scale-95 relative',
colorClasses,
)}
>
<CardContent className="grid grid-cols-1 pl-3 pr-3 gap-1">
<h3 className="font-medium text-s truncate">{sound.name}</h3>
<div className="grid grid-cols-3 gap-1 text-xs text-muted-foreground">
{/* Favorite button */}
<button
data-favorite-button
onClick={handleFavoriteToggle}
className={cn(
'absolute top-2 right-2 p-1 rounded-full transition-all duration-200 hover:scale-110',
'bg-background/80 hover:bg-background/90 shadow-sm',
sound.is_favorited
? 'text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground'
)}
title={sound.is_favorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart
className={cn(
'h-3.5 w-3.5 transition-all duration-200',
sound.is_favorited && 'fill-current'
)}
/>
</button>
<h3 className="font-medium text-s truncate pr-8">{sound.name}</h3>
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
<div className="flex">
<Clock className="h-3.5 w-3.5 mr-0.5" />
<span>{formatDuration(sound.duration)}</span>
</div>
<div className="flex justify-center">
<Weight className="h-3.5 w-3.5 mr-0.5" />
<span>{formatSize(sound.size)}</span>
</div>
<div className="flex justify-end items-center">
<Play className="h-3.5 w-3.5 mr-0.5" />
<NumberFlow value={sound.play_count} />
</div>
</div>
{/* Show favorite count if > 0 */}
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
<div className="flex">
<Weight className="h-3.5 w-3.5 mr-0.5" />
<span>{formatSize(sound.size)}</span>
</div>
<div className="flex justify-end items-center text-xs text-muted-foreground">
<Heart className="h-3.5 w-3.5 mr-0.5" />
<NumberFlow value={sound.favorite_count} />
</div>
</div>
</CardContent>
</Card>
)

View File

@@ -0,0 +1,112 @@
import { apiClient } from '../client'
export interface Favorite {
id: number
user_id: number
sound_id?: number
playlist_id?: number
created_at: string
updated_at: string
}
export interface FavoriteCountsResponse {
total: number
sounds: number
playlists: number
}
export interface FavoritesListResponse {
favorites: Favorite[]
}
export class FavoritesService {
/**
* Add a sound to favorites
*/
async addSoundFavorite(soundId: number): Promise<Favorite> {
const response = await apiClient.post<Favorite>(`/api/v1/favorites/sounds/${soundId}`)
return response
}
/**
* Remove a sound from favorites
*/
async removeSoundFavorite(soundId: number): Promise<void> {
await apiClient.delete(`/api/v1/favorites/sounds/${soundId}`)
}
/**
* Add a playlist to favorites
*/
async addPlaylistFavorite(playlistId: number): Promise<Favorite> {
const response = await apiClient.post<Favorite>(`/api/v1/favorites/playlists/${playlistId}`)
return response
}
/**
* Remove a playlist from favorites
*/
async removePlaylistFavorite(playlistId: number): Promise<void> {
await apiClient.delete(`/api/v1/favorites/playlists/${playlistId}`)
}
/**
* Get all favorites for the current user
*/
async getFavorites(limit = 50, offset = 0): Promise<Favorite[]> {
const response = await apiClient.get<FavoritesListResponse>(
`/api/v1/favorites/?limit=${limit}&offset=${offset}`
)
return response.favorites
}
/**
* Get sound favorites for the current user
*/
async getSoundFavorites(limit = 50, offset = 0): Promise<Favorite[]> {
const response = await apiClient.get<FavoritesListResponse>(
`/api/v1/favorites/sounds?limit=${limit}&offset=${offset}`
)
return response.favorites
}
/**
* Get playlist favorites for the current user
*/
async getPlaylistFavorites(limit = 50, offset = 0): Promise<Favorite[]> {
const response = await apiClient.get<FavoritesListResponse>(
`/api/v1/favorites/playlists?limit=${limit}&offset=${offset}`
)
return response.favorites
}
/**
* Get favorite counts for the current user
*/
async getFavoriteCounts(): Promise<FavoriteCountsResponse> {
const response = await apiClient.get<FavoriteCountsResponse>('/api/v1/favorites/counts')
return response
}
/**
* Check if a sound is favorited
*/
async isSoundFavorited(soundId: number): Promise<boolean> {
const response = await apiClient.get<{ is_favorited: boolean }>(
`/api/v1/favorites/sounds/${soundId}/check`
)
return response.is_favorited
}
/**
* Check if a playlist is favorited
*/
async isPlaylistFavorited(playlistId: number): Promise<boolean> {
const response = await apiClient.get<{ is_favorited: boolean }>(
`/api/v1/favorites/playlists/${playlistId}/check`
)
return response.is_favorited
}
}
export const favoritesService = new FavoritesService()

View File

@@ -3,3 +3,4 @@ export * from './sounds'
export * from './player'
export * from './files'
export * from './extractions'
export * from './favorites'

View File

@@ -17,6 +17,8 @@ export interface Sound {
thumbnail?: string
is_music: boolean
is_deletable: boolean
is_favorited: boolean
favorite_count: number
created_at: string
updated_at: string
}

View File

@@ -17,9 +17,11 @@ import {
type SoundSortField,
soundsService,
} from '@/lib/api/services/sounds'
import { favoritesService } from '@/lib/api/services/favorites'
import { SOUND_EVENTS, soundEvents } from '@/lib/events'
import {
AlertCircle,
Heart,
RefreshCw,
Search,
SortAsc,
@@ -77,6 +79,7 @@ export function SoundsPage() {
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<SoundSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
const handlePlaySound = async (sound: Sound) => {
try {
@@ -89,6 +92,39 @@ export function SoundsPage() {
}
}
const handleFavoriteToggle = async (soundId: number, shouldFavorite: boolean) => {
try {
if (shouldFavorite) {
await favoritesService.addSoundFavorite(soundId)
toast.success('Added to favorites')
} else {
await favoritesService.removeSoundFavorite(soundId)
toast.success('Removed from favorites')
}
// Update the sound in the local state
setSounds(prevSounds =>
prevSounds.map(sound =>
sound.id === soundId
? {
...sound,
is_favorited: shouldFavorite,
favorite_count: shouldFavorite
? sound.favorite_count + 1
: Math.max(0, sound.favorite_count - 1),
}
: sound,
),
)
} catch (error) {
toast.error(
`Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
)
}
}
const { theme } = useTheme()
useEffect(() => {
@@ -188,15 +224,23 @@ export function SoundsPage() {
)
}
if (sounds.length === 0) {
// Filter sounds based on favorites filter
const filteredSounds = showFavoritesOnly ? sounds.filter(sound => sound.is_favorited) : sounds
if (filteredSounds.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<span className="text-2xl">🎵</span>
<span className="text-2xl">{showFavoritesOnly ? '💝' : '🎵'}</span>
</div>
<h3 className="text-lg font-semibold mb-2">No sounds found</h3>
<h3 className="text-lg font-semibold mb-2">
{showFavoritesOnly ? 'No favorite sounds found' : 'No sounds found'}
</h3>
<p className="text-muted-foreground">
No SDB type sounds are available in your library.
{showFavoritesOnly
? 'You haven\'t favorited any sounds yet. Click the heart icon on sounds to add them to your favorites.'
: 'No SDB type sounds are available in your library.'
}
</p>
</div>
)
@@ -204,11 +248,12 @@ export function SoundsPage() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{sounds.map((sound, idx) => (
{filteredSounds.map((sound, idx) => (
<SoundCard
key={sound.id}
sound={sound}
playSound={handlePlaySound}
onFavoriteToggle={handleFavoriteToggle}
colorClasses={getSoundColor(idx)}
/>
))}
@@ -232,7 +277,10 @@ export function SoundsPage() {
</div>
{!loading && !error && (
<div className="text-sm text-muted-foreground">
{sounds.length} sound{sounds.length !== 1 ? 's' : ''}
{showFavoritesOnly
? `${sounds.filter(s => s.is_favorited).length} favorite sound${sounds.filter(s => s.is_favorited).length !== 1 ? 's' : ''}`
: `${sounds.length} sound${sounds.length !== 1 ? 's' : ''}`
}
</div>
)}
</div>
@@ -293,6 +341,15 @@ export function SoundsPage() {
)}
</Button>
<Button
variant={showFavoritesOnly ? "default" : "outline"}
size="icon"
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
title={showFavoritesOnly ? "Show all sounds" : "Show only favorites"}
>
<Heart className={`h-4 w-4 ${showFavoritesOnly ? 'fill-current' : ''}`} />
</Button>
<Button
variant="outline"
size="icon"