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:
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -18,23 +18,50 @@ export const BG_PRESETS: BgPreset[] = [
|
||||
{ id: 'none', name: 'Нет', desc: 'Чистый фон' },
|
||||
]
|
||||
|
||||
export interface RaysConfig {
|
||||
count: number // 4–16
|
||||
speed: number // 0.2–3.0 (rotation speed multiplier)
|
||||
brightness: number // 0.3–2.5
|
||||
spread: number // 0.3–2.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 })
|
||||
},
|
||||
}))
|
||||
|
||||
215
apps/web/src/store/overlayStore.ts
Normal file
215
apps/web/src/store/overlayStore.ts
Normal 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 }) },
|
||||
}))
|
||||
@@ -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: [] }),
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
27
apps/web/src/store/toastStore.ts
Normal file
27
apps/web/src/store/toastStore.ts
Normal 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) })),
|
||||
}))
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user