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:
2026-04-28 00:45:53 +03:00
parent 87ba7a0ecf
commit 428548a620
55 changed files with 5934 additions and 2052 deletions

View File

@@ -1,10 +1,28 @@
'use client'
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { fetchTopCharts } from '@/lib/api'
import type { SearchResult } from '@/types'
const SEARCH_HISTORY_KEY = 'pm_search_history'
const MAX_HISTORY = 8
function getSearchHistory(): string[] {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) ?? '[]') } catch { return [] }
}
function addToSearchHistory(q: string) {
const prev = getSearchHistory().filter((s) => s !== q)
const next = [q, ...prev].slice(0, MAX_HISTORY)
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) } catch {}
}
function clearSearchHistory() {
try { localStorage.removeItem(SEARCH_HISTORY_KEY) } catch {}
}
function TopChartsCard({ tracks }: { tracks: string[] }) {
const { loadPlaylist } = usePartyStore()
const [launched, setLaunched] = useState(false)
@@ -48,24 +66,34 @@ function TopChartsCard({ tracks }: { tracks: string[] }) {
export default function SoloTab() {
const [search, setSearch] = useState('')
const [topTracks, setTopTracks] = useState<string[] | null>(null)
const [searchHistory, setSearchHistory] = useState<string[]>([])
const { loadPlaylist } = usePartyStore()
useEffect(() => {
setSearchHistory(getSearchHistory())
fetchTopCharts().then((results: SearchResult[]) => {
if (!results.length) { setTopTracks([]); return }
setTopTracks(
results.map((r) => (r.artist ? `${r.artist}${r.title}` : r.title))
)
setTopTracks(results.map((r) => (r.artist ? `${r.artist}${r.title}` : r.title)))
})
}, [])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
const q = search.trim()
if (!q) return
loadPlaylist([q])
const doSearch = useCallback((q: string) => {
if (!q.trim()) return
addToSearchHistory(q.trim())
setSearchHistory(getSearchHistory())
loadPlaylist([q.trim()])
setSearch('')
window.scrollTo({ top: 0, behavior: 'smooth' })
}, [loadPlaylist])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
doSearch(search)
}
const handleClearHistory = () => {
clearSearchHistory()
setSearchHistory([])
}
return (
@@ -100,6 +128,31 @@ export default function SoloTab() {
</button>
</form>
{searchHistory.length > 0 && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] text-muted font-display uppercase tracking-[1px]">Недавние</span>
<button
onClick={handleClearHistory}
className="text-[10px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
>
Очистить
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{searchHistory.map((q) => (
<button
key={q}
onClick={() => doSearch(q)}
className="text-[11px] px-2.5 py-1 rounded-[7px] bg-surface2 text-muted hover:text-app-text hover:bg-white/[0.07] transition-all cursor-pointer truncate max-w-[200px]"
>
{q}
</button>
))}
</div>
</div>
)}
</div>
</div>
)