feat: implement favorites functionality across playlists components
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()}`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user