+
+
+
+ } />
+ } />
+ } />
-
-
-
-
Signed in as
-
{username ?? 'demo'}
-
+
+
+
+
+ Aonsoku
-
-
-
-
-
+
+
} onClick={() => setSettingsOpen(true)} />
+
} onClick={() => setUserMenuOpen((value) => !value)} />
+ {userMenuOpen ? (
+
+
+
{username ?? 'demo'}
+
https://music.daemonlord.ru
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+ setSettingsOpen(false)} />
+ setPaletteOpen(false)} />
)
}
-function SidebarLink({
- to,
+function TopIconButton({
icon,
- label,
+ onClick,
}: {
- to: string
icon: React.ReactNode
- label: string
+ onClick?: () => void
}) {
return (
-
- [
- '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(' ')
- }
+
+
)
}
-
diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx
new file mode 100644
index 0000000..b89fedd
--- /dev/null
+++ b/apps/web/src/components/command-palette.tsx
@@ -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 (
+
+
+
+
+ {
+ setQuery(event.target.value)
+ setServerMode(false)
+ }}
+ placeholder="Поиск альбома, исполнителя или трека"
+ value={query}
+ />
+
+
+
+
+ {serverMode ? (
+
+
Управление сервером
+
+
{statusQuery.data?.tracks ?? 0} треков
+
{statusQuery.data?.albums ?? 0} папки
+
+ Последнее сканирование:{' '}
+ {statusQuery.data?.finishedAt
+ ? new Date(statusQuery.data.finishedAt).toLocaleString('ru-RU')
+ : 'нет данных'}
+
+
+
+
+
+
+
+
+ ) : deferredQuery.trim().length > 1 ? (
+
+
Результаты
+
+ {searchQuery.data?.artists.map((artist) => (
+
{
+ navigate('/artists')
+ onClose()
+ }}
+ />
+ ))}
+ {searchQuery.data?.albums.map((album) => (
+ {
+ navigate('/albums')
+ onClose()
+ }}
+ />
+ ))}
+ {searchQuery.data?.tracks.map((track) => (
+ {
+ navigate('/tracks')
+ onClose()
+ }}
+ />
+ ))}
+
+
+ ) : (
+
+
Команды
+
+ {filteredCommands.map((command, index) => {
+ const Icon = command.icon
+ return (
+
+ )
+ })}
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+function PaletteRow({
+ label,
+ meta,
+ onClick,
+}: {
+ label: string
+ meta: string
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
+
+function Pill({ children }: { children: React.ReactNode }) {
+ return
{children}
+}
+
+function KeyCap({ children }: { children: React.ReactNode }) {
+ return
{children}
+}
diff --git a/apps/web/src/components/player-bar.tsx b/apps/web/src/components/player-bar.tsx
index 40ac6b7..ca4146d 100644
--- a/apps/web/src/components/player-bar.tsx
+++ b/apps/web/src/components/player-bar.tsx
@@ -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
(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 (
-