Files
party-mix-app/apps/web/src/components/SoloTab/SoloTab.tsx
Kirko 428548a620 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>
2026-04-28 00:45:53 +03:00

160 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
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)
const handlePlay = () => {
loadPlaylist(tracks)
setLaunched(true)
window.scrollTo({ top: 0, behavior: 'smooth' })
setTimeout(() => setLaunched(false), 2500)
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" style={{ opacity: 0.8 }} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
<span className="text-[10px] font-display font-bold tracking-[1.3px] uppercase text-accent">
Прямо сейчас
</span>
</div>
<h3 className="font-display font-bold text-[15px] text-app-text leading-tight">Топ чарт</h3>
<p className="text-[11px] text-muted mt-0.5">{tracks.length} треков</p>
</div>
<button
onClick={handlePlay}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 mt-1"
style={{
background: launched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.15)',
color: launched ? '#0a0a0f' : 'var(--accent)',
}}
>
{launched ? '▶ Играет' : '▶ Слушать'}
</button>
</div>
</div>
)
}
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)))
})
}, [])
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 (
<div className="animate-fadeUp">
{topTracks === null && (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5 flex items-center gap-2.5 text-muted text-[13px]">
<div className="w-3.5 h-3.5 rounded-full border-2 border-surface2 border-t-accent animate-spin shrink-0" />
Загружаем чарт...
</div>
)}
{topTracks !== null && topTracks.length > 0 && (
<TopChartsCard tracks={topTracks} />
)}
<div className="bg-surface border border-white/[0.07] rounded-app p-4">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-3">
Быстрый поиск
</p>
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
placeholder="Исполнитель — Название трека"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
type="submit"
disabled={!search.trim()}
className="px-4 py-2.5 bg-accent text-bg font-display font-bold text-sm rounded-[9px] hover:brightness-110 active:scale-[0.97] transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
</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>
)
}