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

@@ -2,49 +2,53 @@
import { create } from 'zustand'
import type { User } from '@/types'
import { fetchMe } from '@/lib/authApi'
import { fetchMe, logout } from '@/lib/authApi'
const TOKEN_KEY = 'pm_token'
const USER_KEY = 'pm_user'
interface AuthStore {
token: string | null
user: User | null
setAuth: (token: string, user: User) => void
setAuth: (user: User) => void
clearAuth: () => void
hydrate: () => Promise<void>
}
export const useAuthStore = create<AuthStore>((set, get) => ({
token: null,
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
setAuth: (token, user) => {
setAuth: (user) => {
if (typeof window !== 'undefined') {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
set({ token, user })
set({ user })
},
clearAuth: () => {
clearAuth: async () => {
await logout()
if (typeof window !== 'undefined') {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
// Remove legacy token key if present
localStorage.removeItem('pm_token')
}
set({ token: null, user: null })
set({ user: null })
},
hydrate: async () => {
if (typeof window === 'undefined') return
const token = localStorage.getItem(TOKEN_KEY)
if (!token) return
// Optimistically restore user from cache for instant UI
const cached = localStorage.getItem(USER_KEY)
if (cached) {
try { set({ user: JSON.parse(cached) }) } catch {}
}
// Verify with server via cookie
try {
const user = await fetchMe(token)
set({ token, user })
const user = await fetchMe()
localStorage.setItem(USER_KEY, JSON.stringify(user))
set({ user })
} catch {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem('pm_token')
set({ user: null })
}
},
}))

View File

@@ -18,23 +18,50 @@ export const BG_PRESETS: BgPreset[] = [
{ id: 'none', name: 'Нет', desc: 'Чистый фон' },
]
export interface RaysConfig {
count: number // 416
speed: number // 0.23.0 (rotation speed multiplier)
brightness: number // 0.32.5
spread: number // 0.32.0 (ray width multiplier)
export interface OrbsConfig { brightness: number; speed: number; trail: number }
export interface WavesConfig { amplitude: number; speed: number; trail: number }
export interface ParticlesConfig { speed: number; linkDist: number; trail: number }
export interface AuroraConfig { brightness: number; speed: number; trail: number }
export interface PulseConfig { sensitivity: number; ringSpeed: number; trail: number }
export interface StarsConfig { brightness: number; twinkle: number; trail: number }
export interface RainConfig { drops: number; speed: number; trail: number }
export interface RaysConfig { count: number; speed: number; brightness: number; spread: number; trail: number }
export interface FxConfigs {
orbs: OrbsConfig
waves: WavesConfig
particles: ParticlesConfig
aurora: AuroraConfig
pulse: PulseConfig
stars: StarsConfig
rain: RainConfig
rays: RaysConfig
}
export const DEFAULT_RAYS: RaysConfig = { count: 9, speed: 1, brightness: 1, spread: 1 }
export const DEFAULT_ORBS: OrbsConfig = { brightness: 1, speed: 1, trail: 0 }
export const DEFAULT_WAVES: WavesConfig = { amplitude: 1, speed: 1, trail: 0 }
export const DEFAULT_PARTICLES: ParticlesConfig = { speed: 1, linkDist: 1, trail: 0 }
export const DEFAULT_AURORA: AuroraConfig = { brightness: 1, speed: 1, trail: 0 }
export const DEFAULT_PULSE: PulseConfig = { sensitivity: 0.5, ringSpeed: 1, trail: 0 }
export const DEFAULT_STARS: StarsConfig = { brightness: 1, twinkle: 1, trail: 0 }
export const DEFAULT_RAIN: RainConfig = { drops: 30, speed: 1, trail: 0 }
export const DEFAULT_RAYS: RaysConfig = { count: 9, speed: 1, brightness: 1, spread: 1, trail: 0 }
const KEY_BG = 'pm_bg'
const KEY_RAYS = 'pm_rays'
export const DEFAULT_FX: FxConfigs = {
orbs: DEFAULT_ORBS, waves: DEFAULT_WAVES, particles: DEFAULT_PARTICLES,
aurora: DEFAULT_AURORA, pulse: DEFAULT_PULSE, stars: DEFAULT_STARS,
rain: DEFAULT_RAIN, rays: DEFAULT_RAYS,
}
const KEY_BG = 'pm_bg'
const KEY_FX = 'pm_fx'
interface BgStore {
bgMode: BgMode
raysConfig: RaysConfig
setBg: (mode: BgMode) => void
setRaysConfig: (cfg: Partial<RaysConfig>) => void
bgMode: BgMode
fxConfigs: FxConfigs
setBg: (mode: BgMode) => void
setFxConfig: <M extends keyof FxConfigs>(mode: M, cfg: Partial<FxConfigs[M]>) => void
resetFx: (mode: keyof FxConfigs) => void
}
export const useBgStore = create<BgStore>((set, get) => ({
@@ -43,18 +70,34 @@ export const useBgStore = create<BgStore>((set, get) => ({
const saved = localStorage.getItem(KEY_BG) as BgMode | null
return saved && BG_PRESETS.some(p => p.id === saved) ? saved : 'orbs'
})(),
raysConfig: (() => {
if (typeof window === 'undefined') return DEFAULT_RAYS
try { return { ...DEFAULT_RAYS, ...JSON.parse(localStorage.getItem(KEY_RAYS) || '{}') } }
catch { return DEFAULT_RAYS }
fxConfigs: (() => {
if (typeof window === 'undefined') return DEFAULT_FX
try {
const saved = JSON.parse(localStorage.getItem(KEY_FX) || '{}')
return {
orbs: { ...DEFAULT_ORBS, ...(saved.orbs ?? {}) },
waves: { ...DEFAULT_WAVES, ...(saved.waves ?? {}) },
particles: { ...DEFAULT_PARTICLES, ...(saved.particles ?? {}) },
aurora: { ...DEFAULT_AURORA, ...(saved.aurora ?? {}) },
pulse: { ...DEFAULT_PULSE, ...(saved.pulse ?? {}) },
stars: { ...DEFAULT_STARS, ...(saved.stars ?? {}) },
rain: { ...DEFAULT_RAIN, ...(saved.rain ?? {}) },
rays: { ...DEFAULT_RAYS, ...(saved.rays ?? {}) },
}
} catch { return DEFAULT_FX }
})(),
setBg: (mode) => {
if (typeof window !== 'undefined') localStorage.setItem(KEY_BG, mode)
set({ bgMode: mode })
},
setRaysConfig: (cfg) => {
const next = { ...get().raysConfig, ...cfg }
if (typeof window !== 'undefined') localStorage.setItem(KEY_RAYS, JSON.stringify(next))
set({ raysConfig: next })
setFxConfig: (mode, cfg) => {
const next: FxConfigs = { ...get().fxConfigs, [mode]: { ...get().fxConfigs[mode], ...cfg } }
if (typeof window !== 'undefined') localStorage.setItem(KEY_FX, JSON.stringify(next))
set({ fxConfigs: next })
},
resetFx: (mode) => {
const next: FxConfigs = { ...get().fxConfigs, [mode]: DEFAULT_FX[mode] }
if (typeof window !== 'undefined') localStorage.setItem(KEY_FX, JSON.stringify(next))
set({ fxConfigs: next })
},
}))

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 }) },
}))

View File

@@ -1,18 +1,25 @@
'use client'
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import type { Person, QueueItem, SearchResult, HistoryEntry, ShuffleMode, Color } from '@/types'
import { COLORS } from '@/lib/colors'
import { fairShuffle, randomShuffle } from '@/lib/shuffle'
const HISTORY_LIMIT = 100
export type RepeatMode = 'none' | 'one' | 'all'
interface PartyStore {
people: Person[]
queue: QueueItem[]
curIdx: number
loadKey: number
shuffleMode: ShuffleMode
repeatMode: RepeatMode
history: HistoryEntry[]
currentResults: SearchResult[]
currentResult: SearchResult | null
searchStatus: 'idle' | 'searching' | 'not-found'
addPerson: (name: string, tracks: string[]) => void
@@ -27,174 +34,195 @@ interface PartyStore {
updateQueueItemImg: (idx: number, img: string) => void
reorderQueue: (fromIdx: number, toIdx: number) => void
setCurrentResults: (results: SearchResult[]) => void
setCurrentResult: (r: SearchResult | null) => void
setSearchStatus: (status: PartyStore['searchStatus']) => void
setShuffleMode: (mode: ShuffleMode) => void
setRepeatMode: (mode: RepeatMode) => void
addToHistory: (entry: HistoryEntry) => void
clearHistory: () => void
}
export const usePartyStore = create<PartyStore>((set, get) => ({
people: [],
queue: [],
curIdx: -1,
loadKey: 0,
shuffleMode: 'fair',
history: [],
currentResults: [],
searchStatus: 'idle',
export const usePartyStore = create<PartyStore>()(
persist(
(set, get) => ({
people: [],
queue: [],
curIdx: -1,
loadKey: 0,
shuffleMode: 'fair',
repeatMode: 'none',
history: [],
currentResults: [],
currentResult: null,
searchStatus: 'idle',
addPerson: (name, tracks) => {
set((state) => {
const pi = state.people.length
return {
people: [
...state.people,
{
name,
color: COLORS[pi % COLORS.length],
tracks: tracks.map((t) => ({ title: t })),
},
],
}
})
},
addPerson: (name, tracks) => {
set((state) => {
const pi = state.people.length
return {
people: [
...state.people,
{
name,
color: COLORS[pi % COLORS.length],
tracks: tracks.map((t) => ({ title: t })),
},
],
}
})
},
removePerson: (index) => {
set((state) => ({
people: state.people
.filter((_, i) => i !== index)
.map((p, i) => ({ ...p, color: COLORS[i % COLORS.length] })),
}))
},
removePerson: (index) => {
set((state) => ({
people: state.people
.filter((_, i) => i !== index)
.map((p, i) => ({ ...p, color: COLORS[i % COLORS.length] })),
}))
},
generateMix: () => {
const { people, shuffleMode } = get()
if (!people.length) return
const queue = shuffleMode === 'fair' ? fairShuffle(people) : randomShuffle(people)
set((state) => ({ queue, curIdx: 0, loadKey: state.loadKey + 1 }))
},
generateMix: () => {
const { people, shuffleMode } = get()
if (!people.length) return
const queue = shuffleMode === 'fair' ? fairShuffle(people) : randomShuffle(people)
set((state) => ({ queue, curIdx: 0, loadKey: state.loadKey + 1 }))
},
addFairToQueue: (owner, color, tracks) => {
const { queue, curIdx } = get()
const played = queue.slice(0, Math.max(curIdx + 1, 0))
const remaining = queue.slice(Math.max(curIdx + 1, 0))
addFairToQueue: (owner, color, tracks) => {
const { queue, curIdx } = get()
const played = queue.slice(0, Math.max(curIdx + 1, 0))
const remaining = queue.slice(Math.max(curIdx + 1, 0))
// Group remaining by owner, preserving insertion order
const ownerOrder: string[] = []
const groups = new Map<string, QueueItem[]>()
for (const item of remaining) {
if (!groups.has(item.owner)) {
groups.set(item.owner, [])
ownerOrder.push(item.owner)
}
groups.get(item.owner)!.push(item)
const ownerOrder: string[] = []
const groups = new Map<string, QueueItem[]>()
for (const item of remaining) {
if (!groups.has(item.owner)) {
groups.set(item.owner, [])
ownerOrder.push(item.owner)
}
groups.get(item.owner)!.push(item)
}
const newItems: QueueItem[] = tracks.map((title, ti) => ({
title, owner, color, _pi: 0, _ti: ti, img: '',
}))
if (!groups.has(owner)) {
groups.set(owner, [])
ownerOrder.push(owner)
}
groups.get(owner)!.push(...newItems)
const groupArrays = ownerOrder.map(o => groups.get(o)!)
const maxLen = Math.max(...groupArrays.map(g => g.length))
const interleaved: QueueItem[] = []
for (let i = 0; i < maxLen; i++) {
for (const group of groupArrays) {
if (i < group.length) interleaved.push(group[i])
}
}
const newQueue = [...played, ...interleaved]
const newCurIdx = curIdx < 0 ? 0 : curIdx
set((state) => ({
queue: newQueue,
curIdx: newCurIdx,
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
}))
},
loadPlaylist: (tracks: string[]) => {
const color = COLORS[0]
const queue: QueueItem[] = tracks.map((title, ti) => ({
title,
owner: 'Соло',
color,
_pi: 0,
_ti: ti,
img: '',
}))
set((state) => ({
people: [{ name: 'Соло', color, tracks: tracks.map((t) => ({ title: t })) }],
queue,
curIdx: 0,
loadKey: state.loadKey + 1,
}))
},
removeFromQueue: (idx) => {
set((state) => {
const queue = state.queue.filter((_, i) => i !== idx)
let curIdx = state.curIdx
if (idx < curIdx) curIdx--
else if (idx === curIdx) curIdx = Math.min(curIdx, queue.length - 1)
return { queue, curIdx }
})
},
addTrackToQueue: (title) => {
const { curIdx } = get()
const color = COLORS[0]
const newItem: QueueItem = { title, owner: 'Remote', color, _pi: 0, _ti: 0, img: '' }
set((state) => ({
queue: [...state.queue, newItem],
curIdx: curIdx < 0 ? 0 : state.curIdx,
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
}))
},
setCurIdx: (idx) => {
set((state) => ({ curIdx: idx, loadKey: state.loadKey + 1 }))
},
setQueue: (queue) => set({ queue }),
updateQueueItemImg: (idx, img) => {
set((state) => {
const queue = [...state.queue]
if (queue[idx]) queue[idx] = { ...queue[idx], img }
return { queue }
})
},
reorderQueue: (fromIdx, toIdx) => {
set((state) => {
const queue = [...state.queue]
const [moved] = queue.splice(fromIdx, 1)
queue.splice(toIdx, 0, moved)
let { curIdx } = state
if (curIdx === fromIdx) curIdx = toIdx
else if (fromIdx < curIdx && toIdx >= curIdx) curIdx--
else if (fromIdx > curIdx && toIdx <= curIdx) curIdx++
return { queue, curIdx }
})
},
setCurrentResults: (results) => set({ currentResults: results }),
setCurrentResult: (r) => set({ currentResult: r }),
setSearchStatus: (searchStatus) => set({ searchStatus }),
setShuffleMode: (shuffleMode) => set({ shuffleMode }),
setRepeatMode: (repeatMode) => set({ repeatMode }),
addToHistory: (entry) => {
set((state) => ({
history: [entry, ...state.history].slice(0, HISTORY_LIMIT),
}))
},
clearHistory: () => set({ history: [] }),
}),
{
name: 'pm_party',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
people: state.people,
queue: state.queue,
curIdx: state.curIdx,
shuffleMode: state.shuffleMode,
repeatMode: state.repeatMode,
history: state.history,
}),
}
// Add new tracks to owner's group
const newItems: QueueItem[] = tracks.map((title, ti) => ({
title, owner, color, _pi: 0, _ti: ti, img: '',
}))
if (!groups.has(owner)) {
groups.set(owner, [])
ownerOrder.push(owner)
}
groups.get(owner)!.push(...newItems)
// Round-robin interleave
const groupArrays = ownerOrder.map(o => groups.get(o)!)
const maxLen = Math.max(...groupArrays.map(g => g.length))
const interleaved: QueueItem[] = []
for (let i = 0; i < maxLen; i++) {
for (const group of groupArrays) {
if (i < group.length) interleaved.push(group[i])
}
}
const newQueue = [...played, ...interleaved]
// If nothing was playing yet, start from 0
const newCurIdx = curIdx < 0 ? 0 : curIdx
set((state) => ({
queue: newQueue,
curIdx: newCurIdx,
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
}))
},
loadPlaylist: (tracks: string[]) => {
const color = COLORS[0]
const queue: QueueItem[] = tracks.map((title, ti) => ({
title,
owner: 'Соло',
color,
_pi: 0,
_ti: ti,
img: '',
}))
set((state) => ({
people: [{ name: 'Соло', color, tracks: tracks.map((t) => ({ title: t })) }],
queue,
curIdx: 0,
loadKey: state.loadKey + 1,
}))
},
removeFromQueue: (idx) => {
set((state) => {
const queue = state.queue.filter((_, i) => i !== idx)
let curIdx = state.curIdx
if (idx < curIdx) curIdx--
else if (idx === curIdx) curIdx = Math.min(curIdx, queue.length - 1)
return { queue, curIdx }
})
},
addTrackToQueue: (title) => {
const { curIdx } = get()
const color = COLORS[0]
const newItem: QueueItem = { title, owner: 'Remote', color, _pi: 0, _ti: 0, img: '' }
set((state) => ({
queue: [...state.queue, newItem],
curIdx: curIdx < 0 ? 0 : state.curIdx,
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
}))
},
setCurIdx: (idx) => {
set((state) => ({ curIdx: idx, loadKey: state.loadKey + 1 }))
},
setQueue: (queue) => set({ queue }),
updateQueueItemImg: (idx, img) => {
set((state) => {
const queue = [...state.queue]
if (queue[idx]) queue[idx] = { ...queue[idx], img }
return { queue }
})
},
reorderQueue: (fromIdx, toIdx) => {
set((state) => {
const queue = [...state.queue]
const [moved] = queue.splice(fromIdx, 1)
queue.splice(toIdx, 0, moved)
let { curIdx } = state
if (curIdx === fromIdx) curIdx = toIdx
else if (fromIdx < curIdx && toIdx >= curIdx) curIdx--
else if (fromIdx > curIdx && toIdx <= curIdx) curIdx++
return { queue, curIdx }
})
},
setCurrentResults: (results) => set({ currentResults: results }),
setSearchStatus: (searchStatus) => set({ searchStatus }),
setShuffleMode: (shuffleMode) => set({ shuffleMode }),
addToHistory: (entry) => {
set((state) => ({ history: [entry, ...state.history] }))
},
clearHistory: () => set({ history: [] }),
}))
)
)

View File

@@ -9,6 +9,7 @@ export interface AccentPreset {
}
export const ACCENT_PRESETS: AccentPreset[] = [
{ name: 'Лаванда', accent: '#de9cfe', rgb: '222,156,254' },
{ name: 'Лайм', accent: '#c8ff00', rgb: '200,255,0' },
{ name: 'Синий', accent: '#00D4FF', rgb: '0,212,255' },
{ name: 'Розовый', accent: '#FF2D78', rgb: '255,45,120' },

View File

@@ -0,0 +1,27 @@
'use client'
import { create } from 'zustand'
interface Toast {
id: number
message: string
type: 'error' | 'success' | 'info'
}
interface ToastStore {
toasts: Toast[]
show: (message: string, type?: Toast['type']) => void
dismiss: (id: number) => void
}
let nextId = 0
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
show: (message, type = 'info') => {
const id = ++nextId
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }))
setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 3500)
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}))

View File

@@ -3,6 +3,7 @@
import { create } from 'zustand'
import type { SearchResult } from '@/types'
import { useAuthStore } from '@/store/authStore'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
const STORAGE_KEY = 'pm_versions'
@@ -35,16 +36,12 @@ function saveLocal(v: Record<string, SavedVersion>) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(v)) } catch {}
}
function getToken(): string | null {
return useAuthStore.getState().token
}
async function apiFetch(path: string, opts?: RequestInit): Promise<Response | null> {
const token = getToken()
if (!token) return null
if (!useAuthStore.getState().user) return null
return fetch(`${API_URL}${path}`, {
credentials: 'include',
...opts,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...opts?.headers },
headers: { 'Content-Type': 'application/json', ...opts?.headers },
}).catch(() => null)
}