feat: redesign web interface to match aonsoku layout
Replace the early prototype UI with a darker Aonsoku-inspired shell featuring a compact top bar, library sidebar, command palette, settings overlay, dense track list, artists table, albums grid, and a bottom player bar. Add a supporting albums browse endpoint so the frontend can render the same navigation shape without faking data.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppShell } from '@/components/app-shell'
|
||||
import { AlbumsPage } from '@/pages/albums-page'
|
||||
import { ArtistsPage } from '@/pages/artists-page'
|
||||
import { EmptyStatePage } from '@/pages/empty-state-page'
|
||||
import { HomePage } from '@/pages/home-page'
|
||||
import { LibraryPage } from '@/pages/library-page'
|
||||
import { LoginPage } from '@/pages/login-page'
|
||||
import { TracksPage } from '@/pages/tracks-page'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
|
||||
export default function App() {
|
||||
@@ -16,10 +19,15 @@ export default function App() {
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/library" element={<LibraryPage />} />
|
||||
<Route path="/artists" element={<ArtistsPage />} />
|
||||
<Route path="/tracks" element={<TracksPage />} />
|
||||
<Route path="/albums" element={<AlbumsPage />} />
|
||||
<Route path="/genres" element={<EmptyStatePage compact title="Жанры" />} />
|
||||
<Route path="/favorites" element={<EmptyStatePage compact title="Вы еще не добавили песни в избранное!" />} />
|
||||
<Route path="/playlists" element={<EmptyStatePage title="Пока не создано ни одного плейлиста" action="Создать плейлист" />} />
|
||||
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +1,193 @@
|
||||
import { Disc3, Home, Library, LogOut, Search } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Disc3,
|
||||
Heart,
|
||||
Home,
|
||||
LibraryBig,
|
||||
ListMusic,
|
||||
Music2,
|
||||
Radio,
|
||||
Search,
|
||||
Settings,
|
||||
Tags,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { CommandPalette } from '@/components/command-palette'
|
||||
import { PlayerBar } from '@/components/player-bar'
|
||||
import { SettingsModal } from '@/components/settings-modal'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
|
||||
const libraryLinks = [
|
||||
{ to: '/', label: 'Главная', icon: Home },
|
||||
{ to: '/artists', label: 'Исполнители', icon: User },
|
||||
{ to: '/tracks', label: 'Треки', icon: Music2 },
|
||||
{ to: '/albums', label: 'Альбомы', icon: LibraryBig },
|
||||
{ to: '/genres', label: 'Жанры', icon: Tags },
|
||||
{ to: '/favorites', label: 'Избранное', icon: Heart },
|
||||
{ to: '/playlists', label: 'Плейлисты', icon: ListMusic },
|
||||
{ to: '/radio', label: 'Радио', icon: Radio },
|
||||
] as const
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
const username = useSessionStore((state) => state.username)
|
||||
const clearSession = useSessionStore((state) => state.clearSession)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === '/') {
|
||||
event.preventDefault()
|
||||
setPaletteOpen(true)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setPaletteOpen(false)
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
const title = useMemo(() => {
|
||||
const current = libraryLinks.find((item) => item.to === location.pathname)
|
||||
return current?.label ?? 'Главная'
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-transparent px-4 py-4 text-ink md:px-6">
|
||||
<div className="mx-auto grid min-h-[calc(100vh-2rem)] max-w-7xl grid-cols-1 gap-4 lg:grid-cols-[240px_minmax(0,1fr)]">
|
||||
<aside className="rounded-[28px] border border-line bg-panel/80 p-5 shadow-glow backdrop-blur">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="rounded-2xl bg-accent p-3 text-slate-900">
|
||||
<Disc3 size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-display text-lg font-semibold">TemporServ</div>
|
||||
<div className="text-sm text-slate-400">Subsonic-ready music server</div>
|
||||
</div>
|
||||
<div className="min-h-screen bg-[#0a1220] text-[#f2f5fb]">
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex h-11 items-center justify-between border-b border-[#24314f] bg-[#081225] px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TopIconButton icon={<ArrowLeft size={16} />} />
|
||||
<TopIconButton icon={<ArrowRight size={16} />} />
|
||||
<TopIconButton icon={<Disc3 size={16} />} />
|
||||
</div>
|
||||
|
||||
<nav className="space-y-2">
|
||||
<SidebarLink to="/" icon={<Home size={18} />} label="Home" />
|
||||
<SidebarLink to="/library" icon={<Library size={18} />} label="Library" />
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-slate-300 transition hover:border-line hover:bg-slate-800/40"
|
||||
type="button"
|
||||
>
|
||||
<Search size={18} />
|
||||
Search
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="mt-10 rounded-3xl border border-line bg-slate-900/50 p-4">
|
||||
<div className="text-sm text-slate-400">Signed in as</div>
|
||||
<div className="mt-1 font-medium">{username ?? 'demo'}</div>
|
||||
<button
|
||||
className="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-100 px-4 py-3 text-sm font-semibold text-slate-900 transition hover:bg-white"
|
||||
onClick={clearSession}
|
||||
type="button"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Sign out
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-md bg-[#0ec28c] text-[#081225]">
|
||||
<Disc3 size={14} />
|
||||
</span>
|
||||
Aonsoku
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 flex-col gap-4">
|
||||
<section className="min-h-0 flex-1 rounded-[28px] border border-line bg-panel/60 p-5 backdrop-blur md:p-6">
|
||||
{children}
|
||||
</section>
|
||||
<PlayerBar />
|
||||
</main>
|
||||
<div className="relative flex items-center gap-2">
|
||||
<TopIconButton icon={<Settings size={16} />} onClick={() => setSettingsOpen(true)} />
|
||||
<TopIconButton icon={<User size={16} />} onClick={() => setUserMenuOpen((value) => !value)} />
|
||||
{userMenuOpen ? (
|
||||
<div className="absolute right-0 top-10 z-30 w-64 overflow-hidden rounded-xl border border-[#24314f] bg-[#0d1528] shadow-2xl">
|
||||
<div className="border-b border-[#24314f] px-4 py-3">
|
||||
<div className="text-xl font-semibold text-white">{username ?? 'demo'}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">https://music.daemonlord.ru</div>
|
||||
</div>
|
||||
<button className="flex w-full items-center justify-between px-4 py-3 text-left text-sm text-slate-100 hover:bg-[#18233a]" type="button">
|
||||
Сочетания клавиш
|
||||
<span className="text-slate-400">Ctrl+/</span>
|
||||
</button>
|
||||
<button className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm text-slate-100 hover:bg-[#18233a]" type="button">
|
||||
Описание
|
||||
</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}
|
||||
type="button"
|
||||
>
|
||||
Выйти из аккаунта
|
||||
<span className="text-slate-400">Shift+Ctrl+Q</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[276px_minmax(0,1fr)]">
|
||||
<aside className="flex min-h-0 flex-col border-r border-[#24314f] bg-[#0a1226]">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 rounded-[10px] border border-[#24314f] bg-[#0c1730] px-3 py-2 text-slate-400">
|
||||
<Search size={16} />
|
||||
<input
|
||||
onFocus={() => setPaletteOpen(true)}
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-slate-500"
|
||||
placeholder="Поиск..."
|
||||
/>
|
||||
<span className="rounded-md border border-[#2b3652] px-2 py-0.5 text-xs text-slate-500">/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 text-xs uppercase tracking-[0.18em] text-slate-500">Библиотека</div>
|
||||
<nav className="space-y-1 px-3">
|
||||
{libraryLinks.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'flex items-center gap-3 rounded-[10px] px-4 py-3 text-[0.95rem] transition',
|
||||
isActive ? 'bg-[#313d52] text-white' : 'text-slate-100 hover:bg-[#18233a]',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-5 px-3 pb-3 text-xs uppercase tracking-[0.18em] text-slate-500">Плейлисты</div>
|
||||
<div className="px-3">
|
||||
<div className="mb-3 flex items-center justify-between text-slate-400">
|
||||
<span className="text-sm">Плейлисты</span>
|
||||
<button className="grid h-7 w-7 place-items-center rounded-md bg-[#0ec28c] text-[#081225]" type="button">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-[10px] bg-[#313d52] px-4 py-3 text-[0.95rem] text-slate-100">
|
||||
Пока не создано ни одного плейлиста
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="grid min-h-0 grid-rows-[minmax(0,1fr)_92px] bg-[#131c2f]">
|
||||
<section className="min-h-0 overflow-auto">
|
||||
<div className="border-b border-[#24314f] bg-[#0c1730] px-8 py-6">
|
||||
<div className="text-[2.15rem] font-semibold tracking-tight text-white">{title}</div>
|
||||
</div>
|
||||
<div className="px-8 py-6">{children}</div>
|
||||
</section>
|
||||
<PlayerBar />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
to,
|
||||
function TopIconButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'flex items-center gap-3 rounded-2xl px-4 py-3 transition',
|
||||
isActive ? 'bg-accent text-slate-900' : 'text-slate-300 hover:bg-slate-800/40',
|
||||
].join(' ')
|
||||
}
|
||||
<button
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-slate-300 transition hover:bg-[#18233a] hover:text-white"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
246
apps/web/src/components/command-palette.tsx
Normal file
246
apps/web/src/components/command-palette.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ExternalLink,
|
||||
FolderSync,
|
||||
ListMusic,
|
||||
Palette,
|
||||
Plus,
|
||||
Search,
|
||||
ServerCog,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useDeferredValue, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchScanStatus, searchLibrary, triggerScan } from '@/lib/api'
|
||||
|
||||
const commands = [
|
||||
{ label: 'Перейти на страницу', icon: ExternalLink, action: 'navigate' },
|
||||
{ label: 'Сменить тему', icon: Palette, action: 'theme' },
|
||||
{ label: 'Плейлисты', icon: ListMusic, action: 'playlists' },
|
||||
{ label: 'Создать плейлист', icon: Plus, action: 'create-playlist' },
|
||||
{ label: 'Управление сервером', icon: ServerCog, action: 'server' },
|
||||
] as const
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
const deferredQuery = useDeferredValue(query)
|
||||
const [serverMode, setServerMode] = useState(false)
|
||||
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['palette-search', deferredQuery],
|
||||
queryFn: () => searchLibrary(deferredQuery),
|
||||
enabled: open && deferredQuery.trim().length > 1 && !serverMode,
|
||||
})
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['scan-status'],
|
||||
queryFn: fetchScanStatus,
|
||||
enabled: open && serverMode,
|
||||
})
|
||||
|
||||
const scanMutation = useMutation({
|
||||
mutationFn: triggerScan,
|
||||
onSuccess: () => {
|
||||
void statusQuery.refetch()
|
||||
},
|
||||
})
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (deferredQuery.trim().length > 1 || serverMode) {
|
||||
return []
|
||||
}
|
||||
return commands
|
||||
}, [deferredQuery, serverMode])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center bg-black/65 px-4 py-24 backdrop-blur-sm">
|
||||
<div className="w-full max-w-[530px] overflow-hidden rounded-[12px] border border-[#314061] bg-[#0a1328] shadow-2xl">
|
||||
<div className="flex items-center gap-3 border-b border-[#24314f] px-4 py-4">
|
||||
<Search size={18} className="text-slate-400" />
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent text-base text-slate-100 outline-none placeholder:text-slate-400"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value)
|
||||
setServerMode(false)
|
||||
}}
|
||||
placeholder="Поиск альбома, исполнителя или трека"
|
||||
value={query}
|
||||
/>
|
||||
<button className="text-slate-400 transition hover:text-white" onClick={onClose} type="button">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[238px] bg-[#091228]">
|
||||
{serverMode ? (
|
||||
<div className="px-4 py-4">
|
||||
<div className="text-sm text-slate-400">Управление сервером</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Pill>{statusQuery.data?.tracks ?? 0} треков</Pill>
|
||||
<Pill>{statusQuery.data?.albums ?? 0} папки</Pill>
|
||||
<Pill>
|
||||
Последнее сканирование:{' '}
|
||||
{statusQuery.data?.finishedAt
|
||||
? new Date(statusQuery.data.finishedAt).toLocaleString('ru-RU')
|
||||
: 'нет данных'}
|
||||
</Pill>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-5 text-base text-slate-100">
|
||||
<button
|
||||
className="block transition hover:text-white/80"
|
||||
onClick={() => void statusQuery.refetch()}
|
||||
type="button"
|
||||
>
|
||||
Перезагрузить статус
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-3 transition hover:text-white/80"
|
||||
onClick={() => scanMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
<FolderSync size={18} />
|
||||
Быстрое сканирование
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : deferredQuery.trim().length > 1 ? (
|
||||
<div className="px-4 py-4">
|
||||
<div className="mb-3 text-sm text-slate-400">Результаты</div>
|
||||
<div className="space-y-2">
|
||||
{searchQuery.data?.artists.map((artist) => (
|
||||
<PaletteRow
|
||||
key={`artist-${artist.id}`}
|
||||
label={artist.name}
|
||||
meta="Исполнитель"
|
||||
onClick={() => {
|
||||
navigate('/artists')
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{searchQuery.data?.albums.map((album) => (
|
||||
<PaletteRow
|
||||
key={`album-${album.id}`}
|
||||
label={album.title}
|
||||
meta={album.artistName}
|
||||
onClick={() => {
|
||||
navigate('/albums')
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{searchQuery.data?.tracks.map((track) => (
|
||||
<PaletteRow
|
||||
key={`track-${track.id}`}
|
||||
label={track.title}
|
||||
meta={`${track.artistName} • ${track.albumTitle}`}
|
||||
onClick={() => {
|
||||
navigate('/tracks')
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-4">
|
||||
<div className="mb-3 text-sm text-slate-400">Команды</div>
|
||||
<div className="space-y-2">
|
||||
{filteredCommands.map((command, index) => {
|
||||
const Icon = command.icon
|
||||
return (
|
||||
<button
|
||||
key={command.label}
|
||||
className={[
|
||||
'flex w-full items-center gap-3 rounded-[8px] px-4 py-3 text-left transition',
|
||||
index === 0 ? 'bg-[#263047] text-white' : 'text-slate-100 hover:bg-[#18233a]',
|
||||
].join(' ')}
|
||||
onClick={() => {
|
||||
if (command.action === 'playlists') {
|
||||
navigate('/playlists')
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (command.action === 'navigate') {
|
||||
navigate('/tracks')
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (command.action === 'server') {
|
||||
setServerMode(true)
|
||||
setQuery('')
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'grid h-6 w-6 place-items-center rounded-md text-white',
|
||||
['bg-[#34a5ff]', 'bg-[#d257ff]', 'bg-[#5a63ff]', 'bg-[#18bf8f]', 'bg-[#ff5c5c]'][index],
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<span className="text-[1.02rem]">{command.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t border-[#24314f] px-4 py-3 text-xs text-slate-400">
|
||||
<KeyCap>ESC</KeyCap>
|
||||
<KeyCap>
|
||||
<ArrowDown size={12} />
|
||||
</KeyCap>
|
||||
<KeyCap>
|
||||
<ArrowUp size={12} />
|
||||
</KeyCap>
|
||||
<KeyCap>↵</KeyCap>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PaletteRow({
|
||||
label,
|
||||
meta,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
meta: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button className="block w-full rounded-[8px] px-4 py-3 text-left transition hover:bg-[#18233a]" onClick={onClick} type="button">
|
||||
<div className="text-[0.98rem] text-white">{label}</div>
|
||||
<div className="mt-1 text-sm text-slate-400">{meta}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Pill({ children }: { children: React.ReactNode }) {
|
||||
return <div className="rounded-full bg-[#eef2f8] px-3 py-1 text-sm font-semibold text-[#111827]">{children}</div>
|
||||
}
|
||||
|
||||
function KeyCap({ children }: { children: React.ReactNode }) {
|
||||
return <div className="rounded border border-[#3a4764] bg-[#10192d] px-2 py-1 text-slate-300">{children}</div>
|
||||
}
|
||||
@@ -1,66 +1,119 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Pause, Play, SkipBack, SkipForward, Volume2 } from 'lucide-react'
|
||||
import {
|
||||
Expand,
|
||||
Forward,
|
||||
Heart,
|
||||
ListMusic,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat2,
|
||||
Rewind,
|
||||
Shuffle,
|
||||
Volume2,
|
||||
} from 'lucide-react'
|
||||
import { streamUrl } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
import { useSessionStore } from '@/stores/session-store'
|
||||
|
||||
export function PlayerBar() {
|
||||
const currentTrack = usePlayerStore((state) => state.currentTrack)
|
||||
const token = useSessionStore((state) => state.token)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const apiBase = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040'
|
||||
const currentTrack = usePlayerStore((state) => state.currentTrack)
|
||||
const isPlaying = usePlayerStore((state) => state.isPlaying)
|
||||
const volume = usePlayerStore((state) => state.volume)
|
||||
const togglePlayback = usePlayerStore((state) => state.togglePlayback)
|
||||
const playNext = usePlayerStore((state) => state.playNext)
|
||||
const playPrevious = usePlayerStore((state) => state.playPrevious)
|
||||
const setVolume = usePlayerStore((state) => state.setVolume)
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current || !currentTrack || !token) {
|
||||
if (!audioRef.current || !currentTrack) {
|
||||
return
|
||||
}
|
||||
audioRef.current.src = streamUrl(currentTrack.id)
|
||||
if (isPlaying) {
|
||||
void audioRef.current.play().catch(() => {})
|
||||
}
|
||||
}, [currentTrack, isPlaying])
|
||||
|
||||
audioRef.current.src = `${apiBase}/api/stream/${currentTrack.id}?token=${token}`
|
||||
void audioRef.current.play().catch(() => {})
|
||||
}, [apiBase, currentTrack, token])
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
return
|
||||
}
|
||||
audioRef.current.volume = volume
|
||||
if (isPlaying) {
|
||||
void audioRef.current.play().catch(() => {})
|
||||
} else {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
}, [isPlaying, volume])
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 rounded-[28px] border border-line bg-slate-950/70 p-4 backdrop-blur md:grid-cols-[1.3fr_auto_1fr] md:items-center">
|
||||
<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} preload="metadata" />
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-slate-500">Now playing</div>
|
||||
<div className="mt-1 text-lg font-semibold">{currentTrack?.title ?? 'Nothing queued yet'}</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{currentTrack ? `${currentTrack.artistName} • ${currentTrack.albumTitle}` : 'Pick a track from the library to start testing playback'}
|
||||
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="grid h-[68px] w-[68px] place-items-center rounded-[8px] bg-[#1b2638]">
|
||||
<div className="h-7 w-7 rounded-full border-l-2 border-r-2 border-[#f1f5fb]" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-[1.05rem] font-medium text-white">
|
||||
{currentTrack?.title ?? 'Сейчас трек не играет'}
|
||||
</div>
|
||||
<div className="line-clamp-1 text-sm text-slate-400">{currentTrack ? currentTrack.artistName : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<ControlButton icon={<SkipBack size={16} />} />
|
||||
<ControlButton icon={<Play size={16} />} active />
|
||||
<ControlButton icon={<Pause size={16} />} />
|
||||
<ControlButton icon={<SkipForward size={16} />} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-5 text-slate-400">
|
||||
<BarIcon icon={<Shuffle size={18} />} />
|
||||
<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"
|
||||
onClick={togglePlayback}
|
||||
type="button"
|
||||
>
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
|
||||
</button>
|
||||
<BarIcon icon={<Forward size={18} />} onClick={playNext} />
|
||||
<BarIcon icon={<Repeat2 size={18} />} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 md:justify-end">
|
||||
<Volume2 size={16} className="text-slate-400" />
|
||||
<div className="h-2 w-full max-w-40 rounded-full bg-slate-800">
|
||||
<div className="h-2 w-2/3 rounded-full bg-accent" />
|
||||
<div className="mt-4 flex w-full max-w-xl items-center gap-3 text-xs text-slate-500">
|
||||
<span>00:00</span>
|
||||
<div className="h-1.5 flex-1 rounded-full bg-[#1d2940]">
|
||||
<div className="h-1.5 w-0 rounded-full bg-[#16bf8c]" />
|
||||
</div>
|
||||
<span>00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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
|
||||
className="h-1.5 w-32 accent-[#16bf8c]"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(event) => setVolume(Number(event.target.value))}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={volume}
|
||||
/>
|
||||
<BarIcon icon={<Expand size={17} />} />
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
function BarIcon({
|
||||
icon,
|
||||
active = false,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={[
|
||||
'flex h-11 w-11 items-center justify-center rounded-full border transition',
|
||||
active ? 'border-accent bg-accent text-slate-900' : 'border-line bg-slate-900/70 text-ink hover:bg-slate-800',
|
||||
].join(' ')}
|
||||
type="button"
|
||||
>
|
||||
<button className="transition hover:text-white" onClick={onClick} type="button">
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
|
||||
214
apps/web/src/components/settings-modal.tsx
Normal file
214
apps/web/src/components/settings-modal.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
Globe2,
|
||||
Headphones,
|
||||
MonitorSmartphone,
|
||||
Paintbrush2,
|
||||
ShieldCheck,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
const tabs = [
|
||||
{ id: 'appearance', label: 'Внешний вид', icon: Paintbrush2 },
|
||||
{ id: 'language', label: 'Язык и Регион', icon: Globe2 },
|
||||
{ id: 'player', label: 'Проигрыватель и Звук', icon: Headphones },
|
||||
{ id: 'content', label: 'Содержимое', icon: MonitorSmartphone },
|
||||
{ id: 'privacy', label: 'Безопасность и Приватность', icon: ShieldCheck },
|
||||
] as const
|
||||
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<(typeof tabs)[number]['id']>('appearance')
|
||||
|
||||
const active = useMemo(() => tabs.find((item) => item.id === activeTab) ?? tabs[0], [activeTab])
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-10 backdrop-blur-sm">
|
||||
<div className="grid h-[min(78vh,640px)] w-full max-w-4xl grid-cols-[248px_minmax(0,1fr)] overflow-hidden rounded-[18px] border border-[#24314f] bg-[#151f33] shadow-2xl">
|
||||
<aside className="border-r border-[#24314f] bg-[#0d1528] p-3">
|
||||
<nav className="space-y-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={[
|
||||
'flex w-full items-center gap-3 rounded-[10px] px-4 py-3 text-left text-sm transition',
|
||||
activeTab === tab.id ? 'bg-[#2c374d] text-white' : 'text-slate-200 hover:bg-[#18233a]',
|
||||
].join(' ')}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon size={18} />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-0 flex-col bg-[#151f33]">
|
||||
<header className="flex items-center justify-between border-b border-[#24314f] px-6 py-5">
|
||||
<div className="text-sm text-slate-300">
|
||||
Настройки
|
||||
<span className="mx-3 text-slate-500">›</span>
|
||||
<span className="font-semibold text-white">{active.label}</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-slate-400 transition hover:bg-[#1e2940] hover:text-white"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-6 py-6">
|
||||
{activeTab === 'appearance' ? <AppearancePanel /> : null}
|
||||
{activeTab === 'language' ? <LanguagePanel /> : null}
|
||||
{activeTab === 'player' ? <PlayerPanel /> : null}
|
||||
{activeTab === 'content' ? <ContentPanel /> : null}
|
||||
{activeTab === 'privacy' ? <PrivacyPanel /> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearancePanel() {
|
||||
return (
|
||||
<div className="space-y-8 text-white">
|
||||
<PanelTitle title="Общее" copy="Настройте параметры отображения и внешний вид приложения." />
|
||||
<SettingRow label="Включить автоматический полный экран" />
|
||||
<Divider />
|
||||
<PanelTitle title="Динамические цвета" copy="Использовать ли цвет обложки текущего альбома в качестве фона интерфейса." />
|
||||
<SettingRow label="Очередь и тексты песен" />
|
||||
<SettingRow label="Большой проигрыватель" />
|
||||
<Divider />
|
||||
<PanelTitle title="Тема" copy="" />
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{['Светлая', 'Темная', 'Черно-белая', 'One Dark'].map((theme, index) => (
|
||||
<div key={theme} className="rounded-[12px] border border-[#2c3750] bg-[#11192d] p-3">
|
||||
<div className="grid h-36 grid-cols-[18px_1fr] gap-3 rounded-[10px] border border-[#2d3955] p-2">
|
||||
<div className="grid gap-1">
|
||||
{Array.from({ length: 5 }).map((_, itemIndex) => (
|
||||
<div
|
||||
key={itemIndex}
|
||||
className="rounded-sm"
|
||||
style={{
|
||||
background:
|
||||
index === 0
|
||||
? ['#eceef2', '#d7dce5', '#eceef2', '#d7dce5', '#eceef2'][itemIndex]
|
||||
: index === 1
|
||||
? ['#19243a', '#1f2b44', '#19243a', '#1f2b44', '#19243a'][itemIndex]
|
||||
: index === 2
|
||||
? ['#1d1d1f', '#242426', '#1d1d1f', '#242426', '#1d1d1f'][itemIndex]
|
||||
: ['#51596d', '#3f4655', '#51596d', '#3f4655', '#51596d'][itemIndex],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="rounded-[8px] bg-[#2dc08e]" />
|
||||
<div className="rounded-[8px] bg-white/85" />
|
||||
<div className="rounded-[8px] bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-slate-200">{theme}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LanguagePanel() {
|
||||
return (
|
||||
<div className="space-y-6 text-white">
|
||||
<PanelTitle title="Язык" copy="" />
|
||||
<div className="max-w-md rounded-[10px] border border-[#2b3550] bg-[#10182a] px-4 py-3 text-sm text-slate-200">
|
||||
Русский
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayerPanel() {
|
||||
return (
|
||||
<div className="space-y-8 text-white">
|
||||
<PanelTitle title="Автоматическая громкость" copy="Автоматически выровнять громкость звука для комфортного прослушивания." />
|
||||
<SettingRow label="Включено" />
|
||||
<Divider />
|
||||
<PanelTitle title="Текст" copy="Редактировать настройки текстов песен." />
|
||||
<SettingRow label="Приоритизировать синхронизированный текст песни" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentPanel() {
|
||||
return (
|
||||
<div className="space-y-8 text-white">
|
||||
<PanelTitle title="Боковая панель" copy="Показать или скрыть разделы на боковой панели." />
|
||||
{['Исполнители', 'Треки', 'Альбомы', 'Жанры', 'Избранное', 'Плейлисты', 'Радио'].map((item) => (
|
||||
<SettingRow key={item} label={item} enabled />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivacyPanel() {
|
||||
return (
|
||||
<div className="space-y-8 text-white">
|
||||
<PanelTitle title="Внешние сервисы" copy="Управляйте сервисами, к которым у интерфейса есть доступ." />
|
||||
{['Enable Animated Covers', 'Album Page', 'Big Player', 'Bottom Player Bar', 'Queue and Lyrics', 'LRCLIB'].map((item, index) => (
|
||||
<SettingRow key={item} label={item} enabled={index !== 0} />
|
||||
))}
|
||||
<Divider />
|
||||
<SettingRow label="Use a custom URL" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelTitle({ title, copy }: { title: string; copy: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-[1.05rem] font-semibold">{title}</h3>
|
||||
{copy ? <p className="mt-2 max-w-2xl text-sm text-slate-400">{copy}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingRow({ label, enabled = false }: { label: string; enabled?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 border-b border-[#24314f] pb-4">
|
||||
<div className="text-base text-slate-100">{label}</div>
|
||||
<div
|
||||
className={[
|
||||
'relative h-7 w-12 rounded-full transition',
|
||||
enabled ? 'bg-[#1bc28d]' : 'bg-[#202b44]',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'absolute top-1 h-5 w-5 rounded-full bg-[#08111f] transition',
|
||||
enabled ? 'left-6' : 'left-1',
|
||||
].join(' ')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div className="border-b border-[#24314f]" />
|
||||
}
|
||||
@@ -6,21 +6,21 @@ export type User = {
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export type HomePayload = {
|
||||
recentAlbums: Array<{
|
||||
id: string
|
||||
artistId: string
|
||||
artistName: string
|
||||
title: string
|
||||
year: number
|
||||
trackCount: number
|
||||
coverArtId: string
|
||||
}>
|
||||
artists: Array<{
|
||||
id: string
|
||||
name: string
|
||||
albumCount: number
|
||||
}>
|
||||
export type Artist = {
|
||||
id: string
|
||||
name: string
|
||||
albumCount: number
|
||||
coverArtId: string
|
||||
}
|
||||
|
||||
export type Album = {
|
||||
id: string
|
||||
artistId: string
|
||||
artistName: string
|
||||
title: string
|
||||
year: number
|
||||
trackCount: number
|
||||
coverArtId: string
|
||||
}
|
||||
|
||||
export type Track = {
|
||||
@@ -32,6 +32,36 @@ export type Track = {
|
||||
albumTitle: string
|
||||
trackNumber: number
|
||||
durationSeconds: number
|
||||
coverArtId?: string
|
||||
}
|
||||
|
||||
export type ArtistDetail = Artist & {
|
||||
albums: Album[]
|
||||
}
|
||||
|
||||
export type AlbumDetail = Album & {
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export type SearchResults = {
|
||||
artists: Artist[]
|
||||
albums: Album[]
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export type ScanStatus = {
|
||||
scanning: boolean
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
lastError?: string
|
||||
artists: number
|
||||
albums: number
|
||||
tracks: number
|
||||
}
|
||||
|
||||
export type HomePayload = {
|
||||
recentAlbums: Album[]
|
||||
artists: Artist[]
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040'
|
||||
@@ -65,11 +95,48 @@ export async function fetchHome() {
|
||||
return request<HomePayload>('/api/home')
|
||||
}
|
||||
|
||||
export async function fetchArtists() {
|
||||
return request<{ items: Artist[] }>('/api/artists')
|
||||
}
|
||||
|
||||
export async function fetchArtist(id: string) {
|
||||
return request<ArtistDetail>(`/api/artists/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchAlbums() {
|
||||
return request<{ items: Album[] }>('/api/albums')
|
||||
}
|
||||
|
||||
export async function fetchAlbum(id: string) {
|
||||
return request<AlbumDetail>(`/api/albums/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchTracks() {
|
||||
return request<{ items: Track[] }>('/api/tracks')
|
||||
}
|
||||
|
||||
export async function fetchTrack(id: string) {
|
||||
return request<Track>(`/api/tracks/${id}`)
|
||||
}
|
||||
|
||||
export async function searchLibrary(query: string) {
|
||||
return request<SearchResults>(`/api/search?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
export async function fetchScanStatus() {
|
||||
return request<ScanStatus>('/api/admin/scan-status')
|
||||
}
|
||||
|
||||
export async function triggerScan() {
|
||||
return request<ScanStatus>('/api/admin/scan', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function coverArtUrl(id: string) {
|
||||
const token = useSessionStore.getState().token
|
||||
return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
||||
}
|
||||
|
||||
export function streamUrl(id: string) {
|
||||
const token = useSessionStore.getState().token
|
||||
return `${API_BASE}/api/stream/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`
|
||||
}
|
||||
|
||||
39
apps/web/src/pages/albums-page.tsx
Normal file
39
apps/web/src/pages/albums-page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Search } from 'lucide-react'
|
||||
import { coverArtUrl, fetchAlbums } from '@/lib/api'
|
||||
|
||||
export function AlbumsPage() {
|
||||
const albumsQuery = useQuery({
|
||||
queryKey: ['albums'],
|
||||
queryFn: fetchAlbums,
|
||||
})
|
||||
|
||||
const albums = albumsQuery.data?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<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">
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
||||
{albums.map((album) => (
|
||||
<article key={album.id}>
|
||||
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
|
||||
{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>
|
||||
<div className="text-base text-slate-400">{album.artistName}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
apps/web/src/pages/artists-page.tsx
Normal file
45
apps/web/src/pages/artists-page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import { coverArtUrl, fetchArtists } from '@/lib/api'
|
||||
|
||||
export function ArtistsPage() {
|
||||
const artistsQuery = useQuery({
|
||||
queryKey: ['artists'],
|
||||
queryFn: fetchArtists,
|
||||
})
|
||||
|
||||
const artists = artistsQuery.data?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="rounded-full bg-[#364157] px-3 py-1 text-sm font-semibold text-white">{artists.length}</div>
|
||||
<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">
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-sm rounded-[8px] border border-[#24314f] bg-[#0d1628] px-4 py-3 text-sm text-slate-400">Поиск...</div>
|
||||
|
||||
<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">
|
||||
<div>#</div>
|
||||
<div>Имя</div>
|
||||
<div>Количество альбомов</div>
|
||||
</div>
|
||||
{artists.map((artist, index) => (
|
||||
<div key={artist.id} className="grid grid-cols-[56px_minmax(0,2fr)_260px] gap-4 border-t border-[#1f2940] px-5 py-4">
|
||||
<div className="text-lg text-white">{index + 1}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 overflow-hidden rounded-[6px] bg-[#313d52]">
|
||||
{artist.coverArtId ? <img alt={artist.name} className="h-full w-full object-cover" src={coverArtUrl(artist.id)} /> : null}
|
||||
</div>
|
||||
<div className="text-[1.05rem] text-white">{artist.name}</div>
|
||||
</div>
|
||||
<div className="text-[1.05rem] text-white">{artist.albumCount}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
apps/web/src/pages/empty-state-page.tsx
Normal file
27
apps/web/src/pages/empty-state-page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
export function EmptyStatePage({
|
||||
title,
|
||||
action,
|
||||
compact = false,
|
||||
}: {
|
||||
title: string
|
||||
action?: string
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-[12px] border border-dashed border-[#24314f] bg-[#121b2e]',
|
||||
compact ? 'min-h-[420px]' : 'min-h-[620px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex h-full min-h-[inherit] flex-col items-center justify-center px-6 text-center">
|
||||
<div className="text-4xl font-semibold tracking-tight text-white">{title}</div>
|
||||
{action ? (
|
||||
<button className="mt-6 rounded-[10px] bg-[#15c98b] px-6 py-3 text-base font-medium text-[#081225]" type="button">
|
||||
{action}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +1,110 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { coverArtUrl, fetchHome } from '@/lib/api'
|
||||
import { SectionTitle } from '@/components/section-title'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { coverArtUrl, fetchHome, fetchTracks } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function HomePage() {
|
||||
const setQueue = usePlayerStore((state) => state.setQueue)
|
||||
const homeQuery = useQuery({
|
||||
queryKey: ['home'],
|
||||
queryFn: fetchHome,
|
||||
})
|
||||
const tracksQuery = useQuery({
|
||||
queryKey: ['tracks'],
|
||||
queryFn: fetchTracks,
|
||||
})
|
||||
|
||||
const home = homeQuery.data
|
||||
const heroTrack = tracksQuery.data?.items[0]
|
||||
const recentAlbums = homeQuery.data?.recentAlbums ?? []
|
||||
const popularAlbums = [...recentAlbums].reverse()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
eyebrow="Overview"
|
||||
title="Aonsoku-like web UI on top of your own server"
|
||||
copy="This first scaffold gives us a styled shell, data fetching boundaries, and a clean place to add real library, scan, and playback flows."
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="rounded-[28px] border border-line bg-slate-950/35 p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Recent albums</h2>
|
||||
<span className="text-sm text-slate-500">{homeQuery.isLoading ? 'Loading...' : 'Demo payload'}</span>
|
||||
<div className="space-y-10">
|
||||
<section className="relative overflow-hidden rounded-[14px] bg-[#111b2e]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(168,135,87,0.35),transparent_34%),linear-gradient(180deg,rgba(20,30,47,0.4),rgba(14,20,35,0.92))]" />
|
||||
<div className="relative flex min-h-[290px] items-end gap-4 px-8 py-6">
|
||||
<div className="h-64 w-64 shrink-0 overflow-hidden rounded-[14px] bg-[#1a2437] shadow-2xl">
|
||||
{heroTrack?.coverArtId ? (
|
||||
<img alt={heroTrack.title} className="h-full w-full object-cover" src={coverArtUrl(heroTrack.id)} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{home?.recentAlbums.map((album) => (
|
||||
<article key={album.id} className="rounded-[24px] border border-line bg-panel p-4">
|
||||
{album.coverArtId ? (
|
||||
<img
|
||||
alt={album.title}
|
||||
className="aspect-square w-full rounded-[20px] object-cover"
|
||||
src={coverArtUrl(album.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-square rounded-[20px] bg-[linear-gradient(145deg,#1f2f45,#152236)]" />
|
||||
)}
|
||||
<div className="mt-4 text-lg font-semibold">{album.title}</div>
|
||||
<div className="text-sm text-slate-400">{album.artistName}</div>
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.22em] text-slate-500">
|
||||
{album.year} • {album.trackCount} tracks
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<div className="pb-3">
|
||||
<h2 className="text-6xl font-semibold tracking-tight text-white">
|
||||
{heroTrack?.title ?? 'Dream on (Live in Paris, 2001)'}
|
||||
</h2>
|
||||
<div className="mt-2 text-3xl font-medium text-slate-300">{heroTrack?.artistName ?? 'Depeche Mode'}</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Tag>{new Date().getFullYear()}</Tag>
|
||||
<Tag>{formatDuration(heroTrack?.durationSeconds ?? 339)}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-line bg-slate-950/35 p-5">
|
||||
<h2 className="text-xl font-semibold">Artists</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{home?.artists.map((artist) => (
|
||||
<div key={artist.id} className="flex items-center justify-between rounded-2xl border border-line bg-panel px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium">{artist.name}</div>
|
||||
<div className="text-sm text-slate-400">{artist.albumCount} albums</div>
|
||||
</div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-accentSoft">Artist</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="ml-auto flex gap-2 self-center">
|
||||
<CarouselButton icon={<ChevronLeft size={18} />} />
|
||||
<CarouselButton icon={<ChevronRight size={18} />} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlbumRow title="Недавно прослушанные" albums={recentAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} />
|
||||
<AlbumRow title="Наиболее прослушиваемые" albums={popularAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AlbumRow({
|
||||
title,
|
||||
albums,
|
||||
onPlayAll,
|
||||
}: {
|
||||
title: string
|
||||
albums: Array<{
|
||||
id: string
|
||||
title: string
|
||||
artistName: string
|
||||
coverArtId: string
|
||||
}>
|
||||
onPlayAll: () => void
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h3 className="text-[2rem] font-semibold tracking-tight text-white">{title}</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="text-base text-slate-400 transition hover:text-white" onClick={onPlayAll} type="button">
|
||||
Еще
|
||||
</button>
|
||||
<CarouselButton icon={<ChevronLeft size={18} />} />
|
||||
<CarouselButton icon={<ChevronRight size={18} />} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
|
||||
{albums.map((album) => (
|
||||
<article key={album.id}>
|
||||
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
|
||||
{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>
|
||||
<div className="text-base text-slate-400">{album.artistName}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselButton({ icon }: { icon: React.ReactNode }) {
|
||||
return <button className="grid h-8 w-8 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" type="button">{icon}</button>
|
||||
}
|
||||
|
||||
function Tag({ children }: { children: React.ReactNode }) {
|
||||
return <div className="rounded-full bg-[#f1f4f8] px-4 py-1 text-sm font-semibold text-[#152035]">{children}</div>
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number) {
|
||||
const minutes = Math.floor(durationSeconds / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { SectionTitle } from '@/components/section-title'
|
||||
import { fetchTracks } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function LibraryPage() {
|
||||
const setQueue = usePlayerStore((state) => state.setQueue)
|
||||
const playTrack = usePlayerStore((state) => state.playTrack)
|
||||
const tracksQuery = useQuery({
|
||||
queryKey: ['tracks'],
|
||||
queryFn: fetchTracks,
|
||||
})
|
||||
|
||||
const tracks = tracksQuery.data?.items ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
eyebrow="Library"
|
||||
title="Tracks, queue, and playback boundaries"
|
||||
copy="This is the first useful slice for the app: list tracks from the backend, seed the queue, and hand off current track state to the global player."
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex gap-3">
|
||||
<button
|
||||
className="rounded-2xl bg-accent px-4 py-3 font-semibold text-slate-900"
|
||||
onClick={() => setQueue(tracks)}
|
||||
type="button"
|
||||
>
|
||||
Queue all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-line bg-slate-950/35">
|
||||
<div className="grid grid-cols-[72px_minmax(0,1.4fr)_minmax(0,1fr)_100px] gap-3 border-b border-line px-4 py-3 text-xs uppercase tracking-[0.24em] text-slate-500">
|
||||
<div>#</div>
|
||||
<div>Track</div>
|
||||
<div>Album</div>
|
||||
<div>Length</div>
|
||||
</div>
|
||||
|
||||
{tracks.map((track) => (
|
||||
<button
|
||||
key={track.id}
|
||||
className="grid w-full grid-cols-[72px_minmax(0,1.4fr)_minmax(0,1fr)_100px] gap-3 border-b border-line/60 px-4 py-4 text-left transition hover:bg-slate-900/60"
|
||||
onClick={() => playTrack(track)}
|
||||
type="button"
|
||||
>
|
||||
<div className="text-slate-500">{track.trackNumber}</div>
|
||||
<div>
|
||||
<div className="font-medium">{track.title}</div>
|
||||
<div className="text-sm text-slate-400">{track.artistName}</div>
|
||||
</div>
|
||||
<div className="text-slate-300">{track.albumTitle}</div>
|
||||
<div className="text-slate-400">{formatDuration(track.durationSeconds)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number) {
|
||||
const minutes = Math.floor(durationSeconds / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
85
apps/web/src/pages/tracks-page.tsx
Normal file
85
apps/web/src/pages/tracks-page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Heart, Search } from 'lucide-react'
|
||||
import { coverArtUrl, fetchTracks } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function TracksPage() {
|
||||
const setQueue = usePlayerStore((state) => state.setQueue)
|
||||
const playTrack = usePlayerStore((state) => state.playTrack)
|
||||
const tracksQuery = useQuery({
|
||||
queryKey: ['tracks'],
|
||||
queryFn: fetchTracks,
|
||||
})
|
||||
|
||||
const tracks = tracksQuery.data?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<HeaderSearch />
|
||||
<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>
|
||||
<div>Название</div>
|
||||
<div>Альбом</div>
|
||||
<div>◷</div>
|
||||
<div>Прослушивания</div>
|
||||
<div>Прослушано последний раз</div>
|
||||
<div>Качество</div>
|
||||
<div>♡</div>
|
||||
</div>
|
||||
<div className="max-h-[calc(100vh-280px)] overflow-auto">
|
||||
{tracks.map((track, index) => (
|
||||
<button
|
||||
key={track.id}
|
||||
className="grid w-full grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_140px_180px_120px_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="text-base text-slate-400">1</div>
|
||||
<div className="text-base text-slate-400">1 месяц назад</div>
|
||||
<div>
|
||||
<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} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderSearch() {
|
||||
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">
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number) {
|
||||
const minutes = Math.floor(durationSeconds / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -4,13 +4,57 @@ import type { Track } from '@/lib/api'
|
||||
type PlayerState = {
|
||||
currentTrack: Track | null
|
||||
queue: Track[]
|
||||
setQueue: (tracks: Track[]) => void
|
||||
playTrack: (track: Track) => void
|
||||
isPlaying: boolean
|
||||
volume: number
|
||||
setQueue: (tracks: Track[], startIndex?: number) => void
|
||||
playTrack: (track: Track, queue?: Track[]) => void
|
||||
togglePlayback: () => void
|
||||
playNext: () => void
|
||||
playPrevious: () => void
|
||||
setVolume: (volume: number) => void
|
||||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
currentTrack: null,
|
||||
queue: [],
|
||||
setQueue: (queue) => set({ queue, currentTrack: queue[0] ?? null }),
|
||||
playTrack: (currentTrack) => set({ currentTrack }),
|
||||
isPlaying: false,
|
||||
volume: 0.7,
|
||||
setQueue: (queue, startIndex = 0) =>
|
||||
set({
|
||||
queue,
|
||||
currentTrack: queue[startIndex] ?? null,
|
||||
isPlaying: queue.length > 0,
|
||||
}),
|
||||
playTrack: (currentTrack, queue) =>
|
||||
set((state) => ({
|
||||
currentTrack,
|
||||
queue: queue ?? state.queue,
|
||||
isPlaying: true,
|
||||
})),
|
||||
togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })),
|
||||
playNext: () =>
|
||||
set((state) => {
|
||||
if (!state.currentTrack || state.queue.length === 0) {
|
||||
return state
|
||||
}
|
||||
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
||||
const nextTrack = state.queue[index + 1] ?? state.queue[0] ?? null
|
||||
return {
|
||||
currentTrack: nextTrack,
|
||||
isPlaying: !!nextTrack,
|
||||
}
|
||||
}),
|
||||
playPrevious: () =>
|
||||
set((state) => {
|
||||
if (!state.currentTrack || state.queue.length === 0) {
|
||||
return state
|
||||
}
|
||||
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
||||
const previousTrack = state.queue[index - 1] ?? state.queue[state.queue.length - 1] ?? null
|
||||
return {
|
||||
currentTrack: previousTrack,
|
||||
isPlaying: !!previousTrack,
|
||||
}
|
||||
}),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
}))
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||
:root {
|
||||
color: #dce6f2;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(242, 159, 103, 0.16), transparent 24%),
|
||||
linear-gradient(180deg, #0c1624 0%, #09111c 100%);
|
||||
background: #0a1220;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@@ -23,9 +21,7 @@ body,
|
||||
|
||||
body {
|
||||
color: #dce6f2;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(242, 159, 103, 0.16), transparent 24%),
|
||||
linear-gradient(180deg, #0c1624 0%, #09111c 100%);
|
||||
background: #0a1220;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -38,3 +34,16 @@ input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #10192b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: #26324a;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
private.Get("/home", application.home)
|
||||
private.Get("/artists", application.artists)
|
||||
private.Get("/artists/{id}", application.artistByID)
|
||||
private.Get("/albums", application.albums)
|
||||
private.Get("/albums/{id}", application.albumByID)
|
||||
private.Get("/tracks", application.tracks)
|
||||
private.Get("/tracks/{id}", application.trackByID)
|
||||
@@ -152,6 +153,15 @@ func (a app) artistByID(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a app) albums(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := a.library.Albums(r.Context(), 1000)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load albums"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -173,6 +173,34 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
|
||||
return albums, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Albums(ctx context.Context, limit int) ([]Album, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '')
|
||||
FROM albums al
|
||||
JOIN artists a ON a.id = al.artist_id
|
||||
LEFT JOIN tracks t ON t.album_id = al.id
|
||||
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
||||
ORDER BY al.year DESC, al.title ASC
|
||||
LIMIT ?`,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query all albums: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var albums []Album
|
||||
for rows.Next() {
|
||||
var album Album
|
||||
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan all albums: %w", err)
|
||||
}
|
||||
albums = append(albums, album)
|
||||
}
|
||||
return albums, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error) {
|
||||
var album AlbumDetail
|
||||
|
||||
|
||||
Reference in New Issue
Block a user