feat: nginx reverse proxy, Spotify import, overlay system, UI overhaul
- Add nginx as single entry point: /api/* → backend, /* → web - NEXT_PUBLIC_API_URL="" so all API calls are relative (go through nginx) - Add Spotify playlist import (Client Credentials OAuth, up to 500 tracks) - Add Yandex/Spotify tabbed import UI on /playlists - Add stream overlay system (SSE + polling fallback, 9 styles) - Reorganize pages into (main) route group - Add QueuePanel, VersionsPanel, Toaster components - Add overlay settings tab in /settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
543
apps/web/src/app/(main)/remote/[id]/page.tsx
Normal file
543
apps/web/src/app/(main)/remote/[id]/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
'use client'
|
||||
|
||||
import { use, useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
interface RemoteQueueItem {
|
||||
title: string
|
||||
owner: string
|
||||
color_bg: string
|
||||
color_text: string
|
||||
img?: string
|
||||
}
|
||||
|
||||
interface RemoteVersion {
|
||||
title: string
|
||||
artist: string
|
||||
duration: string
|
||||
img?: string
|
||||
}
|
||||
|
||||
interface RemoteState {
|
||||
title: string
|
||||
artist: string
|
||||
cover: string
|
||||
is_playing: boolean
|
||||
volume: number
|
||||
progress: number
|
||||
duration: number
|
||||
queue_len: number
|
||||
cur_idx: number
|
||||
queue: RemoteQueueItem[]
|
||||
versions: RemoteVersion[]
|
||||
active_version: number
|
||||
}
|
||||
|
||||
function formatTime(s: number) {
|
||||
if (!s || isNaN(s)) return '0:00'
|
||||
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function cmd(id: string, c: string, value?: number, text?: string) {
|
||||
await fetch(`${API_URL}/api/remote/${id}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [state, setState] = useState<RemoteState | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [addText, setAddText] = useState('')
|
||||
const [tab, setTab] = useState<'player' | 'queue'>('player')
|
||||
const [versionsOpen, setVersionsOpen] = useState(false)
|
||||
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
|
||||
const [localProgress, setLocalProgress] = useState(0)
|
||||
const [connected, setConnected] = useState(true)
|
||||
const [seeking, setSeeking] = useState(false)
|
||||
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
|
||||
const lastSuccessRef = useRef(Date.now())
|
||||
const progressBarRef = useRef<HTMLDivElement>(null)
|
||||
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
|
||||
if (s) setSavedVersions(JSON.parse(s))
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
|
||||
if (res.status === 404) { setNotFound(true); return }
|
||||
if (!res.ok) return
|
||||
const data: RemoteState = await res.json()
|
||||
if (active) {
|
||||
setState(data)
|
||||
if (!seeking) {
|
||||
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
|
||||
setLocalProgress(data.progress ?? 0)
|
||||
}
|
||||
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
|
||||
lastSuccessRef.current = Date.now()
|
||||
setConnected(true)
|
||||
}
|
||||
} catch {
|
||||
if (active) setConnected(false)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
const iv = setInterval(poll, 2000)
|
||||
return () => { active = false; clearInterval(iv) }
|
||||
}, [id, seeking])
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
if (seeking) return
|
||||
const { progress, ts, playing } = lastPollRef.current
|
||||
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
|
||||
}, 250)
|
||||
return () => clearInterval(iv)
|
||||
}, [seeking])
|
||||
|
||||
const getSeekPct = useCallback((clientX: number) => {
|
||||
const bar = progressBarRef.current
|
||||
if (!bar || !state?.duration) return null
|
||||
const rect = bar.getBoundingClientRect()
|
||||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
}, [state?.duration])
|
||||
|
||||
const handleSeekStart = useCallback((clientX: number) => {
|
||||
const pct = getSeekPct(clientX)
|
||||
if (pct === null) return
|
||||
setSeeking(true)
|
||||
const seekTo = pct * (state?.duration ?? 0)
|
||||
setLocalProgress(seekTo)
|
||||
}, [getSeekPct, state?.duration])
|
||||
|
||||
const handleSeekMove = useCallback((clientX: number) => {
|
||||
if (!seeking) return
|
||||
const pct = getSeekPct(clientX)
|
||||
if (pct === null) return
|
||||
setLocalProgress(pct * (state?.duration ?? 0))
|
||||
}, [seeking, getSeekPct, state?.duration])
|
||||
|
||||
const handleSeekEnd = useCallback((clientX: number) => {
|
||||
if (!seeking) return
|
||||
const pct = getSeekPct(clientX)
|
||||
if (pct !== null && state) {
|
||||
const seekTo = pct * state.duration
|
||||
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
|
||||
setLocalProgress(seekTo)
|
||||
cmd(id, 'seek', seekTo)
|
||||
}
|
||||
setSeeking(false)
|
||||
}, [seeking, getSeekPct, state, id])
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => handleSeekMove(e.clientX)
|
||||
const onMouseUp = (e: MouseEvent) => handleSeekEnd(e.clientX)
|
||||
const onTouchMove = (e: TouchEvent) => { if (e.touches[0]) handleSeekMove(e.touches[0].clientX) }
|
||||
const onTouchEnd = (e: TouchEvent) => { const t = e.changedTouches[0]; if (t) handleSeekEnd(t.clientX) }
|
||||
if (seeking) {
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
window.addEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
window.removeEventListener('touchmove', onTouchMove)
|
||||
window.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
}, [seeking, handleSeekMove, handleSeekEnd])
|
||||
|
||||
if (notFound) return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-surface2 flex items-center justify-center mb-2">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-app-text font-display font-bold text-[15px] mb-1">Сессия не найдена</p>
|
||||
<p className="text-muted text-[12px]">Ссылка устарела или сессия завершена</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!state) return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
|
||||
const progressPct = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
|
||||
const queue = state.queue ?? []
|
||||
const versions = state.versions ?? []
|
||||
const trackTitle = queue[state.cur_idx]?.title ?? ''
|
||||
const currentOwner = queue[state.cur_idx]
|
||||
|
||||
const isSavedLocally = (v: RemoteVersion) => {
|
||||
const s = savedVersions[trackTitle]
|
||||
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
|
||||
}
|
||||
|
||||
const handleSaveVersion = (i: number) => {
|
||||
const v = versions[i]
|
||||
if (!v || !trackTitle) return
|
||||
const alreadySaved = isSavedLocally(v)
|
||||
const next = { ...savedVersions }
|
||||
if (alreadySaved) delete next[trackTitle]
|
||||
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
|
||||
setSavedVersions(next)
|
||||
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
|
||||
cmd(id, 'save_version', i)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 transition-all duration-500"
|
||||
style={{ background: connected ? '#5cc87a' : '#ff6b6b', boxShadow: connected ? '0 0 5px #5cc87a99' : 'none' }}
|
||||
/>
|
||||
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-surface2 rounded-[9px] p-0.5">
|
||||
{(['player', 'queue'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className="px-3 py-1.5 text-[11px] font-display font-bold rounded-[7px] transition-all cursor-pointer flex items-center gap-1"
|
||||
style={{ background: tab === t ? 'rgba(var(--accent-rgb),0.12)' : 'transparent', color: tab === t ? 'var(--accent)' : '#555' }}
|
||||
>
|
||||
{t === 'player' ? 'Плеер' : 'Очередь'}
|
||||
{t === 'queue' && queue.length > 0 && (
|
||||
<span className="text-[9px] opacity-60 font-display tabular-nums">{queue.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'player' && (
|
||||
<div className="flex flex-col gap-6 items-center flex-1">
|
||||
|
||||
{/* Cover */}
|
||||
<div
|
||||
className="w-full aspect-square rounded-[24px] overflow-hidden bg-surface2 shrink-0"
|
||||
style={{ boxShadow: state.cover ? '0 20px 60px rgba(var(--accent-rgb),0.18), 0 8px 20px rgba(0,0,0,0.4)' : '0 8px 20px rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
{state.cover ? (
|
||||
<img src={state.cover} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#222" strokeWidth="1">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-display text-[20px] font-extrabold tracking-tight leading-tight truncate">
|
||||
{state.title || '—'}
|
||||
</div>
|
||||
{state.artist && (
|
||||
<div className="text-[13px] text-muted mt-1 truncate">{state.artist}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{currentOwner && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-[5px] font-medium" style={{ background: currentOwner.color_bg, color: currentOwner.color_text }}>
|
||||
{currentOwner.owner}
|
||||
</span>
|
||||
)}
|
||||
{state.queue_len > 0 && (
|
||||
<span className="text-[11px] text-muted font-display tabular-nums">{state.cur_idx + 1} / {state.queue_len}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full select-none">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="relative h-4 flex items-center cursor-pointer group"
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSeekStart(e.clientX) }}
|
||||
onTouchStart={(e) => { if (e.touches[0]) handleSeekStart(e.touches[0].clientX) }}
|
||||
>
|
||||
<div className="absolute inset-y-0 flex items-center w-full">
|
||||
<div className="relative w-full h-1.5 bg-white/[0.08] rounded-full">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full transition-none"
|
||||
style={{ width: `${progressPct}%`, background: 'var(--accent)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-white shadow transition-none"
|
||||
style={{ left: `${progressPct}%`, opacity: seeking ? 1 : 0, transition: seeking ? 'none' : 'opacity 0.15s' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums -mt-0.5">
|
||||
<span>{formatTime(clampedProgress)}</span>
|
||||
<span>{formatTime(state.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between w-full px-2">
|
||||
<button
|
||||
onClick={() => cmd(id, 'prev')}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
|
||||
className="w-18 h-18 rounded-full flex items-center justify-center active:scale-90 transition-all cursor-pointer hover:brightness-110"
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
background: 'var(--accent)',
|
||||
color: '#0a0a0f',
|
||||
boxShadow: '0 6px 24px rgba(var(--accent-rgb),0.45)',
|
||||
}}
|
||||
>
|
||||
{state.is_playing ? (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 3 }}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => cmd(id, 'next')}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 18l8.5-6L6 6v12z" /><rect x="16" y="6" width="2" height="12" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.02"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value)
|
||||
setVolume(v)
|
||||
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
|
||||
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
|
||||
}}
|
||||
className="flex-1 cursor-pointer h-1.5"
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Versions */}
|
||||
{versions.length > 1 && (
|
||||
<div className="w-full">
|
||||
<button
|
||||
onClick={() => setVersionsOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 rounded-[10px] bg-surface2 border border-white/[0.07] hover:border-white/[0.12] transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px] font-display font-bold tracking-[0.5px] text-muted">Версии трека</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted opacity-60">{versions.length}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#555" strokeWidth="2.5" className={`transition-transform duration-200 ${versionsOpen ? 'rotate-180' : ''}`}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{versionsOpen && (
|
||||
<div className="mt-1.5 border border-white/[0.07] rounded-[10px] overflow-hidden bg-surface2/50">
|
||||
{versions.map((v, i) => {
|
||||
const active = i === state.active_version
|
||||
const saved = isSavedLocally(v)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
|
||||
>
|
||||
<span className="text-[10px] text-muted w-4 text-right shrink-0 font-display tabular-nums">{i + 1}</span>
|
||||
{v.img ? (
|
||||
<img src={v.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
|
||||
<div className="text-[12px] text-app-text truncate">{v.title}</div>
|
||||
<div className="text-[11px] text-muted mt-px truncate">{v.artist}</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted shrink-0 font-display tabular-nums">{v.duration}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
|
||||
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
|
||||
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
|
||||
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
|
||||
style={{ background: active ? 'var(--accent)' : 'transparent', borderColor: active ? 'var(--accent)' : 'rgba(255,255,255,0.07)' }}
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'queue' && (
|
||||
<div className="flex flex-col gap-3 flex-1">
|
||||
|
||||
{/* Add track */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={addText}
|
||||
onChange={(e) => setAddText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && addText.trim()) {
|
||||
cmd(id, 'add', 0, addText.trim())
|
||||
setAddText('')
|
||||
}
|
||||
}}
|
||||
placeholder="Исполнитель — Название"
|
||||
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[10px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!addText.trim()) return
|
||||
cmd(id, 'add', 0, addText.trim())
|
||||
setAddText('')
|
||||
}}
|
||||
className="shrink-0 w-11 rounded-[10px] flex items-center justify-center bg-accent text-bg font-display font-bold text-[18px] cursor-pointer hover:brightness-110 active:scale-95 transition-all"
|
||||
style={{ color: '#0a0a0f' }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue list */}
|
||||
{queue.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center py-16 gap-3">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1.5">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3" cy="6" r="1" fill="#2a2a2a" stroke="none" />
|
||||
<circle cx="3" cy="12" r="1" fill="#2a2a2a" stroke="none" />
|
||||
<circle cx="3" cy="18" r="1" fill="#2a2a2a" stroke="none" />
|
||||
</svg>
|
||||
<p className="text-[13px] text-muted">Очередь пуста</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{queue.map((item, i) => {
|
||||
const active = i === state.cur_idx
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => cmd(id, 'goto', i)}
|
||||
className="flex items-center gap-2.5 px-2 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer rounded-[8px] transition-colors active:bg-surface2"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
|
||||
>
|
||||
{/* Index / playing indicator */}
|
||||
<div className="w-5 flex items-center justify-center shrink-0">
|
||||
{active ? (
|
||||
<div className="flex items-end gap-[1.5px] h-3.5">
|
||||
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted font-display tabular-nums">{i + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover */}
|
||||
{item.img ? (
|
||||
<img src={item.img} alt="" className="w-9 h-9 rounded-[7px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-[7px] bg-surface2 shrink-0 flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + owner */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[13px] text-app-text truncate block leading-tight"
|
||||
style={{ color: active ? 'var(--accent)' : undefined }}
|
||||
>{item.title}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-[4px] font-medium inline-block mt-0.5" style={{ background: item.color_bg, color: item.color_text }}>
|
||||
{item.owner}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user