feat: implement favorites functionality across playlists components

This commit is contained in:
JSC
2025-08-16 21:41:57 +02:00
parent 1027a67e37
commit ad466e2f91
6 changed files with 108 additions and 14 deletions

View File

@@ -4,15 +4,22 @@ import { TableCell, TableRow } from '@/components/ui/table'
import type { Playlist } from '@/lib/api/services/playlists' import type { Playlist } from '@/lib/api/services/playlists'
import { formatDateDistanceToNow } from '@/utils/format-date' import { formatDateDistanceToNow } from '@/utils/format-date'
import { formatDuration } from '@/utils/format-duration' import { formatDuration } from '@/utils/format-duration'
import { Calendar, Clock, Edit, Music, Play, User } from 'lucide-react' import { cn } from '@/lib/utils'
import { Calendar, Clock, Edit, Heart, Music, Play, User } from 'lucide-react'
interface PlaylistRowProps { interface PlaylistRowProps {
playlist: Playlist playlist: Playlist
onEdit: (playlist: Playlist) => void onEdit: (playlist: Playlist) => void
onSetCurrent: (playlist: Playlist) => void onSetCurrent: (playlist: Playlist) => void
onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
} }
export function PlaylistRow({ playlist, onEdit, onSetCurrent }: PlaylistRowProps) { export function PlaylistRow({ playlist, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistRowProps) {
const handleFavoriteToggle = () => {
if (onFavoriteToggle) {
onFavoriteToggle(playlist.id, !playlist.is_favorited)
}
}
return ( return (
<TableRow className="hover:bg-muted/50"> <TableRow className="hover:bg-muted/50">
<TableCell> <TableCell>
@@ -76,6 +83,24 @@ export function PlaylistRow({ playlist, onEdit, onSetCurrent }: PlaylistRowProps
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{onFavoriteToggle && (
<Button
size="sm"
variant="ghost"
onClick={handleFavoriteToggle}
className="h-8 w-8 p-0"
title={playlist.is_favorited ? "Remove from favorites" : "Add to favorites"}
>
<Heart
className={cn(
'h-4 w-4 transition-all duration-200',
playlist.is_favorited
? 'fill-current text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground'
)}
/>
</Button>
)}
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"

View File

@@ -12,9 +12,10 @@ interface PlaylistTableProps {
playlists: Playlist[] playlists: Playlist[]
onEdit: (playlist: Playlist) => void onEdit: (playlist: Playlist) => void
onSetCurrent: (playlist: Playlist) => void onSetCurrent: (playlist: Playlist) => void
onFavoriteToggle?: (playlistId: number, isFavorited: boolean) => void
} }
export function PlaylistTable({ playlists, onEdit, onSetCurrent }: PlaylistTableProps) { export function PlaylistTable({ playlists, onEdit, onSetCurrent, onFavoriteToggle }: PlaylistTableProps) {
return ( return (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
@@ -37,6 +38,7 @@ export function PlaylistTable({ playlists, onEdit, onSetCurrent }: PlaylistTable
playlist={playlist} playlist={playlist}
onEdit={onEdit} onEdit={onEdit}
onSetCurrent={onSetCurrent} onSetCurrent={onSetCurrent}
onFavoriteToggle={onFavoriteToggle}
/> />
))} ))}
</TableBody> </TableBody>

View File

@@ -8,7 +8,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import type { PlaylistSortField, SortOrder } from '@/lib/api/services/playlists' import type { PlaylistSortField, SortOrder } from '@/lib/api/services/playlists'
import { Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react' import { Heart, Plus, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
interface PlaylistsHeaderProps { interface PlaylistsHeaderProps {
searchQuery: string searchQuery: string
@@ -22,6 +22,8 @@ interface PlaylistsHeaderProps {
loading: boolean loading: boolean
error: string | null error: string | null
playlistCount: number playlistCount: number
showFavoritesOnly: boolean
onFavoritesToggle: (show: boolean) => void
} }
export function PlaylistsHeader({ export function PlaylistsHeader({
@@ -36,6 +38,8 @@ export function PlaylistsHeader({
loading, loading,
error, error,
playlistCount, playlistCount,
showFavoritesOnly,
onFavoritesToggle,
}: PlaylistsHeaderProps) { }: PlaylistsHeaderProps) {
return ( return (
<> <>
@@ -50,7 +54,10 @@ export function PlaylistsHeader({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{!loading && !error && ( {!loading && !error && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{playlistCount} playlist{playlistCount !== 1 ? 's' : ''} {showFavoritesOnly
? `${playlistCount} favorite playlist${playlistCount !== 1 ? 's' : ''}`
: `${playlistCount} playlist${playlistCount !== 1 ? 's' : ''}`
}
</div> </div>
)} )}
<Button onClick={onCreateClick}> <Button onClick={onCreateClick}>
@@ -116,6 +123,15 @@ export function PlaylistsHeader({
)} )}
</Button> </Button>
<Button
variant={showFavoritesOnly ? "default" : "outline"}
size="icon"
onClick={() => onFavoritesToggle(!showFavoritesOnly)}
title={showFavoritesOnly ? "Show all playlists" : "Show only favorites"}
>
<Heart className={`h-4 w-4 ${showFavoritesOnly ? 'fill-current' : ''}`} />
</Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"

View File

@@ -1,5 +1,5 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, Music } from 'lucide-react' import { AlertCircle } from 'lucide-react'
interface PlaylistsLoadingProps { interface PlaylistsLoadingProps {
count?: number count?: number
@@ -39,19 +39,25 @@ export function PlaylistsError({ error, onRetry }: PlaylistsErrorProps) {
interface PlaylistsEmptyProps { interface PlaylistsEmptyProps {
searchQuery: string searchQuery: string
showFavoritesOnly?: boolean
} }
export function PlaylistsEmpty({ searchQuery }: PlaylistsEmptyProps) { export function PlaylistsEmpty({ searchQuery, showFavoritesOnly = false }: PlaylistsEmptyProps) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 text-center"> <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"> <div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Music className="h-6 w-6 text-muted-foreground" /> <span className="text-2xl">{showFavoritesOnly ? '💝' : '🎵'}</span>
</div> </div>
<h3 className="text-lg font-semibold mb-2">No playlists found</h3> <h3 className="text-lg font-semibold mb-2">
{showFavoritesOnly ? 'No favorite playlists found' : 'No playlists found'}
</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{searchQuery {showFavoritesOnly
? 'No playlists match your search criteria.' ? 'You haven\'t favorited any playlists yet. Click the heart icon on playlists to add them to your favorites.'
: 'No playlists are available.'} : searchQuery
? 'No playlists match your search criteria.'
: 'No playlists are available.'
}
</p> </p>
</div> </div>
) )

View File

@@ -19,6 +19,8 @@ export interface Playlist {
is_main: boolean is_main: boolean
is_current: boolean is_current: boolean
is_deletable: boolean is_deletable: boolean
is_favorited: boolean
favorite_count: number
created_at: string created_at: string
updated_at: string | null updated_at: string | null
sound_count: number sound_count: number
@@ -45,6 +47,7 @@ export interface GetPlaylistsParams {
sort_order?: SortOrder sort_order?: SortOrder
limit?: number limit?: number
offset?: number offset?: number
favorites_only?: boolean
} }
export class PlaylistsService { export class PlaylistsService {
@@ -70,6 +73,9 @@ export class PlaylistsService {
if (params?.offset) { if (params?.offset) {
searchParams.append('offset', params.offset.toString()) searchParams.append('offset', params.offset.toString())
} }
if (params?.favorites_only) {
searchParams.append('favorites_only', 'true')
}
const url = searchParams.toString() const url = searchParams.toString()
? `/api/v1/playlists/?${searchParams.toString()}` ? `/api/v1/playlists/?${searchParams.toString()}`

View File

@@ -13,6 +13,7 @@ import {
type SortOrder, type SortOrder,
playlistsService, playlistsService,
} from '@/lib/api/services/playlists' } from '@/lib/api/services/playlists'
import { favoritesService } from '@/lib/api/services/favorites'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -27,6 +28,7 @@ export function PlaylistsPage() {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<PlaylistSortField>('name') const [sortBy, setSortBy] = useState<PlaylistSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc') const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false)
// Create playlist dialog state // Create playlist dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false) const [showCreateDialog, setShowCreateDialog] = useState(false)
@@ -56,6 +58,7 @@ export function PlaylistsPage() {
search: debouncedSearchQuery.trim() || undefined, search: debouncedSearchQuery.trim() || undefined,
sort_by: sortBy, sort_by: sortBy,
sort_order: sortOrder, sort_order: sortOrder,
favorites_only: showFavoritesOnly,
}) })
setPlaylists(playlistData) setPlaylists(playlistData)
} catch (err) { } catch (err) {
@@ -70,7 +73,7 @@ export function PlaylistsPage() {
useEffect(() => { useEffect(() => {
fetchPlaylists() fetchPlaylists()
}, [debouncedSearchQuery, sortBy, sortOrder]) }, [debouncedSearchQuery, sortBy, sortOrder, showFavoritesOnly])
const handleCreatePlaylist = async () => { const handleCreatePlaylist = async () => {
if (!newPlaylist.name.trim()) { if (!newPlaylist.name.trim()) {
@@ -126,6 +129,39 @@ export function PlaylistsPage() {
navigate(`/playlists/${playlist.id}/edit`) navigate(`/playlists/${playlist.id}/edit`)
} }
const handleFavoriteToggle = async (playlistId: number, shouldFavorite: boolean) => {
try {
if (shouldFavorite) {
await favoritesService.addPlaylistFavorite(playlistId)
toast.success('Added to favorites')
} else {
await favoritesService.removePlaylistFavorite(playlistId)
toast.success('Removed from favorites')
}
// Update the playlist in the local state
setPlaylists(prevPlaylists =>
prevPlaylists.map(playlist =>
playlist.id === playlistId
? {
...playlist,
is_favorited: shouldFavorite,
favorite_count: shouldFavorite
? playlist.favorite_count + 1
: Math.max(0, playlist.favorite_count - 1),
}
: playlist,
),
)
} catch (error) {
toast.error(
`Failed to ${shouldFavorite ? 'add to' : 'remove from'} favorites: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
)
}
}
const renderContent = () => { const renderContent = () => {
if (loading) { if (loading) {
return <PlaylistsLoading /> return <PlaylistsLoading />
@@ -136,7 +172,7 @@ export function PlaylistsPage() {
} }
if (playlists.length === 0) { if (playlists.length === 0) {
return <PlaylistsEmpty searchQuery={searchQuery} /> return <PlaylistsEmpty searchQuery={searchQuery} showFavoritesOnly={showFavoritesOnly} />
} }
return ( return (
@@ -144,6 +180,7 @@ export function PlaylistsPage() {
playlists={playlists} playlists={playlists}
onEdit={handleEditPlaylist} onEdit={handleEditPlaylist}
onSetCurrent={handleSetCurrent} onSetCurrent={handleSetCurrent}
onFavoriteToggle={handleFavoriteToggle}
/> />
) )
} }
@@ -167,6 +204,8 @@ export function PlaylistsPage() {
loading={loading} loading={loading}
error={error} error={error}
playlistCount={playlists.length} playlistCount={playlists.length}
showFavoritesOnly={showFavoritesOnly}
onFavoritesToggle={setShowFavoritesOnly}
/> />
<CreatePlaylistDialog <CreatePlaylistDialog