feat: add favorites page and web starring
This commit is contained in:
@@ -5,6 +5,7 @@ import { AlbumDetailPage } from '@/pages/album-detail-page'
|
|||||||
import { ArtistsPage } from '@/pages/artists-page'
|
import { ArtistsPage } from '@/pages/artists-page'
|
||||||
import { ArtistDetailPage } from '@/pages/artist-detail-page'
|
import { ArtistDetailPage } from '@/pages/artist-detail-page'
|
||||||
import { EmptyStatePage } from '@/pages/empty-state-page'
|
import { EmptyStatePage } from '@/pages/empty-state-page'
|
||||||
|
import { FavoritesPage } from '@/pages/favorites-page'
|
||||||
import { HomePage } from '@/pages/home-page'
|
import { HomePage } from '@/pages/home-page'
|
||||||
import { LoginPage } from '@/pages/login-page'
|
import { LoginPage } from '@/pages/login-page'
|
||||||
import { PlaylistDetailPage } from '@/pages/playlist-detail-page'
|
import { PlaylistDetailPage } from '@/pages/playlist-detail-page'
|
||||||
@@ -29,7 +30,7 @@ export default function App() {
|
|||||||
<Route path="/albums" element={<AlbumsPage />} />
|
<Route path="/albums" element={<AlbumsPage />} />
|
||||||
<Route path="/albums/:id" element={<AlbumDetailPage />} />
|
<Route path="/albums/:id" element={<AlbumDetailPage />} />
|
||||||
<Route path="/genres" element={<EmptyStatePage compact title="Жанры" />} />
|
<Route path="/genres" element={<EmptyStatePage compact title="Жанры" />} />
|
||||||
<Route path="/favorites" element={<EmptyStatePage compact title="Вы еще не добавили песни в избранное!" />} />
|
<Route path="/favorites" element={<FavoritesPage />} />
|
||||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||||
<Route path="/playlists/:id" element={<PlaylistDetailPage />} />
|
<Route path="/playlists/:id" element={<PlaylistDetailPage />} />
|
||||||
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
|
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
|
||||||
|
|||||||
60
apps/web/src/components/favorite-toggle.tsx
Normal file
60
apps/web/src/components/favorite-toggle.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Heart } from 'lucide-react'
|
||||||
|
import { starFavorites, unstarFavorites } from '@/lib/api'
|
||||||
|
|
||||||
|
type FavoriteToggleProps = {
|
||||||
|
entityType: 'track' | 'album' | 'artist'
|
||||||
|
entityId: string
|
||||||
|
active: boolean
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoriteToggle({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
active,
|
||||||
|
size = 18,
|
||||||
|
className = '',
|
||||||
|
}: FavoriteToggleProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const payload =
|
||||||
|
entityType === 'track'
|
||||||
|
? { trackIds: [entityId] }
|
||||||
|
: entityType === 'album'
|
||||||
|
? { albumIds: [entityId] }
|
||||||
|
: { artistIds: [entityId] }
|
||||||
|
|
||||||
|
return active ? unstarFavorites(payload) : starFavorites(payload)
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['favorites'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tracks'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['albums'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['artists'] }),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={className || 'text-slate-400 transition hover:text-white'}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
mutation.mutate()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
fill={active ? 'currentColor' : 'none'}
|
||||||
|
size={size}
|
||||||
|
strokeWidth={active ? 1.9 : 1.7}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -64,6 +64,12 @@ export type HomePayload = {
|
|||||||
artists: Artist[]
|
artists: Artist[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FavoritesPayload = {
|
||||||
|
artists: Artist[]
|
||||||
|
albums: Album[]
|
||||||
|
tracks: Track[]
|
||||||
|
}
|
||||||
|
|
||||||
export type PlaylistSummary = {
|
export type PlaylistSummary = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -190,6 +196,32 @@ export async function deletePlaylist(id: string) {
|
|||||||
await request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
|
await request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchFavorites() {
|
||||||
|
return request<FavoritesPayload>('/api/favorites')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function starFavorites(input: {
|
||||||
|
trackIds?: string[]
|
||||||
|
albumIds?: string[]
|
||||||
|
artistIds?: string[]
|
||||||
|
}) {
|
||||||
|
return request<FavoritesPayload>('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unstarFavorites(input: {
|
||||||
|
trackIds?: string[]
|
||||||
|
albumIds?: string[]
|
||||||
|
artistIds?: string[]
|
||||||
|
}) {
|
||||||
|
return request<FavoritesPayload>('/api/favorites', {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function coverArtUrl(id: string) {
|
export function coverArtUrl(id: string) {
|
||||||
const token = useSessionStore.getState().token
|
const token = useSessionStore.getState().token
|
||||||
return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
|
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||||
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { coverArtUrl, fetchAlbum } from '@/lib/api'
|
import { coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
|
||||||
import { usePlayerStore } from '@/stores/player-store'
|
import { usePlayerStore } from '@/stores/player-store'
|
||||||
|
|
||||||
export function AlbumDetailPage() {
|
export function AlbumDetailPage() {
|
||||||
@@ -12,6 +13,10 @@ export function AlbumDetailPage() {
|
|||||||
queryKey: ['album', id],
|
queryKey: ['album', id],
|
||||||
queryFn: () => fetchAlbum(id),
|
queryFn: () => fetchAlbum(id),
|
||||||
})
|
})
|
||||||
|
const favoritesQuery = useQuery({
|
||||||
|
queryKey: ['favorites'],
|
||||||
|
queryFn: fetchFavorites,
|
||||||
|
})
|
||||||
|
|
||||||
const album = albumQuery.data
|
const album = albumQuery.data
|
||||||
|
|
||||||
@@ -20,6 +25,8 @@ export function AlbumDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalDuration = album.tracks.reduce((sum, track) => sum + track.durationSeconds, 0)
|
const totalDuration = album.tracks.reduce((sum, track) => sum + track.durationSeconds, 0)
|
||||||
|
const favoriteAlbumIds = new Set((favoritesQuery.data?.albums ?? []).map((item) => item.id))
|
||||||
|
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
|
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
|
||||||
@@ -50,9 +57,7 @@ export function AlbumDetailPage() {
|
|||||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||||
<Shuffle size={24} />
|
<Shuffle size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
<FavoriteToggle active={favoriteAlbumIds.has(album.id)} className="text-slate-300 transition hover:text-white" entityId={album.id} entityType="album" size={24} />
|
||||||
<Heart size={24} />
|
|
||||||
</button>
|
|
||||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||||
<MoreVertical size={24} />
|
<MoreVertical size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -93,7 +98,9 @@ export function AlbumDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-slate-500">♡</div>
|
<div className="grid place-items-center text-slate-500">
|
||||||
|
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
|
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||||
import { coverArtUrl, fetchArtist } from '@/lib/api'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useParams } from 'react-router-dom'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
|
import { coverArtUrl, fetchArtist, fetchFavorites } from '@/lib/api'
|
||||||
|
|
||||||
export function ArtistDetailPage() {
|
export function ArtistDetailPage() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const artistQuery = useQuery({
|
const artistQuery = useQuery({
|
||||||
queryKey: ['artist', id],
|
queryKey: ['artist', id],
|
||||||
queryFn: () => fetchArtist(id),
|
queryFn: () => fetchArtist(id),
|
||||||
})
|
})
|
||||||
|
const favoritesQuery = useQuery({
|
||||||
|
queryKey: ['favorites'],
|
||||||
|
queryFn: fetchFavorites,
|
||||||
|
})
|
||||||
|
|
||||||
const artist = artistQuery.data
|
const artist = artistQuery.data
|
||||||
|
|
||||||
@@ -16,6 +22,8 @@ export function ArtistDetailPage() {
|
|||||||
return <div className="text-slate-400">Загрузка исполнителя...</div>
|
return <div className="text-slate-400">Загрузка исполнителя...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
|
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
|
||||||
<div className="flex min-h-[300px] items-end gap-5 bg-[linear-gradient(180deg,rgba(189,16,37,0.78),rgba(125,15,29,0.92))] px-8 py-6">
|
<div className="flex min-h-[300px] items-end gap-5 bg-[linear-gradient(180deg,rgba(189,16,37,0.78),rgba(125,15,29,0.92))] px-8 py-6">
|
||||||
@@ -41,9 +49,7 @@ export function ArtistDetailPage() {
|
|||||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||||
<Shuffle size={24} />
|
<Shuffle size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
<FavoriteToggle active={favoriteArtistIds.has(artist.id)} className="text-slate-300 transition hover:text-white" entityId={artist.id} entityType="artist" size={24} />
|
||||||
<Heart size={24} />
|
|
||||||
</button>
|
|
||||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||||
<MoreVertical size={24} />
|
<MoreVertical size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -58,10 +64,10 @@ export function ArtistDetailPage() {
|
|||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
||||||
{artist.albums.map((album) => (
|
{artist.albums.map((album) => (
|
||||||
<article key={album.id}>
|
<article key={album.id}>
|
||||||
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
|
<button className="aspect-square w-full overflow-hidden rounded-[8px] bg-[#232d42]" onClick={() => navigate(`/albums/${album.id}`)} type="button">
|
||||||
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
|
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
|
||||||
</div>
|
</button>
|
||||||
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{album.title}</div>
|
<button className="mt-3 line-clamp-1 text-left text-[1.08rem] font-semibold text-white" onClick={() => navigate(`/albums/${album.id}`)} type="button">{album.title}</button>
|
||||||
<div className="text-base text-slate-400">{artist.name}</div>
|
<div className="text-base text-slate-400">{artist.name}</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|||||||
132
apps/web/src/pages/favorites-page.tsx
Normal file
132
apps/web/src/pages/favorites-page.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
|
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
||||||
|
import { usePlayerStore } from '@/stores/player-store'
|
||||||
|
|
||||||
|
export function FavoritesPage() {
|
||||||
|
const setQueue = usePlayerStore((state) => state.setQueue)
|
||||||
|
const playTrack = usePlayerStore((state) => state.playTrack)
|
||||||
|
const favoritesQuery = useQuery({
|
||||||
|
queryKey: ['favorites'],
|
||||||
|
queryFn: fetchFavorites,
|
||||||
|
})
|
||||||
|
|
||||||
|
const favorites = favoritesQuery.data
|
||||||
|
const tracks = favorites?.tracks ?? []
|
||||||
|
const albums = favorites?.albums ?? []
|
||||||
|
const artists = favorites?.artists ?? []
|
||||||
|
|
||||||
|
if (tracks.length === 0 && albums.length === 0 && artists.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-[520px] place-items-center rounded-[16px] border border-dashed border-[#24314f] bg-[#121b2e]">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl font-semibold text-white">Пока в избранном пусто</div>
|
||||||
|
<div className="mt-4 text-lg text-slate-400">Отмечай треки, альбомы и исполнителей сердечком.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
{tracks.length > 0 ? (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="text-[2rem] font-semibold tracking-tight text-white">Любимые треки</div>
|
||||||
|
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]">
|
||||||
|
<div className="grid grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
|
||||||
|
<div>#</div>
|
||||||
|
<div>Название</div>
|
||||||
|
<div>Альбом</div>
|
||||||
|
<div>◷</div>
|
||||||
|
<div>♡</div>
|
||||||
|
</div>
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<button
|
||||||
|
key={track.id}
|
||||||
|
className="grid w-full grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 border-t border-[#1f2940] px-4 py-3 text-left transition hover:bg-[#172237]"
|
||||||
|
onClick={() => {
|
||||||
|
setQueue(tracks, index)
|
||||||
|
playTrack(track, tracks)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="text-lg text-slate-200">{index + 1}</div>
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-[6px] bg-[#303b4d]">
|
||||||
|
{track.coverArtId ? <img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[1.02rem] text-white">{track.title}</div>
|
||||||
|
<div className="truncate text-base text-slate-400">{track.artistName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-base text-slate-300">{track.albumTitle}</div>
|
||||||
|
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
|
||||||
|
<div className="grid place-items-center">
|
||||||
|
<FavoriteToggle active entityId={track.id} entityType="track" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{albums.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Любимые альбомы</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
||||||
|
{albums.map((album) => (
|
||||||
|
<article key={album.id}>
|
||||||
|
<Link className="block aspect-square overflow-hidden rounded-[8px] bg-[#232d42]" to={`/albums/${album.id}`}>
|
||||||
|
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-3 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link className="line-clamp-1 block text-[1.08rem] font-semibold text-white hover:underline" to={`/albums/${album.id}`}>
|
||||||
|
{album.title}
|
||||||
|
</Link>
|
||||||
|
<div className="text-base text-slate-400">{album.artistName}</div>
|
||||||
|
</div>
|
||||||
|
<FavoriteToggle active entityId={album.id} entityType="album" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{artists.length > 0 ? (
|
||||||
|
<section>
|
||||||
|
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Любимые исполнители</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{artists.map((artist) => (
|
||||||
|
<Link
|
||||||
|
key={artist.id}
|
||||||
|
className="flex items-center justify-between rounded-[12px] border border-[#24314f] bg-[#121b2e] px-5 py-4 transition hover:border-[#39527e] hover:bg-[#16233b]"
|
||||||
|
to={`/artists/${artist.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
|
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-[#303b4d]">
|
||||||
|
{artist.coverArtId ? <img alt={artist.name} className="h-full w-full object-cover" src={coverArtUrl(artist.id)} /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-lg font-medium text-white">{artist.name}</div>
|
||||||
|
<div className="text-sm text-slate-400">{artist.albumCount} альбомов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FavoriteToggle active entityId={artist.id} entityType="artist" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(durationSeconds: number) {
|
||||||
|
const minutes = Math.floor(durationSeconds / 60)
|
||||||
|
const seconds = durationSeconds % 60
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Heart, Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { coverArtUrl, fetchTracks } from '@/lib/api'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
|
import { coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
|
||||||
import { usePlayerStore } from '@/stores/player-store'
|
import { usePlayerStore } from '@/stores/player-store'
|
||||||
|
|
||||||
export function TracksPage() {
|
export function TracksPage() {
|
||||||
@@ -10,8 +11,13 @@ export function TracksPage() {
|
|||||||
queryKey: ['tracks'],
|
queryKey: ['tracks'],
|
||||||
queryFn: fetchTracks,
|
queryFn: fetchTracks,
|
||||||
})
|
})
|
||||||
|
const favoritesQuery = useQuery({
|
||||||
|
queryKey: ['favorites'],
|
||||||
|
queryFn: fetchFavorites,
|
||||||
|
})
|
||||||
|
|
||||||
const tracks = tracksQuery.data?.items ?? []
|
const tracks = tracksQuery.data?.items ?? []
|
||||||
|
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((track) => track.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -58,7 +64,7 @@ export function TracksPage() {
|
|||||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid place-items-center text-slate-500">
|
<div className="grid place-items-center text-slate-500">
|
||||||
<Heart size={16} />
|
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
|||||||
private.Get("/tracks", application.tracks)
|
private.Get("/tracks", application.tracks)
|
||||||
private.Get("/tracks/{id}", application.trackByID)
|
private.Get("/tracks/{id}", application.trackByID)
|
||||||
private.Get("/search", application.search)
|
private.Get("/search", application.search)
|
||||||
|
private.Get("/favorites", application.favorites)
|
||||||
|
private.Post("/favorites", application.starFavorites)
|
||||||
|
private.Delete("/favorites", application.unstarFavorites)
|
||||||
private.Get("/playlists", application.playlistsList)
|
private.Get("/playlists", application.playlistsList)
|
||||||
private.Post("/playlists", application.createPlaylist)
|
private.Post("/playlists", application.createPlaylist)
|
||||||
private.Get("/playlists/{id}", application.playlistByID)
|
private.Get("/playlists/{id}", application.playlistByID)
|
||||||
@@ -234,6 +237,62 @@ func (a app) search(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, results)
|
writeJSON(w, http.StatusOK, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a app) favorites(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUserFromContext(r)
|
||||||
|
results, err := a.library.Starred(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a app) starFavorites(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUserFromContext(r)
|
||||||
|
var payload struct {
|
||||||
|
TrackIDs []string `json:"trackIds"`
|
||||||
|
AlbumIDs []string `json:"albumIds"`
|
||||||
|
ArtistIDs []string `json:"artistIds"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.library.Star(r.Context(), user.ID, payload.TrackIDs, payload.AlbumIDs, payload.ArtistIDs); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to star favorites"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err := a.library.Starred(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a app) unstarFavorites(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUserFromContext(r)
|
||||||
|
var payload struct {
|
||||||
|
TrackIDs []string `json:"trackIds"`
|
||||||
|
AlbumIDs []string `json:"albumIds"`
|
||||||
|
ArtistIDs []string `json:"artistIds"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.library.Unstar(r.Context(), user.ID, payload.TrackIDs, payload.AlbumIDs, payload.ArtistIDs); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to unstar favorites"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err := a.library.Starred(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, results)
|
||||||
|
}
|
||||||
|
|
||||||
func (a app) playlistsList(w http.ResponseWriter, r *http.Request) {
|
func (a app) playlistsList(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUserFromContext(r)
|
user := currentUserFromContext(r)
|
||||||
items, err := a.playlists.List(r.Context(), user.ID)
|
items, err := a.playlists.List(r.Context(), user.ID)
|
||||||
|
|||||||
Reference in New Issue
Block a user