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:
50
apps/web/src/store/authStore.ts
Normal file
50
apps/web/src/store/authStore.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
55
apps/web/src/store/favoritesStore.ts
Normal file
55
apps/web/src/store/favoritesStore.ts
Normal 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: [] })
|
||||
},
|
||||
}))
|
||||
200
apps/web/src/store/partyStore.ts
Normal file
200
apps/web/src/store/partyStore.ts
Normal 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: [] }),
|
||||
}))
|
||||
95
apps/web/src/store/versionStore.ts
Normal file
95
apps/web/src/store/versionStore.ts
Normal 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
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user