- 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>
216 lines
8.5 KiB
TypeScript
216 lines
8.5 KiB
TypeScript
'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 }) },
|
||
}))
|