{artist.albums.map((album) => (
-
+
- {album.title}
+
+
{artist.name}
))}
diff --git a/apps/web/src/pages/favorites-page.tsx b/apps/web/src/pages/favorites-page.tsx
new file mode 100644
index 0000000..b84c9e3
--- /dev/null
+++ b/apps/web/src/pages/favorites-page.tsx
@@ -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 (
+
+
+
Пока в избранном пусто
+
Отмечай треки, альбомы и исполнителей сердечком.
+
+
+ )
+ }
+
+ return (
+
+ {tracks.length > 0 ? (
+
+ Любимые треки
+
+
+
#
+
Название
+
Альбом
+
◷
+
♡
+
+ {tracks.map((track, index) => (
+
+ ))}
+
+
+ ) : null}
+
+ {albums.length > 0 ? (
+
+ Любимые альбомы
+
+ {albums.map((album) => (
+
+
+ {album.coverArtId ?
: null}
+
+
+
+
+ {album.title}
+
+
{album.artistName}
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+ {artists.length > 0 ? (
+
+ Любимые исполнители
+
+ {artists.map((artist) => (
+
+
+
+ {artist.coverArtId ?
})
: null}
+
+
+
{artist.name}
+
{artist.albumCount} альбомов
+
+
+
+
+ ))}
+
+
+ ) : null}
+
+ )
+}
+
+function formatDuration(durationSeconds: number) {
+ const minutes = Math.floor(durationSeconds / 60)
+ const seconds = durationSeconds % 60
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`
+}
diff --git a/apps/web/src/pages/tracks-page.tsx b/apps/web/src/pages/tracks-page.tsx
index 42e7d1b..37ddd15 100644
--- a/apps/web/src/pages/tracks-page.tsx
+++ b/apps/web/src/pages/tracks-page.tsx
@@ -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 (
@@ -58,7 +64,7 @@ export function TracksPage() {
FLAC
-
+
))}
diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go
index 83cbf42..91d9df0 100644
--- a/internal/httpapi/router.go
+++ b/internal/httpapi/router.go
@@ -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)