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

@@ -0,0 +1,215 @@
'use client'
import { create } from 'zustand'
const KEY_ENABLED = 'pm_overlay_enabled'
const KEY_DESIGN = 'pm_overlay_design'
const KEY_STYLE = 'pm_overlay_style'
const KEY_ACCENT = 'pm_overlay_accent'
const KEY_POSITION = 'pm_overlay_position'
const KEY_FONT = 'pm_overlay_font'
const KEY_TEXTCOLOR = 'pm_overlay_textcolor'
const KEY_SHOWCVR = 'pm_overlay_showcvr'
const KEY_SHOWEQ = 'pm_overlay_showeq'
const KEY_PALETTE = 'pm_overlay_palette'
const KEY_CUSTPALETTES = 'pm_overlay_custpalettes'
const KEY_MARGIN = 'pm_overlay_margin'
const KEY_SCALE = 'pm_overlay_scale'
const KEY_OPACITY = 'pm_overlay_opacity'
export type OverlayDesign = 'minimal' | 'card' | 'bar'
export type OverlayStyle =
| 'classic' | 'aero' | 'retro' | 'neon' | 'clean'
| 'y2k' | 'lofi' | 'glam' | 'matrix'
export type OverlayPosition = 'bl' | 'br' | 'tl' | 'tr'
export interface StyleConfig {
bg: string
blur: string
border: string
shadow: string
radius: string
text: string
text2: string
eqColor: string
fontStyle: 'normal' | 'italic'
fontWeight: number
letterSpacing: string
}
export const OVERLAY_STYLES: Record<OverlayStyle, { name: string; desc: string; cfg: StyleConfig }> = {
classic: {
name: 'Классика', desc: 'Тёмное стекло, нейтральный',
cfg: {
bg: 'rgba(10,10,15,0.82)', blur: '20px',
border: '1px solid rgba(255,255,255,0.08)',
shadow: '0 8px 32px rgba(0,0,0,0.5)',
radius: '16px', text: '#ffffff', text2: 'rgba(255,255,255,0.45)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 600, letterSpacing: '0',
},
},
aero: {
name: 'Фрутигер Аеро', desc: 'Глянцевый XP-стиль',
cfg: {
bg: 'linear-gradient(145deg,rgba(200,235,255,0.55) 0%,rgba(140,195,255,0.4) 100%)',
blur: '24px',
border: '1.5px solid rgba(255,255,255,0.75)',
shadow: '0 4px 20px rgba(80,160,255,0.35),inset 0 1px 0 rgba(255,255,255,0.6)',
radius: '22px', text: '#003a6e', text2: 'rgba(0,60,120,0.6)',
eqColor: '#0077cc', fontStyle: 'normal', fontWeight: 700, letterSpacing: '-0.02em',
},
},
retro: {
name: 'Ретро', desc: 'VHS / кассетная эстетика',
cfg: {
bg: 'rgba(18,8,2,0.93)',
blur: '0px',
border: '2px solid #b06828',
shadow: '0 0 24px rgba(180,100,20,0.45),4px 4px 0 rgba(0,0,0,0.5)',
radius: '4px', text: '#f8d090', text2: '#9a6020',
eqColor: '#e08030', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.05em',
},
},
neon: {
name: 'Неон', desc: 'Киберпанк, светящиеся линии',
cfg: {
bg: 'rgba(0,0,10,0.9)',
blur: '8px',
border: '1px solid rgba(var(--accent-rgb),0.55)',
shadow: '0 0 24px rgba(var(--accent-rgb),0.35),inset 0 0 16px rgba(var(--accent-rgb),0.06)',
radius: '8px', text: 'var(--accent)', text2: 'rgba(var(--accent-rgb),0.55)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 800, letterSpacing: '0.04em',
},
},
clean: {
name: 'Минимализм', desc: 'Только текст, без фона',
cfg: {
bg: 'transparent',
blur: '0px',
border: 'none',
shadow: 'none',
radius: '0', text: '#ffffff', text2: 'rgba(255,255,255,0.5)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 600, letterSpacing: '0',
},
},
y2k: {
name: 'Y2K', desc: 'Хром, ранние 2000-е',
cfg: {
bg: 'linear-gradient(160deg,#d8d8e8 0%,#b0b8d0 50%,#c8cce0 100%)',
blur: '4px',
border: '2px solid rgba(255,255,255,0.9)',
shadow: '3px 3px 0 rgba(0,0,80,0.25),inset 0 1px 0 rgba(255,255,255,0.8)',
radius: '6px', text: '#000060', text2: '#444488',
eqColor: '#0044cc', fontStyle: 'normal', fontWeight: 700, letterSpacing: '-0.01em',
},
},
lofi: {
name: 'Ло-фай', desc: 'Тёплые тона, уютная атмосфера',
cfg: {
bg: 'rgba(38,24,12,0.9)',
blur: '14px',
border: '1px solid rgba(240,180,100,0.18)',
shadow: '0 6px 28px rgba(0,0,0,0.45)',
radius: '14px', text: '#fde8c0', text2: 'rgba(240,180,100,0.55)',
eqColor: '#e8a060', fontStyle: 'italic', fontWeight: 500, letterSpacing: '0.01em',
},
},
glam: {
name: 'Гламур', desc: 'Золото, роскошь',
cfg: {
bg: 'linear-gradient(145deg,rgba(28,18,4,0.93) 0%,rgba(18,12,2,0.93) 100%)',
blur: '16px',
border: '1px solid rgba(212,175,55,0.45)',
shadow: '0 8px 32px rgba(0,0,0,0.65),0 0 0 1px rgba(212,175,55,0.2)',
radius: '14px', text: '#f0c840', text2: 'rgba(200,150,30,0.65)',
eqColor: '#d4af37', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.02em',
},
},
matrix: {
name: 'Матрица', desc: 'Зелёный терминал, хакер',
cfg: {
bg: 'rgba(0,8,0,0.92)',
blur: '4px',
border: '1px solid rgba(0,255,50,0.3)',
shadow: '0 0 20px rgba(0,255,50,0.18)',
radius: '4px', text: '#00ff32', text2: 'rgba(0,200,30,0.55)',
eqColor: '#00ff32', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.08em',
},
},
}
interface OverlayStore {
enabled: boolean
design: OverlayDesign
style: OverlayStyle
accentColor: string
position: OverlayPosition
font: string
textColor: string
showCover: boolean
showEq: boolean
palette: string
customPalettes: Record<string, Record<string, string>>
margin: number
scale: number
opacity: number
setEnabled: (v: boolean) => void
setDesign: (d: OverlayDesign) => void
setStyle: (s: OverlayStyle) => void
setAccentColor: (c: string) => void
setPosition: (p: OverlayPosition) => void
setFont: (f: string) => void
setTextColor: (c: string) => void
setShowCover: (v: boolean) => void
setShowEq: (v: boolean) => void
setPalette: (p: string) => void
setCustomPaletteField: (style: string, field: string, value: string) => void
setMargin: (v: number) => void
setScale: (v: number) => void
setOpacity: (v: number) => void
}
const ls = (key: string) => (typeof window !== 'undefined' ? localStorage.getItem(key) : null)
const lsSet = (key: string, v: string) => { if (typeof window !== 'undefined') localStorage.setItem(key, v) }
export const useOverlayStore = create<OverlayStore>((set) => ({
enabled: ls(KEY_ENABLED) !== 'false',
design: (ls(KEY_DESIGN) ?? 'minimal') as OverlayDesign,
style: (ls(KEY_STYLE) ?? 'classic') as OverlayStyle,
accentColor: ls(KEY_ACCENT) ?? '#de9cfe',
position: (ls(KEY_POSITION) ?? 'bl') as OverlayPosition,
font: ls(KEY_FONT) ?? '',
textColor: ls(KEY_TEXTCOLOR) ?? '',
showCover: ls(KEY_SHOWCVR) !== 'false',
showEq: ls(KEY_SHOWEQ) !== 'false',
palette: ls(KEY_PALETTE) ?? 'default',
customPalettes: (() => { try { return JSON.parse(ls(KEY_CUSTPALETTES) ?? '{}') } catch { return {} } })(),
margin: Number(ls(KEY_MARGIN) ?? 24),
scale: Number(ls(KEY_SCALE) ?? 1),
opacity: Number(ls(KEY_OPACITY) ?? 1),
setEnabled: (v) => { lsSet(KEY_ENABLED, String(v)); set({ enabled: v }) },
setDesign: (d) => { lsSet(KEY_DESIGN, d); set({ design: d }) },
setStyle: (s) => { lsSet(KEY_STYLE, s); set({ style: s }) },
setAccentColor: (c) => { lsSet(KEY_ACCENT, c); set({ accentColor: c }) },
setPosition: (p) => { lsSet(KEY_POSITION, p); set({ position: p }) },
setFont: (f) => { lsSet(KEY_FONT, f); set({ font: f }) },
setTextColor: (c) => { lsSet(KEY_TEXTCOLOR, c); set({ textColor: c }) },
setShowCover: (v) => { lsSet(KEY_SHOWCVR, String(v)); set({ showCover: v }) },
setShowEq: (v) => { lsSet(KEY_SHOWEQ, String(v)); set({ showEq: v }) },
setPalette: (p) => { lsSet(KEY_PALETTE, p); set({ palette: p }) },
setCustomPaletteField: (style, field, value) => {
set((state) => {
const next = {
...state.customPalettes,
[style]: { ...(state.customPalettes[style] ?? {}), [field]: value },
}
lsSet(KEY_CUSTPALETTES, JSON.stringify(next))
return { customPalettes: next }
})
},
setMargin: (v) => { lsSet(KEY_MARGIN, String(v)); set({ margin: v }) },
setScale: (v) => { lsSet(KEY_SCALE, String(v)); set({ scale: v }) },
setOpacity: (v) => { lsSet(KEY_OPACITY, String(v)); set({ opacity: v }) },
}))