Initial commit: party-mix-app with prefetch cache, audio preload optimizations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:40:22 +03:00
commit 0097fb5183
83 changed files with 11788 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
'use client'
import { create } from 'zustand'
import type { User } from '@/types'
import { fetchMe } 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
clearAuth: () => void
hydrate: () => Promise<void>
}
export const useAuthStore = create<AuthStore>((set, get) => ({
token: null,
user: null,
setAuth: (token, user) => {
if (typeof window !== 'undefined') {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
set({ token, user })
},
clearAuth: () => {
if (typeof window !== 'undefined') {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
set({ token: null, user: null })
},
hydrate: async () => {
if (typeof window === 'undefined') return
const token = localStorage.getItem(TOKEN_KEY)
if (!token) return
try {
const user = await fetchMe(token)
set({ token, user })
} catch {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
},
}))

View File

@@ -0,0 +1,55 @@
'use client'
import { create } from 'zustand'
const STORAGE_KEY = 'pm_favorites'
function loadFromStorage(): string[] {
if (typeof window === 'undefined') return []
try {
const s = localStorage.getItem(STORAGE_KEY)
return s ? (JSON.parse(s) as string[]) : []
} catch { return [] }
}
function saveToStorage(favorites: string[]) {
if (typeof window === 'undefined') return
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites)) } catch {}
}
interface FavoritesStore {
favorites: string[]
hydrate: () => void
toggleFavorite: (title: string) => void
isFavorite: (title: string) => boolean
removeFavorite: (title: string) => void
clearFavorites: () => void
}
export const useFavoritesStore = create<FavoritesStore>((set, get) => ({
favorites: [],
hydrate: () => set({ favorites: loadFromStorage() }),
toggleFavorite: (title) => {
const { favorites } = get()
const next = favorites.includes(title)
? favorites.filter((f) => f !== title)
: [title, ...favorites]
saveToStorage(next)
set({ favorites: next })
},
isFavorite: (title) => get().favorites.includes(title),
removeFavorite: (title) => {
const next = get().favorites.filter((f) => f !== title)
saveToStorage(next)
set({ favorites: next })
},
clearFavorites: () => {
saveToStorage([])
set({ favorites: [] })
},
}))

View File

@@ -0,0 +1,200 @@
'use client'
import { create } from 'zustand'
import type { Person, QueueItem, SearchResult, HistoryEntry, ShuffleMode, Color } from '@/types'
import { COLORS } from '@/lib/colors'
import { fairShuffle, randomShuffle } from '@/lib/shuffle'
interface PartyStore {
people: Person[]
queue: QueueItem[]
curIdx: number
loadKey: number
shuffleMode: ShuffleMode
history: HistoryEntry[]
currentResults: SearchResult[]
searchStatus: 'idle' | 'searching' | 'not-found'
addPerson: (name: string, tracks: string[]) => void
removePerson: (index: number) => void
generateMix: () => void
loadPlaylist: (tracks: string[]) => void
addFairToQueue: (owner: string, color: Color, tracks: string[]) => void
removeFromQueue: (idx: number) => void
addTrackToQueue: (title: string) => void
setCurIdx: (idx: number) => void
setQueue: (queue: QueueItem[]) => void
updateQueueItemImg: (idx: number, img: string) => void
reorderQueue: (fromIdx: number, toIdx: number) => void
setCurrentResults: (results: SearchResult[]) => void
setSearchStatus: (status: PartyStore['searchStatus']) => void
setShuffleMode: (mode: ShuffleMode) => 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',
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] })),
}))
},
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))
// 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)
}
// 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

@@ -0,0 +1,95 @@
'use client'
import { create } from 'zustand'
import type { SearchResult } from '@/types'
import { useAuthStore } from '@/store/authStore'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
const STORAGE_KEY = 'pm_versions'
interface SavedVersion {
title: string
artist: string
duration: string
}
interface VersionStore {
versions: Record<string, SavedVersion>
hydrate: () => Promise<void>
saveVersion: (trackTitle: string, result: SearchResult) => void
getSavedVersion: (trackTitle: string) => SavedVersion | undefined
removeVersion: (trackTitle: string) => void
isSaved: (trackTitle: string, result: SearchResult) => boolean
}
function loadLocal(): Record<string, SavedVersion> {
if (typeof window === 'undefined') return {}
try {
const s = localStorage.getItem(STORAGE_KEY)
return s ? JSON.parse(s) : {}
} catch { return {} }
}
function saveLocal(v: Record<string, SavedVersion>) {
if (typeof window === 'undefined') return
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
return fetch(`${API_URL}${path}`, {
...opts,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...opts?.headers },
}).catch(() => null)
}
export const useVersionStore = create<VersionStore>((set, get) => ({
versions: {},
hydrate: async () => {
const local = loadLocal()
set({ versions: local })
const res = await apiFetch('/api/versions')
if (res?.ok) {
const remote: Record<string, SavedVersion> = await res.json()
// Merge: remote is authoritative, but keep local entries not yet on server
const merged = { ...local, ...remote }
saveLocal(merged)
set({ versions: merged })
}
},
saveVersion: (trackTitle, result) => {
const v: SavedVersion = { title: result.title, artist: result.artist, duration: result.duration }
const next = { ...get().versions, [trackTitle]: v }
saveLocal(next)
set({ versions: next })
apiFetch('/api/versions', {
method: 'POST',
body: JSON.stringify({ track_title: trackTitle, title: v.title, artist: v.artist, duration: v.duration }),
})
},
getSavedVersion: (trackTitle) => get().versions[trackTitle],
removeVersion: (trackTitle) => {
const next = { ...get().versions }
delete next[trackTitle]
saveLocal(next)
set({ versions: next })
apiFetch('/api/versions', {
method: 'DELETE',
body: JSON.stringify({ track_title: trackTitle }),
})
},
isSaved: (trackTitle, result) => {
const v = get().versions[trackTitle]
return !!v && v.title === result.title && v.artist === result.artist && v.duration === result.duration
},
}))