feat: add favorites page and web starring

This commit is contained in:
2026-04-03 01:36:43 +03:00
parent b3723b2167
commit 1e6f200433
8 changed files with 322 additions and 19 deletions

View File

@@ -5,6 +5,7 @@ import { AlbumDetailPage } from '@/pages/album-detail-page'
import { ArtistsPage } from '@/pages/artists-page'
import { ArtistDetailPage } from '@/pages/artist-detail-page'
import { EmptyStatePage } from '@/pages/empty-state-page'
import { FavoritesPage } from '@/pages/favorites-page'
import { HomePage } from '@/pages/home-page'
import { LoginPage } from '@/pages/login-page'
import { PlaylistDetailPage } from '@/pages/playlist-detail-page'
@@ -29,7 +30,7 @@ export default function App() {
<Route path="/albums" element={<AlbumsPage />} />
<Route path="/albums/:id" element={<AlbumDetailPage />} />
<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/:id" element={<PlaylistDetailPage />} />
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />

View 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>
)
}

View File

@@ -64,6 +64,12 @@ export type HomePayload = {
artists: Artist[]
}
export type FavoritesPayload = {
artists: Artist[]
albums: Album[]
tracks: Track[]
}
export type PlaylistSummary = {
id: string
name: string
@@ -190,6 +196,32 @@ export async function deletePlaylist(id: string) {
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) {
const token = useSessionStore.getState().token
return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`

View File

@@ -1,7 +1,8 @@
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 { coverArtUrl, fetchAlbum } from '@/lib/api'
import { coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function AlbumDetailPage() {
@@ -12,6 +13,10 @@ export function AlbumDetailPage() {
queryKey: ['album', id],
queryFn: () => fetchAlbum(id),
})
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const album = albumQuery.data
@@ -20,6 +25,8 @@ export function AlbumDetailPage() {
}
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 (
<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">
<Shuffle size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<Heart size={24} />
</button>
<FavoriteToggle active={favoriteAlbumIds.has(album.id)} className="text-slate-300 transition hover:text-white" entityId={album.id} entityType="album" size={24} />
<button className="text-slate-300 transition hover:text-white" type="button">
<MoreVertical size={24} />
</button>
@@ -93,7 +98,9 @@ export function AlbumDetailPage() {
<div>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
</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>
))}
</div>

View File

@@ -1,14 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
import { coverArtUrl, fetchArtist } from '@/lib/api'
import { useParams } from 'react-router-dom'
import { MoreVertical, Play, Shuffle } from 'lucide-react'
import { useNavigate, useParams } from 'react-router-dom'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchArtist, fetchFavorites } from '@/lib/api'
export function ArtistDetailPage() {
const { id = '' } = useParams()
const navigate = useNavigate()
const artistQuery = useQuery({
queryKey: ['artist', id],
queryFn: () => fetchArtist(id),
})
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const artist = artistQuery.data
@@ -16,6 +22,8 @@ export function ArtistDetailPage() {
return <div className="text-slate-400">Загрузка исполнителя...</div>
}
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
return (
<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">
@@ -41,9 +49,7 @@ export function ArtistDetailPage() {
<button className="text-slate-300 transition hover:text-white" type="button">
<Shuffle size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<Heart size={24} />
</button>
<FavoriteToggle active={favoriteArtistIds.has(artist.id)} className="text-slate-300 transition hover:text-white" entityId={artist.id} entityType="artist" size={24} />
<button className="text-slate-300 transition hover:text-white" type="button">
<MoreVertical size={24} />
</button>
@@ -58,10 +64,10 @@ export function ArtistDetailPage() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{artist.albums.map((album) => (
<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}
</div>
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{album.title}</div>
</button>
<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>
</article>
))}

View 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')}`
}

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { Heart, Search } from 'lucide-react'
import { coverArtUrl, fetchTracks } from '@/lib/api'
import { Search } from 'lucide-react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function TracksPage() {
@@ -10,8 +11,13 @@ export function TracksPage() {
queryKey: ['tracks'],
queryFn: fetchTracks,
})
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const tracks = tracksQuery.data?.items ?? []
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((track) => track.id))
return (
<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>
</div>
<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>
</button>
))}

View File

@@ -64,6 +64,9 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
private.Get("/tracks", application.tracks)
private.Get("/tracks/{id}", application.trackByID)
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.Post("/playlists", application.createPlaylist)
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)
}
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) {
user := currentUserFromContext(r)
items, err := a.playlists.List(r.Context(), user.ID)