feat: implement favorites functionality with SoundCard integration and FavoritesService
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
112
src/lib/api/services/favorites.ts
Normal file
112
src/lib/api/services/favorites.ts
Normal 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()
|
||||
@@ -3,3 +3,4 @@ export * from './sounds'
|
||||
export * from './player'
|
||||
export * from './files'
|
||||
export * from './extractions'
|
||||
export * from './favorites'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user