diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 37ccb58..bca0c51 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/favorite-toggle.tsx b/apps/web/src/components/favorite-toggle.tsx new file mode 100644 index 0000000..d1c7969 --- /dev/null +++ b/apps/web/src/components/favorite-toggle.tsx @@ -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 ( + + ) +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f30559d..e8e338d 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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(`/api/playlists/${id}`, { method: 'DELETE' }) } +export async function fetchFavorites() { + return request('/api/favorites') +} + +export async function starFavorites(input: { + trackIds?: string[] + albumIds?: string[] + artistIds?: string[] +}) { + return request('/api/favorites', { + method: 'POST', + body: JSON.stringify(input), + }) +} + +export async function unstarFavorites(input: { + trackIds?: string[] + albumIds?: string[] + artistIds?: string[] +}) { + return request('/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)}` : ''}` diff --git a/apps/web/src/pages/album-detail-page.tsx b/apps/web/src/pages/album-detail-page.tsx index 749db89..2fa50ff 100644 --- a/apps/web/src/pages/album-detail-page.tsx +++ b/apps/web/src/pages/album-detail-page.tsx @@ -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 (
@@ -50,9 +57,7 @@ export function AlbumDetailPage() { - + @@ -93,7 +98,9 @@ export function AlbumDetailPage() {
FLAC
-
+
+ +
))}
diff --git a/apps/web/src/pages/artist-detail-page.tsx b/apps/web/src/pages/artist-detail-page.tsx index 097e9ca..2657dcd 100644 --- a/apps/web/src/pages/artist-detail-page.tsx +++ b/apps/web/src/pages/artist-detail-page.tsx @@ -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
Загрузка исполнителя...
} + const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id)) + return (
@@ -41,9 +49,7 @@ export function ArtistDetailPage() { - + @@ -58,10 +64,10 @@ export function ArtistDetailPage() {
{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 ? {album.title} : null} + +
+
+ + {album.title} + +
{album.artistName}
+
+ +
+
+ ))} +
+
+ ) : null} + + {artists.length > 0 ? ( +
+
Любимые исполнители
+
+ {artists.map((artist) => ( + +
+
+ {artist.coverArtId ? {artist.name} : 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)