Compare commits
5 Commits
b3723b2167
...
2bbf52a41b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bbf52a41b | |||
| 62ab2a9417 | |||
| 3abc864abd | |||
| 56aa822730 | |||
| 1e6f200433 |
@@ -522,7 +522,7 @@ Responsibilities:
|
||||
- [x] Implement auth middleware
|
||||
- [x] Implement current user endpoint
|
||||
- [x] Implement admin bootstrap user creation
|
||||
- [ ] Add logout endpoint
|
||||
- [x] Add logout endpoint
|
||||
|
||||
## Library Scanning
|
||||
|
||||
@@ -550,7 +550,7 @@ Responsibilities:
|
||||
- [x] Track detail
|
||||
- [x] Recent albums
|
||||
- [x] Random albums or songs
|
||||
- [ ] Favorites listing
|
||||
- [x] Favorites listing
|
||||
- [x] Search endpoint
|
||||
- [ ] Pagination support
|
||||
- [ ] Sorting support
|
||||
@@ -639,8 +639,8 @@ Responsibilities:
|
||||
- [x] Artist detail page
|
||||
- [x] Album detail page
|
||||
- [x] Playlist page
|
||||
- [ ] Search results page
|
||||
- [ ] Favorites page
|
||||
- [x] Search results page
|
||||
- [x] Favorites page
|
||||
- [ ] Recently played page
|
||||
|
||||
## Frontend Player
|
||||
@@ -649,10 +649,10 @@ Responsibilities:
|
||||
- [x] Queue model
|
||||
- [x] Play/pause
|
||||
- [x] Next/previous
|
||||
- [ ] Seek bar
|
||||
- [x] Seek bar
|
||||
- [x] Volume control
|
||||
- [ ] Repeat modes
|
||||
- [ ] Shuffle
|
||||
- [x] Repeat modes
|
||||
- [x] Shuffle
|
||||
- [x] Track switching
|
||||
- [x] Keyboard shortcuts
|
||||
- [x] Mini player
|
||||
@@ -683,7 +683,7 @@ Responsibilities:
|
||||
- [x] Single app port for web UI and Subsonic clients
|
||||
- [x] Reverse proxy example
|
||||
- [x] HTTP/reverse proxy deployment notes
|
||||
- [ ] Backup/restore notes
|
||||
- [x] Backup/restore notes
|
||||
|
||||
## Nice-to-Have After MVP
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ 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'
|
||||
import { PlaylistsPage } from '@/pages/playlists-page'
|
||||
import { SearchPage } from '@/pages/search-page'
|
||||
import { TracksPage } from '@/pages/tracks-page'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
|
||||
@@ -29,9 +31,10 @@ 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="/search" element={<SearchPage />} />
|
||||
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -19,6 +20,7 @@ import { CommandPalette } from '@/components/command-palette'
|
||||
import { FullPlayer } from '@/components/full-player'
|
||||
import { PlayerBar } from '@/components/player-bar'
|
||||
import { SettingsModal } from '@/components/settings-modal'
|
||||
import { logout } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
|
||||
@@ -42,6 +44,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: logout,
|
||||
onSettled: () => {
|
||||
clearSession()
|
||||
setUserMenuOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
@@ -99,7 +108,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center justify-between border-t border-[#24314f] px-4 py-3 text-left text-sm text-slate-100 hover:bg-[#18233a]"
|
||||
onClick={clearSession}
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
Выйти из аккаунта
|
||||
|
||||
@@ -150,11 +150,21 @@ export function CommandPalette({
|
||||
label={track.title}
|
||||
meta={`${track.artistName} • ${track.albumTitle}`}
|
||||
onClick={() => {
|
||||
navigate('/tracks')
|
||||
navigate(`/search?q=${encodeURIComponent(deferredQuery)}`)
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{(searchQuery.data?.artists.length || searchQuery.data?.albums.length || searchQuery.data?.tracks.length) ? (
|
||||
<PaletteRow
|
||||
label={`Показать все результаты по "${deferredQuery}"`}
|
||||
meta="Страница поиска"
|
||||
onClick={() => {
|
||||
navigate(`/search?q=${encodeURIComponent(deferredQuery)}`)
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -177,7 +187,7 @@ export function CommandPalette({
|
||||
return
|
||||
}
|
||||
if (command.action === 'navigate') {
|
||||
navigate('/tracks')
|
||||
navigate('/search')
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronDown, Heart, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Volume2 } from 'lucide-react'
|
||||
import { ChevronDown, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Trash2, Volume2 } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { coverArtUrl } from '@/lib/api'
|
||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
type LyricsLine = {
|
||||
@@ -16,12 +17,24 @@ export function FullPlayer() {
|
||||
const currentTime = usePlayerStore((state) => state.currentTime)
|
||||
const duration = usePlayerStore((state) => state.duration)
|
||||
const volume = usePlayerStore((state) => state.volume)
|
||||
const shuffle = usePlayerStore((state) => state.shuffle)
|
||||
const repeatMode = usePlayerStore((state) => state.repeatMode)
|
||||
const togglePlayback = usePlayerStore((state) => state.togglePlayback)
|
||||
const playNext = usePlayerStore((state) => state.playNext)
|
||||
const playPrevious = usePlayerStore((state) => state.playPrevious)
|
||||
const playAtIndex = usePlayerStore((state) => state.playAtIndex)
|
||||
const removeFromQueue = usePlayerStore((state) => state.removeFromQueue)
|
||||
const toggleShuffle = usePlayerStore((state) => state.toggleShuffle)
|
||||
const cycleRepeatMode = usePlayerStore((state) => state.cycleRepeatMode)
|
||||
const setVolume = usePlayerStore((state) => state.setVolume)
|
||||
const seekTo = usePlayerStore((state) => state.seekTo)
|
||||
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
|
||||
const [tab, setTab] = useState<'queue' | 'now' | 'lyrics'>('now')
|
||||
const favoritesQuery = useQuery({
|
||||
queryKey: ['favorites'],
|
||||
queryFn: fetchFavorites,
|
||||
enabled: !!currentTrack,
|
||||
})
|
||||
|
||||
const lyricsQuery = useQuery({
|
||||
queryKey: ['lrclib', currentTrack?.id],
|
||||
@@ -40,6 +53,7 @@ export function FullPlayer() {
|
||||
})
|
||||
|
||||
const parsedLyrics = useMemo(() => parseLyrics(lyricsQuery.data?.syncedLyrics ?? lyricsQuery.data?.plainLyrics ?? ''), [lyricsQuery.data])
|
||||
const favoriteTrackIds = useMemo(() => new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id)), [favoritesQuery.data])
|
||||
const activeLine = useMemo(() => {
|
||||
if (parsedLyrics.length === 0) {
|
||||
return -1
|
||||
@@ -113,22 +127,35 @@ export function FullPlayer() {
|
||||
{tab === 'queue' ? (
|
||||
<div className="mx-auto max-w-5xl space-y-3">
|
||||
{queue.map((track, index) => (
|
||||
<div
|
||||
<button
|
||||
key={`${track.id}-${index}`}
|
||||
className={[
|
||||
'flex items-center gap-4 rounded-[12px] px-4 py-3',
|
||||
'flex w-full items-center gap-4 rounded-[12px] px-4 py-3 text-left',
|
||||
track.id === currentTrack.id ? 'bg-white/10 text-white' : 'text-white/70',
|
||||
].join(' ')}
|
||||
onClick={() => playAtIndex(index)}
|
||||
type="button"
|
||||
>
|
||||
<div className="w-8 text-right text-sm">{index + 1}</div>
|
||||
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-white/10">
|
||||
{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="min-w-0 flex-1">
|
||||
<div className="truncate text-lg">{track.title}</div>
|
||||
<div className="truncate text-sm text-white/55">{track.artistName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="text-white/50 transition hover:text-white"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
removeFromQueue(track.id)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -137,9 +164,15 @@ export function FullPlayer() {
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-4 text-white/90">
|
||||
<span className="w-12 text-right text-[2rem]">{formatClock(currentTime)}</span>
|
||||
<div className="h-1.5 flex-1 rounded-full bg-white/20">
|
||||
<div className="h-1.5 rounded-full bg-white" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
|
||||
</div>
|
||||
<input
|
||||
className="h-1.5 flex-1 accent-white"
|
||||
max={duration || 0}
|
||||
min={0}
|
||||
onChange={(event) => seekTo(Number(event.target.value))}
|
||||
step={0.1}
|
||||
type="range"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
/>
|
||||
<span className="w-12 text-[2rem]">{formatClock(duration)}</span>
|
||||
</div>
|
||||
|
||||
@@ -154,17 +187,17 @@ export function FullPlayer() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 text-white/90">
|
||||
<IconControl icon={<Shuffle size={22} />} />
|
||||
<IconControl active={shuffle} icon={<Shuffle size={22} />} onClick={toggleShuffle} />
|
||||
<IconControl icon={<Rewind size={22} />} onClick={playPrevious} />
|
||||
<button className="grid h-16 w-16 place-items-center rounded-full bg-white text-[#121827]" onClick={togglePlayback} type="button">
|
||||
{isPlaying ? <Pause size={28} /> : <Play size={28} className="translate-x-[2px]" />}
|
||||
</button>
|
||||
<IconControl icon={<SkipForward size={22} />} onClick={playNext} />
|
||||
<IconControl icon={<Repeat2 size={22} />} />
|
||||
<IconControl active={repeatMode !== 'off'} icon={<Repeat2 size={22} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-6 text-white/90">
|
||||
<IconControl icon={<Heart size={22} />} />
|
||||
<FavoriteToggle active={favoriteTrackIds.has(currentTrack.id)} className="transition hover:text-white text-white/90" entityId={currentTrack.id} entityType="track" size={22} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 size={22} />
|
||||
<input
|
||||
@@ -214,14 +247,19 @@ function MetaTag({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function IconControl({
|
||||
icon,
|
||||
active = false,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
active?: boolean
|
||||
label?: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button className="transition hover:text-white" onClick={onClick} type="button">
|
||||
<button className={['relative transition hover:text-white', active ? 'text-[#16bf8c]' : ''].join(' ')} onClick={onClick} type="button">
|
||||
{icon}
|
||||
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
Expand,
|
||||
Forward,
|
||||
Heart,
|
||||
ListMusic,
|
||||
Pause,
|
||||
Play,
|
||||
@@ -21,12 +20,20 @@ export function PlayerBar() {
|
||||
const volume = usePlayerStore((state) => state.volume)
|
||||
const currentTime = usePlayerStore((state) => state.currentTime)
|
||||
const duration = usePlayerStore((state) => state.duration)
|
||||
const shuffle = usePlayerStore((state) => state.shuffle)
|
||||
const repeatMode = usePlayerStore((state) => state.repeatMode)
|
||||
const seekRequest = usePlayerStore((state) => state.seekRequest)
|
||||
const togglePlayback = usePlayerStore((state) => state.togglePlayback)
|
||||
const playNext = usePlayerStore((state) => state.playNext)
|
||||
const playPrevious = usePlayerStore((state) => state.playPrevious)
|
||||
const toggleShuffle = usePlayerStore((state) => state.toggleShuffle)
|
||||
const cycleRepeatMode = usePlayerStore((state) => state.cycleRepeatMode)
|
||||
const setVolume = usePlayerStore((state) => state.setVolume)
|
||||
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime)
|
||||
const setDuration = usePlayerStore((state) => state.setDuration)
|
||||
const seekTo = usePlayerStore((state) => state.seekTo)
|
||||
const clearSeekRequest = usePlayerStore((state) => state.clearSeekRequest)
|
||||
const handleTrackEnded = usePlayerStore((state) => state.handleTrackEnded)
|
||||
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,10 +58,19 @@ export function PlayerBar() {
|
||||
}
|
||||
}, [isPlaying, volume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current || seekRequest == null) {
|
||||
return
|
||||
}
|
||||
audioRef.current.currentTime = seekRequest
|
||||
clearSeekRequest()
|
||||
}, [seekRequest, clearSeekRequest])
|
||||
|
||||
return (
|
||||
<footer className="grid grid-cols-[260px_minmax(0,1fr)_280px] items-center border-t border-[#24314f] bg-[#091228] px-4 py-3">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
onEnded={handleTrackEnded}
|
||||
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
|
||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||
preload="metadata"
|
||||
@@ -74,7 +90,7 @@ export function PlayerBar() {
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-5 text-slate-400">
|
||||
<BarIcon icon={<Shuffle size={18} />} />
|
||||
<BarIcon active={shuffle} icon={<Shuffle size={18} />} onClick={toggleShuffle} />
|
||||
<BarIcon icon={<Rewind size={18} />} onClick={playPrevious} />
|
||||
<button
|
||||
className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105"
|
||||
@@ -84,20 +100,25 @@ export function PlayerBar() {
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
|
||||
</button>
|
||||
<BarIcon icon={<Forward size={18} />} onClick={playNext} />
|
||||
<BarIcon icon={<Repeat2 size={18} />} />
|
||||
<BarIcon active={repeatMode !== 'off'} icon={<Repeat2 size={18} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full max-w-xl items-center gap-3 text-xs text-slate-500">
|
||||
<span>{formatClock(currentTime)}</span>
|
||||
<div className="h-1.5 flex-1 rounded-full bg-[#1d2940]">
|
||||
<div className="h-1.5 rounded-full bg-[#16bf8c]" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
|
||||
</div>
|
||||
<input
|
||||
className="h-1.5 flex-1 accent-[#16bf8c]"
|
||||
max={duration || 0}
|
||||
min={0}
|
||||
onChange={(event) => seekTo(Number(event.target.value))}
|
||||
step={0.1}
|
||||
type="range"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
/>
|
||||
<span>{formatClock(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-4 text-slate-400">
|
||||
<BarIcon icon={<Heart size={17} />} />
|
||||
<BarIcon icon={<ListMusic size={17} />} />
|
||||
<BarIcon icon={<Volume2 size={17} />} />
|
||||
<input
|
||||
@@ -117,14 +138,19 @@ export function PlayerBar() {
|
||||
|
||||
function BarIcon({
|
||||
icon,
|
||||
active = false,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
active?: boolean
|
||||
label?: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button className="transition hover:text-white" onClick={onClick} type="button">
|
||||
<button className={['relative transition hover:text-white', active ? 'text-[#16bf8c]' : ''].join(' ')} onClick={onClick} type="button">
|
||||
{icon}
|
||||
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -110,6 +116,12 @@ export async function login(username: string, password: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return request<{ status: string }>('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchHome() {
|
||||
return request<HomePayload>('/api/home')
|
||||
}
|
||||
@@ -190,6 +202,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)}` : ''}`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search } from 'lucide-react'
|
||||
import { coverArtUrl, fetchAlbums } from '@/lib/api'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
|
||||
export function AlbumsPage() {
|
||||
const navigate = useNavigate()
|
||||
const albumsQuery = useQuery({
|
||||
queryKey: ['albums'],
|
||||
queryFn: fetchAlbums,
|
||||
@@ -17,7 +18,7 @@ export function AlbumsPage() {
|
||||
<button className="rounded-[10px] border border-[#24314f] bg-[#0d1628] px-5 py-3 text-base text-white" type="button">
|
||||
Недавно добавленные
|
||||
</button>
|
||||
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" type="button">
|
||||
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" onClick={() => navigate('/search')} type="button">
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import { Search, SlidersHorizontal } from 'lucide-react'
|
||||
import { coverArtUrl, fetchArtists } from '@/lib/api'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
|
||||
export function ArtistsPage() {
|
||||
const navigate = useNavigate()
|
||||
const artistsQuery = useQuery({
|
||||
queryKey: ['artists'],
|
||||
queryFn: fetchArtists,
|
||||
@@ -20,7 +21,10 @@ export function ArtistsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-sm rounded-[8px] border border-[#24314f] bg-[#0d1628] px-4 py-3 text-sm text-slate-400">Поиск...</div>
|
||||
<button className="flex max-w-sm items-center gap-3 rounded-[8px] border border-[#24314f] bg-[#0d1628] px-4 py-3 text-sm text-slate-400" onClick={() => navigate('/search')} type="button">
|
||||
<Search size={16} />
|
||||
Поиск...
|
||||
</button>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#0f182a]">
|
||||
<div className="grid grid-cols-[56px_minmax(0,2fr)_260px] gap-4 border-b border-[#24314f] px-5 py-3 text-base text-slate-300">
|
||||
|
||||
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')}`
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function LoginPage() {
|
||||
|
||||
{mutation.isError ? (
|
||||
<div className="rounded-2xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
Could not reach the backend. Make sure the API is running on `http://localhost:4040`.
|
||||
Could not reach the backend. Make sure the API is running on the same origin, or on `http://localhost:5050` in dev.
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
@@ -111,4 +111,3 @@ function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
165
apps/web/src/pages/search-page.tsx
Normal file
165
apps/web/src/pages/search-page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search } from 'lucide-react'
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { coverArtUrl, fetchFavorites, searchLibrary } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function SearchPage() {
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const initialQuery = params.get('q') ?? ''
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const setQueue = usePlayerStore((state) => state.setQueue)
|
||||
const playTrack = usePlayerStore((state) => state.playTrack)
|
||||
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['search-page', initialQuery],
|
||||
queryFn: () => searchLibrary(initialQuery),
|
||||
enabled: initialQuery.trim().length > 0,
|
||||
})
|
||||
const favoritesQuery = useQuery({
|
||||
queryKey: ['favorites'],
|
||||
queryFn: fetchFavorites,
|
||||
})
|
||||
|
||||
const results = searchQuery.data
|
||||
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id))
|
||||
const favoriteAlbumIds = new Set((favoritesQuery.data?.albums ?? []).map((item) => item.id))
|
||||
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
|
||||
|
||||
function onSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
navigate(`/search?q=${encodeURIComponent(query.trim())}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<form className="flex items-center gap-3" onSubmit={onSubmit}>
|
||||
<div className="flex flex-1 items-center gap-3 rounded-[12px] border border-[#24314f] bg-[#0d1628] px-4 py-3">
|
||||
<Search size={18} className="text-slate-400" />
|
||||
<input
|
||||
className="w-full bg-transparent text-base text-white outline-none placeholder:text-slate-500"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Поиск альбома, исполнителя или трека"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<button className="rounded-[10px] bg-[#16bf8c] px-5 py-3 text-base font-medium text-[#081225]" type="submit">
|
||||
Искать
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!initialQuery ? (
|
||||
<div className="rounded-[14px] border border-dashed border-[#24314f] bg-[#121b2e] px-8 py-14 text-center text-slate-400">
|
||||
Введи название трека, альбома или исполнителя.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results?.artists.length ? (
|
||||
<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">
|
||||
{results.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={favoriteArtistIds.has(artist.id)} entityId={artist.id} entityType="artist" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{results?.albums.length ? (
|
||||
<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">
|
||||
{results.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={favoriteAlbumIds.has(album.id)} entityId={album.id} entityType="album" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{results?.tracks.length ? (
|
||||
<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>
|
||||
{results.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(results.tracks, index)
|
||||
playTrack(track, results.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={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{initialQuery && !searchQuery.isLoading && !results?.artists.length && !results?.albums.length && !results?.tracks.length ? (
|
||||
<div className="rounded-[14px] border border-dashed border-[#24314f] bg-[#121b2e] px-8 py-14 text-center text-slate-400">
|
||||
Ничего не найдено по запросу "{initialQuery}".
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number) {
|
||||
const minutes = Math.floor(durationSeconds / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
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 { useNavigate } from 'react-router-dom'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function TracksPage() {
|
||||
const navigate = useNavigate()
|
||||
const setQueue = usePlayerStore((state) => state.setQueue)
|
||||
const playTrack = usePlayerStore((state) => state.playTrack)
|
||||
const tracksQuery = useQuery({
|
||||
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">
|
||||
<HeaderSearch />
|
||||
<HeaderSearch onClick={() => navigate('/search')} />
|
||||
<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_140px_180px_120px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
|
||||
<div>#</div>
|
||||
@@ -58,7 +66,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>
|
||||
))}
|
||||
@@ -68,10 +76,10 @@ export function TracksPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderSearch() {
|
||||
function HeaderSearch({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" type="button">
|
||||
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" onClick={onClick} type="button">
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,25 @@ type PlayerState = {
|
||||
volume: number
|
||||
currentTime: number
|
||||
duration: number
|
||||
shuffle: boolean
|
||||
repeatMode: 'off' | 'all' | 'one'
|
||||
fullPlayerOpen: boolean
|
||||
seekRequest: number | null
|
||||
setQueue: (tracks: Track[], startIndex?: number) => void
|
||||
playTrack: (track: Track, queue?: Track[]) => void
|
||||
togglePlayback: () => void
|
||||
playNext: () => void
|
||||
playPrevious: () => void
|
||||
playAtIndex: (index: number) => void
|
||||
removeFromQueue: (trackId: string) => void
|
||||
toggleShuffle: () => void
|
||||
cycleRepeatMode: () => void
|
||||
setVolume: (volume: number) => void
|
||||
setCurrentTime: (currentTime: number) => void
|
||||
setDuration: (duration: number) => void
|
||||
seekTo: (currentTime: number) => void
|
||||
clearSeekRequest: () => void
|
||||
handleTrackEnded: () => void
|
||||
setFullPlayerOpen: (fullPlayerOpen: boolean) => void
|
||||
}
|
||||
|
||||
@@ -27,7 +37,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
volume: 0.7,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
shuffle: false,
|
||||
repeatMode: 'off',
|
||||
fullPlayerOpen: false,
|
||||
seekRequest: null,
|
||||
setQueue: (queue, startIndex = 0) =>
|
||||
set({
|
||||
queue,
|
||||
@@ -48,11 +61,26 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
if (!state.currentTrack || state.queue.length === 0) {
|
||||
return state
|
||||
}
|
||||
if (state.repeatMode === 'one') {
|
||||
return {
|
||||
currentTrack: state.currentTrack,
|
||||
isPlaying: true,
|
||||
currentTime: 0,
|
||||
seekRequest: 0,
|
||||
}
|
||||
}
|
||||
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
||||
const nextTrack = state.queue[index + 1] ?? state.queue[0] ?? null
|
||||
let nextTrack: Track | null = null
|
||||
if (state.shuffle && state.queue.length > 1) {
|
||||
const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id)
|
||||
nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null
|
||||
} else {
|
||||
nextTrack = state.queue[index + 1] ?? (state.repeatMode === 'all' ? state.queue[0] ?? null : null)
|
||||
}
|
||||
return {
|
||||
currentTrack: nextTrack,
|
||||
isPlaying: !!nextTrack,
|
||||
currentTime: 0,
|
||||
}
|
||||
}),
|
||||
playPrevious: () =>
|
||||
@@ -68,8 +96,40 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
currentTime: 0,
|
||||
}
|
||||
}),
|
||||
playAtIndex: (index) =>
|
||||
set((state) => ({
|
||||
currentTrack: state.queue[index] ?? state.currentTrack,
|
||||
isPlaying: !!state.queue[index],
|
||||
currentTime: 0,
|
||||
})),
|
||||
removeFromQueue: (trackId) =>
|
||||
set((state) => {
|
||||
const nextQueue = state.queue.filter((track) => track.id !== trackId)
|
||||
const removedCurrent = state.currentTrack?.id === trackId
|
||||
const nextCurrent = removedCurrent ? nextQueue[0] ?? null : state.currentTrack
|
||||
return {
|
||||
queue: nextQueue,
|
||||
currentTrack: nextCurrent,
|
||||
isPlaying: removedCurrent ? !!nextCurrent : state.isPlaying,
|
||||
}
|
||||
}),
|
||||
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
|
||||
cycleRepeatMode: () =>
|
||||
set((state) => ({
|
||||
repeatMode: state.repeatMode === 'off' ? 'all' : state.repeatMode === 'all' ? 'one' : 'off',
|
||||
})),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
setCurrentTime: (currentTime) => set({ currentTime }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
seekTo: (currentTime) => set({ currentTime, seekRequest: currentTime }),
|
||||
clearSeekRequest: () => set({ seekRequest: null }),
|
||||
handleTrackEnded: () => {
|
||||
const state = get()
|
||||
if (state.repeatMode === 'one') {
|
||||
set({ currentTime: 0, seekRequest: 0, isPlaying: true })
|
||||
return
|
||||
}
|
||||
state.playNext()
|
||||
},
|
||||
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
|
||||
}))
|
||||
|
||||
52
deploy/BACKUP_RESTORE.md
Normal file
52
deploy/BACKUP_RESTORE.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Backup And Restore
|
||||
|
||||
The minimum persistent state for this project is:
|
||||
|
||||
- `data/app.db`
|
||||
- `data/artwork/`
|
||||
- your music library mount, if the server machine is the primary storage location
|
||||
|
||||
## What To Back Up
|
||||
|
||||
Recommended:
|
||||
|
||||
- entire `data/` directory
|
||||
- entire `media/` directory if the same host stores the original files
|
||||
- your `.env` or deployment environment settings
|
||||
|
||||
Why:
|
||||
|
||||
- `app.db` stores users, sessions, playlists, favorites, and scanned metadata
|
||||
- `artwork/` stores extracted embedded covers
|
||||
- `media/` contains the source files used to rebuild the library index
|
||||
|
||||
## Simple Backup Example
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
New-Item -ItemType Directory -Force -Path ".\\backups\\$stamp" | Out-Null
|
||||
Copy-Item -Recurse -Force .\\data ".\\backups\\$stamp\\data"
|
||||
Copy-Item -Recurse -Force .\\media ".\\backups\\$stamp\\media"
|
||||
```
|
||||
|
||||
## Restore Example
|
||||
|
||||
1. Stop the server.
|
||||
2. Restore `data/` from backup.
|
||||
3. Restore `media/` if needed.
|
||||
4. Start the server again.
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Copy-Item -Recurse -Force ".\\backups\\20260403-010000\\data\\*" ".\\data"
|
||||
Copy-Item -Recurse -Force ".\\backups\\20260403-010000\\media\\*" ".\\media"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- If `media/` is already backed up elsewhere, restoring `data/app.db` and `data/artwork/` is usually enough.
|
||||
- If `artwork/` is lost but `media/` is intact, the server can rebuild extracted covers during future scans.
|
||||
- If `app.db` is lost, the library can be rescanned from `media/`, but playlists, favorites, sessions, and users will be lost unless restored from backup.
|
||||
@@ -75,3 +75,23 @@ server {
|
||||
- In production the frontend uses relative URLs, so it works correctly behind the same origin without hardcoded API hosts.
|
||||
- In local frontend development, Vite proxies `/api`, `/rest`, and `/health` to `http://127.0.0.1:5050`.
|
||||
- If you later enable HTTPS on an external reverse proxy, clients should still connect to one public base URL only.
|
||||
- Web UI and Subsonic clients should always use the same public base URL, only differing by path usage.
|
||||
|
||||
## Recommended Public Contract
|
||||
|
||||
Public examples:
|
||||
|
||||
- browser: `https://music.example.com/`
|
||||
- Subsonic clients: `https://music.example.com`
|
||||
|
||||
Internal upstream:
|
||||
|
||||
- `http://127.0.0.1:5050`
|
||||
|
||||
Do not publish separate public ports for:
|
||||
|
||||
- web UI
|
||||
- `/api/*`
|
||||
- `/rest/*`
|
||||
- `/api/stream/*`
|
||||
- `/api/cover-art/*`
|
||||
|
||||
@@ -47,6 +47,10 @@ func NewService(db *sql.DB, encryptionKey string) *Service {
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, username, password string) (Session, error) {
|
||||
if err := s.cleanupExpiredSessions(ctx); err != nil {
|
||||
return Session{}, fmt.Errorf("cleanup expired sessions: %w", err)
|
||||
}
|
||||
|
||||
user, passwordHash, _, err := s.findUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -107,6 +111,8 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e
|
||||
return User{}, ErrUnauthorized
|
||||
}
|
||||
|
||||
_ = s.cleanupExpiredSessions(ctx)
|
||||
|
||||
user, err := s.findUserByToken(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -118,6 +124,16 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, token string) error {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, strings.TrimSpace(token)); err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, password, token, salt string) (User, error) {
|
||||
if username == "" {
|
||||
return User{}, ErrUnauthorized
|
||||
@@ -262,6 +278,11 @@ func (s *Service) storeSubsonicSecret(ctx context.Context, userID, password stri
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) cleanupExpiredSessions(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func EncryptSubsonicSecret(value, key string) (string, error) {
|
||||
return encryptSecret(value, key)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Post("/auth/login", application.login)
|
||||
api.Post("/auth/logout", application.logout)
|
||||
|
||||
api.Group(func(private chi.Router) {
|
||||
private.Use(application.requireAuth)
|
||||
@@ -64,6 +65,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)
|
||||
@@ -139,6 +143,25 @@ func (a app) login(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
}
|
||||
|
||||
func (a app) logout(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if token == "" {
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil {
|
||||
token = strings.TrimSpace(payload.Token)
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.auth.Logout(r.Context(), token); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "logout failed"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
func (a app) me(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUserFromContext(r)
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
@@ -234,6 +257,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)
|
||||
|
||||
Reference in New Issue
Block a user