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:
2026-04-02 22:53:13 +03:00
parent 2f7034fae2
commit 2e7283baad
16 changed files with 1201 additions and 242 deletions

View File

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

View File

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

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

View File

@@ -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>
)

View 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]" />
}

View File

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

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

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

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

View File

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

View File

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

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

View File

@@ -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 }),
}))

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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