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:
5
apps/web/.dockerignore
Normal file
5
apps/web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.log
|
||||
.env*.local
|
||||
@@ -9,6 +9,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
6
apps/web/next-env.d.ts
vendored
Normal file
6
apps/web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
559
apps/web/src/app/(main)/community/page.tsx
Normal file
559
apps/web/src/app/(main)/community/page.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { getPublicPlaylists, createPlaylist } from '@/lib/authApi'
|
||||
import type { PublicPlaylist, PlaylistTrack } from '@/types'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
const TAG_PALETTE = ['var(--accent)', '#ff6b9d', '#6bcdff', '#ffb86b', '#b86bff', '#6bffb8']
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
let h = 0
|
||||
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h)
|
||||
return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length]
|
||||
}
|
||||
|
||||
type SortMode = 'newest' | 'tracks' | 'alpha'
|
||||
|
||||
function TrackList({ tracks }: { tracks: PlaylistTrack[] }) {
|
||||
return (
|
||||
<div className="border-t border-white/[0.05] px-4 pt-3 pb-3.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{tracks.map((track, i) => (
|
||||
<div key={track.id} className="flex items-center gap-2.5 py-[3px] group">
|
||||
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
|
||||
<span className="text-[12px] text-app-text/75 group-hover:text-app-text truncate transition-colors duration-100">{track.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaylistCard({
|
||||
pl, onPlay, isLaunched, onFork, isForkDone, isForkLoading, canFork,
|
||||
}: {
|
||||
pl: PublicPlaylist
|
||||
onPlay: () => void
|
||||
isLaunched: boolean
|
||||
onFork: () => void
|
||||
isForkDone: boolean
|
||||
isForkLoading: boolean
|
||||
canFork: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const tags = pl.tags ?? []
|
||||
const trackCount = pl.tracks?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.13] transition-all duration-200">
|
||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center font-display font-extrabold text-[15px] select-none"
|
||||
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||
>
|
||||
{pl.username[0].toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-display text-[14px] font-bold text-app-text truncate leading-tight">{pl.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<span className="text-[11px] font-medium text-accent">{pl.username}</span>
|
||||
<span className="text-muted/30 text-[11px]">·</span>
|
||||
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
|
||||
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{trackCount > 0 && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all duration-150 cursor-pointer"
|
||||
title={expanded ? 'Скрыть треки' : 'Показать треки'}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
|
||||
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canFork && trackCount > 0 && (
|
||||
<button
|
||||
onClick={onFork}
|
||||
disabled={isForkDone || isForkLoading}
|
||||
className="w-7 h-7 rounded-[8px] flex items-center justify-center transition-all duration-150 cursor-pointer disabled:cursor-default"
|
||||
style={isForkDone ? { background: 'rgba(107,255,184,0.12)', color: '#6bffb8' } : { color: 'var(--muted)' }}
|
||||
title={isForkDone ? 'Добавлено!' : 'Скопировать к себе'}
|
||||
>
|
||||
{isForkLoading ? (
|
||||
<div className="w-3 h-3 rounded-full border-[1.5px] border-white/20 border-t-accent animate-spin" />
|
||||
) : isForkDone ? (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="hover:text-app-text">
|
||||
<path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M12 13v4M10 15h4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onPlay}
|
||||
disabled={!trackCount}
|
||||
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: isLaunched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.12)',
|
||||
color: isLaunched ? '#0a0a0f' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{isLaunched ? '▶ Играет' : '▶ Play'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && pl.tracks && pl.tracks.length > 0 && <TrackList tracks={pl.tracks} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer whitespace-nowrap"
|
||||
style={active
|
||||
? { background: 'rgba(var(--accent-rgb),0.15)', color: 'var(--accent)' }
|
||||
: { background: 'rgba(255,255,255,0.04)', color: 'var(--muted)' }
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TagToggleButton({
|
||||
count, open, onClick,
|
||||
}: {
|
||||
count: number
|
||||
open: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-3 py-2 rounded-[11px] transition-all duration-150 cursor-pointer border whitespace-nowrap shrink-0"
|
||||
style={count > 0
|
||||
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.25)' }
|
||||
: { background: 'rgba(255,255,255,0.04)', color: 'var(--muted)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
Теги
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="flex items-center justify-center w-4 h-4 rounded-full text-[10px] font-extrabold leading-none"
|
||||
style={{ background: 'var(--accent)', color: '#0a0a0f' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<svg width="9" height="9" viewBox="0 0 12 12" fill="none"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.18s' }}>
|
||||
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const TAG_VISIBLE_DEFAULT = 12
|
||||
|
||||
function TagPanel({
|
||||
allTags,
|
||||
activeTags,
|
||||
tagCounts,
|
||||
onToggle,
|
||||
onClear,
|
||||
}: {
|
||||
allTags: string[]
|
||||
activeTags: Set<string>
|
||||
tagCounts: Record<string, number>
|
||||
onToggle: (tag: string) => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const [tagSearch, setTagSearch] = useState('')
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const count = activeTags.size
|
||||
|
||||
const sorted = [...allTags].sort((a, b) => (tagCounts[b] ?? 0) - (tagCounts[a] ?? 0))
|
||||
|
||||
const q = tagSearch.toLowerCase().trim()
|
||||
const matched = q ? sorted.filter(t => t.toLowerCase().includes(q)) : sorted
|
||||
const visible = q || showAll ? matched : matched.slice(0, TAG_VISIBLE_DEFAULT)
|
||||
const hiddenCount = matched.length - TAG_VISIBLE_DEFAULT
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-surface border border-white/[0.07] rounded-[14px]">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="11" height="11" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={tagSearch}
|
||||
onChange={e => setTagSearch(e.target.value)}
|
||||
placeholder="Найти тег..."
|
||||
className="w-full font-sans text-[12px] bg-white/[0.04] border border-white/[0.07] rounded-[9px] pl-7 pr-2.5 py-1.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
{tagSearch && (
|
||||
<button
|
||||
onClick={() => setTagSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-[11px] font-medium text-muted hover:text-app-text transition-colors cursor-pointer whitespace-nowrap shrink-0"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{matched.length === 0 ? (
|
||||
<p className="text-[12px] text-muted text-center py-2">Не найдено</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{visible.map(tag => {
|
||||
const active = activeTags.has(tag)
|
||||
const color = tagColor(tag)
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => onToggle(tag)}
|
||||
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-2.5 py-1.5 rounded-[9px] transition-all duration-150 cursor-pointer"
|
||||
style={active
|
||||
? { background: color, color: '#0a0a0f' }
|
||||
: { background: `${color}14`, color: color, border: `1px solid ${color}30` }
|
||||
}
|
||||
>
|
||||
{active && (
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
{tag}
|
||||
<span className="text-[10px] font-sans font-medium opacity-60 leading-none">
|
||||
{tagCounts[tag] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!q && hiddenCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAll(v => !v)}
|
||||
className="mt-2.5 text-[11px] font-medium text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||
>
|
||||
{showAll ? 'Скрыть' : `Ещё ${hiddenCount} тегов`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommunityPage() {
|
||||
const [playlists, setPlaylists] = useState<PublicPlaylist[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [launched, setLaunched] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeTags, setActiveTags] = useState<Set<string>>(new Set())
|
||||
const [tagsOpen, setTagsOpen] = useState(false)
|
||||
const [sort, setSort] = useState<SortMode>('newest')
|
||||
const [forkStates, setForkStates] = useState<Record<string, 'idle' | 'loading' | 'done'>>({})
|
||||
const { loadPlaylist } = usePartyStore()
|
||||
const { user } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
getPublicPlaylists()
|
||||
.then(setPlaylists)
|
||||
.catch(() => setPlaylists([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handlePlay = (pl: PublicPlaylist) => {
|
||||
const tracks = pl.tracks?.map(t => t.title) ?? []
|
||||
if (!tracks.length) return
|
||||
loadPlaylist(tracks)
|
||||
setLaunched(pl.id)
|
||||
setTimeout(() => setLaunched(null), 2500)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const handlePlayAll = () => {
|
||||
const tracks = filtered.flatMap(pl => pl.tracks?.map(t => t.title) ?? [])
|
||||
if (!tracks.length) return
|
||||
loadPlaylist(tracks)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const handleFork = useCallback(async (pl: PublicPlaylist) => {
|
||||
if (!user || forkStates[pl.id] === 'loading' || forkStates[pl.id] === 'done') return
|
||||
const tracks = pl.tracks?.map(t => t.title) ?? []
|
||||
if (!tracks.length) return
|
||||
setForkStates(s => ({ ...s, [pl.id]: 'loading' }))
|
||||
try {
|
||||
await createPlaylist(`${pl.name} (от ${pl.username})`, tracks, false, pl.tags ?? [])
|
||||
setForkStates(s => ({ ...s, [pl.id]: 'done' }))
|
||||
} catch {
|
||||
setForkStates(s => ({ ...s, [pl.id]: 'idle' }))
|
||||
}
|
||||
}, [user, forkStates])
|
||||
|
||||
const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? [])))
|
||||
|
||||
const tagCounts = allTags.reduce<Record<string, number>>((acc, tag) => {
|
||||
acc[tag] = playlists.filter(pl => (pl.tags ?? []).includes(tag)).length
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const totalTracks = playlists.reduce((sum, pl) => sum + (pl.tracks?.length ?? 0), 0)
|
||||
const uniqueAuthors = new Set(playlists.map(pl => pl.username)).size
|
||||
|
||||
const filtered = playlists
|
||||
.filter(pl => {
|
||||
const q = search.toLowerCase().trim()
|
||||
const matchesSearch = !q
|
||||
|| pl.name.toLowerCase().includes(q)
|
||||
|| pl.username.toLowerCase().includes(q)
|
||||
|| (pl.tags ?? []).some(t => t.toLowerCase().includes(q))
|
||||
const matchesTags = activeTags.size === 0 || (pl.tags ?? []).some(t => activeTags.has(t))
|
||||
return matchesSearch && matchesTags
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sort === 'tracks') return (b.tracks?.length ?? 0) - (a.tracks?.length ?? 0)
|
||||
if (sort === 'alpha') return a.name.localeCompare(b.name, 'ru')
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
|
||||
const filteredTracks = filtered.reduce((sum, pl) => sum + (pl.tracks?.length ?? 0), 0)
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setActiveTags(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(tag) ? next.delete(tag) : next.add(tag)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="max-w-app mx-auto">
|
||||
<Header />
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight">Сообщество</h2>
|
||||
{!loading && (
|
||||
<span className="text-[12px] text-muted font-sans">{playlists.length} плейлистов</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12px] text-muted mt-0.5">Публичные плейлисты пользователей</p>
|
||||
</div>
|
||||
|
||||
{!loading && playlists.length > 0 && (
|
||||
<>
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-4 mb-4 px-4 py-2.5 bg-surface border border-white/[0.06] rounded-app">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
|
||||
<path d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12 0c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="text-[12px] font-bold text-app-text">{filteredTracks}</span>
|
||||
<span className="text-[11px] text-muted">треков</span>
|
||||
</div>
|
||||
<div className="w-px h-3.5 bg-white/[0.07]" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<circle cx="9" cy="7" r="4" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="text-[12px] font-bold text-app-text">{new Set(filtered.map(pl => pl.username)).size}</span>
|
||||
<span className="text-[11px] text-muted">{new Set(filtered.map(pl => pl.username)).size === 1 ? 'автор' : new Set(filtered.map(pl => pl.username)).size < 5 ? 'автора' : 'авторов'}</span>
|
||||
</div>
|
||||
<div className="w-px h-3.5 bg-white/[0.07]" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M3 9h18M9 21V9" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="text-[12px] font-bold text-app-text">{filtered.length}</span>
|
||||
<span className="text-[11px] text-muted">{filtered.length === 1 ? 'плейлист' : filtered.length < 5 ? 'плейлиста' : 'плейлистов'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + sort + tags */}
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Поиск по названию, автору или тегу..."
|
||||
className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<TagToggleButton
|
||||
count={activeTags.size}
|
||||
open={tagsOpen}
|
||||
onClick={() => setTagsOpen(v => !v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tagsOpen && allTags.length > 0 && (
|
||||
<TagPanel
|
||||
allTags={allTags}
|
||||
activeTags={activeTags}
|
||||
tagCounts={tagCounts}
|
||||
onToggle={toggleTag}
|
||||
onClear={() => setActiveTags(new Set())}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
<div className="flex items-center gap-1">
|
||||
<SortButton active={sort === 'newest'} onClick={() => setSort('newest')}>Новые</SortButton>
|
||||
<SortButton active={sort === 'tracks'} onClick={() => setSort('tracks')}>По трекам</SortButton>
|
||||
<SortButton active={sort === 'alpha'} onClick={() => setSort('alpha')}>А–Я</SortButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active tag chips */}
|
||||
{activeTags.size > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{Array.from(activeTags).map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className="flex items-center gap-1 text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
|
||||
style={{ background: tagColor(tag), color: '#0a0a0f' }}
|
||||
>
|
||||
{tag}
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setActiveTags(new Set())}
|
||||
className="text-[11px] text-muted hover:text-app-text transition-colors cursor-pointer px-1"
|
||||
>
|
||||
Сбросить всё
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : !filtered.length ? (
|
||||
<div className="text-center py-14 text-muted">
|
||||
<div className="text-4xl mb-3 opacity-20">🎵</div>
|
||||
<p className="text-[13px] font-medium">
|
||||
{playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
|
||||
</p>
|
||||
<p className="text-[12px] mt-1.5 opacity-50">
|
||||
{playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<span className="text-[11px] text-muted">
|
||||
{filtered.length !== playlists.length
|
||||
? `${filtered.length} из ${playlists.length} · ${filteredTracks} треков`
|
||||
: `${filtered.length} плейлистов · ${filteredTracks} треков`
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
onClick={handlePlayAll}
|
||||
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
|
||||
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M5 3l14 9-14 9V3z" />
|
||||
</svg>
|
||||
Играть всё
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{filtered.map(pl => (
|
||||
<PlaylistCard
|
||||
key={pl.id}
|
||||
pl={pl}
|
||||
onPlay={() => handlePlay(pl)}
|
||||
isLaunched={launched === pl.id}
|
||||
onFork={() => handleFork(pl)}
|
||||
isForkDone={forkStates[pl.id] === 'done'}
|
||||
isForkLoading={forkStates[pl.id] === 'loading'}
|
||||
canFork={!!user && pl.username !== user.username}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
20
apps/web/src/app/(main)/layout.tsx
Normal file
20
apps/web/src/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import AuthHydrator from '@/components/AuthHydrator'
|
||||
import AudioBackground from '@/components/AudioBackground'
|
||||
import GlobalPlayer from '@/components/GlobalPlayer'
|
||||
import ThemeApplier from '@/components/ThemeApplier'
|
||||
import Toaster from '@/components/Toaster'
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-bg min-h-screen">
|
||||
<ThemeApplier />
|
||||
<AudioBackground />
|
||||
<div className="relative pb-[72px] px-4 pt-5 sm:px-4" style={{ zIndex: 1 }}>
|
||||
<AuthHydrator />
|
||||
{children}
|
||||
</div>
|
||||
<GlobalPlayer />
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,9 +31,9 @@ export default function LoginPage() {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const { token, user } = await login(email, password)
|
||||
setAuth(token, user)
|
||||
router.push('/')
|
||||
const user = await login(email, password)
|
||||
setAuth(user)
|
||||
router.push('/app')
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка входа')
|
||||
} finally {
|
||||
212
apps/web/src/app/(main)/page.tsx
Normal file
212
apps/web/src/app/(main)/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const EQ_BARS = [
|
||||
{ h: 35, d: 0 }, { h: 72, d: 0.12 }, { h: 50, d: 0.23 }, { h: 90, d: 0.06 },
|
||||
{ h: 55, d: 0.17 }, { h: 82, d: 0.28 }, { h: 44, d: 0.09 }, { h: 96, d: 0.20 },
|
||||
{ h: 60, d: 0.14 }, { h: 76, d: 0.03 }, { h: 40, d: 0.25 }, { h: 85, d: 0.18 },
|
||||
{ h: 52, d: 0.07 }, { h: 67, d: 0.30 }, { h: 30, d: 0.11 }, { h: 78, d: 0.22 },
|
||||
{ h: 48, d: 0.16 }, { h: 88, d: 0.04 }, { h: 62, d: 0.26 }, { h: 38, d: 0.19 },
|
||||
]
|
||||
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
n: '01',
|
||||
title: 'Создай вечеринку',
|
||||
desc: 'Открой плеер, добавь гостей — каждый получает свой цвет',
|
||||
},
|
||||
{
|
||||
n: '02',
|
||||
title: 'Добавьте треки',
|
||||
desc: 'Каждый гость пишет своё — плеер сам найдёт и поставит',
|
||||
},
|
||||
{
|
||||
n: '03',
|
||||
title: 'Включай музыку',
|
||||
desc: 'Умный шаффл чередует очередь — никто не обделён эфиром',
|
||||
},
|
||||
]
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: 'Совместная очередь',
|
||||
desc: 'Каждый гость добавляет треки — никто не обделён эфиром',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3" cy="6" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="3" cy="18" r="1.2" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Умный шаффл',
|
||||
desc: 'Честный режим или случайный — музыка для всех, без диктатуры',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 3 21 3 21 8" /><line x1="4" y1="20" x2="21" y2="3" />
|
||||
<polyline points="21 16 21 21 16 21" /><line x1="15" y1="15" x2="21" y2="21" />
|
||||
<line x1="4" y1="4" x2="9" y2="9" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Плейлисты',
|
||||
desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Пульт с телефона',
|
||||
desc: 'Управляй плеером с любого телефона по QR-ссылке — без установки',
|
||||
icon: (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" />
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="max-w-app mx-auto min-h-[calc(100vh-40px)] flex flex-col">
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex items-center justify-between py-3 mb-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-[9px] bg-accent flex items-center justify-center shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
|
||||
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-display font-extrabold text-lg tracking-tight">
|
||||
Party<span className="text-accent">Mix</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/login" className="text-[13px] font-sans text-muted hover:text-app-text transition-colors px-2 py-1">
|
||||
Войти
|
||||
</Link>
|
||||
<Link href="/register" className="text-[13px] font-display font-semibold px-4 py-1.5 bg-surface border border-white/[0.07] rounded-xl text-app-text hover:border-white/20 hover:bg-surface2 transition-all">
|
||||
Регистрация
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="flex flex-col items-center text-center pt-10 pb-4 relative">
|
||||
{/* Glow */}
|
||||
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[300px] pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute inset-0 rounded-full blur-3xl" style={{ background: 'rgba(var(--accent-rgb),0.07)' }} />
|
||||
</div>
|
||||
|
||||
{/* EQ */}
|
||||
<div className="relative flex items-end gap-[4px] mb-7 h-14" aria-hidden="true">
|
||||
{EQ_BARS.map(({ h, d }, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="eq-bar"
|
||||
style={{ height: `${h}%`, animationDelay: `${d}s`, animationDuration: `${0.6 + d * 0.8}s`, width: '3px' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="font-display font-extrabold leading-none tracking-tight mb-4 text-[68px] sm:text-[96px]">
|
||||
Party<span className="text-accent">Mix</span>
|
||||
</h1>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="font-sans text-base text-muted max-w-[280px] mb-8 leading-relaxed">
|
||||
Совместные плейлисты для вечеринок.<br />
|
||||
Каждый гость — часть музыки.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-center mb-12">
|
||||
<Link
|
||||
href="/app"
|
||||
className="px-7 py-3 font-display font-bold text-sm rounded-xl hover:brightness-110 active:scale-[0.97] transition-all"
|
||||
style={{ background: 'var(--accent)', color: '#0a0a0f', boxShadow: '0 0 28px rgba(var(--accent-rgb),0.3)' }}
|
||||
>
|
||||
Начать вечеринку →
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-7 py-3 bg-surface border border-white/[0.07] text-app-text font-sans text-sm rounded-xl hover:bg-surface2 hover:border-white/20 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* Steps */}
|
||||
<section className="py-10">
|
||||
<p className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted mb-5">Как начать</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
{STEPS.map(({ n, title, desc }) => (
|
||||
<div key={n} className="flex items-start gap-4">
|
||||
<span
|
||||
className="text-[11px] font-display font-bold shrink-0 mt-0.5 w-8 h-8 rounded-[8px] flex items-center justify-center"
|
||||
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[14px] font-display font-bold mb-0.5">{title}</div>
|
||||
<div className="text-[12px] text-muted leading-relaxed">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-white/[0.05] mb-8" />
|
||||
|
||||
{/* Features */}
|
||||
<section className="pb-10">
|
||||
<p className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted mb-5">Возможности</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{FEATURES.map(({ icon, title, desc }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="bg-surface border border-white/[0.07] rounded-[14px] p-4 hover:bg-surface2 hover:border-white/[0.12] transition-all duration-200"
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[9px] flex items-center justify-center mb-3"
|
||||
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="font-display font-bold text-[13px] mb-1 text-app-text">{title}</h3>
|
||||
<p className="text-[11px] text-muted font-sans leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-white/[0.05] pt-5 pb-4 flex items-center justify-between">
|
||||
<span className="text-[12px] font-display font-bold tracking-tight text-muted">
|
||||
Party<span style={{ color: 'var(--accent)' }}>Mix</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/search" className="text-[11px] text-muted hover:text-app-text transition-colors">Поиск</Link>
|
||||
<Link href="/community" className="text-[11px] text-muted hover:text-app-text transition-colors">Сообщество</Link>
|
||||
<Link href="/app" className="text-[11px] text-muted hover:text-app-text transition-colors">Плеер</Link>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useFavoritesStore } from '@/store/favoritesStore'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import { useVersionStore } from '@/store/versionStore'
|
||||
import { getPlaylists, createPlaylist, updatePlaylist, deletePlaylist } from '@/lib/authApi'
|
||||
import { searchTracks, proxyImgUrl, fetchYandexPlaylist } from '@/lib/api'
|
||||
import { searchTracks, proxyImgUrl, fetchYandexPlaylist, fetchSpotifyPlaylist } from '@/lib/api'
|
||||
import type { Playlist, SearchResult } from '@/types'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
@@ -161,16 +161,21 @@ function PlaylistCard({
|
||||
pl,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddTrack,
|
||||
}: {
|
||||
pl: Playlist
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onAddTrack: (id: string, title: string) => Promise<void>
|
||||
}) {
|
||||
const { loadPlaylist } = usePartyStore()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||
const [versionsFor, setVersionsFor] = useState<string | null>(null)
|
||||
const [launched, setLaunched] = useState(false)
|
||||
const [addingTrack, setAddingTrack] = useState(false)
|
||||
const [quickAdd, setQuickAdd] = useState('')
|
||||
const [quickAdding, setQuickAdding] = useState(false)
|
||||
const tags = pl.tags ?? []
|
||||
const trackCount = pl.tracks?.length ?? 0
|
||||
|
||||
@@ -201,6 +206,16 @@ function PlaylistCard({
|
||||
setTimeout(() => setLaunched(false), 2500)
|
||||
}
|
||||
|
||||
const handleQuickAdd = async () => {
|
||||
const t = quickAdd.trim()
|
||||
if (!t) return
|
||||
setQuickAdding(true)
|
||||
await onAddTrack(pl.id, t)
|
||||
setQuickAdd('')
|
||||
setAddingTrack(false)
|
||||
setQuickAdding(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.11] transition-all duration-200">
|
||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||
@@ -250,6 +265,14 @@ function PlaylistCard({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setAddingTrack(v => !v)}
|
||||
title="Добавить трек"
|
||||
className="w-7 h-7 rounded-[8px] flex items-center justify-center border transition-all cursor-pointer"
|
||||
style={{ borderColor: addingTrack ? 'rgba(var(--accent-rgb),0.35)' : 'rgba(255,255,255,0.07)', color: addingTrack ? 'var(--accent)' : undefined, background: addingTrack ? 'rgba(var(--accent-rgb),0.06)' : 'transparent' }}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
</button>
|
||||
<button onClick={onEdit}
|
||||
className="text-[11px] px-2.5 py-1.5 border border-white/[0.07] rounded-lg bg-transparent text-muted hover:text-app-text hover:border-white/20 transition-all cursor-pointer font-display font-semibold">
|
||||
Изменить
|
||||
@@ -274,6 +297,27 @@ function PlaylistCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addingTrack && (
|
||||
<div className="flex gap-2 px-4 py-2.5 border-t border-white/[0.05] bg-surface2/30">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={quickAdd}
|
||||
onChange={e => setQuickAdd(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleQuickAdd(); if (e.key === 'Escape') { setAddingTrack(false); setQuickAdd('') } }}
|
||||
placeholder="Исполнитель — Название"
|
||||
className="flex-1 text-[12px] bg-surface2 border border-white/[0.07] rounded-[8px] px-3 py-2 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={handleQuickAdd}
|
||||
disabled={!quickAdd.trim() || quickAdding}
|
||||
className="px-3 py-2 text-[12px] font-display font-bold rounded-[8px] bg-accent text-bg hover:brightness-110 disabled:opacity-40 cursor-pointer transition-all"
|
||||
>
|
||||
{quickAdding ? '...' : 'Добавить'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && pl.tracks && pl.tracks.length > 0 && (
|
||||
<div className="border-t border-white/[0.05]">
|
||||
{pl.tracks.map((track, i) => (
|
||||
@@ -491,7 +535,7 @@ function FavoritesCard() {
|
||||
)
|
||||
}
|
||||
|
||||
function YandexImportForm({ onImport, onClose }: {
|
||||
function YandexImportForm({ onImport }: {
|
||||
onImport: (name: string, tracks: string[]) => Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
@@ -533,18 +577,7 @@ function YandexImportForm({ onImport, onClose }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
|
||||
Импорт из Яндекс.Музыки
|
||||
</p>
|
||||
<button onClick={onClose} className="text-muted hover:text-app-text transition-colors cursor-pointer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-3">
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="url"
|
||||
@@ -609,12 +642,121 @@ function YandexImportForm({ onImport, onClose }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SpotifyImportForm({ onImport, onClose }: {
|
||||
onImport: (name: string, tracks: string[]) => Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [importUrl, setImportUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [preview, setPreview] = useState<{ name: string; tracks: string[] } | null>(null)
|
||||
const [playlistName, setPlaylistName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const handleLoad = async () => {
|
||||
if (!importUrl.trim()) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setPreview(null)
|
||||
try {
|
||||
const data = await fetchSpotifyPlaylist(importUrl.trim())
|
||||
if (!data.tracks.length) {
|
||||
setError('Плейлист пуст или не удалось получить треки')
|
||||
return
|
||||
}
|
||||
setPreview(data)
|
||||
setPlaylistName(data.name || 'Плейлист из Spotify')
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка загрузки')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!preview || !playlistName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await onImport(playlistName.trim(), preview.tracks)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-3">
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
type="url"
|
||||
value={importUrl}
|
||||
onChange={e => { setImportUrl(e.target.value); setPreview(null); setError('') }}
|
||||
onKeyDown={e => e.key === 'Enter' && handleLoad()}
|
||||
placeholder="https://open.spotify.com/playlist/..."
|
||||
className="flex-1 font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={loading || !importUrl.trim()}
|
||||
className="px-3.5 py-2.5 font-display text-[12px] font-bold rounded-[9px] border border-white/[0.1] text-muted hover:text-app-text hover:border-white/20 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0 flex items-center gap-1.5"
|
||||
>
|
||||
{loading ? (
|
||||
<><div className="w-3 h-3 rounded-full border-2 border-surface2 border-t-accent animate-spin" /> Загрузка</>
|
||||
) : 'Загрузить'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[#ff6b6b] bg-[rgba(255,107,107,0.08)] border border-[rgba(255,107,107,0.15)] px-3 py-2 rounded-lg mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<div className="bg-surface2 border border-white/[0.06] rounded-[9px] p-3 mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] text-muted">Найдено треков:</span>
|
||||
<span className="text-[11px] font-display font-bold" style={{ color: 'var(--accent)' }}>{preview.tracks.length}</span>
|
||||
</div>
|
||||
<div className="max-h-[140px] overflow-y-auto flex flex-col gap-0.5">
|
||||
{preview.tracks.map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-2 py-0.5">
|
||||
<span className="text-[10px] text-muted/40 font-mono w-4 text-right shrink-0">{i + 1}</span>
|
||||
<span className="text-[11px] text-app-text/70 truncate">{t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={playlistName}
|
||||
onChange={e => setPlaylistName(e.target.value)}
|
||||
className="w-full font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 mb-3 transition-colors"
|
||||
placeholder="Название плейлиста"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !playlistName.trim()}
|
||||
className="w-full py-2.5 font-display text-[13px] font-bold bg-accent border-none rounded-[9px] text-bg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Создаём...' : `Создать плейлист (${preview.tracks.length} треков)`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlaylistsPage() {
|
||||
const router = useRouter()
|
||||
const { token, user } = useAuthStore()
|
||||
const { user } = useAuthStore()
|
||||
const { hydrate: hydrateFavorites } = useFavoritesStore()
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [plSearch, setPlSearch] = useState('')
|
||||
const [plSort, setPlSort] = useState<'date' | 'name' | 'tracks'>('date')
|
||||
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newTracks, setNewTracks] = useState('')
|
||||
@@ -626,30 +768,31 @@ export default function PlaylistsPage() {
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [importSource, setImportSource] = useState<'yandex' | 'spotify'>('yandex')
|
||||
|
||||
useEffect(() => {
|
||||
hydrateFavorites()
|
||||
}, [hydrateFavorites])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { router.push('/login'); return }
|
||||
getPlaylists(token)
|
||||
if (!user) { router.push('/login'); return }
|
||||
getPlaylists()
|
||||
.then(setPlaylists)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [token, router])
|
||||
}, [user, router])
|
||||
|
||||
const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!token || !newName.trim()) return
|
||||
if (!user || !newName.trim()) return
|
||||
const tracks = parseTracks(newTracks)
|
||||
if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return }
|
||||
setCreateError('')
|
||||
setSaving(true)
|
||||
try {
|
||||
const pl = await createPlaylist(token, newName.trim(), tracks, newIsPublic, newTags)
|
||||
const pl = await createPlaylist(newName.trim(), tracks, newIsPublic, newTags)
|
||||
setPlaylists(prev => [pl, ...prev])
|
||||
setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([])
|
||||
setShowForm(false)
|
||||
@@ -661,23 +804,39 @@ export default function PlaylistsPage() {
|
||||
}
|
||||
|
||||
const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => {
|
||||
if (!token) return
|
||||
if (!user) return
|
||||
try {
|
||||
const updated = await updatePlaylist(token, id, name, tracks, isPublic, tags)
|
||||
const updated = await updatePlaylist(id, name, tracks, isPublic, tags)
|
||||
setPlaylists(prev => prev.map(p => p.id === id ? updated : p))
|
||||
setEditingId(null)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!token) return
|
||||
if (!user) return
|
||||
setPlaylists(prev => prev.filter(p => p.id !== id))
|
||||
await deletePlaylist(token, id).catch(() => {})
|
||||
await deletePlaylist(id).catch(() => {})
|
||||
}
|
||||
|
||||
const handleAddTrack = async (id: string, title: string) => {
|
||||
const pl = playlists.find(p => p.id === id)
|
||||
if (!pl) return
|
||||
const existing = pl.tracks?.map(t => t.title) ?? []
|
||||
const updated = await updatePlaylist(id, pl.name, [...existing, title], pl.is_public, pl.tags ?? [])
|
||||
setPlaylists(prev => prev.map(p => p.id === id ? updated : p))
|
||||
}
|
||||
|
||||
const filteredPlaylists = playlists
|
||||
.filter(p => !plSearch.trim() || p.name.toLowerCase().includes(plSearch.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
if (plSort === 'name') return a.name.localeCompare(b.name, 'ru')
|
||||
if (plSort === 'tracks') return (b.tracks?.length ?? 0) - (a.tracks?.length ?? 0)
|
||||
return new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()
|
||||
})
|
||||
|
||||
const handleImport = async (name: string, tracks: string[]) => {
|
||||
if (!token) return
|
||||
const pl = await createPlaylist(token, name, tracks, false, [])
|
||||
if (!user) return
|
||||
const pl = await createPlaylist(name, tracks, false, [])
|
||||
setPlaylists(prev => [pl, ...prev])
|
||||
setShowImport(false)
|
||||
}
|
||||
@@ -740,10 +899,33 @@ export default function PlaylistsPage() {
|
||||
<FavoritesCard />
|
||||
|
||||
{showImport && (
|
||||
<YandexImportForm
|
||||
onImport={handleImport}
|
||||
onClose={() => setShowImport(false)}
|
||||
/>
|
||||
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-1 bg-surface2 rounded-[9px] p-0.5">
|
||||
{(['yandex', 'spotify'] as const).map(src => (
|
||||
<button
|
||||
key={src}
|
||||
onClick={() => setImportSource(src)}
|
||||
className="px-3 py-1 text-[11px] font-display font-bold rounded-[7px] transition-all cursor-pointer"
|
||||
style={importSource === src
|
||||
? { background: 'rgba(var(--accent-rgb),0.15)', color: 'var(--accent)' }
|
||||
: { background: 'transparent', color: 'var(--color-muted)' }}
|
||||
>
|
||||
{src === 'yandex' ? 'Яндекс' : 'Spotify'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setShowImport(false)} className="text-muted hover:text-app-text transition-colors cursor-pointer">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{importSource === 'yandex'
|
||||
? <YandexImportForm onImport={handleImport} onClose={() => setShowImport(false)} />
|
||||
: <SpotifyImportForm onImport={handleImport} onClose={() => setShowImport(false)} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
@@ -801,24 +983,56 @@ export default function PlaylistsPage() {
|
||||
<p className="text-[12px] mt-1.5 opacity-50">Нажмите «Создать» чтобы добавить первый</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{playlists.map(pl => (
|
||||
<div key={pl.id} className="rounded-app overflow-hidden border border-white/[0.07]">
|
||||
<PlaylistCard
|
||||
pl={pl}
|
||||
onEdit={() => setEditingId(editingId === pl.id ? null : pl.id)}
|
||||
onDelete={() => handleDelete(pl.id)}
|
||||
<>
|
||||
{/* Search + sort */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={plSearch}
|
||||
onChange={e => setPlSearch(e.target.value)}
|
||||
placeholder="Поиск по плейлистам..."
|
||||
className="w-full text-[12px] bg-surface border border-white/[0.07] rounded-[9px] pl-8 pr-3 py-2 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
{editingId === pl.id && (
|
||||
<EditForm
|
||||
pl={pl}
|
||||
onSave={(name, tracks, isPublic, tags) => handleUpdate(pl.id, name, tracks, isPublic, tags)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={plSort}
|
||||
onChange={e => setPlSort(e.target.value as typeof plSort)}
|
||||
className="text-[11px] bg-surface border border-white/[0.07] rounded-[9px] px-2.5 py-2 text-muted outline-none cursor-pointer"
|
||||
>
|
||||
<option value="date">По дате</option>
|
||||
<option value="name">По имени</option>
|
||||
<option value="tracks">По трекам</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{filteredPlaylists.map(pl => (
|
||||
<div key={pl.id} className="rounded-app overflow-hidden border border-white/[0.07]">
|
||||
<PlaylistCard
|
||||
pl={pl}
|
||||
onEdit={() => setEditingId(editingId === pl.id ? null : pl.id)}
|
||||
onDelete={() => handleDelete(pl.id)}
|
||||
onAddTrack={handleAddTrack}
|
||||
/>
|
||||
{editingId === pl.id && (
|
||||
<EditForm
|
||||
pl={pl}
|
||||
onSave={(name, tracks, isPublic, tags) => handleUpdate(pl.id, name, tracks, isPublic, tags)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredPlaylists.length === 0 && plSearch && (
|
||||
<div className="text-center py-8 text-muted text-[13px]">Ничего не найдено</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
543
apps/web/src/app/(main)/remote/[id]/page.tsx
Normal file
543
apps/web/src/app/(main)/remote/[id]/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
'use client'
|
||||
|
||||
import { use, useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
interface RemoteQueueItem {
|
||||
title: string
|
||||
owner: string
|
||||
color_bg: string
|
||||
color_text: string
|
||||
img?: string
|
||||
}
|
||||
|
||||
interface RemoteVersion {
|
||||
title: string
|
||||
artist: string
|
||||
duration: string
|
||||
img?: string
|
||||
}
|
||||
|
||||
interface RemoteState {
|
||||
title: string
|
||||
artist: string
|
||||
cover: string
|
||||
is_playing: boolean
|
||||
volume: number
|
||||
progress: number
|
||||
duration: number
|
||||
queue_len: number
|
||||
cur_idx: number
|
||||
queue: RemoteQueueItem[]
|
||||
versions: RemoteVersion[]
|
||||
active_version: number
|
||||
}
|
||||
|
||||
function formatTime(s: number) {
|
||||
if (!s || isNaN(s)) return '0:00'
|
||||
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function cmd(id: string, c: string, value?: number, text?: string) {
|
||||
await fetch(`${API_URL}/api/remote/${id}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [state, setState] = useState<RemoteState | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [addText, setAddText] = useState('')
|
||||
const [tab, setTab] = useState<'player' | 'queue'>('player')
|
||||
const [versionsOpen, setVersionsOpen] = useState(false)
|
||||
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
|
||||
const [localProgress, setLocalProgress] = useState(0)
|
||||
const [connected, setConnected] = useState(true)
|
||||
const [seeking, setSeeking] = useState(false)
|
||||
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
|
||||
const lastSuccessRef = useRef(Date.now())
|
||||
const progressBarRef = useRef<HTMLDivElement>(null)
|
||||
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
|
||||
if (s) setSavedVersions(JSON.parse(s))
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
|
||||
if (res.status === 404) { setNotFound(true); return }
|
||||
if (!res.ok) return
|
||||
const data: RemoteState = await res.json()
|
||||
if (active) {
|
||||
setState(data)
|
||||
if (!seeking) {
|
||||
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
|
||||
setLocalProgress(data.progress ?? 0)
|
||||
}
|
||||
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
|
||||
lastSuccessRef.current = Date.now()
|
||||
setConnected(true)
|
||||
}
|
||||
} catch {
|
||||
if (active) setConnected(false)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
const iv = setInterval(poll, 2000)
|
||||
return () => { active = false; clearInterval(iv) }
|
||||
}, [id, seeking])
|
||||
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
if (seeking) return
|
||||
const { progress, ts, playing } = lastPollRef.current
|
||||
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
|
||||
}, 250)
|
||||
return () => clearInterval(iv)
|
||||
}, [seeking])
|
||||
|
||||
const getSeekPct = useCallback((clientX: number) => {
|
||||
const bar = progressBarRef.current
|
||||
if (!bar || !state?.duration) return null
|
||||
const rect = bar.getBoundingClientRect()
|
||||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
}, [state?.duration])
|
||||
|
||||
const handleSeekStart = useCallback((clientX: number) => {
|
||||
const pct = getSeekPct(clientX)
|
||||
if (pct === null) return
|
||||
setSeeking(true)
|
||||
const seekTo = pct * (state?.duration ?? 0)
|
||||
setLocalProgress(seekTo)
|
||||
}, [getSeekPct, state?.duration])
|
||||
|
||||
const handleSeekMove = useCallback((clientX: number) => {
|
||||
if (!seeking) return
|
||||
const pct = getSeekPct(clientX)
|
||||
if (pct === null) return
|
||||
setLocalProgress(pct * (state?.duration ?? 0))
|
||||
}, [seeking, getSeekPct, state?.duration])
|
||||
|
||||
const handleSeekEnd = useCallback((clientX: number) => {
|
||||
if (!seeking) return
|
||||
const pct = getSeekPct(clientX)
|
||||
if (pct !== null && state) {
|
||||
const seekTo = pct * state.duration
|
||||
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
|
||||
setLocalProgress(seekTo)
|
||||
cmd(id, 'seek', seekTo)
|
||||
}
|
||||
setSeeking(false)
|
||||
}, [seeking, getSeekPct, state, id])
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => handleSeekMove(e.clientX)
|
||||
const onMouseUp = (e: MouseEvent) => handleSeekEnd(e.clientX)
|
||||
const onTouchMove = (e: TouchEvent) => { if (e.touches[0]) handleSeekMove(e.touches[0].clientX) }
|
||||
const onTouchEnd = (e: TouchEvent) => { const t = e.changedTouches[0]; if (t) handleSeekEnd(t.clientX) }
|
||||
if (seeking) {
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||
window.addEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
window.removeEventListener('touchmove', onTouchMove)
|
||||
window.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
}, [seeking, handleSeekMove, handleSeekEnd])
|
||||
|
||||
if (notFound) return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-surface2 flex items-center justify-center mb-2">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-app-text font-display font-bold text-[15px] mb-1">Сессия не найдена</p>
|
||||
<p className="text-muted text-[12px]">Ссылка устарела или сессия завершена</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!state) return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
|
||||
const progressPct = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
|
||||
const queue = state.queue ?? []
|
||||
const versions = state.versions ?? []
|
||||
const trackTitle = queue[state.cur_idx]?.title ?? ''
|
||||
const currentOwner = queue[state.cur_idx]
|
||||
|
||||
const isSavedLocally = (v: RemoteVersion) => {
|
||||
const s = savedVersions[trackTitle]
|
||||
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
|
||||
}
|
||||
|
||||
const handleSaveVersion = (i: number) => {
|
||||
const v = versions[i]
|
||||
if (!v || !trackTitle) return
|
||||
const alreadySaved = isSavedLocally(v)
|
||||
const next = { ...savedVersions }
|
||||
if (alreadySaved) delete next[trackTitle]
|
||||
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
|
||||
setSavedVersions(next)
|
||||
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
|
||||
cmd(id, 'save_version', i)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0 transition-all duration-500"
|
||||
style={{ background: connected ? '#5cc87a' : '#ff6b6b', boxShadow: connected ? '0 0 5px #5cc87a99' : 'none' }}
|
||||
/>
|
||||
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-surface2 rounded-[9px] p-0.5">
|
||||
{(['player', 'queue'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className="px-3 py-1.5 text-[11px] font-display font-bold rounded-[7px] transition-all cursor-pointer flex items-center gap-1"
|
||||
style={{ background: tab === t ? 'rgba(var(--accent-rgb),0.12)' : 'transparent', color: tab === t ? 'var(--accent)' : '#555' }}
|
||||
>
|
||||
{t === 'player' ? 'Плеер' : 'Очередь'}
|
||||
{t === 'queue' && queue.length > 0 && (
|
||||
<span className="text-[9px] opacity-60 font-display tabular-nums">{queue.length}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'player' && (
|
||||
<div className="flex flex-col gap-6 items-center flex-1">
|
||||
|
||||
{/* Cover */}
|
||||
<div
|
||||
className="w-full aspect-square rounded-[24px] overflow-hidden bg-surface2 shrink-0"
|
||||
style={{ boxShadow: state.cover ? '0 20px 60px rgba(var(--accent-rgb),0.18), 0 8px 20px rgba(0,0,0,0.4)' : '0 8px 20px rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
{state.cover ? (
|
||||
<img src={state.cover} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#222" strokeWidth="1">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-display text-[20px] font-extrabold tracking-tight leading-tight truncate">
|
||||
{state.title || '—'}
|
||||
</div>
|
||||
{state.artist && (
|
||||
<div className="text-[13px] text-muted mt-1 truncate">{state.artist}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{currentOwner && (
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-[5px] font-medium" style={{ background: currentOwner.color_bg, color: currentOwner.color_text }}>
|
||||
{currentOwner.owner}
|
||||
</span>
|
||||
)}
|
||||
{state.queue_len > 0 && (
|
||||
<span className="text-[11px] text-muted font-display tabular-nums">{state.cur_idx + 1} / {state.queue_len}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full select-none">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="relative h-4 flex items-center cursor-pointer group"
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSeekStart(e.clientX) }}
|
||||
onTouchStart={(e) => { if (e.touches[0]) handleSeekStart(e.touches[0].clientX) }}
|
||||
>
|
||||
<div className="absolute inset-y-0 flex items-center w-full">
|
||||
<div className="relative w-full h-1.5 bg-white/[0.08] rounded-full">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full transition-none"
|
||||
style={{ width: `${progressPct}%`, background: 'var(--accent)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-white shadow transition-none"
|
||||
style={{ left: `${progressPct}%`, opacity: seeking ? 1 : 0, transition: seeking ? 'none' : 'opacity 0.15s' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums -mt-0.5">
|
||||
<span>{formatTime(clampedProgress)}</span>
|
||||
<span>{formatTime(state.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between w-full px-2">
|
||||
<button
|
||||
onClick={() => cmd(id, 'prev')}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
|
||||
className="w-18 h-18 rounded-full flex items-center justify-center active:scale-90 transition-all cursor-pointer hover:brightness-110"
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
background: 'var(--accent)',
|
||||
color: '#0a0a0f',
|
||||
boxShadow: '0 6px 24px rgba(var(--accent-rgb),0.45)',
|
||||
}}
|
||||
>
|
||||
{state.is_playing ? (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 3 }}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => cmd(id, 'next')}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 18l8.5-6L6 6v12z" /><rect x="16" y="6" width="2" height="12" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.02"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value)
|
||||
setVolume(v)
|
||||
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
|
||||
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
|
||||
}}
|
||||
className="flex-1 cursor-pointer h-1.5"
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Versions */}
|
||||
{versions.length > 1 && (
|
||||
<div className="w-full">
|
||||
<button
|
||||
onClick={() => setVersionsOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 rounded-[10px] bg-surface2 border border-white/[0.07] hover:border-white/[0.12] transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px] font-display font-bold tracking-[0.5px] text-muted">Версии трека</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted opacity-60">{versions.length}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#555" strokeWidth="2.5" className={`transition-transform duration-200 ${versionsOpen ? 'rotate-180' : ''}`}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{versionsOpen && (
|
||||
<div className="mt-1.5 border border-white/[0.07] rounded-[10px] overflow-hidden bg-surface2/50">
|
||||
{versions.map((v, i) => {
|
||||
const active = i === state.active_version
|
||||
const saved = isSavedLocally(v)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
|
||||
>
|
||||
<span className="text-[10px] text-muted w-4 text-right shrink-0 font-display tabular-nums">{i + 1}</span>
|
||||
{v.img ? (
|
||||
<img src={v.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
|
||||
<div className="text-[12px] text-app-text truncate">{v.title}</div>
|
||||
<div className="text-[11px] text-muted mt-px truncate">{v.artist}</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted shrink-0 font-display tabular-nums">{v.duration}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
|
||||
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
|
||||
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
|
||||
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
|
||||
style={{ background: active ? 'var(--accent)' : 'transparent', borderColor: active ? 'var(--accent)' : 'rgba(255,255,255,0.07)' }}
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'queue' && (
|
||||
<div className="flex flex-col gap-3 flex-1">
|
||||
|
||||
{/* Add track */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={addText}
|
||||
onChange={(e) => setAddText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && addText.trim()) {
|
||||
cmd(id, 'add', 0, addText.trim())
|
||||
setAddText('')
|
||||
}
|
||||
}}
|
||||
placeholder="Исполнитель — Название"
|
||||
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[10px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!addText.trim()) return
|
||||
cmd(id, 'add', 0, addText.trim())
|
||||
setAddText('')
|
||||
}}
|
||||
className="shrink-0 w-11 rounded-[10px] flex items-center justify-center bg-accent text-bg font-display font-bold text-[18px] cursor-pointer hover:brightness-110 active:scale-95 transition-all"
|
||||
style={{ color: '#0a0a0f' }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue list */}
|
||||
{queue.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center py-16 gap-3">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1.5">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="3" cy="6" r="1" fill="#2a2a2a" stroke="none" />
|
||||
<circle cx="3" cy="12" r="1" fill="#2a2a2a" stroke="none" />
|
||||
<circle cx="3" cy="18" r="1" fill="#2a2a2a" stroke="none" />
|
||||
</svg>
|
||||
<p className="text-[13px] text-muted">Очередь пуста</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{queue.map((item, i) => {
|
||||
const active = i === state.cur_idx
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => cmd(id, 'goto', i)}
|
||||
className="flex items-center gap-2.5 px-2 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer rounded-[8px] transition-colors active:bg-surface2"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
|
||||
>
|
||||
{/* Index / playing indicator */}
|
||||
<div className="w-5 flex items-center justify-center shrink-0">
|
||||
{active ? (
|
||||
<div className="flex items-end gap-[1.5px] h-3.5">
|
||||
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted font-display tabular-nums">{i + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover */}
|
||||
{item.img ? (
|
||||
<img src={item.img} alt="" className="w-9 h-9 rounded-[7px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-[7px] bg-surface2 shrink-0 flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + owner */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[13px] text-app-text truncate block leading-tight"
|
||||
style={{ color: active ? 'var(--accent)' : undefined }}
|
||||
>{item.title}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-[4px] font-medium inline-block mt-0.5" style={{ background: item.color_bg, color: item.color_text }}>
|
||||
{item.owner}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import { useFavoritesStore } from '@/store/favoritesStore'
|
||||
import { useVersionStore } from '@/store/versionStore'
|
||||
@@ -9,13 +9,31 @@ import AddToPlaylist from '@/components/AddToPlaylist'
|
||||
import Header from '@/components/Header'
|
||||
import type { SearchResult } from '@/types'
|
||||
|
||||
// Module-level cache: survives navigation, cleared on page refresh
|
||||
let _cachedQuery = ''
|
||||
let _cachedResults: SearchResult[] | null = null
|
||||
|
||||
const SEARCH_HISTORY_KEY = 'pm_search_history'
|
||||
const MAX_HISTORY = 8
|
||||
|
||||
function getHistory(): string[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) ?? '[]') } catch { return [] }
|
||||
}
|
||||
function pushHistory(q: string) {
|
||||
const next = [q, ...getHistory().filter(s => s !== q)].slice(0, MAX_HISTORY)
|
||||
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) } catch {}
|
||||
return next
|
||||
}
|
||||
|
||||
function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: SearchResult) => void }) {
|
||||
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
||||
const { isSaved, saveVersion, removeVersion } = useVersionStore()
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||
const addBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const favorited = isFavorite(result.title)
|
||||
const favKey = result.artist ? `${result.artist} — ${result.title}` : result.title
|
||||
const favorited = isFavorite(favKey)
|
||||
const saved = isSaved(result.title, result)
|
||||
const hasImg = result.img && !result.img.includes('no-cover')
|
||||
|
||||
@@ -94,7 +112,7 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
|
||||
|
||||
{/* Favorite */}
|
||||
<button
|
||||
onClick={() => toggleFavorite(result.title)}
|
||||
onClick={() => toggleFavorite(favKey)}
|
||||
title={favorited ? 'Убрать из избранного' : 'В избранное'}
|
||||
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
|
||||
style={{
|
||||
@@ -122,28 +140,45 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[] | null>(null)
|
||||
const [query, setQuery] = useState(_cachedQuery)
|
||||
const [results, setResults] = useState<SearchResult[] | null>(_cachedResults)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [lastQuery, setLastQuery] = useState('')
|
||||
const [lastQuery, setLastQuery] = useState(_cachedQuery)
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([])
|
||||
const { loadPlaylist } = usePartyStore()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleSearch = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
const q = query.trim()
|
||||
if (!q) return
|
||||
useEffect(() => { setSearchHistory(getHistory()) }, [])
|
||||
|
||||
const runSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) return
|
||||
setLoading(true)
|
||||
setResults(null)
|
||||
setLastQuery(q)
|
||||
setSearchHistory(pushHistory(q))
|
||||
try {
|
||||
const found = await searchTracks(q)
|
||||
const raw = await searchTracks(q)
|
||||
const seen = new Set<string>()
|
||||
const found = raw.filter(r => {
|
||||
const key = `${r.artist}|||${r.title}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
setResults(found)
|
||||
_cachedQuery = q
|
||||
_cachedResults = found
|
||||
} catch {
|
||||
setResults([])
|
||||
_cachedResults = null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSearch = (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
runSearch(query.trim())
|
||||
}
|
||||
|
||||
const handlePlay = (r: SearchResult) => {
|
||||
@@ -202,6 +237,35 @@ export default function SearchPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Search history */}
|
||||
{!loading && results === null && searchHistory.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Недавние</span>
|
||||
<button
|
||||
onClick={() => { try { localStorage.removeItem(SEARCH_HISTORY_KEY) } catch {} setSearchHistory([]) }}
|
||||
className="text-[11px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{searchHistory.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => { setQuery(q); runSearch(q) }}
|
||||
className="flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-[9px] bg-surface border border-white/[0.07] text-muted hover:text-app-text hover:border-white/[0.14] transition-all cursor-pointer truncate max-w-[220px]"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="shrink-0 opacity-50">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" strokeLinecap="round" />
|
||||
</svg>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-14 gap-2.5 text-muted">
|
||||
996
apps/web/src/app/(main)/settings/page.tsx
Normal file
996
apps/web/src/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,996 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
||||
import { useBgStore, BG_PRESETS, DEFAULT_FX, type BgMode, type FxConfigs } from '@/store/bgStore'
|
||||
import { getActiveAccent } from '@/store/themeStore'
|
||||
import { useOverlayStore, OVERLAY_STYLES, type OverlayDesign, type OverlayStyle, type OverlayPosition } from '@/store/overlayStore'
|
||||
import { getPalettes, STYLE_CUSTOM_FIELDS } from '@/lib/overlayPalettes'
|
||||
import { OverlayPreview } from '@/components/OverlayWidget'
|
||||
import Header from '@/components/Header'
|
||||
import ColorWheel from '@/components/ColorWheel'
|
||||
|
||||
// ── Preview SVGs ─────────────────────────────────────────────────────────────
|
||||
|
||||
function OrbsPreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="p-o1" cx="12%" cy="6%" r="60%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.65"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
<radialGradient id="p-o2" cx="90%" cy="96%" r="55%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.55"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
|
||||
<radialGradient id="p-o3" cx="50%" cy="50%" r="40%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.35"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<ellipse cx="14" cy="4" rx="72" ry="72" fill="url(#p-o1)"/>
|
||||
<ellipse cx="108" cy="65" rx="58" ry="58" fill="url(#p-o2)"/>
|
||||
<ellipse cx="60" cy="34" rx="36" ry="36" fill="url(#p-o3)"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function WavesPreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<path d="M0 47 Q15 41 30 47 Q45 53 60 47 Q75 41 90 47 Q105 53 120 47 L120 68 L0 68Z" fill="var(--accent)" opacity="0.16"/>
|
||||
<path d="M0 55 Q15 49 30 55 Q45 61 60 55 Q75 49 90 55 Q105 61 120 55 L120 68 L0 68Z" fill="var(--accent)" opacity="0.22"/>
|
||||
<path d="M0 41 Q20 35 40 41 Q60 47 80 41 Q100 35 120 41 L120 68 L0 68Z" fill="rgb(255,60,172)" opacity="0.10"/>
|
||||
<path d="M0 61 Q20 57 40 61 Q60 65 80 61 Q100 57 120 61 L120 68 L0 68Z" fill="rgb(140,100,255)" opacity="0.14"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ParticlesPreview() {
|
||||
const d: [number,number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]]
|
||||
const ln: [number,number][] = [[0,1],[1,2],[0,3],[1,7],[3,5],[2,4],[4,6],[3,7],[7,8],[1,8],[9,0],[9,3]]
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
{ln.map(([a,b],i) => <line key={i} x1={d[a][0]} y1={d[a][1]} x2={d[b][0]} y2={d[b][1]} stroke="var(--accent)" strokeWidth="0.6" opacity="0.2"/>)}
|
||||
{d.map(([x,y],i) => <circle key={i} cx={x} cy={y} r={i%3===0?2:1.3} fill="var(--accent)" opacity={i%2===0?0.7:0.45}/>)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AuroraPreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="p-a1" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.55"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
<radialGradient id="p-a2" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.45"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
|
||||
<radialGradient id="p-a3" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.40"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<ellipse cx="10" cy="10" rx="80" ry="22" fill="url(#p-a1)"/>
|
||||
<ellipse cx="66" cy="6" rx="90" ry="16" fill="url(#p-a2)"/>
|
||||
<ellipse cx="106" cy="19" rx="66" ry="20" fill="url(#p-a3)"/>
|
||||
<ellipse cx="34" cy="32" rx="85" ry="15" fill="url(#p-a1)" opacity="0.5"/>
|
||||
<ellipse cx="86" cy="44" rx="60" ry="16" fill="url(#p-a2)" opacity="0.45"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PulsePreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<defs>
|
||||
<radialGradient id="p-pg" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<circle cx="60" cy="34" r="12" fill="url(#p-pg)"/>
|
||||
<circle cx="60" cy="34" r="9" fill="none" stroke="var(--accent)" strokeWidth="1.8" opacity="0.75"/>
|
||||
<circle cx="60" cy="34" r="20" fill="none" stroke="var(--accent)" strokeWidth="1.2" opacity="0.5"/>
|
||||
<circle cx="60" cy="34" r="30" fill="none" stroke="var(--accent)" strokeWidth="0.9" opacity="0.32"/>
|
||||
<circle cx="60" cy="34" r="42" fill="none" stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
|
||||
<circle cx="60" cy="34" r="55" fill="none" stroke="var(--accent)" strokeWidth="0.4" opacity="0.1"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StarsPreview() {
|
||||
const stars: [number,number,number][] = [
|
||||
[12,8,1.4],[34,5,0.9],[58,12,1.1],[80,4,1.5],[102,9,0.8],[18,22,1.0],[45,18,1.3],[72,20,0.9],[96,25,1.2],
|
||||
[8,38,0.8],[28,42,1.1],[55,35,1.4],[78,40,0.9],[108,36,1.0],[22,56,1.2],[50,58,0.8],[75,54,1.3],[100,60,1.0],
|
||||
[40,28,0.7],[88,14,1.1],[15,48,0.9],[64,48,0.8]
|
||||
]
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<defs>
|
||||
<radialGradient id="p-neb" cx="35%" cy="45%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.12"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<rect width="120" height="68" fill="url(#p-neb)"/>
|
||||
{stars.map(([x,y,r],i) => (
|
||||
<circle key={i} cx={x} cy={y} r={r} fill="var(--accent)" opacity={0.4 + (i % 5) * 0.12}/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RainPreview() {
|
||||
const drops: [number,number,number][] = [
|
||||
[10,8,20],[25,0,28],[40,15,22],[55,5,25],[70,10,18],[85,2,30],[100,12,24],[112,6,20],
|
||||
[18,35,22],[33,28,26],[48,40,19],[63,30,24],[78,38,21],[93,25,28],[108,34,20],
|
||||
]
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
{drops.map(([x,y,len],i) => (
|
||||
<g key={i}>
|
||||
<line x1={x} y1={y} x2={x} y2={y+len} stroke="var(--accent)" strokeWidth="1.5" opacity={0.15 + (i%4)*0.08}/>
|
||||
<circle cx={x} cy={y+len} r="1.5" fill="var(--accent)" opacity={0.5 + (i%3)*0.15}/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RaysPreview() {
|
||||
const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="p-rg" cx="50%" cy="60%" r="55%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
{rays.map((deg, i) => {
|
||||
const rad = (deg * Math.PI) / 180
|
||||
const x2 = 60 + Math.cos(rad) * 90
|
||||
const y2 = 41 + Math.sin(rad) * 90
|
||||
return (
|
||||
<line key={i} x1="60" y1="41" x2={x2} y2={y2}
|
||||
stroke="var(--accent)" strokeWidth={i%2===0?2.5:1.5} opacity={i%2===0?0.18:0.10}/>
|
||||
)
|
||||
})}
|
||||
<circle cx="60" cy="41" r="14" fill="url(#p-rg)"/>
|
||||
<circle cx="60" cy="41" r="3" fill="var(--accent)" opacity="0.7"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function NonePreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<line x1="48" y1="34" x2="72" y2="34" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
<line x1="60" y1="22" x2="60" y2="46" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
|
||||
orbs: <OrbsPreview />,
|
||||
waves: <WavesPreview />,
|
||||
particles: <ParticlesPreview />,
|
||||
aurora: <AuroraPreview />,
|
||||
pulse: <PulsePreview />,
|
||||
stars: <StarsPreview />,
|
||||
rain: <RainPreview />,
|
||||
rays: <RaysPreview />,
|
||||
none: <NonePreview />,
|
||||
}
|
||||
|
||||
// ── Per-effect slider definitions ─────────────────────────────────────────────
|
||||
|
||||
type SliderDef = { key: string; label: string; min: number; max: number; step: number; fmt: (v: number) => string }
|
||||
|
||||
const TRAIL_SLIDER: SliderDef = {
|
||||
key: 'trail', label: 'Шлейф', min: 0, max: 0.95, step: 0.05,
|
||||
fmt: v => v < 0.02 ? 'Нет' : Math.round(v * 100) + '%',
|
||||
}
|
||||
|
||||
const FX_SLIDERS: Partial<Record<keyof FxConfigs, SliderDef[]>> = {
|
||||
orbs: [
|
||||
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
TRAIL_SLIDER,
|
||||
],
|
||||
waves: [
|
||||
{ key: 'amplitude', label: 'Амплитуда', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
TRAIL_SLIDER,
|
||||
],
|
||||
particles: [
|
||||
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
{ key: 'linkDist', label: 'Связи', min: 0.3, max: 2.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
TRAIL_SLIDER,
|
||||
],
|
||||
aurora: [
|
||||
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
TRAIL_SLIDER,
|
||||
],
|
||||
pulse: [
|
||||
{ key: 'sensitivity', label: 'Чувствительность', min: 0, max: 1.0, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||
{ key: 'ringSpeed', label: 'Скорость колец', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
TRAIL_SLIDER,
|
||||
],
|
||||
stars: [
|
||||
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
{ key: 'twinkle', label: 'Мерцание', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
TRAIL_SLIDER,
|
||||
],
|
||||
rain: [
|
||||
{ key: 'drops', label: 'Количество', min: 5, max: 60, step: 1, fmt: v => String(Math.round(v)) },
|
||||
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
TRAIL_SLIDER,
|
||||
],
|
||||
rays: [
|
||||
{ key: 'count', label: 'Количество', min: 4, max: 16, step: 1, fmt: v => String(Math.round(v)) },
|
||||
{ key: 'speed', label: 'Скорость', min: 0.2, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
{ key: 'spread', label: 'Ширина', min: 0.3, max: 2.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||
TRAIL_SLIDER,
|
||||
],
|
||||
}
|
||||
|
||||
// ── Chevron icon ──────────────────────────────────────────────────────────────
|
||||
function Chevron({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||
className="shrink-0 transition-transform duration-200"
|
||||
style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
||||
>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Overlay style previews ────────────────────────────────────────────────────
|
||||
|
||||
function OverlayStylePreview({ style }: { style: OverlayStyle }) {
|
||||
const W = 160, H = 90
|
||||
if (style === 'classic') return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||
<rect width={W} height={H} fill="#0a0a10"/>
|
||||
<rect x="10" y="60" width="100" height="22" rx="8" fill="rgba(10,10,16,0.85)" stroke="rgba(255,255,255,0.09)" strokeWidth="0.6"/>
|
||||
<rect x="18" y="67" width="10" height="10" rx="3" fill="rgba(255,255,255,0.08)"/>
|
||||
<rect x="34" y="68" width="50" height="3.5" rx="1.5" fill="rgba(255,255,255,0.7)"/>
|
||||
<rect x="34" y="74" width="32" height="2" rx="1" fill="rgba(255,255,255,0.3)"/>
|
||||
<rect x="102" y="66" width="2" height="3" rx="1" fill="var(--accent)"/>
|
||||
<rect x="105" y="64" width="2" height="7" rx="1" fill="var(--accent)"/>
|
||||
<rect x="108" y="67" width="2" height="4" rx="1" fill="var(--accent)"/>
|
||||
</svg>
|
||||
)
|
||||
if (style === 'aero') return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||
<rect width={W} height={H} fill="#1a2a3a"/>
|
||||
<rect x="10" y="62" width="110" height="20" rx="12" fill="rgba(160,210,255,0.4)" stroke="rgba(255,255,255,0.7)" strokeWidth="0.8"/>
|
||||
<rect x="10" y="62" width="110" height="10" rx="12" fill="rgba(255,255,255,0.3)"/>
|
||||
<circle cx="24" cy="72" r="7" fill="rgba(255,255,255,0.3)" stroke="rgba(255,255,255,0.6)" strokeWidth="0.6"/>
|
||||
<rect x="37" y="68" width="48" height="3" rx="1.5" fill="rgba(0,40,120,0.8)"/>
|
||||
<rect x="37" y="73" width="30" height="2" rx="1" fill="rgba(0,60,160,0.5)"/>
|
||||
</svg>
|
||||
)
|
||||
if (style === 'retro') return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||
<rect width={W} height={H} fill="#0a0400"/>
|
||||
<rect x="10" y="55" width="120" height="28" fill="rgba(12,5,0,0.95)" stroke="#b06828" strokeWidth="1.2"/>
|
||||
<rect x="10" y="55" width="120" height="10" fill="rgba(160,70,10,0.4)"/>
|
||||
<text x="15" y="63" fontSize="6" fill="#e08030" fontFamily="monospace">▶ NOW PLAYING</text>
|
||||
<text x="108" y="63" fontSize="6" fill="#f09040" fontFamily="monospace">● REC</text>
|
||||
<rect x="15" y="70" width="90" height="3.5" rx="0" fill="rgba(248,208,144,0.8)" fontFamily="monospace"/>
|
||||
<rect x="15" y="76" width="55" height="2.5" rx="0" fill="rgba(154,96,32,0.7)"/>
|
||||
<text x="112" y="80" fontSize="5" fill="#7a4810" fontFamily="monospace">00:42</text>
|
||||
</svg>
|
||||
)
|
||||
if (style === 'neon') return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||
<rect width={W} height={H} fill="#00000f"/>
|
||||
<rect x="10" y="54" width="120" height="30" rx="6" fill="rgba(0,0,10,0.9)" stroke="rgba(var(--accent-rgb, 222,156,254),0.65)" strokeWidth="0.8"/>
|
||||
<rect x="10" y="54" width="6" height="6" rx="1" fill="none" stroke="var(--accent)" strokeWidth="0.8"/>
|
||||
<rect x="124" y="54" width="6" height="6" rx="1" fill="none" stroke="var(--accent)" strokeWidth="0.8"/>
|
||||
<text x="16" y="64" fontSize="5" fill="rgba(222,156,254,0.5)" fontFamily="monospace" letterSpacing="2">◈ STREAM</text>
|
||||
<rect x="16" y="68" width="80" height="3.5" rx="1" fill="var(--accent)" opacity="0.85"/>
|
||||
<rect x="16" y="74" width="50" height="2" rx="1" fill="rgba(222,156,254,0.45)"/>
|
||||
</svg>
|
||||
)
|
||||
if (style === 'clean') return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||
<rect width={W} height={H} fill="#111"/>
|
||||
<rect x="14" y="58" width="3" height="22" rx="1.5" fill="var(--accent)"/>
|
||||
<rect x="22" y="60" width="95" height="5" rx="2" fill="rgba(255,255,255,0.85)"/>
|
||||
<rect x="22" y="68" width="60" height="3" rx="1.5" fill="rgba(255,255,255,0.4)"/>
|
||||
</svg>
|
||||
)
|
||||
if (style === 'y2k') return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||
<rect width={W} height={H} fill="#8090a0"/>
|
||||
<rect x="15" y="48" width="120" height="36" rx="3" fill="#c8d0e0" stroke="rgba(255,255,255,0.9)" strokeWidth="1"/>
|
||||
<rect x="15" y="48" width="120" height="12" rx="3" fill="url(#xp)"/>
|
||||
<defs><linearGradient id="xp" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="#1a4090"/><stop offset="50%" stopColor="#4a80d0"/><stop offset="100%" stopColor="#1a4090"/></linearGradient></defs>
|
||||
<text x="20" y="57" fontSize="6" fill="white" fontFamily="Tahoma,sans-serif" fontWeight="700">🎵 Party Mix Player</text>
|
||||
<rect x="120" y="50" width="7" height="6" rx="1" fill="#c0c8d8" stroke="rgba(100,120,160,0.8)" strokeWidth="0.5"/>
|
||||
<text x="121.5" y="55" fontSize="6" fill="#000" fontWeight="700">×</text>
|
||||
<rect x="20" y="64" width="100" height="8" rx="1" fill="rgba(255,255,255,0.5)" stroke="#8090b0" strokeWidth="0.5"/>
|
||||
<rect x="20" y="68" width="45" height="4" rx="0.5" fill="#2060b0"/>
|
||||
<rect x="20" y="75" width="100" height="5" rx="1" fill="rgba(255,255,255,0.4)" stroke="#8090b0" strokeWidth="0.5"/>
|
||||
</svg>
|
||||
)
|
||||
if (style === 'lofi') return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`} style={{ transform: 'rotate(-0.5deg)' }}>
|
||||
<rect width={W} height={H} fill="#1a0e06"/>
|
||||
<rect x="12" y="52" width="120" height="34" rx="12" fill="rgba(42,26,14,0.92)" stroke="rgba(240,180,100,0.2)" strokeWidth="0.8"/>
|
||||
<circle cx="34" cy="69" r="14" fill="radial-gradient(#3a2010,#1a0e06)" stroke="rgba(255,180,80,0.3)" strokeWidth="0.8"/>
|
||||
<circle cx="34" cy="69" r="14" fill="rgba(30,16,6,0.9)"/>
|
||||
<circle cx="34" cy="69" r="5" fill="rgba(255,160,60,0.4)"/>
|
||||
<text x="53" y="62" fontSize="5" fill="rgba(240,180,100,0.5)" fontFamily="serif" fontStyle="italic">now playing</text>
|
||||
<rect x="53" y="65" width="65" height="3.5" rx="1" fill="rgba(253,232,192,0.8)"/>
|
||||
<rect x="53" y="71" width="42" height="2.5" rx="1" fill="rgba(240,180,100,0.45)"/>
|
||||
</svg>
|
||||
)
|
||||
if (style === 'glam') return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||
<rect width={W} height={H} fill="#0a0800"/>
|
||||
<rect x="15" y="48" width="120" height="38" rx="10" fill="rgba(22,14,2,0.96)" stroke="rgba(212,175,55,0.55)" strokeWidth="0.8"/>
|
||||
<rect x="15" y="48" width="120" height="2" rx="1" fill="url(#gld)"/>
|
||||
<rect x="15" y="84" width="120" height="2" rx="1" fill="url(#gld)"/>
|
||||
<defs><linearGradient id="gld" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="transparent"/><stop offset="30%" stopColor="rgba(212,175,55,0.9)"/><stop offset="50%" stopColor="#ffd700"/><stop offset="70%" stopColor="rgba(212,175,55,0.9)"/><stop offset="100%" stopColor="transparent"/></linearGradient></defs>
|
||||
<text x="24" y="56" fontSize="5" fill="rgba(212,175,55,0.5)">✦</text>
|
||||
<text x="125" y="56" fontSize="5" fill="rgba(212,175,55,0.5)">✦</text>
|
||||
<text x="80" y="63" fontSize="5" fill="rgba(212,175,55,0.45)" textAnchor="middle" letterSpacing="2">NOW PLAYING</text>
|
||||
<rect x="30" y="65" width="90" height="0.8" fill="rgba(212,175,55,0.3)"/>
|
||||
<rect x="30" y="69" width="90" height="4" rx="1" fill="rgba(240,200,64,0.8)" textAnchor="middle"/>
|
||||
<rect x="45" y="75" width="60" height="2.5" rx="1" fill="rgba(200,150,30,0.5)"/>
|
||||
</svg>
|
||||
)
|
||||
// matrix
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||
<rect width={W} height={H} fill="#000800"/>
|
||||
<rect x="10" y="50" width="130" height="34" rx="3" fill="rgba(0,8,0,0.93)" stroke="rgba(0,255,50,0.35)" strokeWidth="0.7"/>
|
||||
<text x="16" y="61" fontSize="5" fill="rgba(0,200,40,0.5)" fontFamily="monospace">PARTY_MIX@STREAM:~$</text>
|
||||
<text x="16" y="70" fontSize="7" fill="#00ff32" fontFamily="monospace" fontWeight="700">> Земфира — Хочешь?▌</text>
|
||||
<text x="16" y="78" fontSize="5" fill="rgba(0,180,30,0.4)" fontFamily="monospace">[LOADING...]</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SettingsTab = 'appearance' | 'overlay'
|
||||
|
||||
const OVERLAY_DESIGNS: { id: OverlayDesign; name: string; desc: string }[] = [
|
||||
{ id: 'minimal', name: 'Минимальный', desc: 'Маленькая плашка с EQ и названием' },
|
||||
{ id: 'card', name: 'Карточка', desc: 'Обложка, название и артист' },
|
||||
{ id: 'bar', name: 'Широкая', desc: 'Полоса во всю ширину снизу' },
|
||||
]
|
||||
|
||||
const OVERLAY_POSITIONS: { id: OverlayPosition; arrow: string; label: string }[] = [
|
||||
{ id: 'tl', arrow: '↖', label: 'Лево сверху' },
|
||||
{ id: 'tr', arrow: '↗', label: 'Право сверху' },
|
||||
{ id: 'bl', arrow: '↙', label: 'Лево снизу' },
|
||||
{ id: 'br', arrow: '↘', label: 'Право снизу' },
|
||||
]
|
||||
|
||||
const OVERLAY_FONTS: { id: string; name: string; css: string }[] = [
|
||||
{ id: '', name: 'Авто', css: 'inherit' },
|
||||
{ id: 'Inter, sans-serif', name: 'Inter', css: 'Inter, sans-serif' },
|
||||
{ id: 'Space Grotesk, sans-serif', name: 'Space Grotesk',css: 'Space Grotesk, sans-serif' },
|
||||
{ id: 'Unbounded, sans-serif', name: 'Unbounded', css: 'Unbounded, sans-serif' },
|
||||
{ id: 'JetBrains Mono, monospace', name: 'JetBrains Mono', css: 'JetBrains Mono, monospace' },
|
||||
{ id: 'Playfair Display, serif', name: 'Playfair Display', css: 'Playfair Display, serif' },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuthStore()
|
||||
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
|
||||
const { bgMode, fxConfigs, setBg, setFxConfig, resetFx } = useBgStore()
|
||||
const {
|
||||
enabled: overlayEnabled, design: overlayDesign, style: overlayStyle,
|
||||
accentColor: overlayAccentColor, position: overlayPosition,
|
||||
font: overlayFont, textColor: overlayTextColor,
|
||||
showCover: overlayShowCover, showEq: overlayShowEq,
|
||||
setEnabled: setOverlayEnabled, setDesign: setOverlayDesign, setStyle: setOverlayStyle,
|
||||
setAccentColor: setOverlayAccentColor, setPosition: setOverlayPosition,
|
||||
setFont: setOverlayFont, setTextColor: setOverlayTextColor,
|
||||
setShowCover: setOverlayShowCover, setShowEq: setOverlayShowEq,
|
||||
palette: overlayPalette, setPalette: setOverlayPalette,
|
||||
customPalettes, setCustomPaletteField,
|
||||
margin: overlayMargin, scale: overlayScale, opacity: overlayOpacity,
|
||||
setMargin: setOverlayMargin, setScale: setOverlayScale, setOpacity: setOverlayOpacity,
|
||||
} = useOverlayStore()
|
||||
const router = useRouter()
|
||||
const activeAccent = getActiveAccent(accentIdx, customHex)
|
||||
|
||||
const [tab, setTab] = useState<SettingsTab>('appearance')
|
||||
const [showAccent, setShowAccent] = useState(true)
|
||||
const [showBg, setShowBg] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [activeColorPicker, setActiveColorPicker] = useState<'accent' | 'text' | null>(null)
|
||||
const [activeCustomField, setActiveCustomField] = useState<string | null>(null)
|
||||
const [showOvPreview, setShowOvPreview] = useState(true)
|
||||
const [showOvStyle, setShowOvStyle] = useState(true)
|
||||
const [showOvPalette, setShowOvPalette] = useState(true)
|
||||
const [showOvColors, setShowOvColors] = useState(true)
|
||||
const [showOvFont, setShowOvFont] = useState(true)
|
||||
const [showOvLayout, setShowOvLayout] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) router.replace('/login')
|
||||
}, [user, router])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const overlayUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/overlay/${user.id}`
|
||||
|
||||
const copyUrl = () => {
|
||||
navigator.clipboard.writeText(overlayUrl).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
const activeFxMode = bgMode !== 'none' ? bgMode as keyof FxConfigs : null
|
||||
const fxSliders = activeFxMode ? FX_SLIDERS[activeFxMode] ?? null : null
|
||||
|
||||
return (
|
||||
<main className="max-w-app mx-auto relative z-10">
|
||||
<Header />
|
||||
|
||||
<div className="animate-fadeUp">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-app-text mb-4">Настройки</h2>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-surface2 rounded-[10px] p-1 mb-4">
|
||||
{([['appearance', 'Внешний вид'], ['overlay', 'Оверлей']] as [SettingsTab, string][]).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className="flex-1 py-2 text-[12px] font-display font-bold rounded-[8px] transition-all cursor-pointer"
|
||||
style={tab === id
|
||||
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)' }
|
||||
: { color: '#555' }
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Appearance tab ─────────────────────────────────────────────── */}
|
||||
{tab === 'appearance' && (
|
||||
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3">
|
||||
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
|
||||
Внешний вид
|
||||
</p>
|
||||
|
||||
{/* ── Accent color (collapsible) ─────────────────────────────── */}
|
||||
<button
|
||||
onClick={() => setShowAccent(v => !v)}
|
||||
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px]">Акцентный цвет</span>
|
||||
<Chevron open={showAccent} />
|
||||
</button>
|
||||
|
||||
{showAccent && (
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{ACCENT_PRESETS.map((preset, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setAccent(i)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
||||
style={accentIdx === i
|
||||
? { background: `rgba(${preset.rgb},0.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
|
||||
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ background: preset.accent, boxShadow: accentIdx === i ? `0 0 7px ${preset.accent}90` : 'none' }}/>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCustom(customHex)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
||||
style={accentIdx === -1
|
||||
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
|
||||
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
|
||||
style={{
|
||||
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
|
||||
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
|
||||
}}/>
|
||||
Свой
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{accentIdx === -1 && (
|
||||
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||
<ColorWheel value={customHex} onChange={setCustom} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Live background (collapsible) ─────────────────────────── */}
|
||||
<button
|
||||
onClick={() => setShowBg(v => !v)}
|
||||
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px]">Живой фон</span>
|
||||
<Chevron open={showBg} />
|
||||
</button>
|
||||
|
||||
{showBg && (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{BG_PRESETS.map((preset) => {
|
||||
const active = bgMode === preset.id
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => setBg(preset.id)}
|
||||
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
|
||||
style={active
|
||||
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
|
||||
: { borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<div className="w-full aspect-video overflow-hidden">
|
||||
{BG_PREVIEWS[preset.id]}
|
||||
</div>
|
||||
<div className="px-3 py-2" style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}>
|
||||
<p className="text-[12px] font-display font-bold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
||||
{preset.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted mt-0.5">{preset.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Per-effect config panel */}
|
||||
{activeFxMode && fxSliders && (
|
||||
<div className="mt-4 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
|
||||
Настройки эффекта
|
||||
</p>
|
||||
<button
|
||||
onClick={() => resetFx(activeFxMode)}
|
||||
className="text-[10px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{fxSliders.map(({ key, label, min, max, step, fmt }) => {
|
||||
const val = (fxConfigs[activeFxMode] as unknown as Record<string, number>)[key] ?? (DEFAULT_FX[activeFxMode] as unknown as Record<string, number>)[key]
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[12px] text-app-text/70">{label}</span>
|
||||
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>
|
||||
{fmt(val)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min} max={max} step={step}
|
||||
value={val}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange={(e) => setFxConfig(activeFxMode, { [key]: Number(e.target.value) } as any)}
|
||||
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Overlay tab ────────────────────────────────────────────────── */}
|
||||
{tab === 'overlay' && (
|
||||
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3 flex flex-col gap-5">
|
||||
|
||||
{/* ── Header: enable toggle + URL ──────────────────────────────── */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setOverlayEnabled(!overlayEnabled)}
|
||||
className="relative w-11 h-6 rounded-full transition-all duration-200 cursor-pointer shrink-0"
|
||||
style={{ background: overlayEnabled ? 'var(--accent)' : 'rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all duration-200"
|
||||
style={{ left: overlayEnabled ? 'calc(100% - 22px)' : '2px' }}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-medium text-app-text">Оверлей для стрима</p>
|
||||
<p className="text-[10px] font-mono text-muted/50 truncate mt-0.5">{overlayUrl}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
title="Скопировать URL"
|
||||
className="shrink-0 px-3 py-1.5 rounded-[8px] text-[11px] font-display font-bold cursor-pointer transition-all border"
|
||||
style={copied
|
||||
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.3)' }
|
||||
: { background: 'transparent', color: '#666', borderColor: 'rgba(255,255,255,0.08)' }
|
||||
}
|
||||
>
|
||||
{copied ? '✓' : 'URL'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Live preview ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowOvPreview(v => !v)}
|
||||
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px]">Превью</span>
|
||||
<Chevron open={showOvPreview} />
|
||||
</button>
|
||||
{showOvPreview && <OverlayPreview />}
|
||||
</div>
|
||||
|
||||
{/* ── Style picker ─────────────────────────────────────────────── */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowOvStyle(v => !v)}
|
||||
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px]">Стиль</span>
|
||||
<Chevron open={showOvStyle} />
|
||||
</button>
|
||||
{showOvStyle && <div className="grid grid-cols-3 gap-2">
|
||||
{(Object.entries(OVERLAY_STYLES) as [OverlayStyle, typeof OVERLAY_STYLES[OverlayStyle]][]).map(([id, { name, desc }]) => {
|
||||
const active = overlayStyle === id
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => { setOverlayStyle(id); setOverlayPalette('default'); setActiveCustomField(null) }}
|
||||
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
|
||||
style={active
|
||||
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
|
||||
: { borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<div className="w-full aspect-video bg-black overflow-hidden">
|
||||
<OverlayStylePreview style={id} />
|
||||
</div>
|
||||
<div
|
||||
className="px-2 py-1.5"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}
|
||||
>
|
||||
<p className="text-[11px] font-display font-bold truncate" style={{ color: active ? 'var(--accent)' : '#bbb' }}>{name}</p>
|
||||
<p className="text-[9px] text-muted mt-0.5 truncate">{desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Palette picker ───────────────────────────────────────────── */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowOvPalette(v => !v)}
|
||||
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px]">Палитра</span>
|
||||
<Chevron open={showOvPalette} />
|
||||
</button>
|
||||
{showOvPalette && <>
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
{getPalettes(overlayStyle).map((pal) => {
|
||||
const active = overlayPalette === pal.id
|
||||
return (
|
||||
<button
|
||||
key={pal.id}
|
||||
onClick={() => { setOverlayPalette(pal.id); setActiveCustomField(null) }}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer text-left"
|
||||
style={active
|
||||
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{pal.swatches.slice(0, 3).map((color, i) => (
|
||||
<span key={i} className="w-3.5 h-3.5 rounded-full border border-white/10"
|
||||
style={{ background: color.startsWith('var(') ? 'var(--accent)' : color }} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[12px] font-display font-semibold truncate" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
||||
{pal.name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Custom palette button */}
|
||||
{(() => {
|
||||
const active = overlayPalette === 'custom'
|
||||
const cp = customPalettes[overlayStyle] ?? {}
|
||||
const fields = STYLE_CUSTOM_FIELDS[overlayStyle] ?? []
|
||||
const swatchColors = fields.slice(0, 3).map(f => cp[f.key] ?? f.default)
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
setOverlayPalette('custom')
|
||||
if (!activeCustomField) setActiveCustomField(fields[0]?.key ?? null)
|
||||
}}
|
||||
className="col-span-2 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer text-left"
|
||||
style={active
|
||||
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{swatchColors.map((color, i) => (
|
||||
<span key={i} className="w-3.5 h-3.5 rounded-full border border-white/20" style={{ background: color }} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[12px] font-display font-semibold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
||||
Свой
|
||||
</span>
|
||||
<span className="ml-auto text-[11px]" style={{ color: active ? 'var(--accent)' : '#555' }}>✏️</span>
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Custom palette editor */}
|
||||
{overlayPalette === 'custom' && (() => {
|
||||
const fields = STYLE_CUSTOM_FIELDS[overlayStyle] ?? []
|
||||
const cp = customPalettes[overlayStyle] ?? {}
|
||||
const currentField = activeCustomField ?? fields[0]?.key ?? null
|
||||
const currentValue = currentField
|
||||
? (cp[currentField] ?? (fields.find(f => f.key === currentField)?.default ?? '#ffffff'))
|
||||
: '#ffffff'
|
||||
return (
|
||||
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||
<div className="flex gap-1.5 flex-wrap mb-4">
|
||||
{fields.map((f) => {
|
||||
const val = cp[f.key] ?? f.default
|
||||
const isActive = currentField === f.key
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setActiveCustomField(f.key)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[7px] border text-[11px] font-display font-semibold transition-all cursor-pointer"
|
||||
style={isActive
|
||||
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.4)', color: 'var(--accent)' }
|
||||
: { background: 'rgba(255,255,255,0.03)', borderColor: 'rgba(255,255,255,0.07)', color: '#888' }
|
||||
}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full border border-white/15 shrink-0" style={{ background: val }} />
|
||||
{f.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{currentField && (
|
||||
<ColorWheel
|
||||
value={currentValue}
|
||||
onChange={(hex) => setCustomPaletteField(overlayStyle, currentField, hex)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* ── Colors: accent + text ─────────────────────────────────────── */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowOvColors(v => !v)}
|
||||
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px]">Цвета</span>
|
||||
<Chevron open={showOvColors} />
|
||||
</button>
|
||||
{showOvColors && <>
|
||||
<div className="flex gap-2 mb-2">
|
||||
{/* Accent color */}
|
||||
<button
|
||||
onClick={() => setActiveColorPicker(p => p === 'accent' ? null : 'accent')}
|
||||
className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer"
|
||||
style={activeColorPicker === 'accent'
|
||||
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<span className="w-4 h-4 rounded-full border border-white/20 shrink-0" style={{ background: overlayAccentColor }} />
|
||||
<span className="text-[12px] font-display font-semibold" style={{ color: activeColorPicker === 'accent' ? 'var(--accent)' : '#bbb' }}>
|
||||
Акцент
|
||||
</span>
|
||||
<Chevron open={activeColorPicker === 'accent'} />
|
||||
</button>
|
||||
{/* Text color */}
|
||||
<button
|
||||
onClick={() => setActiveColorPicker(p => p === 'text' ? null : 'text')}
|
||||
className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer"
|
||||
style={activeColorPicker === 'text'
|
||||
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
{overlayTextColor
|
||||
? <span className="w-4 h-4 rounded-full border border-white/20 shrink-0" style={{ background: overlayTextColor }} />
|
||||
: <span className="w-4 h-4 rounded-full border border-white/15 shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
|
||||
<span className="text-[7px] text-muted">A</span>
|
||||
</span>
|
||||
}
|
||||
<span className="text-[12px] font-display font-semibold" style={{ color: activeColorPicker === 'text' ? 'var(--accent)' : '#bbb' }}>
|
||||
Текст
|
||||
</span>
|
||||
{!overlayTextColor && <span className="ml-auto text-[10px] text-muted/50 font-mono">авто</span>}
|
||||
<Chevron open={activeColorPicker === 'text'} />
|
||||
</button>
|
||||
</div>
|
||||
{activeColorPicker === 'accent' && (
|
||||
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||
<ColorWheel value={overlayAccentColor} onChange={setOverlayAccentColor} />
|
||||
</div>
|
||||
)}
|
||||
{activeColorPicker === 'text' && (
|
||||
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||
<ColorWheel value={overlayTextColor || '#ffffff'} onChange={setOverlayTextColor} />
|
||||
{overlayTextColor && (
|
||||
<button
|
||||
onClick={() => setOverlayTextColor('')}
|
||||
className="mt-3 text-[11px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||
>
|
||||
Авто (по стилю)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* ── Font: horizontal chips ───────────────────────────────────── */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowOvFont(v => !v)}
|
||||
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px]">Шрифт</span>
|
||||
<Chevron open={showOvFont} />
|
||||
</button>
|
||||
{showOvFont && <div className="flex gap-1.5 overflow-x-auto pb-0.5 -mx-1 px-1">
|
||||
{OVERLAY_FONTS.map(({ id, name, css }) => {
|
||||
const active = overlayFont === id
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setOverlayFont(id)}
|
||||
className="flex-none flex flex-col items-center gap-1 px-3 pt-2 pb-2 rounded-[10px] border transition-all duration-150 cursor-pointer min-w-[70px]"
|
||||
style={active
|
||||
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="text-[15px] leading-none"
|
||||
style={{ fontFamily: css, color: active ? 'var(--accent)' : '#aaa', fontWeight: active ? 700 : 400 }}
|
||||
>
|
||||
Ag
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] font-display font-semibold truncate max-w-[64px]"
|
||||
style={{ color: active ? 'var(--accent)' : '#555' }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Layout: position + cover/eq ──────────────────────────────── */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowOvLayout(v => !v)}
|
||||
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-[12px]">Макет</span>
|
||||
<Chevron open={showOvLayout} />
|
||||
</button>
|
||||
{showOvLayout && <div className="flex items-start gap-6">
|
||||
{/* Position grid */}
|
||||
<div>
|
||||
<p className="text-[10px] text-muted mb-2">Позиция</p>
|
||||
<div className="grid grid-cols-2 gap-1 w-[72px]">
|
||||
{OVERLAY_POSITIONS.map(({ id, arrow, label }) => {
|
||||
const active = overlayPosition === id
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
title={label}
|
||||
onClick={() => setOverlayPosition(id)}
|
||||
className="flex items-center justify-center h-8 rounded-[7px] text-base transition-all duration-150 cursor-pointer border"
|
||||
style={active
|
||||
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||
: { background: 'rgba(255,255,255,0.03)', color: '#555', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
{arrow}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="flex-1 flex flex-col gap-3 pt-[22px]">
|
||||
{([
|
||||
{ label: 'Обложка', val: overlayShowCover, set: setOverlayShowCover },
|
||||
{ label: 'EQ-анимация', val: overlayShowEq, set: setOverlayShowEq },
|
||||
] as const).map(({ label, val, set }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<span className="text-[12px] text-app-text/70">{label}</span>
|
||||
<button
|
||||
onClick={() => set(!val)}
|
||||
className="relative w-10 h-5 rounded-full transition-all duration-200 cursor-pointer shrink-0"
|
||||
style={{ background: val ? 'var(--accent)' : 'rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all duration-200"
|
||||
style={{ left: val ? 'calc(100% - 18px)' : '2px' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Sliders: margin / scale / opacity */}
|
||||
{showOvLayout && (
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
{([
|
||||
{ label: 'Отступ', value: overlayMargin, set: setOverlayMargin, min: 8, max: 64, step: 2, fmt: (v: number) => `${v}px` },
|
||||
{ label: 'Масштаб', value: overlayScale, set: setOverlayScale, min: 0.5, max: 2, step: 0.05, fmt: (v: number) => `${v.toFixed(2)}×` },
|
||||
{ label: 'Прозрачность', value: overlayOpacity, set: setOverlayOpacity, min: 0.1, max: 1, step: 0.05, fmt: (v: number) => `${Math.round(v * 100)}%` },
|
||||
] as const).map(({ label, value, set, min, max, step, fmt }) => (
|
||||
<div key={label}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[12px] text-app-text/70">{label}</span>
|
||||
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>{fmt(value)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min} max={max} step={step}
|
||||
value={value}
|
||||
onChange={(e) => set(Number(e.target.value))}
|
||||
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Note ─────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted/50">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
В OBS: источник «Браузер» → вставь URL. CSS: <code className="font-mono">body {'{ background: transparent !important; }'}</code>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import { getPublicPlaylists } from '@/lib/authApi'
|
||||
import type { PublicPlaylist, PlaylistTrack } from '@/types'
|
||||
import Header from '@/components/Header'
|
||||
|
||||
const TAG_PALETTE = ['var(--accent)', '#ff6b9d', '#6bcdff', '#ffb86b', '#b86bff', '#6bffb8']
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
let h = 0
|
||||
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h)
|
||||
return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length]
|
||||
}
|
||||
|
||||
function TrackList({ tracks }: { tracks: PlaylistTrack[] }) {
|
||||
return (
|
||||
<div className="border-t border-white/[0.05] px-4 pt-3 pb-3.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{tracks.map((track, i) => (
|
||||
<div key={track.id} className="flex items-center gap-2.5 py-[3px] group">
|
||||
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
|
||||
<span className="text-[12px] text-app-text/75 group-hover:text-app-text truncate transition-colors duration-100">{track.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaylistCard({
|
||||
pl,
|
||||
onPlay,
|
||||
isLaunched,
|
||||
}: {
|
||||
pl: PublicPlaylist
|
||||
onPlay: () => void
|
||||
isLaunched: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const tags = pl.tags ?? []
|
||||
const trackCount = pl.tracks?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.13] transition-all duration-200">
|
||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center font-display font-extrabold text-[15px] select-none"
|
||||
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||
>
|
||||
{pl.username[0].toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-display text-[14px] font-bold text-app-text truncate leading-tight">{pl.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<span className="text-[11px] font-medium text-accent">{pl.username}</span>
|
||||
<span className="text-muted/30 text-[11px]">·</span>
|
||||
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
|
||||
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{trackCount > 0 && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all duration-150 cursor-pointer"
|
||||
title={expanded ? 'Скрыть треки' : 'Показать треки'}
|
||||
>
|
||||
<svg
|
||||
width="11" height="11" viewBox="0 0 12 12" fill="none"
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}
|
||||
>
|
||||
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onPlay}
|
||||
disabled={!trackCount}
|
||||
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: isLaunched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.12)',
|
||||
color: isLaunched ? '#0a0a0f' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{isLaunched ? '▶ Играет' : '▶ Play'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && pl.tracks && pl.tracks.length > 0 && (
|
||||
<TrackList tracks={pl.tracks} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CommunityPage() {
|
||||
const [playlists, setPlaylists] = useState<PublicPlaylist[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [launched, setLaunched] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||
const { loadPlaylist } = usePartyStore()
|
||||
|
||||
useEffect(() => {
|
||||
getPublicPlaylists()
|
||||
.then(setPlaylists)
|
||||
.catch(() => setPlaylists([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handlePlay = (pl: PublicPlaylist) => {
|
||||
const tracks = pl.tracks?.map(t => t.title) ?? []
|
||||
if (!tracks.length) return
|
||||
loadPlaylist(tracks)
|
||||
setLaunched(pl.id)
|
||||
setTimeout(() => setLaunched(null), 2500)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? [])))
|
||||
|
||||
const filtered = playlists.filter(pl => {
|
||||
const q = search.toLowerCase().trim()
|
||||
const matchesSearch = !q
|
||||
|| pl.name.toLowerCase().includes(q)
|
||||
|| pl.username.toLowerCase().includes(q)
|
||||
|| (pl.tags ?? []).some(t => t.toLowerCase().includes(q))
|
||||
const matchesTag = !activeTag || (pl.tags ?? []).includes(activeTag)
|
||||
return matchesSearch && matchesTag
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="max-w-app mx-auto">
|
||||
<Header />
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="flex items-baseline gap-2.5">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight">Сообщество</h2>
|
||||
{!loading && (
|
||||
<span className="text-[12px] text-muted font-sans">{playlists.length} плейлистов</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12px] text-muted mt-0.5">Публичные плейлисты пользователей</p>
|
||||
</div>
|
||||
|
||||
{!loading && playlists.length > 0 && (
|
||||
<div className="mb-4 flex flex-col gap-2.5">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Поиск по названию, автору или тегу..."
|
||||
className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setActiveTag(activeTag === tag ? null : tag)}
|
||||
className="text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
|
||||
style={activeTag === tag
|
||||
? { background: tagColor(tag), color: '#0a0a0f' }
|
||||
: { background: `${tagColor(tag)}15`, color: tagColor(tag), border: `1px solid ${tagColor(tag)}35` }
|
||||
}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
||||
Загрузка...
|
||||
</div>
|
||||
) : !filtered.length ? (
|
||||
<div className="text-center py-14 text-muted">
|
||||
<div className="text-4xl mb-3 opacity-20">🎵</div>
|
||||
<p className="text-[13px] font-medium">
|
||||
{playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
|
||||
</p>
|
||||
<p className="text-[12px] mt-1.5 opacity-50">
|
||||
{playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{filtered.map(pl => (
|
||||
<PlaylistCard
|
||||
key={pl.id}
|
||||
pl={pl}
|
||||
onPlay={() => handlePlay(pl)}
|
||||
isLaunched={launched === pl.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
:root {
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--accent: #c8ff00;
|
||||
--accent-rgb: 200,255,0;
|
||||
--accent: #de9cfe;
|
||||
--accent-rgb: 222,156,254;
|
||||
}
|
||||
|
||||
*,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Syne, DM_Sans } from 'next/font/google'
|
||||
import AuthHydrator from '@/components/AuthHydrator'
|
||||
import AudioBackground from '@/components/AudioBackground'
|
||||
import GlobalPlayer from '@/components/GlobalPlayer'
|
||||
import ThemeApplier from '@/components/ThemeApplier'
|
||||
import './globals.css'
|
||||
|
||||
const syne = Syne({
|
||||
@@ -29,17 +25,16 @@ export const viewport: Viewport = {
|
||||
maximumScale: 1,
|
||||
}
|
||||
|
||||
const accentInitScript = `(function(){try{var P=[['#de9cfe','222,156,254'],['#c8ff00','200,255,0'],['#00D4FF','0,212,255'],['#FF2D78','255,45,120'],['#A855F7','168,85,247'],['#FF6B35','255,107,53'],['#00FFB2','0,255,178']];var idx=parseInt(localStorage.getItem('pm_accent')||'0',10);var a,r;if(idx===-1){a=localStorage.getItem('pm_accent_custom')||'#de9cfe';var h=a.replace('#','');r=parseInt(h.slice(0,2),16)+','+parseInt(h.slice(2,4),16)+','+parseInt(h.slice(4,6),16);}else{var p=P[idx]||P[0];a=p[0];r=p[1];}document.documentElement.style.setProperty('--accent',a);document.documentElement.style.setProperty('--accent-rgb',r);}catch(e){}})();`
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ru" className={`${syne.variable} ${dmSans.variable}`}>
|
||||
<body className="font-sans bg-bg text-app-text min-h-screen pb-[72px] px-4 pt-5 sm:px-4">
|
||||
<ThemeApplier />
|
||||
<AudioBackground />
|
||||
<div className="relative" style={{ zIndex: 1 }}>
|
||||
<AuthHydrator />
|
||||
{children}
|
||||
</div>
|
||||
<GlobalPlayer />
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: accentInitScript }} />
|
||||
</head>
|
||||
<body className="font-sans text-app-text">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
207
apps/web/src/app/overlay/[token]/page.tsx
Normal file
207
apps/web/src/app/overlay/[token]/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { use, useEffect, useRef, useState } from 'react'
|
||||
import { getPalette, buildCustomPalette } from '@/lib/overlayPalettes'
|
||||
import { OVERLAY_STYLE_MAP, useLocalProgress, type OverlayWidgetState } from '@/components/OverlayWidget'
|
||||
import type { OverlayStyle } from '@/store/overlayStore'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||
const GFONTS = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;700&family=Unbounded:wght@700;800&family=Playfair+Display:ital,wght@0,700;1,700&display=swap'
|
||||
|
||||
type OverlayState = OverlayWidgetState
|
||||
|
||||
const ORIGINS: Record<string, string> = {
|
||||
bl: 'bottom left', br: 'bottom right', tl: 'top left', tr: 'top right',
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function OverlayPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params)
|
||||
const [display, setDisplay] = useState<OverlayState | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const prevTitle = useRef('')
|
||||
|
||||
// Transparent background
|
||||
useEffect(() => {
|
||||
const html = document.documentElement
|
||||
const body = document.body
|
||||
html.style.cssText = 'background:transparent!important;height:100%;'
|
||||
body.style.cssText = 'background:transparent!important;min-height:0;height:100%;padding:0;margin:0;overflow:hidden;'
|
||||
return () => { html.style.cssText = ''; body.style.cssText = '' }
|
||||
}, [])
|
||||
|
||||
// Load Google Fonts
|
||||
useEffect(() => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = GFONTS
|
||||
document.head.appendChild(link)
|
||||
return () => { if (document.head.contains(link)) document.head.removeChild(link) }
|
||||
}, [])
|
||||
|
||||
// Apply accent color
|
||||
useEffect(() => {
|
||||
const hex = display?.accent_color
|
||||
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
document.documentElement.style.setProperty('--accent', hex)
|
||||
document.documentElement.style.setProperty('--accent-rgb', `${r},${g},${b}`)
|
||||
}, [display?.accent_color])
|
||||
|
||||
// Apply position + scale + opacity via CSS vars
|
||||
useEffect(() => {
|
||||
const pos = display?.position || 'bl'
|
||||
const margin = display?.margin ?? 24
|
||||
const scale = display?.scale ?? 1
|
||||
const opacity = display?.opacity ?? 1
|
||||
const isT = pos[0] === 't', isR = pos[1] === 'r'
|
||||
const h = document.documentElement
|
||||
const m = `${margin}px`
|
||||
h.style.setProperty('--ov-b', isT ? 'auto' : m)
|
||||
h.style.setProperty('--ov-t', isT ? m : 'auto')
|
||||
h.style.setProperty('--ov-l', isR ? 'auto' : m)
|
||||
h.style.setProperty('--ov-r', isR ? m : 'auto')
|
||||
h.style.setProperty('--ov-scale', String(scale))
|
||||
h.style.setProperty('--ov-origin', ORIGINS[pos] ?? 'bottom left')
|
||||
h.style.opacity = String(opacity)
|
||||
}, [display?.position, display?.margin, display?.scale, display?.opacity])
|
||||
|
||||
// Apply font to body
|
||||
useEffect(() => {
|
||||
document.body.style.fontFamily = display?.font || ''
|
||||
}, [display?.font])
|
||||
|
||||
// Track-change animation: briefly fade out on new track
|
||||
useEffect(() => {
|
||||
if (!display?.title) return
|
||||
if (display.title === prevTitle.current) return
|
||||
prevTitle.current = display.title
|
||||
const html = document.documentElement
|
||||
html.style.transition = 'opacity 0.18s'
|
||||
html.style.opacity = '0'
|
||||
const t = setTimeout(() => {
|
||||
html.style.opacity = String(display.opacity ?? 1)
|
||||
}, 180)
|
||||
return () => clearTimeout(t)
|
||||
}, [display?.title, display?.opacity])
|
||||
|
||||
// SSE connection with polling fallback
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const clearHide = () => {
|
||||
if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null }
|
||||
}
|
||||
const scheduleHide = (ms: number) => {
|
||||
if (hideTimer.current) return
|
||||
hideTimer.current = setTimeout(() => {
|
||||
if (active) setDisplay(null)
|
||||
hideTimer.current = null
|
||||
}, ms)
|
||||
}
|
||||
|
||||
const applyState = (data: OverlayState) => {
|
||||
if (!active) return
|
||||
setNotFound(false)
|
||||
if (!data.enabled) { scheduleHide(500); return }
|
||||
clearHide()
|
||||
setDisplay(prev => {
|
||||
const title = data.title || prev?.title || ''
|
||||
if (!title) return null
|
||||
return {
|
||||
...data,
|
||||
title,
|
||||
artist: data.title ? data.artist : (prev?.artist ?? ''),
|
||||
cover: data.title ? data.cover : (prev?.cover ?? ''),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Try SSE first
|
||||
let es: EventSource | null = null
|
||||
let pollIv: ReturnType<typeof setInterval> | null = null
|
||||
let sseOk = false
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollIv) return
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/overlay/${token}/state`)
|
||||
if (res.status === 404) { setNotFound(true); return }
|
||||
if (!res.ok) return
|
||||
const data: OverlayState = await res.json()
|
||||
applyState(data)
|
||||
} catch {}
|
||||
}
|
||||
poll()
|
||||
pollIv = setInterval(poll, 2500)
|
||||
}
|
||||
|
||||
try {
|
||||
es = new EventSource(`${API_URL}/api/overlay/${token}/stream`)
|
||||
es.onmessage = (e) => {
|
||||
sseOk = true
|
||||
const data: OverlayState = JSON.parse(e.data)
|
||||
applyState(data)
|
||||
}
|
||||
es.addEventListener('notfound', () => { setNotFound(true) })
|
||||
es.onerror = () => {
|
||||
if (!sseOk) {
|
||||
// SSE failed before first message → fall back to polling
|
||||
es?.close()
|
||||
es = null
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
startPolling()
|
||||
}
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
es?.close()
|
||||
if (pollIv) clearInterval(pollIv)
|
||||
clearHide()
|
||||
}
|
||||
}, [token])
|
||||
|
||||
if (notFound) return (
|
||||
<div style={{ position: 'fixed', bottom: 20, left: 20, color: 'rgba(255,255,255,0.25)', fontSize: 11, fontFamily: 'monospace' }}>
|
||||
overlay not found
|
||||
</div>
|
||||
)
|
||||
if (!display) return null
|
||||
|
||||
const style = (display.style ?? 'classic') as OverlayStyle
|
||||
const render = OVERLAY_STYLE_MAP[style] ?? OVERLAY_STYLE_MAP.classic
|
||||
const palId = display.palette ?? 'default'
|
||||
const pal = palId === 'custom'
|
||||
? buildCustomPalette(style, {
|
||||
bg: display.custom_bg,
|
||||
text: display.custom_text,
|
||||
text2: display.custom_text2,
|
||||
chroma: display.custom_chroma,
|
||||
titleBg: display.custom_title_bg,
|
||||
bodyBg: display.custom_body_bg,
|
||||
})
|
||||
: getPalette(style, palId)
|
||||
|
||||
return <OverlayRenderer display={display} render={render} pal={pal} />
|
||||
}
|
||||
|
||||
// Separate component so hooks (useLocalProgress) run after display is confirmed non-null
|
||||
function OverlayRenderer({
|
||||
display, render, pal,
|
||||
}: {
|
||||
display: OverlayState
|
||||
render: (s: OverlayWidgetState, pal: ReturnType<typeof getPalette>) => React.ReactNode
|
||||
pal: ReturnType<typeof getPalette>
|
||||
}) {
|
||||
const progress = useLocalProgress(display.progress, display.duration, display.is_playing, display.updated_at)
|
||||
const state = { ...display, progress }
|
||||
return <>{render(state, pal)}</>
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
const EQ_BARS = [
|
||||
{ h: 35, d: 0 },
|
||||
{ h: 72, d: 0.12 },
|
||||
{ h: 50, d: 0.23 },
|
||||
{ h: 90, d: 0.06 },
|
||||
{ h: 55, d: 0.17 },
|
||||
{ h: 82, d: 0.28 },
|
||||
{ h: 44, d: 0.09 },
|
||||
{ h: 96, d: 0.20 },
|
||||
{ h: 60, d: 0.14 },
|
||||
{ h: 76, d: 0.03 },
|
||||
{ h: 40, d: 0.25 },
|
||||
{ h: 85, d: 0.18 },
|
||||
{ h: 52, d: 0.07 },
|
||||
{ h: 67, d: 0.30 },
|
||||
{ h: 30, d: 0.11 },
|
||||
{ h: 78, d: 0.22 },
|
||||
{ h: 48, d: 0.16 },
|
||||
{ h: 88, d: 0.04 },
|
||||
{ h: 62, d: 0.26 },
|
||||
{ h: 38, d: 0.19 },
|
||||
]
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: '🎵',
|
||||
title: 'Совместная очередь',
|
||||
desc: 'Каждый гость добавляет свои треки — никто не обделён эфиром',
|
||||
},
|
||||
{
|
||||
icon: '🎲',
|
||||
title: 'Умный шаффл',
|
||||
desc: 'По очереди или случайно — два режима миксовки плейлиста',
|
||||
},
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'Поиск версий',
|
||||
desc: 'Автоматически находит нужную версию трека',
|
||||
},
|
||||
{
|
||||
icon: '📋',
|
||||
title: 'Плейлисты',
|
||||
desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
|
||||
},
|
||||
]
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="max-w-app mx-auto min-h-[calc(100vh-40px)] flex flex-col">
|
||||
|
||||
{/* ── Nav ── */}
|
||||
<nav className="flex items-center justify-between py-3 mb-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-[9px] bg-accent flex items-center justify-center shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
|
||||
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-display font-extrabold text-lg tracking-tight">
|
||||
Party<span className="text-accent">Mix</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[13px] font-sans text-muted hover:text-app-text transition-colors duration-150 px-2 py-1"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-[13px] font-display font-semibold px-4 py-1.5 bg-surface border border-white/[0.07] rounded-xl text-app-text hover:border-white/20 hover:bg-surface2 transition-all duration-150"
|
||||
>
|
||||
Регистрация
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<section className="flex-1 flex flex-col items-center justify-center text-center py-12 relative">
|
||||
|
||||
{/* Ambient glow behind EQ */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[200px] pointer-events-none" aria-hidden="true">
|
||||
<div className="absolute inset-0 bg-accent/[0.04] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* EQ visualization */}
|
||||
<div className="relative flex items-end gap-[4px] mb-8 h-20" aria-hidden="true">
|
||||
{EQ_BARS.map(({ h, d }, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="eq-bar"
|
||||
style={{
|
||||
height: `${h}%`,
|
||||
animationDelay: `${d}s`,
|
||||
animationDuration: `${0.6 + d * 0.8}s`,
|
||||
width: '3px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="font-display font-extrabold leading-none tracking-tight mb-5 text-[72px] sm:text-[104px]">
|
||||
Party<span className="text-accent">Mix</span>
|
||||
</h1>
|
||||
|
||||
{/* Tagline */}
|
||||
<p className="font-sans text-base sm:text-lg text-muted max-w-[320px] mb-10 leading-relaxed">
|
||||
Совместные плейлисты для вечеринок.
|
||||
<br />
|
||||
Каждый гость — часть музыки.
|
||||
</p>
|
||||
|
||||
{/* CTA buttons */}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
||||
<Link
|
||||
href="/app"
|
||||
className="px-8 py-3 bg-accent text-bg font-display font-bold text-sm rounded-xl hover:brightness-110 active:scale-[0.97] transition-all duration-150 shadow-[0_0_24px_rgba(var(--accent-rgb),0.25)]"
|
||||
>
|
||||
Начать вечеринку →
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-8 py-3 bg-surface border border-white/[0.07] text-app-text font-sans text-sm rounded-xl hover:bg-surface2 hover:border-white/20 active:scale-[0.97] transition-all duration-150"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Divider ── */}
|
||||
<div className="border-t border-white/[0.05] mb-8" />
|
||||
|
||||
{/* ── Features ── */}
|
||||
<section className="pb-10">
|
||||
<p className="text-[11px] font-display font-semibold tracking-[1.5px] uppercase text-muted mb-4">
|
||||
Возможности
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{FEATURES.map(({ icon, title, desc }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="group bg-surface border border-white/[0.07] rounded-app p-4 hover:bg-surface2 hover:border-white/[0.12] transition-all duration-200"
|
||||
>
|
||||
<span className="text-lg mb-2.5 block">{icon}</span>
|
||||
<h3 className="font-display font-bold text-[13px] mb-1 text-app-text">{title}</h3>
|
||||
<p className="text-[12px] text-muted font-sans leading-relaxed">{desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<footer className="pb-4 text-center">
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
interface RemoteQueueItem {
|
||||
title: string
|
||||
owner: string
|
||||
color_bg: string
|
||||
color_text: string
|
||||
img?: string
|
||||
}
|
||||
|
||||
interface RemoteVersion {
|
||||
title: string
|
||||
artist: string
|
||||
duration: string
|
||||
img?: string
|
||||
}
|
||||
|
||||
interface RemoteState {
|
||||
title: string
|
||||
artist: string
|
||||
cover: string
|
||||
is_playing: boolean
|
||||
volume: number
|
||||
progress: number
|
||||
duration: number
|
||||
queue_len: number
|
||||
cur_idx: number
|
||||
queue: RemoteQueueItem[]
|
||||
versions: RemoteVersion[]
|
||||
active_version: number
|
||||
}
|
||||
|
||||
function formatTime(s: number) {
|
||||
if (!s || isNaN(s)) return '0:00'
|
||||
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function cmd(id: string, c: string, value?: number, text?: string) {
|
||||
await fetch(`${API_URL}/api/remote/${id}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [state, setState] = useState<RemoteState | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
const [volume, setVolume] = useState(1)
|
||||
const [addText, setAddText] = useState('')
|
||||
const [tab, setTab] = useState<'player' | 'queue'>('player')
|
||||
const [versionsOpen, setVersionsOpen] = useState(false)
|
||||
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
|
||||
const [localProgress, setLocalProgress] = useState(0)
|
||||
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
|
||||
if (s) setSavedVersions(JSON.parse(s))
|
||||
} catch {}
|
||||
}, [])
|
||||
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
|
||||
if (res.status === 404) { setNotFound(true); return }
|
||||
if (!res.ok) return
|
||||
const data: RemoteState = await res.json()
|
||||
if (active) {
|
||||
setState(data)
|
||||
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
|
||||
setLocalProgress(data.progress ?? 0)
|
||||
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
poll()
|
||||
const iv = setInterval(poll, 2000)
|
||||
return () => { active = false; clearInterval(iv) }
|
||||
}, [id])
|
||||
|
||||
// Local progress interpolation — advance every 250ms when playing
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
const { progress, ts, playing } = lastPollRef.current
|
||||
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
|
||||
}, 250)
|
||||
return () => clearInterval(iv)
|
||||
}, [])
|
||||
|
||||
if (notFound) return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-3">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
<p className="text-muted text-[13px]">Сессия не найдена или истекла</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!state) return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
|
||||
const progress = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
|
||||
const queue = state.queue ?? []
|
||||
const versions = state.versions ?? []
|
||||
const trackTitle = queue[state.cur_idx]?.title ?? ''
|
||||
|
||||
const isSavedLocally = (v: RemoteVersion) => {
|
||||
const s = savedVersions[trackTitle]
|
||||
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
|
||||
}
|
||||
|
||||
const handleSaveVersion = (i: number) => {
|
||||
const v = versions[i]
|
||||
if (!v || !trackTitle) return
|
||||
const alreadySaved = isSavedLocally(v)
|
||||
const next = { ...savedVersions }
|
||||
if (alreadySaved) delete next[trackTitle]
|
||||
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
|
||||
setSavedVersions(next)
|
||||
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
|
||||
cmd(id, 'save_version', i)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-6">
|
||||
|
||||
{/* Header + tabs */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
|
||||
<div className="flex items-center gap-1 bg-surface2 rounded-[8px] p-0.5">
|
||||
<button
|
||||
onClick={() => setTab('player')}
|
||||
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer"
|
||||
style={{ background: tab === 'player' ? 'rgba(var(--accent-rgb),0.1)' : 'transparent', color: tab === 'player' ? 'var(--accent)' : '#555' }}
|
||||
>
|
||||
Плеер
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('queue')}
|
||||
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer flex items-center gap-1"
|
||||
style={{ background: tab === 'queue' ? 'rgba(var(--accent-rgb),0.1)' : 'transparent', color: tab === 'queue' ? 'var(--accent)' : '#555' }}
|
||||
>
|
||||
Очередь
|
||||
{queue.length > 0 && <span className="text-[10px] opacity-60">{queue.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'player' && (
|
||||
<div className="flex flex-col gap-7 items-center flex-1">
|
||||
{/* Cover */}
|
||||
<div className="w-full aspect-square max-w-[220px] rounded-[20px] overflow-hidden bg-surface2 shadow-2xl">
|
||||
{state.cover ? (
|
||||
<img src={state.cover} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="w-full text-center px-2">
|
||||
<div className="font-display text-[18px] font-extrabold tracking-tight truncate leading-tight">
|
||||
{state.title || '—'}
|
||||
</div>
|
||||
{state.artist && (
|
||||
<div className="text-[13px] text-muted mt-1.5 truncate">{state.artist}</div>
|
||||
)}
|
||||
{state.queue_len > 0 && (
|
||||
<div className="text-[11px] text-muted mt-1 font-display">{state.cur_idx + 1} / {state.queue_len}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="relative h-1.5 bg-white/[0.07] rounded-full mb-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
const seekTo = pct * state.duration
|
||||
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
|
||||
setLocalProgress(seekTo)
|
||||
cmd(id, 'seek', seekTo)
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums">
|
||||
<span>{formatTime(clampedProgress)}</span>
|
||||
<span>{formatTime(state.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-8">
|
||||
<button
|
||||
onClick={() => cmd(id, 'prev')}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center bg-accent text-bg active:scale-95 transition-all cursor-pointer hover:brightness-110"
|
||||
>
|
||||
{state.is_playing ? (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => cmd(id, 'next')}
|
||||
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 18l8.5-6L6 6v12z"/><rect x="16" y="6" width="2" height="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume */}
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.02"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value)
|
||||
setVolume(v)
|
||||
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
|
||||
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
|
||||
}}
|
||||
className="flex-1 cursor-pointer h-1.5"
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Versions */}
|
||||
{versions.length > 1 && (
|
||||
<div className="w-full">
|
||||
<button
|
||||
onClick={() => setVersionsOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 rounded-[9px] bg-surface2 border border-white/[0.07] text-[12px] text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="font-display font-bold tracking-[0.5px]">Версии трека</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] opacity-60">{versions.length}</span>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className={`transition-transform ${versionsOpen ? 'rotate-180' : ''}`}>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
{versionsOpen && (
|
||||
<div className="mt-1 border border-white/[0.07] rounded-[9px] overflow-hidden">
|
||||
{versions.map((v, i) => {
|
||||
const active = i === state.active_version
|
||||
const saved = isSavedLocally(v)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0 transition-colors"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
|
||||
>
|
||||
<span className="text-[11px] text-muted w-4 text-right shrink-0 font-display">{i + 1}</span>
|
||||
{v.img ? (
|
||||
<img src={v.img} alt="" className="w-8 h-8 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-[5px] bg-surface2 shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
|
||||
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{v.title}</div>
|
||||
<div className="text-[11px] text-muted mt-px">{v.artist}</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted shrink-0 font-display">{v.duration}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
|
||||
title={saved ? 'Забыть версию' : 'Запомнить версию'}
|
||||
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
|
||||
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
|
||||
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${active ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
|
||||
style={{ width: 26, height: 26 }}
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'queue' && (
|
||||
<div className="flex flex-col gap-3 flex-1">
|
||||
{/* Add track */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={addText}
|
||||
onChange={(e) => setAddText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && addText.trim()) {
|
||||
cmd(id, 'add', 0, addText.trim())
|
||||
setAddText('')
|
||||
}
|
||||
}}
|
||||
placeholder="Исполнитель — Название"
|
||||
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!addText.trim()) return
|
||||
cmd(id, 'add', 0, addText.trim())
|
||||
setAddText('')
|
||||
}}
|
||||
className="shrink-0 px-3 py-2.5 rounded-[9px] bg-accent text-bg text-[13px] font-display font-bold cursor-pointer hover:brightness-110 active:scale-95 transition-all"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Queue list */}
|
||||
{queue.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center text-[13px] text-muted text-center py-10">
|
||||
Очередь пуста
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{queue.map((item, i) => {
|
||||
const active = i === state.cur_idx
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => cmd(id, 'goto', i)}
|
||||
className="flex items-center gap-2.5 px-1 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer active:bg-surface2 transition-colors rounded-[6px]"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
|
||||
>
|
||||
{active ? (
|
||||
<div className="flex items-end gap-[1.5px] w-3.5 h-3.5 shrink-0 ml-0.5">
|
||||
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted w-5 text-right shrink-0 font-display">{i + 1}</span>
|
||||
)}
|
||||
{item.img ? (
|
||||
<img src={item.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color_bg, color: item.color_text }}>
|
||||
{item.owner}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
||||
import { useBgStore, BG_PRESETS, DEFAULT_RAYS, type BgMode } from '@/store/bgStore'
|
||||
import { getActiveAccent } from '@/store/themeStore'
|
||||
import Header from '@/components/Header'
|
||||
import ColorWheel from '@/components/ColorWheel'
|
||||
|
||||
// ── Preview SVGs ─────────────────────────────────────────────────────────────
|
||||
|
||||
function OrbsPreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="p-o1" cx="12%" cy="6%" r="60%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.65"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
<radialGradient id="p-o2" cx="90%" cy="96%" r="55%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.55"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
|
||||
<radialGradient id="p-o3" cx="50%" cy="50%" r="40%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.35"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<ellipse cx="14" cy="4" rx="72" ry="72" fill="url(#p-o1)"/>
|
||||
<ellipse cx="108" cy="65" rx="58" ry="58" fill="url(#p-o2)"/>
|
||||
<ellipse cx="60" cy="34" rx="36" ry="36" fill="url(#p-o3)"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function WavesPreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<path d="M0 47 Q15 41 30 47 Q45 53 60 47 Q75 41 90 47 Q105 53 120 47 L120 68 L0 68Z" fill="var(--accent)" opacity="0.16"/>
|
||||
<path d="M0 55 Q15 49 30 55 Q45 61 60 55 Q75 49 90 55 Q105 61 120 55 L120 68 L0 68Z" fill="var(--accent)" opacity="0.22"/>
|
||||
<path d="M0 41 Q20 35 40 41 Q60 47 80 41 Q100 35 120 41 L120 68 L0 68Z" fill="rgb(255,60,172)" opacity="0.10"/>
|
||||
<path d="M0 61 Q20 57 40 61 Q60 65 80 61 Q100 57 120 61 L120 68 L0 68Z" fill="rgb(140,100,255)" opacity="0.14"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ParticlesPreview() {
|
||||
const d: [number,number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]]
|
||||
const ln: [number,number][] = [[0,1],[1,2],[0,3],[1,7],[3,5],[2,4],[4,6],[3,7],[7,8],[1,8],[9,0],[9,3]]
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
{ln.map(([a,b],i) => <line key={i} x1={d[a][0]} y1={d[a][1]} x2={d[b][0]} y2={d[b][1]} stroke="var(--accent)" strokeWidth="0.6" opacity="0.2"/>)}
|
||||
{d.map(([x,y],i) => <circle key={i} cx={x} cy={y} r={i%3===0?2:1.3} fill="var(--accent)" opacity={i%2===0?0.7:0.45}/>)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AuroraPreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="p-a1" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.55"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
<radialGradient id="p-a2" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.45"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
|
||||
<radialGradient id="p-a3" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.40"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<ellipse cx="10" cy="10" rx="80" ry="22" fill="url(#p-a1)"/>
|
||||
<ellipse cx="66" cy="6" rx="90" ry="16" fill="url(#p-a2)"/>
|
||||
<ellipse cx="106" cy="19" rx="66" ry="20" fill="url(#p-a3)"/>
|
||||
<ellipse cx="34" cy="32" rx="85" ry="15" fill="url(#p-a1)" opacity="0.5"/>
|
||||
<ellipse cx="86" cy="44" rx="60" ry="16" fill="url(#p-a2)" opacity="0.45"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PulsePreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<defs>
|
||||
<radialGradient id="p-pg" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<circle cx="60" cy="34" r="12" fill="url(#p-pg)"/>
|
||||
<circle cx="60" cy="34" r="9" fill="none" stroke="var(--accent)" strokeWidth="1.8" opacity="0.75"/>
|
||||
<circle cx="60" cy="34" r="20" fill="none" stroke="var(--accent)" strokeWidth="1.2" opacity="0.5"/>
|
||||
<circle cx="60" cy="34" r="30" fill="none" stroke="var(--accent)" strokeWidth="0.9" opacity="0.32"/>
|
||||
<circle cx="60" cy="34" r="42" fill="none" stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
|
||||
<circle cx="60" cy="34" r="55" fill="none" stroke="var(--accent)" strokeWidth="0.4" opacity="0.1"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StarsPreview() {
|
||||
const stars: [number,number,number][] = [
|
||||
[12,8,1.4],[34,5,0.9],[58,12,1.1],[80,4,1.5],[102,9,0.8],[18,22,1.0],[45,18,1.3],[72,20,0.9],[96,25,1.2],
|
||||
[8,38,0.8],[28,42,1.1],[55,35,1.4],[78,40,0.9],[108,36,1.0],[22,56,1.2],[50,58,0.8],[75,54,1.3],[100,60,1.0],
|
||||
[40,28,0.7],[88,14,1.1],[15,48,0.9],[64,48,0.8]
|
||||
]
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<defs>
|
||||
<radialGradient id="p-neb" cx="35%" cy="45%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.12"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<rect width="120" height="68" fill="url(#p-neb)"/>
|
||||
{stars.map(([x,y,r],i) => (
|
||||
<circle key={i} cx={x} cy={y} r={r} fill="var(--accent)" opacity={0.4 + (i % 5) * 0.12}/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RainPreview() {
|
||||
const drops: [number,number,number][] = [
|
||||
[10,8,20],[25,0,28],[40,15,22],[55,5,25],[70,10,18],[85,2,30],[100,12,24],[112,6,20],
|
||||
[18,35,22],[33,28,26],[48,40,19],[63,30,24],[78,38,21],[93,25,28],[108,34,20],
|
||||
]
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
{drops.map(([x,y,len],i) => (
|
||||
<g key={i}>
|
||||
<line x1={x} y1={y} x2={x} y2={y+len} stroke="var(--accent)" strokeWidth="1.5" opacity={0.15 + (i%4)*0.08}
|
||||
style={{background: `linear-gradient(to bottom, transparent, var(--accent))`}}/>
|
||||
<circle cx={x} cy={y+len} r="1.5" fill="var(--accent)" opacity={0.5 + (i%3)*0.15}/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function RaysPreview() {
|
||||
const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="p-rg" cx="50%" cy="60%" r="55%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||
</defs>
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
{rays.map((deg, i) => {
|
||||
const rad = (deg * Math.PI) / 180
|
||||
const x2 = 60 + Math.cos(rad) * 90
|
||||
const y2 = 41 + Math.sin(rad) * 90
|
||||
return (
|
||||
<line key={i} x1="60" y1="41" x2={x2} y2={y2}
|
||||
stroke="var(--accent)" strokeWidth={i%2===0?2.5:1.5} opacity={i%2===0?0.18:0.10}/>
|
||||
)
|
||||
})}
|
||||
<circle cx="60" cy="41" r="14" fill="url(#p-rg)"/>
|
||||
<circle cx="60" cy="41" r="3" fill="var(--accent)" opacity="0.7"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function NonePreview() {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||
<line x1="48" y1="34" x2="72" y2="34" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
<line x1="60" y1="22" x2="60" y2="46" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
|
||||
orbs: <OrbsPreview />,
|
||||
waves: <WavesPreview />,
|
||||
particles: <ParticlesPreview />,
|
||||
aurora: <AuroraPreview />,
|
||||
pulse: <PulsePreview />,
|
||||
stars: <StarsPreview />,
|
||||
rain: <RainPreview />,
|
||||
rays: <RaysPreview />,
|
||||
none: <NonePreview />,
|
||||
}
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuthStore()
|
||||
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
|
||||
const { bgMode, setBg, raysConfig, setRaysConfig } = useBgStore()
|
||||
const router = useRouter()
|
||||
const activeAccent = getActiveAccent(accentIdx, customHex)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) router.replace('/login')
|
||||
}, [user, router])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<main className="max-w-app mx-auto relative z-10">
|
||||
<Header />
|
||||
|
||||
<div className="animate-fadeUp">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-app-text mb-5">Настройки</h2>
|
||||
|
||||
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3">
|
||||
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
|
||||
Внешний вид
|
||||
</p>
|
||||
|
||||
{/* Accent color */}
|
||||
<p className="text-[12px] text-muted mb-2.5">Акцентный цвет</p>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{ACCENT_PRESETS.map((preset, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setAccent(i)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
||||
style={accentIdx === i
|
||||
? { background: `rgba(${preset.rgb},0.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
|
||||
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full shrink-0"
|
||||
style={{ background: preset.accent, boxShadow: accentIdx === i ? `0 0 7px ${preset.accent}90` : 'none' }}/>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
{/* Custom color button */}
|
||||
<button
|
||||
onClick={() => setCustom(customHex)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
||||
style={accentIdx === -1
|
||||
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
|
||||
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
|
||||
style={{
|
||||
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
|
||||
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
|
||||
}}/>
|
||||
Свой
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Color wheel — shown when custom is selected */}
|
||||
{accentIdx === -1 && (
|
||||
<div className="mb-6 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||
<ColorWheel value={customHex} onChange={setCustom} />
|
||||
</div>
|
||||
)}
|
||||
{accentIdx !== -1 && <div className="mb-2" />}
|
||||
|
||||
{/* Live background */}
|
||||
<p className="text-[12px] text-muted mb-2.5">Живой фон</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{BG_PRESETS.map((preset) => {
|
||||
const active = bgMode === preset.id
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => setBg(preset.id)}
|
||||
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
|
||||
style={active
|
||||
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
|
||||
: { borderColor: 'rgba(255,255,255,0.07)' }
|
||||
}
|
||||
>
|
||||
<div className="w-full aspect-video overflow-hidden">
|
||||
{BG_PREVIEWS[preset.id]}
|
||||
</div>
|
||||
<div className="px-3 py-2" style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}>
|
||||
<p className="text-[12px] font-display font-bold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
||||
{preset.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted mt-0.5">{preset.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Rays config — shown only when rays mode is active */}
|
||||
{bgMode === 'rays' && (
|
||||
<div className="mt-4 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
|
||||
Настройки лучей
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setRaysConfig(DEFAULT_RAYS)}
|
||||
className="text-[10px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{([
|
||||
{ key: 'count', label: 'Количество', min: 4, max: 16, step: 1, fmt: (v: number) => String(Math.round(v)) },
|
||||
{ key: 'speed', label: 'Скорость', min: 0.2, max: 3.0, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
|
||||
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
|
||||
{ key: 'spread', label: 'Ширина', min: 0.3, max: 2.0, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
|
||||
] as const).map(({ key, label, min, max, step, fmt }) => (
|
||||
<div key={key}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[12px] text-app-text/70">{label}</span>
|
||||
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>
|
||||
{fmt(raysConfig[key])}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min} max={max} step={step}
|
||||
value={raysConfig[key]}
|
||||
onChange={(e) => setRaysConfig({ [key]: Number(e.target.value) })}
|
||||
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
|
||||
style={{ accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
'use client'
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useFavoritesStore } from '@/store/favoritesStore'
|
||||
@@ -14,16 +14,16 @@ interface Props {
|
||||
|
||||
export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) {
|
||||
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
||||
const { token } = useAuthStore()
|
||||
const { user } = useAuthStore()
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||
const [added, setAdded] = useState<Record<string, boolean>>({})
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const favorited = isFavorite(trackTitle)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
getPlaylists(token).then(setPlaylists).catch(() => {})
|
||||
}, [token])
|
||||
if (!user) return
|
||||
getPlaylists().then(setPlaylists).catch(() => {})
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -37,9 +37,9 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
const handleAdd = async (playlist: Playlist) => {
|
||||
if (!token || added[playlist.id]) return
|
||||
if (!user || added[playlist.id]) return
|
||||
try {
|
||||
await addTrackToPlaylist(token, playlist.id, trackTitle)
|
||||
await addTrackToPlaylist(playlist.id, trackTitle)
|
||||
setAdded(prev => ({ ...prev, [playlist.id]: true }))
|
||||
} catch {}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
|
||||
)}
|
||||
</button>
|
||||
|
||||
{token ? (
|
||||
{user ? (
|
||||
playlists.length > 0 ? (
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
{playlists.map(pl => (
|
||||
|
||||
@@ -10,12 +10,12 @@ type Star = { x: number; y: number; r: number; ba: number; ph: number; sp: numbe
|
||||
type Drop = { x: number; y: number; speed: number; len: number; alpha: number }
|
||||
|
||||
export default function AudioBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const { bgMode, raysConfig } = useBgStore()
|
||||
const raysRef = useRef(raysConfig)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const { bgMode, fxConfigs } = useBgStore()
|
||||
const fxRef = useRef(fxConfigs)
|
||||
|
||||
// Keep rays config in sync without restarting the animation loop
|
||||
useEffect(() => { raysRef.current = raysConfig }, [raysConfig])
|
||||
// Sync config changes without restarting the animation loop
|
||||
useEffect(() => { fxRef.current = fxConfigs }, [fxConfigs])
|
||||
|
||||
useEffect(() => {
|
||||
if (bgMode === 'none') return
|
||||
@@ -28,7 +28,7 @@ export default function AudioBackground() {
|
||||
let rafId: number
|
||||
let smoothBass = 0
|
||||
let smoothMid = 0
|
||||
let fastBass = 0 // fast tracker for onset/beat detection
|
||||
let fastBass = 0
|
||||
let dataBuf: Uint8Array<ArrayBuffer> | null = null
|
||||
|
||||
const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight }
|
||||
@@ -54,35 +54,45 @@ export default function AudioBackground() {
|
||||
|
||||
const ac = () => document.documentElement.style.getPropertyValue('--accent-rgb') || '200,255,0'
|
||||
|
||||
// ── ORBS — ambient, subtle ────────────────────────────────────────────────
|
||||
const clear = (W: number, H: number, trail: number) => {
|
||||
if (trail > 0.005) {
|
||||
ctx.fillStyle = `rgba(10,10,15,${(1 - trail).toFixed(3)})`
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
} else {
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
ctx.fillStyle = '#0a0a0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ORBS ─────────────────────────────────────────────────────────────────
|
||||
const drawOrbs = () => {
|
||||
const W = canvas.width, H = canvas.height, a = ac()
|
||||
const t = Date.now() / 5000
|
||||
const cfg = fxRef.current.orbs
|
||||
const t = Date.now() / (5000 / cfg.speed)
|
||||
const br = Math.sin(t) * 0.04 + Math.cos(t * 0.7) * 0.02
|
||||
const diag = Math.hypot(W, H), base = diag * 0.62
|
||||
const bri = cfg.brightness
|
||||
|
||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
|
||||
clear(W, H, cfg.trail)
|
||||
|
||||
// accent top-left
|
||||
const r1 = base * (0.78 + smoothBass * 0.45 + br)
|
||||
const a1 = 0.07 + smoothBass * 0.08
|
||||
const a1 = (0.07 + smoothBass * 0.08) * bri
|
||||
const g1 = ctx.createRadialGradient(W * 0.12, H * 0.06, 0, W * 0.12, H * 0.06, r1)
|
||||
g1.addColorStop(0, `rgba(${a},${a1})`); g1.addColorStop(0.42, `rgba(${a},${a1 * 0.25})`); g1.addColorStop(1, `rgba(${a},0)`)
|
||||
ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// pink bottom-right
|
||||
const r2 = base * (0.70 + smoothMid * 0.40 - br * 0.5)
|
||||
const a2 = 0.06 + smoothMid * 0.07
|
||||
const a2 = (0.06 + smoothMid * 0.07) * bri
|
||||
const g2 = ctx.createRadialGradient(W * 0.88, H * 0.94, 0, W * 0.88, H * 0.94, r2)
|
||||
g2.addColorStop(0, `rgba(255,60,172,${a2})`); g2.addColorStop(0.42, `rgba(255,60,172,${a2 * 0.25})`); g2.addColorStop(1, 'rgba(255,60,172,0)')
|
||||
ctx.fillStyle = g2; ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// purple center — only shows with audio
|
||||
const c = smoothBass * 0.5 + smoothMid * 0.5
|
||||
if (c > 0.008) {
|
||||
const r3 = base * (0.40 + c * 0.35)
|
||||
const g3 = ctx.createRadialGradient(W * 0.5, H * 0.5, 0, W * 0.5, H * 0.5, r3)
|
||||
g3.addColorStop(0, `rgba(140,100,255,${0.035 + c * 0.06})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
|
||||
g3.addColorStop(0, `rgba(140,100,255,${(0.035 + c * 0.06) * bri})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
|
||||
ctx.fillStyle = g3; ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
}
|
||||
@@ -90,14 +100,16 @@ export default function AudioBackground() {
|
||||
// ── WAVES ─────────────────────────────────────────────────────────────────
|
||||
const drawWaves = () => {
|
||||
const W = canvas.width, H = canvas.height, a = ac()
|
||||
const t = Date.now() / 1000
|
||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
|
||||
const cfg = fxRef.current.waves
|
||||
const t = (Date.now() / 1000) * cfg.speed
|
||||
const amp = cfg.amplitude
|
||||
clear(W, H, cfg.trail)
|
||||
const layers = [
|
||||
{ y: 0.70, amp: 20 + smoothBass * 60, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${a},` },
|
||||
{ y: 0.76, amp: 16 + smoothMid * 45, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' },
|
||||
{ y: 0.81, amp: 24 + smoothBass * 75, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` },
|
||||
{ y: 0.87, amp: 12 + smoothMid * 35, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' },
|
||||
{ y: 0.93, amp: 30 + smoothBass * 90, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` },
|
||||
{ y: 0.70, amp: (20 + smoothBass * 60) * amp, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${a},` },
|
||||
{ y: 0.76, amp: (16 + smoothMid * 45) * amp, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' },
|
||||
{ y: 0.81, amp: (24 + smoothBass * 75) * amp, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` },
|
||||
{ y: 0.87, amp: (12 + smoothMid * 35) * amp, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' },
|
||||
{ y: 0.93, amp: (30 + smoothBass * 90) * amp, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` },
|
||||
]
|
||||
for (const l of layers) {
|
||||
const baseY = H * l.y
|
||||
@@ -119,8 +131,9 @@ export default function AudioBackground() {
|
||||
}))
|
||||
const drawParticles = () => {
|
||||
const W = canvas.width, H = canvas.height, a = ac()
|
||||
ctx.fillStyle = 'rgba(10,10,15,0.2)'; ctx.fillRect(0, 0, W, H)
|
||||
const spd = 1 + smoothBass * 3
|
||||
const cfg = fxRef.current.particles
|
||||
clear(W, H, cfg.trail)
|
||||
const spd = (1 + smoothBass * 3) * cfg.speed
|
||||
for (const p of PTS) {
|
||||
p.x += p.vx * spd; p.y += p.vy * spd
|
||||
if (p.x < 0) p.x += 1; if (p.x > 1) p.x -= 1
|
||||
@@ -133,7 +146,7 @@ export default function AudioBackground() {
|
||||
ctx.beginPath(); ctx.arc(p.x * W, p.y * H, p.r * (1 + smoothBass * 1.2), 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(${a},${p.a * (0.7 + smoothMid * 0.3)})`; ctx.fill()
|
||||
}
|
||||
const maxD = Math.min(W, H) * 0.12; ctx.lineWidth = 0.5
|
||||
const maxD = Math.min(W, H) * 0.12 * cfg.linkDist; ctx.lineWidth = 0.5
|
||||
for (let i = 0; i < PTS.length; i++)
|
||||
for (let j = i + 1; j < PTS.length; j++) {
|
||||
const dx = (PTS[i].x - PTS[j].x) * W, dy = (PTS[i].y - PTS[j].y) * H
|
||||
@@ -145,19 +158,21 @@ export default function AudioBackground() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── AURORA — subtle shimmer, not a flood ──────────────────────────────────
|
||||
// ── AURORA ────────────────────────────────────────────────────────────────
|
||||
const drawAurora = () => {
|
||||
const W = canvas.width, H = canvas.height, a = ac()
|
||||
const t = Date.now() / 3500
|
||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
|
||||
const cfg = fxRef.current.aurora
|
||||
const t = (Date.now() / 3500) * cfg.speed
|
||||
const bri = cfg.brightness
|
||||
clear(W, H, cfg.trail)
|
||||
|
||||
const bands = [
|
||||
{ cx: 0.10, cy: 0.16, w: 0.72, h: 0.22, ph: t * 0.7, al: 0.10 + smoothBass * 0.08, c: a },
|
||||
{ cx: 0.58, cy: 0.09, w: 0.88, h: 0.18, ph: -t * 0.5, al: 0.08 + smoothMid * 0.07, c: '140,100,255' },
|
||||
{ cx: 0.85, cy: 0.30, w: 0.62, h: 0.20, ph: t * 0.6, al: 0.09 + smoothBass * 0.07, c: '255,60,172' },
|
||||
{ cx: 0.30, cy: 0.50, w: 0.78, h: 0.16, ph: -t * 0.8, al: 0.07 + smoothMid * 0.06, c: a },
|
||||
{ cx: 0.70, cy: 0.64, w: 0.58, h: 0.18, ph: t * 0.5, al: 0.06 + smoothBass * 0.05, c: '140,100,255' },
|
||||
{ cx: 0.18, cy: 0.78, w: 0.68, h: 0.16, ph: -t * 0.65,al: 0.05 + smoothMid * 0.05, c: '255,60,172' },
|
||||
{ cx: 0.10, cy: 0.16, w: 0.72, h: 0.22, ph: t * 0.7, al: (0.10 + smoothBass * 0.08) * bri, c: a },
|
||||
{ cx: 0.58, cy: 0.09, w: 0.88, h: 0.18, ph: -t * 0.5, al: (0.08 + smoothMid * 0.07) * bri, c: '140,100,255' },
|
||||
{ cx: 0.85, cy: 0.30, w: 0.62, h: 0.20, ph: t * 0.6, al: (0.09 + smoothBass * 0.07) * bri, c: '255,60,172' },
|
||||
{ cx: 0.30, cy: 0.50, w: 0.78, h: 0.16, ph: -t * 0.8, al: (0.07 + smoothMid * 0.06) * bri, c: a },
|
||||
{ cx: 0.70, cy: 0.64, w: 0.58, h: 0.18, ph: t * 0.5, al: (0.06 + smoothBass * 0.05) * bri, c: '140,100,255' },
|
||||
{ cx: 0.18, cy: 0.78, w: 0.68, h: 0.16, ph: -t * 0.65,al: (0.05 + smoothMid * 0.05) * bri, c: '255,60,172' },
|
||||
]
|
||||
for (const band of bands) {
|
||||
const cx = band.cx * W, cy = (band.cy + Math.sin(band.ph) * 0.07) * H
|
||||
@@ -169,48 +184,68 @@ export default function AudioBackground() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── PULSE — beat-driven expanding rings ───────────────────────────────────
|
||||
// ── PULSE — expanding ring waves ──────────────────────────────────────────
|
||||
const RINGS: Ring[] = []
|
||||
let prevFast = 0
|
||||
let lastRingMs = 0
|
||||
let lastAmbMs = 0
|
||||
|
||||
const drawPulse = () => {
|
||||
const W = canvas.width, H = canvas.height, a = ac()
|
||||
const cfg = fxRef.current.pulse
|
||||
const cx = W * 0.5, cy = H * 0.5
|
||||
const maxR = Math.hypot(W, H) * 0.8
|
||||
const maxR = Math.hypot(W, H) * 0.72
|
||||
|
||||
ctx.fillStyle = 'rgba(10,10,15,0.18)'; ctx.fillRect(0, 0, W, H)
|
||||
clear(W, H, cfg.trail)
|
||||
|
||||
// Beat onset: fastBass rises sharply above smoothBass
|
||||
const onset = fastBass > smoothBass + 0.10 && fastBass > 0.22 && fastBass > prevFast + 0.04
|
||||
if (onset) RINGS.push({ r: 10, alpha: 0.7 + fastBass * 0.3, speed: 3 + fastBass * 9 })
|
||||
prevFast = fastBass
|
||||
const now = Date.now()
|
||||
|
||||
// Slow ambient rings so mode looks alive without audio
|
||||
if (Math.random() < 0.008 && RINGS.length < 3) RINGS.push({ r: 5, alpha: 0.22, speed: 1.5 })
|
||||
// Beat-triggered ring
|
||||
const threshold = 0.45 - cfg.sensitivity * 0.3
|
||||
if (fastBass > threshold && fastBass > smoothBass + 0.05 && now - lastRingMs > 200) {
|
||||
RINGS.push({ r: 4, alpha: 1.0, speed: (5 + fastBass * 12) * cfg.ringSpeed })
|
||||
lastRingMs = now
|
||||
}
|
||||
|
||||
// Ambient ring on timer so it always looks alive even without audio
|
||||
if (now - lastAmbMs > 1800) {
|
||||
RINGS.push({ r: 4, alpha: 0.7, speed: 5 * cfg.ringSpeed })
|
||||
lastAmbMs = now
|
||||
}
|
||||
|
||||
for (let i = RINGS.length - 1; i >= 0; i--) {
|
||||
const ring = RINGS[i]
|
||||
ring.r += ring.speed
|
||||
ring.alpha -= 0.008
|
||||
if (ring.alpha <= 0 || ring.r > maxR) { RINGS.splice(i, 1); continue }
|
||||
ring.alpha *= 0.982 // exponential fade — stays bright longer, fades smoothly
|
||||
if (ring.alpha < 0.015 || ring.r > maxR) { RINGS.splice(i, 1); continue }
|
||||
|
||||
const lw = 2 + ring.alpha * 5
|
||||
|
||||
// Outer glow
|
||||
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
|
||||
ctx.strokeStyle = `rgba(${a},${ring.alpha * 0.2})`
|
||||
ctx.lineWidth = lw * 5; ctx.stroke()
|
||||
|
||||
// Main ring
|
||||
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
|
||||
ctx.strokeStyle = `rgba(${a},${ring.alpha})`
|
||||
ctx.lineWidth = 1.5 + ring.alpha * 3.5; ctx.stroke()
|
||||
ctx.lineWidth = lw; ctx.stroke()
|
||||
|
||||
// Inner echo
|
||||
if (ring.r > 40 && ring.alpha > 0.15) {
|
||||
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.80, 0, Math.PI * 2)
|
||||
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.4})`
|
||||
ctx.lineWidth = 0.8; ctx.stroke()
|
||||
// Pink echo
|
||||
if (ring.r > 30 && ring.alpha > 0.08) {
|
||||
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.85, 0, Math.PI * 2)
|
||||
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.35})`
|
||||
ctx.lineWidth = lw * 0.55; ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// Persistent center glow
|
||||
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80 + smoothBass * 120)
|
||||
cg.addColorStop(0, `rgba(${a},${0.08 + smoothBass * 0.14})`); cg.addColorStop(1, `rgba(${a},0)`)
|
||||
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)
|
||||
// Center dot — pulses with bass, small footprint so it doesn't drown rings
|
||||
const dotR = 14 + smoothBass * 55
|
||||
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, dotR)
|
||||
cg.addColorStop(0, `rgba(${a},${0.85 + smoothBass * 0.15})`)
|
||||
cg.addColorStop(0.45,`rgba(${a},0.25)`)
|
||||
cg.addColorStop(1, `rgba(${a},0)`)
|
||||
ctx.fillStyle = cg
|
||||
ctx.beginPath(); ctx.arc(cx, cy, dotR, 0, Math.PI * 2); ctx.fill()
|
||||
}
|
||||
|
||||
// ── STARS ─────────────────────────────────────────────────────────────────
|
||||
@@ -220,22 +255,24 @@ export default function AudioBackground() {
|
||||
}))
|
||||
const drawStars = () => {
|
||||
const W = canvas.width, H = canvas.height, a = ac()
|
||||
const cfg = fxRef.current.stars
|
||||
const t = Date.now() / 1000
|
||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
|
||||
const bri = cfg.brightness
|
||||
clear(W, H, cfg.trail)
|
||||
const n1 = ctx.createRadialGradient(W*0.30, H*0.35, 0, W*0.30, H*0.35, W*0.55)
|
||||
n1.addColorStop(0, `rgba(${a},${0.04+smoothMid*0.04})`); n1.addColorStop(1, `rgba(${a},0)`)
|
||||
n1.addColorStop(0, `rgba(${a},${(0.04+smoothMid*0.04)*bri})`); n1.addColorStop(1, `rgba(${a},0)`)
|
||||
ctx.fillStyle = n1; ctx.fillRect(0, 0, W, H)
|
||||
for (const s of STARS) {
|
||||
const tw = (Math.sin(t * s.sp + s.ph) + 1) * 0.5
|
||||
const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4)
|
||||
const tw = (Math.sin(t * s.sp * cfg.twinkle + s.ph) + 1) * 0.5
|
||||
const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4) * bri
|
||||
const r = s.r * (1 + smoothBass * tw * 1.0)
|
||||
if (s.r > 1.1) { ctx.beginPath(); ctx.arc(s.x*W, s.y*H, r*2.8, 0, Math.PI*2); ctx.fillStyle = `rgba(${a},${alpha*0.18})`; ctx.fill() }
|
||||
ctx.beginPath(); ctx.arc(s.x*W, s.y*H, r, 0, Math.PI*2); ctx.fillStyle = `rgba(${a},${alpha})`; ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// ── RAIN — sparse, soft streaks ───────────────────────────────────────────
|
||||
const DROPS: Drop[] = Array.from({ length: 30 }, () => ({
|
||||
// ── RAIN ──────────────────────────────────────────────────────────────────
|
||||
const DROPS: Drop[] = Array.from({ length: 60 }, () => ({
|
||||
x: Math.random(), y: Math.random(),
|
||||
speed: Math.random() * 0.0015 + 0.0008,
|
||||
len: Math.random() * 0.055 + 0.03,
|
||||
@@ -243,9 +280,12 @@ export default function AudioBackground() {
|
||||
}))
|
||||
const drawRain = () => {
|
||||
const W = canvas.width, H = canvas.height, a = ac()
|
||||
ctx.fillStyle = 'rgba(10,10,15,0.12)'; ctx.fillRect(0, 0, W, H)
|
||||
const spd = 1 + smoothBass * 2.5
|
||||
for (const d of DROPS) {
|
||||
const cfg = fxRef.current.rain
|
||||
const count = Math.round(cfg.drops)
|
||||
clear(W, H, cfg.trail)
|
||||
const spd = (1 + smoothBass * 2.5) * cfg.speed
|
||||
for (let i = 0; i < count && i < DROPS.length; i++) {
|
||||
const d = DROPS[i]
|
||||
d.y += d.speed * spd
|
||||
if (d.y > 1.08) { d.y = -d.len - 0.02; d.x = Math.random() }
|
||||
const x = d.x * W, y = d.y * H, len = d.len * H * (1 + smoothBass * 0.4)
|
||||
@@ -256,34 +296,29 @@ export default function AudioBackground() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── RAYS — tapered beams from center ──────────────────────────────────────
|
||||
// ── RAYS ──────────────────────────────────────────────────────────────────
|
||||
const drawRays = () => {
|
||||
const W = canvas.width, H = canvas.height, a = ac()
|
||||
const cfg = raysRef.current
|
||||
const cfg = fxRef.current.rays
|
||||
const t = (Date.now() / 1000) * cfg.speed * 0.08
|
||||
const cx = W * 0.5, cy = H * 0.55
|
||||
const maxR = Math.hypot(W, H) * 1.1
|
||||
const br = cfg.brightness
|
||||
const sp = cfg.spread
|
||||
|
||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
|
||||
clear(W, H, cfg.trail)
|
||||
|
||||
const count = cfg.count
|
||||
|
||||
// Primary rays — rotate forward
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (i / count) * Math.PI * 2 + t
|
||||
const isMain = i % 2 === 0
|
||||
const hw = Math.tan((0.055 + smoothBass * 0.035) * sp) * maxR
|
||||
const al = ((isMain ? 0.12 : 0.07) + smoothBass * 0.10 + smoothMid * 0.03) * br
|
||||
|
||||
const ex = cx + Math.cos(angle) * maxR
|
||||
const ey = cy + Math.sin(angle) * maxR
|
||||
const px = -Math.sin(angle) * hw
|
||||
const py = Math.cos(angle) * hw
|
||||
const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
|
||||
const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
|
||||
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex + px, ey + py); ctx.lineTo(ex - px, ey - py); ctx.closePath()
|
||||
|
||||
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
|
||||
grad.addColorStop(0, `rgba(${a},${al * 2.5})`)
|
||||
grad.addColorStop(0.12, `rgba(${a},${al})`)
|
||||
@@ -292,17 +327,14 @@ export default function AudioBackground() {
|
||||
ctx.fillStyle = grad; ctx.fill()
|
||||
}
|
||||
|
||||
// Secondary rays — counter-rotate, pink tint
|
||||
const cnt2 = Math.max(2, Math.floor(count / 2))
|
||||
for (let i = 0; i < cnt2; i++) {
|
||||
const angle = (i / cnt2) * Math.PI * 2 - t * 0.65 + Math.PI / count
|
||||
const hw = Math.tan(0.035 * sp) * maxR
|
||||
const al = (0.05 + smoothMid * 0.06) * br
|
||||
|
||||
const ex = cx + Math.cos(angle) * maxR
|
||||
const ey = cy + Math.sin(angle) * maxR
|
||||
const px = -Math.sin(angle) * hw
|
||||
const py = Math.cos(angle) * hw
|
||||
const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
|
||||
const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
|
||||
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex + px, ey + py); ctx.lineTo(ex - px, ey - py); ctx.closePath()
|
||||
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
|
||||
@@ -312,7 +344,6 @@ export default function AudioBackground() {
|
||||
ctx.fillStyle = grad; ctx.fill()
|
||||
}
|
||||
|
||||
// Center glow
|
||||
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 100 + smoothBass * 140)
|
||||
cg.addColorStop(0, `rgba(${a},${(0.14 + smoothBass * 0.18) * br})`); cg.addColorStop(1, `rgba(${a},0)`)
|
||||
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
'use client'
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
@@ -9,6 +9,9 @@ import { useAudioEngine } from '@/hooks/usePlayer'
|
||||
import { useAudioViz } from '@/hooks/useAudioViz'
|
||||
import { audioState } from '@/lib/audioState'
|
||||
import AddToPlaylist from '@/components/AddToPlaylist'
|
||||
import QueuePanel from '@/components/Player/QueuePanel'
|
||||
import VersionsPanel from '@/components/Player/VersionsPanel'
|
||||
import { useToastStore } from '@/store/toastStore'
|
||||
import type { SearchResult } from '@/types'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||
@@ -25,10 +28,14 @@ function formatTime(s: number) {
|
||||
}
|
||||
|
||||
export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus, reorderQueue, setCurIdx, generateMix, removeFromQueue, addTrackToQueue } =
|
||||
usePartyStore()
|
||||
const {
|
||||
queue, curIdx, loadKey, repeatMode,
|
||||
updateQueueItemImg, setCurrentResults, setCurrentResult, setSearchStatus, searchStatus,
|
||||
setCurIdx, setRepeatMode, removeFromQueue, addTrackToQueue,
|
||||
} = usePartyStore()
|
||||
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
||||
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
|
||||
const showToast = useToastStore((s) => s.show)
|
||||
|
||||
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
@@ -43,7 +50,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
const [panel, setPanel] = useState<'queue' | 'versions' | null>(null)
|
||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||
const playlistBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const dragSrcIdx = useRef<number | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Remote control
|
||||
@@ -62,6 +68,9 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
const prefetchInflightRef = useRef<Map<string, Promise<SearchResult[]>>>(new Map())
|
||||
const preloadAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// Touch swipe → prev/next track
|
||||
const touchStartX = useRef<number | null>(null)
|
||||
|
||||
useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isPlaying)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,6 +86,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
if (!r) return
|
||||
setActiveResultIdx(resIdx)
|
||||
activeResultIdxRef.current = resIdx
|
||||
setCurrentResult(r)
|
||||
setAudioMeta({ title: r.title, artist: r.artist })
|
||||
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
|
||||
const audio = audioRef.current
|
||||
@@ -89,14 +99,12 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
if (err?.name !== 'AbortError') console.warn('[player] play() failed:', err?.name)
|
||||
})
|
||||
},
|
||||
[audioRef, resumeContext],
|
||||
[audioRef, resumeContext, setCurrentResult],
|
||||
)
|
||||
|
||||
const playResultRef = useRef(playResult)
|
||||
useEffect(() => { playResultRef.current = playResult }, [playResult])
|
||||
|
||||
// Returns cached results immediately, awaits in-flight request, or starts new one.
|
||||
// Deduplicates parallel requests for the same title.
|
||||
const prefetchOrGet = useCallback(async (title: string): Promise<SearchResult[]> => {
|
||||
const cached = prefetchCacheRef.current.get(title)
|
||||
if (cached) return cached
|
||||
@@ -142,6 +150,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
|
||||
if (!found.length) {
|
||||
setSearchStatus('not-found')
|
||||
showToast(`Трек не найден: ${track.title}`, 'error')
|
||||
setTimeout(() => {
|
||||
if (loadingKeyRef.current !== key) return
|
||||
const s = usePartyStore.getState()
|
||||
@@ -167,7 +176,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
|
||||
playResult(found, startIdx)
|
||||
|
||||
// Prefetch next 3 tracks; for N+1 also preload audio into hidden element
|
||||
for (let offset = 1; offset <= 3; offset++) {
|
||||
const nextTrack = queue[idx + offset]
|
||||
if (!nextTrack) continue
|
||||
@@ -176,29 +184,25 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
p.then(nextResults => {
|
||||
if (loadingKeyRef.current !== key) return
|
||||
if (!nextResults.length) return
|
||||
const saved = getSavedVersion(nextTrack.title)
|
||||
const si = saved ? nextResults.findIndex(r => r.title === saved.title && r.artist === saved.artist) : -1
|
||||
const sv = getSavedVersion(nextTrack.title)
|
||||
const si = sv ? nextResults.findIndex(r => r.title === sv.title && r.artist === sv.artist) : -1
|
||||
const resIdx = si >= 0 ? si : 0
|
||||
const preloadEl = preloadAudioRef.current
|
||||
if (!preloadEl) return
|
||||
const url = proxyMp3Url(nextResults[resIdx].mp3)
|
||||
if (preloadEl.src !== url) {
|
||||
preloadEl.src = url
|
||||
preloadEl.load()
|
||||
}
|
||||
if (preloadEl.src !== url) { preloadEl.src = url; preloadEl.load() }
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
p.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Evict stale cache entries outside the current window
|
||||
const windowTitles = new Set(queue.slice(Math.max(0, idx - 1), idx + 6).map(t => t.title))
|
||||
prefetchCacheRef.current.forEach((_, title) => {
|
||||
if (!windowTitles.has(title)) prefetchCacheRef.current.delete(title)
|
||||
})
|
||||
},
|
||||
[queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion, prefetchOrGet],
|
||||
[queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion, prefetchOrGet, showToast],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -220,8 +224,8 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
audioState.analyser = analyserRef.current
|
||||
}
|
||||
const onPause = () => { setIsPlaying(false); isPlayingRef.current = false; audioState.isPlaying = false }
|
||||
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
|
||||
const onDuration = () => setDuration(audio.duration || 0)
|
||||
const onTimeUpdate = () => { setCurrentTime(audio.currentTime); audioState.currentTime = audio.currentTime }
|
||||
const onDuration = () => { const d = audio.duration || 0; setDuration(d); audioState.duration = d }
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false)
|
||||
isPlayingRef.current = false
|
||||
@@ -229,15 +233,18 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
const r = activeResultsRef.current[activeResultIdxRef.current]
|
||||
if (r) onTrackEnd(r)
|
||||
const s = usePartyStore.getState()
|
||||
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
|
||||
const repeat = s.repeatMode
|
||||
if (repeat === 'one') {
|
||||
s.setCurIdx(s.curIdx)
|
||||
} else if (repeat === 'all') {
|
||||
s.setCurIdx(s.curIdx < s.queue.length - 1 ? s.curIdx + 1 : 0)
|
||||
} else {
|
||||
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
|
||||
}
|
||||
}
|
||||
const onError = () => {
|
||||
if (!audio.src) return
|
||||
setTimeout(() => {
|
||||
resumeContext()
|
||||
audio.load()
|
||||
audio.play().catch(() => {})
|
||||
}, 1500)
|
||||
setTimeout(() => { resumeContext(); audio.load(); audio.play().catch(() => {}) }, 1500)
|
||||
}
|
||||
audio.addEventListener('play', onPlay)
|
||||
audio.addEventListener('pause', onPause)
|
||||
@@ -255,11 +262,10 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
}
|
||||
}, [audioRef, analyserRef, initAudioViz, resumeContext, onTrackEnd])
|
||||
|
||||
// Keep meta refs in sync for remote
|
||||
useEffect(() => { audioMetaRef.current = audioMeta }, [audioMeta])
|
||||
useEffect(() => { coverSrcRef.current = coverSrc }, [coverSrc])
|
||||
|
||||
// Remote: push state every 2s, poll commands every 500ms
|
||||
// Remote: push state every 2s, poll commands every 1.5s
|
||||
useEffect(() => {
|
||||
if (!roomId) return
|
||||
|
||||
@@ -331,7 +337,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
pushState()
|
||||
pollCommands()
|
||||
const ivState = setInterval(pushState, 2000)
|
||||
const ivCmd = setInterval(pollCommands, 500)
|
||||
const ivCmd = setInterval(pollCommands, 1500)
|
||||
return () => { clearInterval(ivState); clearInterval(ivCmd) }
|
||||
}, [roomId, audioRef, resumeContext])
|
||||
|
||||
@@ -360,6 +366,13 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
const togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
if (audio.readyState === 0) {
|
||||
if (loadingKeyRef.current < 0) {
|
||||
const s = usePartyStore.getState()
|
||||
if (s.curIdx >= 0 && s.queue.length > 0) s.setCurIdx(s.curIdx)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (audio.paused) audio.play().catch(() => {})
|
||||
else audio.pause()
|
||||
}, [audioRef])
|
||||
@@ -368,331 +381,314 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||
const reloadTrack = () => { const s = usePartyStore.getState(); s.setCurIdx(s.curIdx) }
|
||||
const nextTrack = () => { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) }
|
||||
|
||||
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
|
||||
dragSrcIdx.current = idx
|
||||
el.classList.add('dragging')
|
||||
}, [])
|
||||
const onDragEnd = useCallback((el: HTMLElement) => {
|
||||
el.classList.remove('dragging')
|
||||
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
|
||||
}, [])
|
||||
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
|
||||
e.preventDefault()
|
||||
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
|
||||
el.classList.add('drag-over')
|
||||
}, [])
|
||||
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
|
||||
e.preventDefault()
|
||||
el.classList.remove('drag-over')
|
||||
if (dragSrcIdx.current === null || dragSrcIdx.current === tgtIdx) return
|
||||
reorderQueue(dragSrcIdx.current, tgtIdx)
|
||||
dragSrcIdx.current = null
|
||||
}, [reorderQueue])
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
}
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) return
|
||||
const delta = e.changedTouches[0].clientX - touchStartX.current
|
||||
touchStartX.current = null
|
||||
if (delta > 60) prevTrack()
|
||||
else if (delta < -60) nextTrack()
|
||||
}
|
||||
|
||||
const track = queue[Math.max(0, curIdx)] ?? null
|
||||
const trackTitle = track?.title ?? ''
|
||||
const favorited = isFavorite(trackTitle)
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
const repeatLabel = repeatMode === 'none' ? 'Повтор выкл' : repeatMode === 'all' ? 'Повтор всего' : 'Повтор трека'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Audio is always mounted at the same tree position so audioRef never changes and event listeners persist */}
|
||||
<audio ref={audioRef} preload="auto" crossOrigin="anonymous" style={{ display: 'none' }} />
|
||||
{(queue.length > 0 && track) && <div className="fixed bottom-0 left-0 right-0 z-50" ref={panelRef}>
|
||||
{/* Slide-up panels */}
|
||||
{panel && (
|
||||
<div className="bg-surface border-t border-white/[0.07] max-h-[50vh] overflow-hidden flex flex-col">
|
||||
{/* Versions panel */}
|
||||
{panel === 'versions' && results.length > 1 && (
|
||||
<div className="overflow-y-auto">
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between">
|
||||
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Версии трека</span>
|
||||
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text">✕</button>
|
||||
</div>
|
||||
{results.map((r, i) => {
|
||||
const saved = isSaved(trackTitle, r)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => playResult(results, i)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
|
||||
>
|
||||
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
|
||||
{r.img && !r.img.includes('no-cover') && (
|
||||
<img src={proxyImgUrl(r.img)} alt="" className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">{r.title}</div>
|
||||
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
|
||||
title={saved ? 'Забыть версию' : 'Запомнить'}
|
||||
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
|
||||
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); playResult(results, i) }}
|
||||
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
|
||||
style={{ width: 26, height: 26 }}
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Queue panel */}
|
||||
{panel === 'queue' && (
|
||||
<div className="overflow-y-auto flex flex-col">
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Очередь · {queue.length}</span>
|
||||
<button
|
||||
onClick={() => generateMix()}
|
||||
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
|
||||
>↺</button>
|
||||
</div>
|
||||
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text">✕</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
{queue.map((item, i) => {
|
||||
const active = i === curIdx
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
|
||||
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
|
||||
onDragStart={(e) => onDragStart(i, e.currentTarget)}
|
||||
onDragEnd={(e) => onDragEnd(e.currentTarget)}
|
||||
onDragOver={(e) => onDragOver(e, e.currentTarget)}
|
||||
onDrop={(e) => onDrop(e, i, e.currentTarget)}
|
||||
>
|
||||
<div className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
|
||||
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
{active ? (
|
||||
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0">
|
||||
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
|
||||
)}
|
||||
{item.img ? (
|
||||
<img src={item.img} alt="" className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color.bg, color: item.color.text }}>
|
||||
{item.owner}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="bg-surface border-t border-white/[0.07] px-4 py-2.5">
|
||||
{/* Progress bar */}
|
||||
<div className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group" onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
const audio = audioRef.current
|
||||
if (audio && duration) audio.currentTime = pct * duration
|
||||
}}>
|
||||
<div className="absolute inset-y-0 left-0 bg-accent rounded-full transition-all duration-100" style={{ width: `${progress}%` }} />
|
||||
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-accent rounded-full opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="max-w-app mx-auto flex items-center gap-3">
|
||||
{/* Cover */}
|
||||
<div className="relative shrink-0 w-11 h-11 rounded-[8px] overflow-hidden bg-surface2">
|
||||
{coverSrc ? (
|
||||
<img src={coverSrc} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-surface2" />
|
||||
)}
|
||||
<canvas ref={canvasRef} className={`absolute inset-0 w-full h-full pointer-events-none transition-opacity duration-300 ${isPlaying ? 'opacity-100' : 'opacity-0'}`} />
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{searchStatus === 'searching' ? (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-muted">
|
||||
<div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" />
|
||||
<span>Ищем...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[13px] font-display font-bold whitespace-nowrap overflow-hidden text-ellipsis leading-tight">{track.title}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{audioMeta.artist && <span className="text-[11px] text-muted truncate">{audioMeta.artist}</span>}
|
||||
<span className="text-[10px] px-1.5 py-px rounded-[5px] font-medium shrink-0" style={{ background: track.color.bg, color: track.color.text }}>{track.owner}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-[10px] text-muted font-display shrink-0 hidden sm:block tabular-nums">
|
||||
{formatTime(currentTime)}<span className="opacity-40 mx-0.5">/</span>{formatTime(duration)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={prevTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
|
||||
</button>
|
||||
<button onClick={togglePlay} className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80">
|
||||
{isPlaying ? (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
||||
) : (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={nextTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Reload */}
|
||||
<button onClick={reloadTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer hidden sm:flex">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
|
||||
</button>
|
||||
{/* Versions */}
|
||||
{results.length > 1 && (
|
||||
<button
|
||||
onClick={() => setPanel(p => p === 'versions' ? null : 'versions')}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||
style={{ color: panel === 'versions' ? 'var(--accent)' : undefined, background: panel === 'versions' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||
title="Версии трека"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* Add to playlist */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={playlistBtnRef}
|
||||
onClick={() => setPlaylistOpen(v => !v)}
|
||||
title="Добавить в плейлист"
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||
style={{ color: playlistOpen ? 'var(--accent)' : undefined, background: playlistOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{playlistOpen && (
|
||||
<AddToPlaylist
|
||||
{/* Audio is always mounted at the same tree position so audioRef never changes and event listeners persist */}
|
||||
<audio ref={audioRef} preload="auto" crossOrigin="anonymous" style={{ display: 'none' }} />
|
||||
{(queue.length > 0 && track) && (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50" ref={panelRef}>
|
||||
{/* Slide-up panels */}
|
||||
{panel && (
|
||||
<div className="bg-surface border-t border-white/[0.07] max-h-[50vh] overflow-hidden flex flex-col">
|
||||
{panel === 'versions' && results.length > 1 && (
|
||||
<VersionsPanel
|
||||
results={results}
|
||||
activeResultIdx={activeResultIdx}
|
||||
trackTitle={trackTitle}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
anchorRef={playlistBtnRef as React.RefObject<HTMLElement | null>}
|
||||
onPlay={playResult}
|
||||
isSaved={isSaved}
|
||||
saveVersion={saveVersion}
|
||||
removeVersion={removeVersion}
|
||||
onClose={() => setPanel(null)}
|
||||
/>
|
||||
)}
|
||||
{panel === 'queue' && (
|
||||
<QueuePanel
|
||||
queue={queue}
|
||||
curIdx={curIdx}
|
||||
onClose={() => setPanel(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Favorite */}
|
||||
<button
|
||||
onClick={() => toggleFavorite(trackTitle)}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2"
|
||||
style={{ color: favorited ? 'var(--accent)' : undefined }}
|
||||
)}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
className="bg-surface border-t border-white/[0.07] px-4 py-2.5"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Прогресс трека"
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group"
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const pct = (e.clientX - rect.left) / rect.width
|
||||
const audio = audioRef.current
|
||||
if (audio && duration) audio.currentTime = pct * duration
|
||||
}}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? 'var(--accent)' : 'none'} stroke={favorited ? 'var(--accent)' : 'currentColor'} strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Queue */}
|
||||
<button
|
||||
onClick={() => setPanel(p => p === 'queue' ? null : 'queue')}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||
style={{ color: panel === 'queue' ? 'var(--accent)' : undefined, background: panel === 'queue' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||
title="Очередь"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* Remote / Share */}
|
||||
<div className="relative share-popover-root">
|
||||
<button
|
||||
ref={shareBtnRef}
|
||||
onClick={async () => {
|
||||
let id = roomId
|
||||
if (!id) {
|
||||
const res = await fetch(`${API_URL}/api/remote`, { method: 'POST' }).catch(() => null)
|
||||
if (res?.ok) {
|
||||
const data = await res.json()
|
||||
id = data.id as string
|
||||
setRoomId(id)
|
||||
}
|
||||
}
|
||||
if (id) setShareOpen(v => !v)
|
||||
}}
|
||||
title="Пульт управления"
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||
style={{ color: shareOpen || roomId ? 'var(--accent)' : undefined, background: shareOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
||||
<circle cx="12" cy="20" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
{shareOpen && roomId && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-64 bg-surface border border-white/[0.07] rounded-[12px] p-3 shadow-2xl z-50">
|
||||
<p className="text-[11px] text-muted font-display font-bold tracking-[1px] uppercase mb-2">Пульт управления</p>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
readOnly
|
||||
value={typeof window !== 'undefined' ? `${window.location.origin}/remote/${roomId}` : `/remote/${roomId}`}
|
||||
className="flex-1 min-w-0 text-[11px] bg-surface2 border border-white/[0.07] rounded-[7px] px-2 py-1.5 text-muted outline-none font-mono truncate"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/remote/${roomId}`)
|
||||
setShareCopied(true)
|
||||
setTimeout(() => setShareCopied(false), 2000)
|
||||
}
|
||||
}}
|
||||
className="shrink-0 px-2 py-1.5 text-[11px] rounded-[7px] border border-white/[0.07] hover:bg-surface2 transition-all cursor-pointer font-display"
|
||||
style={{ color: shareCopied ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{shareCopied ? '✓' : 'Копировать'}
|
||||
</button>
|
||||
<div className="absolute inset-y-0 left-0 bg-accent rounded-full transition-all duration-100" style={{ width: `${progress}%` }} />
|
||||
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-accent rounded-full opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="max-w-app mx-auto flex items-center gap-3">
|
||||
{/* Cover */}
|
||||
<div className="relative shrink-0 w-11 h-11 rounded-[8px] overflow-hidden bg-surface2">
|
||||
{coverSrc ? (
|
||||
<img src={coverSrc} alt="Обложка" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-surface2" />
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className={`absolute inset-0 w-full h-full pointer-events-none transition-opacity duration-300 ${isPlaying ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Track info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{searchStatus === 'searching' ? (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-muted">
|
||||
<div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" aria-hidden="true" />
|
||||
<span>Ищем...</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted mt-1.5 leading-relaxed">Откройте ссылку на другом устройстве для управления плеером</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[13px] font-display font-bold whitespace-nowrap overflow-hidden text-ellipsis leading-tight">
|
||||
{track.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{audioMeta.artist && <span className="text-[11px] text-muted truncate">{audioMeta.artist}</span>}
|
||||
<span className="text-[10px] px-1.5 py-px rounded-[5px] font-medium shrink-0" style={{ background: track.color.bg, color: track.color.text }}>
|
||||
{track.owner}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div className="text-[10px] text-muted font-display shrink-0 hidden sm:block tabular-nums" aria-label={`${formatTime(currentTime)} из ${formatTime(duration)}`}>
|
||||
{formatTime(currentTime)}<span className="opacity-40 mx-0.5">/</span>{formatTime(duration)}
|
||||
</div>
|
||||
|
||||
{/* Playback controls */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={prevTrack}
|
||||
aria-label="Предыдущий трек"
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
aria-label={isPlaying ? 'Пауза' : 'Воспроизвести'}
|
||||
className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
||||
) : (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={nextTrack}
|
||||
aria-label="Следующий трек"
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* Reload */}
|
||||
<button
|
||||
onClick={reloadTrack}
|
||||
aria-label="Перезагрузить трек"
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer hidden sm:flex"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
|
||||
</button>
|
||||
|
||||
{/* Repeat */}
|
||||
<button
|
||||
onClick={() => setRepeatMode(repeatMode === 'none' ? 'all' : repeatMode === 'all' ? 'one' : 'none')}
|
||||
aria-label={repeatLabel}
|
||||
title={repeatLabel}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hidden sm:flex relative"
|
||||
style={{
|
||||
color: repeatMode !== 'none' ? 'var(--accent)' : undefined,
|
||||
background: repeatMode !== 'none' ? 'rgba(var(--accent-rgb),0.08)' : undefined,
|
||||
}}
|
||||
>
|
||||
{repeatMode === 'one' ? (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
|
||||
<text x="10" y="14" fontSize="6" fill="currentColor" stroke="none" fontWeight="bold">1</text>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
||||
style={{ opacity: repeatMode === 'none' ? 0.4 : 1 }}>
|
||||
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Versions */}
|
||||
{results.length > 1 && (
|
||||
<button
|
||||
onClick={() => setPanel(p => p === 'versions' ? null : 'versions')}
|
||||
aria-label="Версии трека"
|
||||
aria-expanded={panel === 'versions'}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||
style={{ color: panel === 'versions' ? 'var(--accent)' : undefined, background: panel === 'versions' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add to playlist */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={playlistBtnRef}
|
||||
onClick={() => setPlaylistOpen(v => !v)}
|
||||
aria-label="Добавить в плейлист"
|
||||
aria-expanded={playlistOpen}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||
style={{ color: playlistOpen ? 'var(--accent)' : undefined, background: playlistOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
{playlistOpen && (
|
||||
<AddToPlaylist
|
||||
trackTitle={trackTitle}
|
||||
onClose={() => setPlaylistOpen(false)}
|
||||
anchorRef={playlistBtnRef as React.RefObject<HTMLElement | null>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorite */}
|
||||
<button
|
||||
onClick={() => toggleFavorite(trackTitle)}
|
||||
aria-label={favorited ? 'Убрать из избранного' : 'В избранное'}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2"
|
||||
style={{ color: favorited ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? 'var(--accent)' : 'none'} stroke={favorited ? 'var(--accent)' : 'currentColor'} strokeWidth="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Queue */}
|
||||
<button
|
||||
onClick={() => setPanel(p => p === 'queue' ? null : 'queue')}
|
||||
aria-label="Очередь"
|
||||
aria-expanded={panel === 'queue'}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||
style={{ color: panel === 'queue' ? 'var(--accent)' : undefined, background: panel === 'queue' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||||
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Remote / Share */}
|
||||
<div className="relative share-popover-root">
|
||||
<button
|
||||
ref={shareBtnRef}
|
||||
onClick={async () => {
|
||||
let id = roomId
|
||||
if (!id) {
|
||||
const res = await fetch(`${API_URL}/api/remote`, { method: 'POST' }).catch(() => null)
|
||||
if (res?.ok) {
|
||||
const data = await res.json()
|
||||
id = data.id as string
|
||||
setRoomId(id)
|
||||
}
|
||||
}
|
||||
if (id) setShareOpen(v => !v)
|
||||
}}
|
||||
aria-label="Пульт управления"
|
||||
aria-expanded={shareOpen}
|
||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||
style={{ color: shareOpen || roomId ? 'var(--accent)' : undefined, background: shareOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
||||
<circle cx="12" cy="20" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
{shareOpen && roomId && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-64 bg-surface border border-white/[0.07] rounded-[12px] p-3 shadow-2xl z-50">
|
||||
<p className="text-[11px] text-muted font-display font-bold tracking-[1px] uppercase mb-2">Пульт управления</p>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
readOnly
|
||||
aria-label="Ссылка на пульт"
|
||||
value={typeof window !== 'undefined' ? `${window.location.origin}/remote/${roomId}` : `/remote/${roomId}`}
|
||||
className="flex-1 min-w-0 text-[11px] bg-surface2 border border-white/[0.07] rounded-[7px] px-2 py-1.5 text-muted outline-none font-mono truncate"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/remote/${roomId}`)
|
||||
setShareCopied(true)
|
||||
setTimeout(() => setShareCopied(false), 2000)
|
||||
}
|
||||
}}
|
||||
aria-label="Копировать ссылку на пульт"
|
||||
className="shrink-0 px-2 py-1.5 text-[11px] rounded-[7px] border border-white/[0.07] hover:bg-surface2 transition-all cursor-pointer font-display"
|
||||
style={{ color: shareCopied ? 'var(--accent)' : undefined }}
|
||||
>
|
||||
{shareCopied ? '✓' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted mt-1.5 leading-relaxed">Откройте ссылку на другом устройстве для управления плеером</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>}
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import { useFavoritesStore } from '@/store/favoritesStore'
|
||||
import { useVersionStore } from '@/store/versionStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useOverlayStore } from '@/store/overlayStore'
|
||||
import { audioState } from '@/lib/audioState'
|
||||
import BottomPlayer from '@/components/BottomPlayer'
|
||||
import type { SearchResult } from '@/types'
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
export default function GlobalPlayer() {
|
||||
const { hydrate: hydrateFavorites } = useFavoritesStore()
|
||||
const { hydrate: hydrateVersions } = useVersionStore()
|
||||
const { token } = useAuthStore()
|
||||
const { hydrate: hydrateVersions } = useVersionStore()
|
||||
const { user } = useAuthStore()
|
||||
const lastPushedRef = useRef('')
|
||||
|
||||
useEffect(() => { hydrateFavorites() }, [hydrateFavorites])
|
||||
useEffect(() => { hydrateVersions() }, [user, hydrateVersions])
|
||||
|
||||
const pushOverlay = useCallback(async (result: SearchResult | null) => {
|
||||
if (!user) return
|
||||
const { enabled, design, style, accentColor, position, font, textColor, showCover, showEq, palette, customPalettes, margin, scale, opacity } = useOverlayStore.getState()
|
||||
const cp = customPalettes[style] ?? {}
|
||||
const isPlaying = audioState.isPlaying
|
||||
const key = `${result?.title}|${isPlaying}|${enabled}|${design}|${style}|${accentColor}|${position}|${font}|${textColor}|${showCover}|${showEq}|${palette}|${JSON.stringify(cp)}|${margin}|${scale}|${opacity}`
|
||||
if (key === lastPushedRef.current) return
|
||||
lastPushedRef.current = key
|
||||
try {
|
||||
await fetch(`${API_URL}/api/overlay/state`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
title: result?.title ?? '',
|
||||
artist: result?.artist ?? '',
|
||||
cover: result?.img ?? '',
|
||||
is_playing: isPlaying,
|
||||
progress: audioState.currentTime,
|
||||
duration: audioState.duration,
|
||||
enabled,
|
||||
design,
|
||||
style,
|
||||
accent_color: accentColor,
|
||||
position,
|
||||
font,
|
||||
text_color: textColor,
|
||||
show_cover: showCover,
|
||||
show_eq: showEq,
|
||||
palette,
|
||||
custom_bg: cp.bg ?? '',
|
||||
custom_text: cp.text ?? '',
|
||||
custom_text2: cp.text2 ?? '',
|
||||
custom_chroma: cp.chroma ?? '',
|
||||
custom_title_bg: cp.titleBg ?? '',
|
||||
custom_body_bg: cp.bodyBg ?? '',
|
||||
margin,
|
||||
scale,
|
||||
opacity,
|
||||
}),
|
||||
})
|
||||
} catch {}
|
||||
}, [user])
|
||||
|
||||
// Initial push on mount (and whenever user changes)
|
||||
useEffect(() => {
|
||||
pushOverlay(usePartyStore.getState().currentResult ?? null)
|
||||
}, [pushOverlay])
|
||||
|
||||
useEffect(() => {
|
||||
hydrateFavorites()
|
||||
}, [hydrateFavorites])
|
||||
return usePartyStore.subscribe((state, prev) => {
|
||||
const result = state.currentResult ?? null
|
||||
const prevResult = prev.currentResult ?? null
|
||||
if (result?.title !== prevResult?.title || state.curIdx !== prev.curIdx) {
|
||||
pushOverlay(result)
|
||||
}
|
||||
})
|
||||
}, [pushOverlay])
|
||||
|
||||
// Re-hydrate versions whenever auth changes (null → token = fetch from API)
|
||||
useEffect(() => {
|
||||
hydrateVersions()
|
||||
}, [token, hydrateVersions])
|
||||
return useOverlayStore.subscribe(() => {
|
||||
pushOverlay(usePartyStore.getState().currentResult ?? null)
|
||||
})
|
||||
}, [pushOverlay])
|
||||
|
||||
const handleTrackEnd = useCallback((result: SearchResult) => {
|
||||
const { queue, curIdx, addToHistory } = usePartyStore.getState()
|
||||
const track = queue[curIdx]
|
||||
if (!track) return
|
||||
addToHistory({
|
||||
title: result.title,
|
||||
artist: result.artist,
|
||||
img: result.img,
|
||||
owner: track.owner,
|
||||
color: track.color,
|
||||
title: result.title,
|
||||
artist: result.artist,
|
||||
img: result.img,
|
||||
owner: track.owner,
|
||||
color: track.color,
|
||||
playedAt: new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }),
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -10,8 +10,10 @@ export default function Header() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
@@ -22,8 +24,12 @@ export default function Header() {
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
// Close mobile menu on navigation
|
||||
useEffect(() => { setMenuOpen(false) }, [pathname])
|
||||
|
||||
const handleLogout = () => {
|
||||
setOpen(false)
|
||||
setMenuOpen(false)
|
||||
clearAuth()
|
||||
router.push('/')
|
||||
}
|
||||
@@ -33,6 +39,8 @@ export default function Header() {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label={label}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className="flex items-center gap-1.5 text-[12px] font-display font-semibold px-3 py-1.5 rounded-xl border transition-all duration-150 hidden sm:flex"
|
||||
style={active
|
||||
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.3)', color: 'var(--accent)' }
|
||||
@@ -45,118 +53,213 @@ export default function Header() {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 mb-5 pb-5 border-b border-white/[0.07]">
|
||||
<Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline">
|
||||
<div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
|
||||
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-extrabold tracking-tight text-app-text">Party Mix</h1>
|
||||
const mobileNavLink = (href: string, label: string, icon: React.ReactNode) => {
|
||||
const active = pathname === href
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150"
|
||||
style={active
|
||||
? { color: 'var(--accent)', background: 'rgba(var(--accent-rgb),0.06)' }
|
||||
: { color: 'var(--color-muted)' }
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{navLink('/search', 'Поиск',
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
const searchIcon = (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
const communityIcon = (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
const playlistsIcon = (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
const settingsIcon = (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
{navLink('/community', 'Сообщество',
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
return (
|
||||
<div className="relative mb-5">
|
||||
<div className="flex items-center gap-2.5 pb-5 border-b border-white/[0.07]">
|
||||
<Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline" aria-label="Party Mix — главная">
|
||||
<div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0" aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
|
||||
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-extrabold tracking-tight text-app-text">Party Mix</h1>
|
||||
</Link>
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
{navLink('/playlists', 'Плейлисты',
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{navLink('/search', 'Поиск', searchIcon)}
|
||||
{navLink('/community', 'Сообщество', communityIcon)}
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
{navLink('/playlists', 'Плейлисты', playlistsIcon)}
|
||||
|
||||
{/* User badge with dropdown */}
|
||||
<div className="relative hidden sm:block" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-label={`Меню пользователя ${user.username}`}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5 cursor-pointer transition-colors duration-150 hover:border-white/[0.14]"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0" aria-hidden="true">
|
||||
<span className="text-[10px] font-display font-extrabold text-accent">
|
||||
{user.username[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
aria-hidden="true"
|
||||
className="text-muted transition-transform duration-200 ml-0.5"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1.5 w-52 bg-surface border border-white/[0.09] rounded-xl shadow-xl z-50 py-1 overflow-hidden"
|
||||
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-app-text hover:bg-white/[0.04] transition-all duration-150"
|
||||
>
|
||||
{settingsIcon}
|
||||
Настройки
|
||||
</Link>
|
||||
<div className="border-t border-white/[0.07] mx-1 my-0.5" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
aria-label="Выйти из аккаунта"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted hover:text-[#ff6b6b] hover:bg-white/[0.04] transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5 hidden sm:block"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150 hidden sm:block"
|
||||
>
|
||||
Регистрация
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
onClick={() => setMenuOpen(v => !v)}
|
||||
aria-label={menuOpen ? 'Закрыть меню' : 'Открыть меню'}
|
||||
aria-expanded={menuOpen}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-[8px] sm:hidden text-muted hover:text-app-text hover:bg-surface2 transition-all cursor-pointer"
|
||||
>
|
||||
{menuOpen ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* User badge with dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5 cursor-pointer transition-colors duration-150 hover:border-white/[0.14]"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-[10px] font-display font-extrabold text-accent">
|
||||
{user.username[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
className="text-muted transition-transform duration-200 ml-0.5"
|
||||
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1.5 w-52 bg-surface border border-white/[0.09] rounded-xl shadow-xl z-50 py-1 overflow-hidden"
|
||||
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
{/* Settings link */}
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-app-text hover:bg-white/[0.04] transition-all duration-150"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
Настройки
|
||||
</Link>
|
||||
|
||||
<div className="border-t border-white/[0.07] mx-1 my-0.5" />
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted hover:text-[#ff6b6b] hover:bg-white/[0.04] transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
|
||||
>
|
||||
Регистрация
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu drawer */}
|
||||
{menuOpen && (
|
||||
<div
|
||||
className="absolute left-0 right-0 top-full bg-surface border border-white/[0.09] rounded-[12px] z-40 overflow-hidden shadow-xl flex flex-col sm:hidden"
|
||||
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
|
||||
>
|
||||
{mobileNavLink('/search', 'Поиск', searchIcon)}
|
||||
{mobileNavLink('/community', 'Сообщество', communityIcon)}
|
||||
{user && mobileNavLink('/playlists', 'Плейлисты', playlistsIcon)}
|
||||
|
||||
<div className="border-t border-white/[0.07] mx-2 my-1" />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
{mobileNavLink('/settings', 'Настройки', settingsIcon)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150 cursor-pointer w-full text-left"
|
||||
style={{ color: '#ff6b6b' }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
Выйти
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="mx-3 mb-2 flex items-center justify-center py-2.5 text-[14px] font-display font-semibold bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
|
||||
>
|
||||
Регистрация
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useToastStore } from '@/store/toastStore'
|
||||
import { createPlaylist } from '@/lib/authApi'
|
||||
import { proxyImgUrl } from '@/lib/api'
|
||||
|
||||
export default function HistoryTab() {
|
||||
const { history, clearHistory } = usePartyStore()
|
||||
const { user } = useAuthStore()
|
||||
const showToast = useToastStore((s) => s.show)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSavePlaylist = async () => {
|
||||
if (!history.length) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const tracks = [...history].reverse().map((h) => h.title)
|
||||
const name = `История ${new Date().toLocaleDateString('ru', { day: 'numeric', month: 'short' })}`
|
||||
await createPlaylist(name, tracks)
|
||||
showToast('Плейлист сохранён', 'success')
|
||||
} catch {
|
||||
showToast('Не удалось сохранить плейлист', 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
|
||||
@@ -13,12 +35,23 @@ export default function HistoryTab() {
|
||||
Уже сыграло
|
||||
</span>
|
||||
{history.length > 0 && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="text-[11px] px-2.5 py-0.5 border border-[rgba(255,100,100,0.2)] rounded-[7px] bg-transparent text-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.08)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.35)] transition-all duration-150 cursor-pointer font-sans"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{user && (
|
||||
<button
|
||||
onClick={handleSavePlaylist}
|
||||
disabled={saving}
|
||||
className="text-[11px] px-2.5 py-0.5 border border-accent/20 rounded-[7px] text-accent/60 hover:bg-accent/08 hover:text-accent hover:border-accent/35 transition-all duration-150 cursor-pointer font-sans disabled:opacity-40"
|
||||
>
|
||||
{saving ? 'Сохраняем...' : '↓ В плейлист'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="text-[11px] px-2.5 py-0.5 border border-[rgba(255,100,100,0.2)] rounded-[7px] bg-transparent text-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.08)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.35)] transition-all duration-150 cursor-pointer font-sans"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
489
apps/web/src/components/OverlayWidget.tsx
Normal file
489
apps/web/src/components/OverlayWidget.tsx
Normal file
@@ -0,0 +1,489 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getPalette, buildCustomPalette, type OverlayPalette } from '@/lib/overlayPalettes'
|
||||
import { useOverlayStore, type OverlayStyle } from '@/store/overlayStore'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
|
||||
// Shared state type used by both the overlay page and the settings preview
|
||||
export interface OverlayWidgetState {
|
||||
title: string
|
||||
artist: string
|
||||
cover: string
|
||||
is_playing: boolean
|
||||
progress: number
|
||||
duration: number
|
||||
enabled: boolean
|
||||
design: string
|
||||
style: string
|
||||
accent_color: string
|
||||
position: string
|
||||
font: string
|
||||
text_color: string
|
||||
show_cover: boolean
|
||||
show_eq: boolean
|
||||
palette: string
|
||||
custom_bg: string
|
||||
custom_text: string
|
||||
custom_text2: string
|
||||
custom_chroma: string
|
||||
custom_title_bg: string
|
||||
custom_body_bg: string
|
||||
margin: number
|
||||
scale: number
|
||||
opacity: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCover(src: string) {
|
||||
const [err, setErr] = useState(false)
|
||||
useEffect(() => setErr(false), [src])
|
||||
return { src: err ? '' : src, onError: () => setErr(true) }
|
||||
}
|
||||
|
||||
export function EqBars({ color, size = 14, playing = true }: { color: string; size?: number; playing?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2.5, height: size, flexShrink: 0 }}>
|
||||
{[65, 100, 50, 85, 40].map((h, i) => (
|
||||
<div key={i} className="eq-bar" style={{ height: `${h}%`, width: 2.5, background: color, borderRadius: 2, animationDelay: `${i * 0.13}s`, animationPlayState: playing ? 'running' : 'paused' }} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MusicIcon() {
|
||||
return (
|
||||
<svg width="55%" height="55%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressBar({ progress, duration, color, style: barStyle }: { progress: number; duration: number; color: string; style?: React.CSSProperties }) {
|
||||
const pct = duration > 0 ? Math.min(100, (progress / duration) * 100) : 0
|
||||
if (!duration) return null
|
||||
return (
|
||||
<div style={{ height: 2, background: 'rgba(255,255,255,0.1)', borderRadius: 1, overflow: 'hidden', ...barStyle }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 1, transition: 'width 1s linear' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// position: fixed → in the overlay page, fixed to viewport.
|
||||
// In the preview box (which has transform:translateZ(0)), fixed children are contained inside.
|
||||
// --ov-scale is set by the overlay page per the user's scale setting.
|
||||
// transform-origin follows --ov-origin (set based on position).
|
||||
export const OVERLAY_POS: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
bottom: 'var(--ov-b)' as string,
|
||||
top: 'var(--ov-t)' as string,
|
||||
left: 'var(--ov-l)' as string,
|
||||
right: 'var(--ov-r)' as string,
|
||||
transform: 'scale(var(--ov-scale,1))',
|
||||
transformOrigin: 'var(--ov-origin, bottom left)',
|
||||
}
|
||||
|
||||
// ── 1. Классика ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ClassicStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const cover = useCover(s.cover)
|
||||
const bg = pal.bg ?? 'rgba(10,10,16,0.82)'
|
||||
const border = pal.border ?? '1px solid rgba(255,255,255,0.09)'
|
||||
const shadow = pal.shadow ?? '0 8px 32px rgba(0,0,0,0.55)'
|
||||
const text = s.text_color || pal.text || '#fff'
|
||||
const text2 = pal.text2 ?? 'rgba(255,255,255,0.45)'
|
||||
const coverBg = pal.chroma ?? 'rgba(255,255,255,0.06)'
|
||||
return (
|
||||
<div style={{ ...OVERLAY_POS, display: 'flex', flexDirection: 'column', gap: 0, padding: '10px 16px 10px 10px', borderRadius: 16, background: bg, backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', border, boxShadow: shadow, maxWidth: 340 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{s.show_cover !== false && (
|
||||
<div style={{ width: 44, height: 44, borderRadius: 10, overflow: 'hidden', background: coverBg, flexShrink: 0 }}>
|
||||
{cover.src
|
||||
? <img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
|
||||
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}><MusicIcon /></div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
|
||||
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||
</div>
|
||||
{s.show_eq !== false && <EqBars color="var(--accent)" playing={s.is_playing} />}
|
||||
</div>
|
||||
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ marginTop: 8 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 2. Фрутигер Аеро ──────────────────────────────────────────────────────────
|
||||
|
||||
export function AeroStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const cover = useCover(s.cover)
|
||||
const bg = pal.bg ?? 'linear-gradient(160deg,rgba(190,230,255,0.55) 0%,rgba(120,185,255,0.42) 100%)'
|
||||
const border = pal.border ?? '1.5px solid rgba(255,255,255,0.72)'
|
||||
const shadow = pal.shadow ?? '0 4px 20px rgba(80,160,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)'
|
||||
const text = s.text_color || pal.text || '#003a6e'
|
||||
const text2 = pal.text2 ?? 'rgba(0,60,130,0.60)'
|
||||
return (
|
||||
<div style={{ ...OVERLAY_POS, display: 'flex', flexDirection: 'column', borderRadius: 50, background: bg, backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)', border, boxShadow: shadow, maxWidth: 340, overflow: 'hidden', position: 'fixed' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '55%', background: 'linear-gradient(180deg,rgba(255,255,255,0.38) 0%,rgba(255,255,255,0) 100%)', borderRadius: '50px 50px 0 0', pointerEvents: 'none' }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 20px 8px 8px' }}>
|
||||
{s.show_cover !== false && (
|
||||
<div style={{ width: 42, height: 42, borderRadius: '50%', overflow: 'hidden', background: 'rgba(255,255,255,0.25)', flexShrink: 0, border: '2px solid rgba(255,255,255,0.6)', boxShadow: '0 2px 8px rgba(0,80,180,0.2)' }}>
|
||||
{cover.src
|
||||
? <img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
|
||||
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(0,80,160,0.5)' }}><MusicIcon /></div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0, position: 'relative' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '-0.02em' }}>{s.title || '—'}</div>
|
||||
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||
</div>
|
||||
{s.show_eq !== false && <EqBars color="var(--accent)" size={12} playing={s.is_playing} />}
|
||||
</div>
|
||||
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ borderRadius: 0, margin: '0 0 0 0' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 3. Ретро ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RetroStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const [blink, setBlink] = useState(true)
|
||||
useEffect(() => { const iv = setInterval(() => setBlink(b => !b), 800); return () => clearInterval(iv) }, [])
|
||||
const now = new Date()
|
||||
const ts = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`
|
||||
const bg = pal.bg ?? 'rgba(12,5,0,0.94)'
|
||||
const border = pal.border ?? '2px solid #c07030'
|
||||
const shadow = pal.shadow ?? '0 0 28px rgba(180,90,20,0.45),4px 4px 0 rgba(0,0,0,0.6)'
|
||||
const chromaA = pal.chroma ?? 'rgba(180,100,20,0.30)'
|
||||
const text = s.text_color || pal.text || '#f8d090'
|
||||
const text2 = pal.text2 ?? '#9a6020'
|
||||
const blinkColor = pal.chroma?.startsWith('#') ? pal.chroma : 'var(--accent)'
|
||||
const pct = s.duration > 0 ? Math.min(100, (s.progress / s.duration) * 100) : 0
|
||||
return (
|
||||
<div style={{ ...OVERLAY_POS, width: 310, fontFamily: s.font || 'monospace', background: bg, border, boxShadow: shadow, overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px)', pointerEvents: 'none', zIndex: 2 }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: chromaA, borderBottom: `1px solid ${blinkColor}44` }}>
|
||||
<span style={{ fontSize: 10, color: blinkColor, letterSpacing: '0.1em' }}>▶ NOW PLAYING</span>
|
||||
<span style={{ fontSize: 10, color: blink ? blinkColor : 'transparent', letterSpacing: '0.05em' }}>● REC</span>
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px 8px', position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: text, letterSpacing: '0.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
|
||||
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 3, letterSpacing: '0.05em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||
{s.duration > 0 && (
|
||||
<div style={{ marginTop: 8, height: 3, background: 'rgba(255,255,255,0.1)', borderRadius: 0 }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: blinkColor, transition: 'width 1s linear' }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
<span style={{ fontSize: 9, color: text2, fontFamily: 'monospace' }}>{ts}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 4. Неон ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function NeonStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const bg = pal.bg ?? 'rgba(0,0,10,0.9)'
|
||||
const vars = pal.chroma && pal.chroma2
|
||||
? { '--accent': pal.chroma, '--accent-rgb': pal.chroma2 } as React.CSSProperties
|
||||
: {}
|
||||
const text = s.text_color || 'var(--accent)'
|
||||
return (
|
||||
<div style={{ ...vars, ...OVERLAY_POS, padding: '12px 18px', background: bg, backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', border: '1px solid rgba(var(--accent-rgb),0.6)', boxShadow: '0 0 28px rgba(var(--accent-rgb),0.3),inset 0 0 20px rgba(var(--accent-rgb),0.04)', borderRadius: 8, maxWidth: 340 }}>
|
||||
{[['0','0'],['0','auto'],['auto','0'],['auto','auto']].map(([t,b], i) => (
|
||||
<div key={i} style={{ position: 'absolute', top: t === '0' ? -1 : 'auto', bottom: b === 'auto' ? 'auto' : -1, left: i < 2 ? -1 : 'auto', right: i < 2 ? 'auto' : -1, width: 8, height: 8, border: '2px solid var(--accent)', borderRadius: 1 }} />
|
||||
))}
|
||||
<div style={{ fontSize: 10, color: 'rgba(var(--accent-rgb),0.5)', letterSpacing: '0.2em', marginBottom: 6, fontFamily: 'monospace' }}>◈ STREAM</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: text, letterSpacing: '0.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', textShadow: '0 0 12px rgba(var(--accent-rgb),0.8)' }}>{s.title || '—'}</div>
|
||||
{s.artist && <div style={{ fontSize: 11, color: 'rgba(var(--accent-rgb),0.5)', marginTop: 4, letterSpacing: '0.06em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
|
||||
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ flex: 1, background: 'rgba(var(--accent-rgb),0.15)' }} />
|
||||
{(!s.duration) && <div style={{ flex: 1, height: 1, background: 'linear-gradient(90deg,rgba(var(--accent-rgb),0.6),transparent)' }} />}
|
||||
{s.show_eq !== false && <EqBars color="var(--accent)" size={10} playing={s.is_playing} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 5. Минимализм ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function CleanStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const text = s.text_color || pal.text || '#ffffff'
|
||||
const text2 = pal.text2 ?? 'rgba(255,255,255,0.55)'
|
||||
return (
|
||||
<div style={{ ...OVERLAY_POS }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 4, height: 32, background: 'var(--accent)', borderRadius: 2, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: text, lineHeight: 1.1, letterSpacing: '-0.03em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 400, textShadow: '0 2px 16px rgba(0,0,0,0.8)' }}>{s.title || '—'}</div>
|
||||
{s.artist && <div style={{ fontSize: 13, color: text2, marginTop: 3, letterSpacing: '-0.01em', textShadow: '0 2px 8px rgba(0,0,0,0.8)' }}>{s.artist}</div>}
|
||||
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ marginTop: 6, width: 200 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 6. Y2K ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Y2kStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const titleBg = pal.titleBg ?? 'linear-gradient(90deg,#1a4090 0%,#4a80d0 40%,#1a4090 100%)'
|
||||
const bodyBg = pal.bodyBg ?? 'linear-gradient(160deg,#dce4f0,#c0c8e0)'
|
||||
const text = s.text_color || pal.text || '#000060'
|
||||
const text2 = pal.text2 ?? '#404880'
|
||||
const pct = s.duration > 0 ? Math.min(100, (s.progress / s.duration) * 100) : 42
|
||||
return (
|
||||
<div style={{ ...OVERLAY_POS, width: 300, fontFamily: s.font || 'Tahoma, Arial, sans-serif', boxShadow: '3px 3px 0 rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.9)', border: '2px solid #7080b0', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '3px 4px 3px 6px', background: titleBg, userSelect: 'none' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 9 }}>🎵</span>
|
||||
<span style={{ fontSize: 11, color: '#fff', fontWeight: 700, letterSpacing: '0.01em' }}>Party Mix Player</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
{['_','□','×'].map(c => (
|
||||
<div key={c} style={{ width: 16, height: 14, background: 'linear-gradient(180deg,#d0d8e8,#9aa8c0)', border: '1px solid #607090', borderRadius: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, color: '#000', fontWeight: 700, cursor: 'default' }}>{c}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: bodyBg, padding: '10px 12px 12px' }}>
|
||||
<div style={{ background: 'rgba(255,255,255,0.5)', border: '1px solid #8090b0', borderRadius: 2, padding: '6px 10px', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
|
||||
{s.artist && <div style={{ fontSize: 10, color: text2, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||
</div>
|
||||
<div style={{ height: 14, background: 'rgba(255,255,255,0.4)', border: '1px solid #8090b0', borderRadius: 2, overflow: 'hidden', boxShadow: 'inset 1px 1px 2px rgba(0,0,0,0.15)' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: 'linear-gradient(180deg,var(--accent),rgba(var(--accent-rgb),0.65))', borderRadius: 1, transition: 'width 1s linear' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 3 }}>
|
||||
<span style={{ fontSize: 9, color: text2 }}>♪ {s.is_playing ? 'Playing' : 'Paused'}</span>
|
||||
<span style={{ fontSize: 9, color: text2 }}>WMP 9.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 7. Ло-фай ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function LofiStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const cover = useCover(s.cover)
|
||||
const bg = pal.bg ?? 'rgba(42,26,14,0.92)'
|
||||
const text = s.text_color || pal.text || '#fde8c0'
|
||||
const text2 = pal.text2 ?? 'rgba(var(--accent-rgb),0.60)'
|
||||
return (
|
||||
<div style={{ ...OVERLAY_POS, display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px', borderRadius: 18, background: bg, backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', border: '1px solid rgba(var(--accent-rgb),0.2)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', transform: `rotate(-0.8deg) scale(var(--ov-scale,1))`, maxWidth: 320 }}>
|
||||
{s.show_cover !== false && (
|
||||
<div style={{ width: 52, height: 52, borderRadius: '50%', flexShrink: 0, background: 'radial-gradient(circle at center,#3a2010 0%,#1a0e06 40%,#2a1808 60%,#1a0e06 100%)', border: '2px solid rgba(var(--accent-rgb),0.3)', boxShadow: '0 2px 12px rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-radial-gradient(circle at center,transparent 0%,transparent 14%,rgba(255,255,255,0.03) 14.5%,transparent 15%)', borderRadius: '50%' }} />
|
||||
{cover.src
|
||||
? <div style={{ width: 22, height: 22, borderRadius: '50%', overflow: 'hidden', zIndex: 1 }}><img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" /></div>
|
||||
: <div style={{ width: 10, height: 10, borderRadius: '50%', background: 'rgba(var(--accent-rgb),0.5)', zIndex: 1 }} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.55)', letterSpacing: '0.15em', marginBottom: 4, fontStyle: 'italic' }}>now playing</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontStyle: 'italic', letterSpacing: '0.01em' }}>{s.title || '—'}</div>
|
||||
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 3, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontStyle: 'italic' }}>{s.artist}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 }}>
|
||||
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ flex: 1, background: 'rgba(var(--accent-rgb),0.2)' }} />
|
||||
{!s.duration && <div style={{ flex: 1, height: 1, background: 'rgba(var(--accent-rgb),0.25)' }} />}
|
||||
{s.show_eq !== false && <EqBars color="var(--accent)" size={10} playing={s.is_playing} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 8. Гламур ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function GlamStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const bg = pal.bg ?? 'linear-gradient(145deg,rgba(28,18,4,0.95),rgba(16,10,2,0.95))'
|
||||
const chroma = pal.chroma ?? '212,175,55'
|
||||
const text = s.text_color || pal.text || `rgb(${chroma})`
|
||||
const text2 = pal.text2 ?? `rgba(${chroma},0.65)`
|
||||
const metalG = `linear-gradient(90deg,transparent,rgba(${chroma},0.8) 30%,rgb(${chroma}) 50%,rgba(${chroma},0.8) 70%,transparent)`
|
||||
return (
|
||||
<div style={{ ...OVERLAY_POS, padding: '16px 20px', background: bg, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', border: `1px solid rgba(${chroma},0.45)`, boxShadow: `0 8px 40px rgba(0,0,0,0.7),0 0 0 1px rgba(${chroma},0.2),inset 0 1px 0 rgba(${chroma},0.15)`, borderRadius: 14, maxWidth: 340, overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: metalG }} />
|
||||
<div style={{ position: 'absolute', top: 6, left: 6, fontSize: 10, color: `rgba(${chroma},0.55)` }}>✦</div>
|
||||
<div style={{ position: 'absolute', top: 6, right: 6, fontSize: 10, color: `rgba(${chroma},0.55)` }}>✦</div>
|
||||
<div style={{ textAlign: 'center', fontSize: 8, color: `rgba(${chroma},0.5)`, letterSpacing: '0.3em', marginBottom: 8, textTransform: 'uppercase' }}>Now Playing</div>
|
||||
<div style={{ height: 1, background: `linear-gradient(90deg,transparent,rgba(${chroma},0.4),transparent)`, marginBottom: 10 }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: text, textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '0.02em', textShadow: `0 0 20px rgba(${chroma},0.4)` }}>{s.title || '—'}</div>
|
||||
{s.artist && <div style={{ fontSize: 11, color: text2, textAlign: 'center', marginTop: 5, letterSpacing: '0.08em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||
<div style={{ height: 1, background: `linear-gradient(90deg,transparent,rgba(${chroma},0.4),transparent)`, marginTop: 10 }} />
|
||||
{s.show_eq !== false && <div style={{ textAlign: 'center', marginTop: 6 }}><EqBars color={`rgb(${chroma})`} size={10} playing={s.is_playing} /></div>}
|
||||
<ProgressBar progress={s.progress} duration={s.duration} color={`rgb(${chroma})`} style={{ marginTop: 8, background: `rgba(${chroma},0.15)` }} />
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: metalG }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 9. Матрица ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function MatrixStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||
const [cursor, setCursor] = useState(true)
|
||||
const [dots, setDots] = useState(0)
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => { setCursor(c => !c); setDots(d => (d + 1) % 4) }, 530)
|
||||
return () => clearInterval(iv)
|
||||
}, [])
|
||||
const bg = pal.bg ?? 'rgba(0,8,0,0.93)'
|
||||
const border = pal.border ?? '1px solid rgba(0,255,50,0.35)'
|
||||
const vars = pal.chroma && pal.chroma2
|
||||
? { '--accent': pal.chroma, '--accent-rgb': pal.chroma2 } as React.CSSProperties
|
||||
: {}
|
||||
const text = s.text_color || pal.chroma || '#00ff32'
|
||||
return (
|
||||
<div style={{ ...vars, ...OVERLAY_POS, padding: '12px 16px', background: bg, backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', border, boxShadow: '0 0 24px rgba(var(--accent-rgb),0.15),inset 0 0 12px rgba(var(--accent-rgb),0.03)', borderRadius: 4, maxWidth: 360, fontFamily: s.font || '"Courier New",Courier,monospace' }}>
|
||||
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.5)', letterSpacing: '0.08em', marginBottom: 6 }}>PARTY_MIX@STREAM:~$ play --now</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(var(--accent-rgb),0.6)', marginRight: 6 }}>></span>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 280, textShadow: '0 0 8px rgba(var(--accent-rgb),0.6)' }}>{s.title || 'NULL'}</span>
|
||||
<span style={{ color: text, opacity: cursor ? 1 : 0, marginLeft: 2, textShadow: '0 0 6px rgba(var(--accent-rgb),0.8)' }}>▌</span>
|
||||
</div>
|
||||
{s.artist && <div style={{ fontSize: 11, color: 'rgba(var(--accent-rgb),0.55)', marginTop: 3, letterSpacing: '0.05em' }}>{`:: artist="${s.artist}"`}</div>}
|
||||
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.4)', marginTop: 6, letterSpacing: '0.05em' }}>{`[LOADING${'.'.repeat(dots)}]`}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Style map ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const OVERLAY_STYLE_MAP: Record<string, (s: OverlayWidgetState, pal: OverlayPalette) => React.ReactNode> = {
|
||||
classic: (s, pal) => <ClassicStyle s={s} pal={pal} />,
|
||||
aero: (s, pal) => <AeroStyle s={s} pal={pal} />,
|
||||
retro: (s, pal) => <RetroStyle s={s} pal={pal} />,
|
||||
neon: (s, pal) => <NeonStyle s={s} pal={pal} />,
|
||||
clean: (s, pal) => <CleanStyle s={s} pal={pal} />,
|
||||
y2k: (s, pal) => <Y2kStyle s={s} pal={pal} />,
|
||||
lofi: (s, pal) => <LofiStyle s={s} pal={pal} />,
|
||||
glam: (s, pal) => <GlamStyle s={s} pal={pal} />,
|
||||
matrix: (s, pal) => <MatrixStyle s={s} pal={pal} />,
|
||||
}
|
||||
|
||||
// ── Settings preview ──────────────────────────────────────────────────────────
|
||||
|
||||
export function OverlayPreview() {
|
||||
const {
|
||||
style: overlayStyle,
|
||||
palette: overlayPalette,
|
||||
customPalettes,
|
||||
accentColor,
|
||||
textColor,
|
||||
showCover,
|
||||
showEq,
|
||||
font,
|
||||
} = useOverlayStore()
|
||||
|
||||
const currentResult = usePartyStore(s => s.currentResult)
|
||||
|
||||
const cp = customPalettes[overlayStyle] ?? {}
|
||||
const pal = overlayPalette === 'custom'
|
||||
? buildCustomPalette(overlayStyle as OverlayStyle, {
|
||||
bg: cp.bg ?? '',
|
||||
text: cp.text ?? '',
|
||||
text2: cp.text2 ?? '',
|
||||
chroma: cp.chroma ?? '',
|
||||
titleBg: cp.titleBg ?? '',
|
||||
bodyBg: cp.bodyBg ?? '',
|
||||
})
|
||||
: getPalette(overlayStyle as OverlayStyle, overlayPalette)
|
||||
|
||||
const hex = /^#[0-9a-fA-F]{6}$/.test(accentColor) ? accentColor : '#de9cfe'
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
|
||||
// Use real track if playing, otherwise mock
|
||||
const mockState: OverlayWidgetState = {
|
||||
title: currentResult?.title ?? 'Название трека',
|
||||
artist: currentResult?.artist ?? 'Артист',
|
||||
cover: currentResult?.img ?? '',
|
||||
is_playing: true,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
enabled: true,
|
||||
design: 'minimal',
|
||||
style: overlayStyle,
|
||||
accent_color: hex,
|
||||
position: 'bl',
|
||||
font,
|
||||
text_color: textColor,
|
||||
show_cover: showCover,
|
||||
show_eq: showEq,
|
||||
palette: overlayPalette,
|
||||
custom_bg: cp.bg ?? '',
|
||||
custom_text: cp.text ?? '',
|
||||
custom_text2: cp.text2 ?? '',
|
||||
custom_chroma: cp.chroma ?? '',
|
||||
custom_title_bg: cp.titleBg ?? '',
|
||||
custom_body_bg: cp.bodyBg ?? '',
|
||||
margin: 24,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
updated_at: 0,
|
||||
}
|
||||
|
||||
const render = OVERLAY_STYLE_MAP[overlayStyle] ?? OVERLAY_STYLE_MAP.classic
|
||||
|
||||
// Re-key on style change to replay enter animation
|
||||
const animKey = overlayStyle + overlayPalette
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: 160,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
// transform creates a new containing block for position:fixed children
|
||||
transform: 'translateZ(0)',
|
||||
'--accent': hex,
|
||||
'--accent-rgb': `${r},${g},${b}`,
|
||||
'--ov-b': '20px',
|
||||
'--ov-t': 'auto',
|
||||
'--ov-l': '20px',
|
||||
'--ov-r': 'auto',
|
||||
'--ov-scale': '1',
|
||||
'--ov-origin': 'bottom left',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{/* "stream" background */}
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'rgba(8,8,12,0.97)' }} />
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(255,255,255,0.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.022) 1px,transparent 1px)', backgroundSize: '22px 22px' }} />
|
||||
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse at 15% 80%,rgba(${r},${g},${b},0.10) 0%,transparent 55%)` }} />
|
||||
<div key={animKey}>{render(mockState, pal)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Local progress hook (for overlay page) ────────────────────────────────────
|
||||
|
||||
export function useLocalProgress(serverProgress: number, serverDuration: number, isPlaying: boolean, updatedAt: number) {
|
||||
const [progress, setProgress] = useState(serverProgress)
|
||||
const startRef = useRef({ t: Date.now(), p: serverProgress })
|
||||
|
||||
useEffect(() => {
|
||||
startRef.current = { t: Date.now(), p: serverProgress }
|
||||
setProgress(serverProgress)
|
||||
}, [serverProgress, updatedAt])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !serverDuration) return
|
||||
const iv = setInterval(() => {
|
||||
const elapsed = (Date.now() - startRef.current.t) / 1000
|
||||
setProgress(Math.min(serverDuration, startRef.current.p + elapsed))
|
||||
}, 500)
|
||||
return () => clearInterval(iv)
|
||||
}, [isPlaying, serverDuration])
|
||||
|
||||
return progress
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { Playlist } from '@/types'
|
||||
|
||||
export default function AddPersonForm() {
|
||||
const { addPerson } = usePartyStore()
|
||||
const { token } = useAuthStore()
|
||||
const { user } = useAuthStore()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [tracks, setTracks] = useState('')
|
||||
@@ -18,17 +18,17 @@ export default function AddPersonForm() {
|
||||
const [loadingPlaylist, setLoadingPlaylist] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
getPlaylists(token)
|
||||
if (!user) return
|
||||
getPlaylists()
|
||||
.then(setPlaylists)
|
||||
.catch(() => {})
|
||||
}, [token])
|
||||
}, [user])
|
||||
|
||||
const handleSelectPlaylist = async (id: string) => {
|
||||
if (!token || !id) return
|
||||
if (!user || !id) return
|
||||
setLoadingPlaylist(true)
|
||||
try {
|
||||
const pl = await getPlaylist(token, id)
|
||||
const pl = await getPlaylist(id)
|
||||
if (pl.tracks?.length) {
|
||||
setTracks(pl.tracks.map((t) => t.title).join('\n'))
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export default function AddPersonForm() {
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
|
||||
{token && playlists.length > 0 && (
|
||||
{user && playlists.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<select
|
||||
onChange={(e) => handleSelectPlaylist(e.target.value)}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import PersonCard from './PersonCard'
|
||||
import AddPersonForm from './AddPersonForm'
|
||||
|
||||
export default function PartyTab() {
|
||||
const { people, shuffleMode, setShuffleMode, generateMix } = usePartyStore()
|
||||
const [hint, setHint] = useState(false)
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (!people.length) {
|
||||
setHint(true)
|
||||
setTimeout(() => setHint(false), 2500)
|
||||
return
|
||||
}
|
||||
setHint(false)
|
||||
generateMix()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fadeUp">
|
||||
@@ -31,14 +43,8 @@ export default function PartyTab() {
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!people.length) {
|
||||
alert('Добавьте участников!')
|
||||
return
|
||||
}
|
||||
generateMix()
|
||||
}}
|
||||
className="w-full py-3.5 mb-4 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
|
||||
onClick={handleGenerate}
|
||||
className="w-full py-3.5 mb-1.5 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 3 21 3 21 8" />
|
||||
@@ -49,7 +55,17 @@ export default function PartyTab() {
|
||||
Перемешать и включить
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-2.5 mb-4">
|
||||
<div className={`overflow-hidden transition-all duration-300 ${hint ? 'max-h-10 mb-2.5' : 'max-h-0 mb-0'}`}>
|
||||
<div className="flex items-center gap-2 text-[12px] text-[#ffb86b] bg-[rgba(255,184,107,0.08)] border border-[rgba(255,184,107,0.18)] px-3 py-1.5 rounded-[9px]">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="shrink-0">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M12 8v4M12 16h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
Сначала добавьте участников ниже
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 mb-4 mt-2.5">
|
||||
{people.map((person, i) => (
|
||||
<PersonCard key={`${person.name}-${i}`} person={person} index={i} />
|
||||
))}
|
||||
@@ -59,3 +75,4 @@ export default function PartyTab() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
116
apps/web/src/components/Player/QueuePanel.tsx
Normal file
116
apps/web/src/components/Player/QueuePanel.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import type { QueueItem } from '@/types'
|
||||
|
||||
interface Props {
|
||||
queue: QueueItem[]
|
||||
curIdx: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function QueuePanel({ queue, curIdx, onClose }: Props) {
|
||||
const { setCurIdx, generateMix, reorderQueue } = usePartyStore()
|
||||
const dragSrc = useRef<number | null>(null)
|
||||
|
||||
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
|
||||
dragSrc.current = idx
|
||||
el.classList.add('dragging')
|
||||
}, [])
|
||||
|
||||
const onDragEnd = useCallback((el: HTMLElement) => {
|
||||
el.classList.remove('dragging')
|
||||
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
|
||||
}, [])
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
|
||||
e.preventDefault()
|
||||
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
|
||||
el.classList.add('drag-over')
|
||||
}, [])
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
|
||||
e.preventDefault()
|
||||
el.classList.remove('drag-over')
|
||||
if (dragSrc.current === null || dragSrc.current === tgtIdx) return
|
||||
reorderQueue(dragSrc.current, tgtIdx)
|
||||
dragSrc.current = null
|
||||
}, [reorderQueue])
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto flex flex-col">
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">
|
||||
Очередь · {queue.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => generateMix()}
|
||||
aria-label="Пересортировать очередь"
|
||||
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
|
||||
>↺</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть очередь"
|
||||
className="text-muted text-[11px] cursor-pointer hover:text-app-text"
|
||||
>✕</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto">
|
||||
{queue.map((item, i) => {
|
||||
const active = i === curIdx
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
|
||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
|
||||
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
|
||||
onDragStart={(e) => onDragStart(i, e.currentTarget)}
|
||||
onDragEnd={(e) => onDragEnd(e.currentTarget)}
|
||||
onDragOver={(e) => onDragOver(e, e.currentTarget)}
|
||||
onDrop={(e) => onDrop(e, i, e.currentTarget)}
|
||||
>
|
||||
<div
|
||||
className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
|
||||
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
{active ? (
|
||||
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0" aria-label="Играет">
|
||||
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
|
||||
)}
|
||||
{item.img ? (
|
||||
<img
|
||||
src={item.img}
|
||||
alt=""
|
||||
className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2"
|
||||
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium"
|
||||
style={{ background: item.color.bg, color: item.color.text }}
|
||||
>
|
||||
{item.owner}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
apps/web/src/components/Player/VersionsPanel.tsx
Normal file
87
apps/web/src/components/Player/VersionsPanel.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { proxyImgUrl } from '@/lib/api'
|
||||
import type { SearchResult } from '@/types'
|
||||
|
||||
interface Props {
|
||||
results: SearchResult[]
|
||||
activeResultIdx: number
|
||||
trackTitle: string
|
||||
onPlay: (results: SearchResult[], i: number) => void
|
||||
isSaved: (title: string, r: SearchResult) => boolean
|
||||
saveVersion: (title: string, r: SearchResult) => void
|
||||
removeVersion: (title: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function VersionsPanel({
|
||||
results, activeResultIdx, trackTitle, onPlay, isSaved, saveVersion, removeVersion, onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between">
|
||||
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Версии трека</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Закрыть версии"
|
||||
className="text-muted text-[11px] cursor-pointer hover:text-app-text"
|
||||
>✕</button>
|
||||
</div>
|
||||
{results.map((r, i) => {
|
||||
const saved = isSaved(trackTitle, r)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onPlay(results, i)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
|
||||
>
|
||||
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
|
||||
{r.img && !r.img.includes('no-cover') && (
|
||||
<img
|
||||
src={proxyImgUrl(r.img)}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2"
|
||||
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">
|
||||
{r.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
|
||||
aria-label={saved ? 'Забыть версию' : 'Запомнить версию'}
|
||||
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
|
||||
style={{
|
||||
borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)',
|
||||
background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 24 24"
|
||||
fill={saved ? 'var(--accent)' : 'none'}
|
||||
stroke={saved ? 'var(--accent)' : '#555'}
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPlay(results, i) }}
|
||||
aria-label={i === activeResultIdx ? 'Воспроизводится' : 'Воспроизвести эту версию'}
|
||||
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
|
||||
style={{ width: 26, height: 26 }}
|
||||
>
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
'use client'
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { usePartyStore } from '@/store/partyStore'
|
||||
import { fetchTopCharts } from '@/lib/api'
|
||||
import type { SearchResult } from '@/types'
|
||||
|
||||
const SEARCH_HISTORY_KEY = 'pm_search_history'
|
||||
const MAX_HISTORY = 8
|
||||
|
||||
function getSearchHistory(): string[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) ?? '[]') } catch { return [] }
|
||||
}
|
||||
|
||||
function addToSearchHistory(q: string) {
|
||||
const prev = getSearchHistory().filter((s) => s !== q)
|
||||
const next = [q, ...prev].slice(0, MAX_HISTORY)
|
||||
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) } catch {}
|
||||
}
|
||||
|
||||
function clearSearchHistory() {
|
||||
try { localStorage.removeItem(SEARCH_HISTORY_KEY) } catch {}
|
||||
}
|
||||
|
||||
function TopChartsCard({ tracks }: { tracks: string[] }) {
|
||||
const { loadPlaylist } = usePartyStore()
|
||||
const [launched, setLaunched] = useState(false)
|
||||
@@ -48,24 +66,34 @@ function TopChartsCard({ tracks }: { tracks: string[] }) {
|
||||
export default function SoloTab() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [topTracks, setTopTracks] = useState<string[] | null>(null)
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([])
|
||||
const { loadPlaylist } = usePartyStore()
|
||||
|
||||
useEffect(() => {
|
||||
setSearchHistory(getSearchHistory())
|
||||
fetchTopCharts().then((results: SearchResult[]) => {
|
||||
if (!results.length) { setTopTracks([]); return }
|
||||
setTopTracks(
|
||||
results.map((r) => (r.artist ? `${r.artist} — ${r.title}` : r.title))
|
||||
)
|
||||
setTopTracks(results.map((r) => (r.artist ? `${r.artist} — ${r.title}` : r.title)))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const q = search.trim()
|
||||
if (!q) return
|
||||
loadPlaylist([q])
|
||||
const doSearch = useCallback((q: string) => {
|
||||
if (!q.trim()) return
|
||||
addToSearchHistory(q.trim())
|
||||
setSearchHistory(getSearchHistory())
|
||||
loadPlaylist([q.trim()])
|
||||
setSearch('')
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}, [loadPlaylist])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
doSearch(search)
|
||||
}
|
||||
|
||||
const handleClearHistory = () => {
|
||||
clearSearchHistory()
|
||||
setSearchHistory([])
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -100,6 +128,31 @@ export default function SoloTab() {
|
||||
▶
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{searchHistory.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] text-muted font-display uppercase tracking-[1px]">Недавние</span>
|
||||
<button
|
||||
onClick={handleClearHistory}
|
||||
className="text-[10px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{searchHistory.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => doSearch(q)}
|
||||
className="text-[11px] px-2.5 py-1 rounded-[7px] bg-surface2 text-muted hover:text-app-text hover:bg-white/[0.07] transition-all cursor-pointer truncate max-w-[200px]"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
38
apps/web/src/components/Toaster.tsx
Normal file
38
apps/web/src/components/Toaster.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useToastStore } from '@/store/toastStore'
|
||||
|
||||
export default function Toaster() {
|
||||
const { toasts, dismiss } = useToastStore()
|
||||
if (!toasts.length) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[84px] left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 z-[9999] pointer-events-none">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="pointer-events-auto flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-[13px] font-sans shadow-xl cursor-pointer animate-fadeUp"
|
||||
style={{
|
||||
background: t.type === 'error' ? 'rgba(255,80,80,0.12)' : t.type === 'success' ? 'rgba(80,200,120,0.12)' : 'rgba(255,255,255,0.08)',
|
||||
border: `1px solid ${t.type === 'error' ? 'rgba(255,80,80,0.25)' : t.type === 'success' ? 'rgba(80,200,120,0.25)' : 'rgba(255,255,255,0.1)'}`,
|
||||
color: t.type === 'error' ? '#ff6b6b' : t.type === 'success' ? '#5cc87a' : '#ccc',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
{t.type === 'error' && (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="shrink-0">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
)}
|
||||
{t.type === 'success' && (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="shrink-0">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,15 @@ export async function fetchYandexPlaylist(yandexUrl: string): Promise<{ name: st
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchSpotifyPlaylist(spotifyUrl: string): Promise<{ name: string; tracks: string[] }> {
|
||||
const res = await fetch(`${API_URL}/api/proxy/spotify-playlist?url=${encodeURIComponent(spotifyUrl)}`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error ?? 'Ошибка загрузки Spotify плейлиста')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function searchTracks(query: string): Promise<SearchResult[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/proxy/search?q=${encodeURIComponent(query)}`, {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const audioState = {
|
||||
analyser: null as AnalyserNode | null,
|
||||
isPlaying: false,
|
||||
analyser: null as AnalyserNode | null,
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
}
|
||||
|
||||
@@ -4,9 +4,22 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`${API_URL}${path}`, {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const pub = ['/login', '/register', '/']
|
||||
const isPublic = pub.includes(window.location.pathname) || window.location.pathname.startsWith('/remote/')
|
||||
if (!isPublic) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`)
|
||||
@@ -15,10 +28,6 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function bearer(token: string): HeadersInit {
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
export async function register(username: string, email: string, password: string): Promise<User> {
|
||||
return request<User>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
@@ -26,31 +35,35 @@ export async function register(username: string, email: string, password: string
|
||||
})
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string): Promise<{ token: string; user: User }> {
|
||||
return request<{ token: string; user: User }>('/api/auth/login', {
|
||||
export async function login(email: string, password: string): Promise<User> {
|
||||
const res = await request<{ user: User }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
return res.user
|
||||
}
|
||||
|
||||
export async function fetchMe(token: string): Promise<User> {
|
||||
return request<User>('/api/auth/me', { headers: bearer(token) })
|
||||
export async function logout(): Promise<void> {
|
||||
await request<void>('/api/auth/logout', { method: 'POST' }).catch(() => {})
|
||||
}
|
||||
|
||||
export async function getPlaylists(token: string): Promise<Playlist[]> {
|
||||
return request<Playlist[]>('/api/playlists', { headers: bearer(token) })
|
||||
export async function fetchMe(): Promise<User> {
|
||||
return request<User>('/api/auth/me')
|
||||
}
|
||||
|
||||
export async function getPlaylists(): Promise<Playlist[]> {
|
||||
return request<Playlist[]>('/api/playlists')
|
||||
}
|
||||
|
||||
export async function getPublicPlaylists(): Promise<PublicPlaylist[]> {
|
||||
return request<PublicPlaylist[]>('/api/playlists/public')
|
||||
}
|
||||
|
||||
export async function getPlaylist(token: string, id: string): Promise<Playlist> {
|
||||
return request<Playlist>(`/api/playlists/${id}`, { headers: bearer(token) })
|
||||
export async function getPlaylist(id: string): Promise<Playlist> {
|
||||
return request<Playlist>(`/api/playlists/${id}`)
|
||||
}
|
||||
|
||||
export async function createPlaylist(
|
||||
token: string,
|
||||
name: string,
|
||||
tracks: string[],
|
||||
isPublic = false,
|
||||
@@ -58,13 +71,11 @@ export async function createPlaylist(
|
||||
): Promise<Playlist> {
|
||||
return request<Playlist>('/api/playlists', {
|
||||
method: 'POST',
|
||||
headers: bearer(token),
|
||||
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updatePlaylist(
|
||||
token: string,
|
||||
id: string,
|
||||
name: string,
|
||||
tracks: string[],
|
||||
@@ -73,22 +84,17 @@ export async function updatePlaylist(
|
||||
): Promise<Playlist> {
|
||||
return request<Playlist>(`/api/playlists/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: bearer(token),
|
||||
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function addTrackToPlaylist(token: string, playlistId: string, title: string): Promise<void> {
|
||||
export async function addTrackToPlaylist(playlistId: string, title: string): Promise<void> {
|
||||
return request<void>(`/api/playlists/${playlistId}/tracks`, {
|
||||
method: 'POST',
|
||||
headers: bearer(token),
|
||||
body: JSON.stringify({ title }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePlaylist(token: string, id: string): Promise<void> {
|
||||
return request<void>(`/api/playlists/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: bearer(token),
|
||||
})
|
||||
export async function deletePlaylist(id: string): Promise<void> {
|
||||
return request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
368
apps/web/src/lib/overlayPalettes.ts
Normal file
368
apps/web/src/lib/overlayPalettes.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import type { OverlayStyle } from '@/store/overlayStore'
|
||||
|
||||
export interface OverlayPalette {
|
||||
id: string
|
||||
name: string
|
||||
swatches: string[] // preview dots (2-3 hex/rgba)
|
||||
bg?: string // panel background (color or gradient)
|
||||
border?: string // full CSS border declaration
|
||||
shadow?: string // box-shadow
|
||||
text?: string // primary text color
|
||||
text2?: string // secondary text color
|
||||
chroma?: string // characteristic color: retro border hex, neon/matrix phosphor hex, glam metal rgb-string
|
||||
chroma2?: string // for RGB string "r,g,b" when chroma is hex (used for --accent-rgb override)
|
||||
titleBg?: string // Y2K titlebar gradient
|
||||
bodyBg?: string // Y2K body gradient
|
||||
}
|
||||
|
||||
const PALETTES: Record<OverlayStyle, OverlayPalette[]> = {
|
||||
classic: [
|
||||
{
|
||||
id: 'default', name: 'Тёмный', swatches: ['#0a0a10', '#ffffff', '#888888'],
|
||||
bg: 'rgba(10,10,16,0.82)', border: '1px solid rgba(255,255,255,0.09)',
|
||||
shadow: '0 8px 32px rgba(0,0,0,0.55)',
|
||||
text: '#ffffff', text2: 'rgba(255,255,255,0.45)', chroma: 'rgba(255,255,255,0.06)',
|
||||
},
|
||||
{
|
||||
id: 'warm', name: 'Тёплый', swatches: ['#160c06', '#ffeedd', '#dca850'],
|
||||
bg: 'rgba(22,12,6,0.86)', border: '1px solid rgba(220,168,80,0.20)',
|
||||
shadow: '0 8px 32px rgba(20,8,0,0.6)',
|
||||
text: '#ffeedd', text2: 'rgba(255,200,120,0.50)', chroma: 'rgba(220,168,80,0.10)',
|
||||
},
|
||||
{
|
||||
id: 'ocean', name: 'Морской', swatches: ['#040a1a', '#ddeeff', '#3c82ff'],
|
||||
bg: 'rgba(4,10,26,0.88)', border: '1px solid rgba(60,130,255,0.22)',
|
||||
shadow: '0 8px 32px rgba(0,10,40,0.6)',
|
||||
text: '#ddeeff', text2: 'rgba(100,170,255,0.50)', chroma: 'rgba(60,130,255,0.08)',
|
||||
},
|
||||
{
|
||||
id: 'frost', name: 'Белый', swatches: ['#ffffff', '#111111', '#bbbbbb'],
|
||||
bg: 'rgba(255,255,255,0.90)', border: '1px solid rgba(0,0,0,0.09)',
|
||||
shadow: '0 8px 32px rgba(0,0,0,0.15)',
|
||||
text: '#111111', text2: 'rgba(0,0,0,0.42)', chroma: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
],
|
||||
|
||||
aero: [
|
||||
{
|
||||
id: 'default', name: 'Небо', swatches: ['#bee6ff', '#78b9ff', '#003a6e'],
|
||||
bg: 'linear-gradient(160deg,rgba(190,230,255,0.55) 0%,rgba(120,185,255,0.42) 100%)',
|
||||
border: '1.5px solid rgba(255,255,255,0.72)',
|
||||
shadow: '0 4px 20px rgba(80,160,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
|
||||
text: '#003a6e', text2: 'rgba(0,60,130,0.60)',
|
||||
},
|
||||
{
|
||||
id: 'rose', name: 'Роза', swatches: ['#ffc8d7', '#ff87af', '#6e0030'],
|
||||
bg: 'linear-gradient(160deg,rgba(255,200,215,0.55) 0%,rgba(255,135,175,0.42) 100%)',
|
||||
border: '1.5px solid rgba(255,255,255,0.72)',
|
||||
shadow: '0 4px 20px rgba(255,100,160,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
|
||||
text: '#6e0030', text2: 'rgba(130,0,60,0.60)',
|
||||
},
|
||||
{
|
||||
id: 'mint', name: 'Мята', swatches: ['#b4ffd2', '#50dc9b', '#003a20'],
|
||||
bg: 'linear-gradient(160deg,rgba(180,255,210,0.55) 0%,rgba(80,220,155,0.42) 100%)',
|
||||
border: '1.5px solid rgba(255,255,255,0.72)',
|
||||
shadow: '0 4px 20px rgba(60,200,130,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
|
||||
text: '#003a20', text2: 'rgba(0,80,50,0.60)',
|
||||
},
|
||||
{
|
||||
id: 'lavender', name: 'Лаванда', swatches: ['#dcc8ff', '#9b7dff', '#3a006e'],
|
||||
bg: 'linear-gradient(160deg,rgba(220,200,255,0.55) 0%,rgba(155,125,255,0.42) 100%)',
|
||||
border: '1.5px solid rgba(255,255,255,0.72)',
|
||||
shadow: '0 4px 20px rgba(130,80,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
|
||||
text: '#3a006e', text2: 'rgba(70,0,140,0.55)',
|
||||
},
|
||||
],
|
||||
|
||||
retro: [
|
||||
{
|
||||
id: 'default', name: 'Янтарь', swatches: ['#0c0500', '#c07030', '#f8d090'],
|
||||
bg: 'rgba(12,5,0,0.94)', border: '2px solid #c07030',
|
||||
shadow: '0 0 28px rgba(180,90,20,0.45),4px 4px 0 rgba(0,0,0,0.6)',
|
||||
text: '#f8d090', text2: '#9a6020', chroma: 'rgba(180,100,20,0.30)',
|
||||
},
|
||||
{
|
||||
id: 'phosphor', name: 'Фосфор', swatches: ['#000c04', '#30c060', '#90f8b0'],
|
||||
bg: 'rgba(0,12,4,0.94)', border: '2px solid #30c060',
|
||||
shadow: '0 0 28px rgba(20,160,60,0.45),4px 4px 0 rgba(0,0,0,0.6)',
|
||||
text: '#90f8b0', text2: '#207840', chroma: 'rgba(20,160,60,0.28)',
|
||||
},
|
||||
{
|
||||
id: 'crt', name: 'CRT синий', swatches: ['#000412', '#3060c0', '#90b8f8'],
|
||||
bg: 'rgba(0,4,18,0.94)', border: '2px solid #3060c0',
|
||||
shadow: '0 0 28px rgba(30,80,200,0.45),4px 4px 0 rgba(0,0,0,0.6)',
|
||||
text: '#90b8f8', text2: '#204880', chroma: 'rgba(30,80,200,0.30)',
|
||||
},
|
||||
{
|
||||
id: 'blood', name: 'Красный', swatches: ['#0e0202', '#c02828', '#f89090'],
|
||||
bg: 'rgba(14,2,2,0.94)', border: '2px solid #c02828',
|
||||
shadow: '0 0 28px rgba(180,28,20,0.45),4px 4px 0 rgba(0,0,0,0.6)',
|
||||
text: '#f89090', text2: '#802020', chroma: 'rgba(180,28,20,0.28)',
|
||||
},
|
||||
],
|
||||
|
||||
neon: [
|
||||
{
|
||||
id: 'default', name: 'Акцент', swatches: ['#00000a', 'var(--accent)', 'var(--accent)'],
|
||||
bg: 'rgba(0,0,10,0.90)',
|
||||
},
|
||||
{
|
||||
id: 'cyan', name: 'Голубой', swatches: ['#00040c', '#00e5ff', '#00e5ff'],
|
||||
bg: 'rgba(0,4,12,0.92)', chroma: '#00e5ff', chroma2: '0,229,255',
|
||||
},
|
||||
{
|
||||
id: 'magenta', name: 'Пурпур', swatches: ['#0a0008', '#ff00cc', '#ff00cc'],
|
||||
bg: 'rgba(10,0,8,0.92)', chroma: '#ff00cc', chroma2: '255,0,204',
|
||||
},
|
||||
{
|
||||
id: 'lime', name: 'Лайм', swatches: ['#000a04', '#00ff88', '#00ff88'],
|
||||
bg: 'rgba(0,10,4,0.92)', chroma: '#00ff88', chroma2: '0,255,136',
|
||||
},
|
||||
],
|
||||
|
||||
clean: [
|
||||
{
|
||||
id: 'default', name: 'Стандарт', swatches: ['transparent', '#ffffff', 'var(--accent)'],
|
||||
},
|
||||
{
|
||||
id: 'shadow', name: 'С тенью', swatches: ['transparent', '#ffffff', '#666666'],
|
||||
text: '#ffffff',
|
||||
},
|
||||
{
|
||||
id: 'dark-text', name: 'Тёмный', swatches: ['transparent', '#111111', '#333333'],
|
||||
text: '#111111', text2: 'rgba(0,0,0,0.50)',
|
||||
},
|
||||
{
|
||||
id: 'warm-text', name: 'Тёплый', swatches: ['transparent', '#ffeedd', '#ddaa66'],
|
||||
text: '#ffeedd', text2: 'rgba(255,200,120,0.60)',
|
||||
},
|
||||
],
|
||||
|
||||
y2k: [
|
||||
{
|
||||
id: 'default', name: 'Лунный', swatches: ['#c8d0e0', '#1a4090', '#000060'],
|
||||
titleBg: 'linear-gradient(90deg,#1a4090 0%,#4a80d0 40%,#1a4090 100%)',
|
||||
bodyBg: 'linear-gradient(160deg,#dce4f0,#c0c8e0)',
|
||||
text: '#000060', text2: '#404880',
|
||||
},
|
||||
{
|
||||
id: 'rose', name: 'Розовый', swatches: ['#f0d4dc', '#901440', '#600020'],
|
||||
titleBg: 'linear-gradient(90deg,#901440 0%,#d04080 40%,#901440 100%)',
|
||||
bodyBg: 'linear-gradient(160deg,#f0d4dc,#d0b0c0)',
|
||||
text: '#600020', text2: '#804060',
|
||||
},
|
||||
{
|
||||
id: 'dark', name: 'Тёмный', swatches: ['#c0c0c8', '#484848', '#000020'],
|
||||
titleBg: 'linear-gradient(90deg,#282828 0%,#484848 40%,#282828 100%)',
|
||||
bodyBg: 'linear-gradient(160deg,#c0c0c8,#a0a0b0)',
|
||||
text: '#000020', text2: '#303040',
|
||||
},
|
||||
{
|
||||
id: 'forest', name: 'Лесной', swatches: ['#d4ecd8', '#106020', '#004010'],
|
||||
titleBg: 'linear-gradient(90deg,#106020 0%,#308040 40%,#106020 100%)',
|
||||
bodyBg: 'linear-gradient(160deg,#d4ecd8,#b8d8c0)',
|
||||
text: '#004010', text2: '#306040',
|
||||
},
|
||||
],
|
||||
|
||||
lofi: [
|
||||
{
|
||||
id: 'default', name: 'Коричневый', swatches: ['#2a1a0e', '#fde8c0', '#e8a060'],
|
||||
bg: 'rgba(42,26,14,0.92)',
|
||||
text: '#fde8c0', text2: 'rgba(var(--accent-rgb),0.60)',
|
||||
},
|
||||
{
|
||||
id: 'night', name: 'Ночной', swatches: ['#0c1020', '#c0d4f8', '#7090d8'],
|
||||
bg: 'rgba(12,16,32,0.92)',
|
||||
text: '#c0d4f8', text2: 'rgba(var(--accent-rgb),0.60)',
|
||||
},
|
||||
{
|
||||
id: 'forest', name: 'Лесной', swatches: ['#0c1c10', '#c8f0c8', '#60c870'],
|
||||
bg: 'rgba(12,28,16,0.92)',
|
||||
text: '#c8f0c8', text2: 'rgba(var(--accent-rgb),0.60)',
|
||||
},
|
||||
{
|
||||
id: 'plum', name: 'Сливовый', swatches: ['#1c0c20', '#e8c0f8', '#c060d8'],
|
||||
bg: 'rgba(28,12,32,0.92)',
|
||||
text: '#e8c0f8', text2: 'rgba(var(--accent-rgb),0.60)',
|
||||
},
|
||||
],
|
||||
|
||||
glam: [
|
||||
{
|
||||
id: 'default', name: 'Золото', swatches: ['#1c1204', '#d4af37', '#f0c840'],
|
||||
bg: 'linear-gradient(145deg,rgba(28,18,4,0.95),rgba(16,10,2,0.95))',
|
||||
chroma: '212,175,55', text: '#f0c840', text2: 'rgba(200,150,30,0.65)',
|
||||
},
|
||||
{
|
||||
id: 'silver', name: 'Серебро', swatches: ['#101214', '#c0c8d2', '#e0e8f0'],
|
||||
bg: 'linear-gradient(145deg,rgba(16,18,20,0.96),rgba(10,12,14,0.96))',
|
||||
chroma: '192,200,210', text: '#e0e8f0', text2: 'rgba(160,170,180,0.65)',
|
||||
},
|
||||
{
|
||||
id: 'rose-gold', name: 'Розовое золото', swatches: ['#160e0e', '#dc9e8e', '#f0c8b8'],
|
||||
bg: 'linear-gradient(145deg,rgba(22,14,14,0.95),rgba(14,8,8,0.95))',
|
||||
chroma: '220,158,142', text: '#f0c8b8', text2: 'rgba(200,140,120,0.65)',
|
||||
},
|
||||
{
|
||||
id: 'emerald', name: 'Изумруд', swatches: ['#08140e', '#32b478', '#80f8c0'],
|
||||
bg: 'linear-gradient(145deg,rgba(8,20,14,0.95),rgba(4,12,8,0.95))',
|
||||
chroma: '50,180,120', text: '#80f8c0', text2: 'rgba(40,160,100,0.65)',
|
||||
},
|
||||
],
|
||||
|
||||
matrix: [
|
||||
{
|
||||
id: 'default', name: 'Зелёный', swatches: ['#000800', '#00ff32', '#00ff32'],
|
||||
bg: 'rgba(0,8,0,0.93)', chroma: '#00ff32', chroma2: '0,255,50',
|
||||
border: '1px solid rgba(0,255,50,0.35)',
|
||||
},
|
||||
{
|
||||
id: 'cyan', name: 'Голубой', swatches: ['#00040c', '#00e5ff', '#00e5ff'],
|
||||
bg: 'rgba(0,4,12,0.93)', chroma: '#00e5ff', chroma2: '0,229,255',
|
||||
border: '1px solid rgba(0,229,255,0.35)',
|
||||
},
|
||||
{
|
||||
id: 'amber', name: 'Янтарь', swatches: ['#0c0800', '#ffb400', '#ffb400'],
|
||||
bg: 'rgba(12,8,0,0.93)', chroma: '#ffb400', chroma2: '255,180,0',
|
||||
border: '1px solid rgba(255,180,0,0.35)',
|
||||
},
|
||||
{
|
||||
id: 'violet', name: 'Пурпур', swatches: ['#08000c', '#cc00ff', '#cc00ff'],
|
||||
bg: 'rgba(8,0,12,0.93)', chroma: '#cc00ff', chroma2: '204,0,255',
|
||||
border: '1px solid rgba(204,0,255,0.35)',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function getPalettes(style: OverlayStyle): OverlayPalette[] {
|
||||
return PALETTES[style] ?? PALETTES.classic
|
||||
}
|
||||
|
||||
export function getPalette(style: OverlayStyle, paletteId: string): OverlayPalette {
|
||||
const list = getPalettes(style)
|
||||
return list.find(p => p.id === paletteId) ?? list[0]
|
||||
}
|
||||
|
||||
// ── Custom palette ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ColorFieldDef {
|
||||
key: string
|
||||
label: string
|
||||
default: string // hex default for the color picker
|
||||
}
|
||||
|
||||
export const STYLE_CUSTOM_FIELDS: Record<OverlayStyle, ColorFieldDef[]> = {
|
||||
classic: [
|
||||
{ key: 'bg', label: 'Фон', default: '#0a0a10' },
|
||||
{ key: 'chroma', label: 'Рамка', default: '#ffffff' },
|
||||
{ key: 'text', label: 'Текст', default: '#ffffff' },
|
||||
{ key: 'text2', label: 'Текст 2', default: '#888888' },
|
||||
],
|
||||
aero: [
|
||||
{ key: 'bg', label: 'Цвет', default: '#bee6ff' },
|
||||
{ key: 'text', label: 'Текст', default: '#003a6e' },
|
||||
{ key: 'text2', label: 'Текст 2', default: '#004488' },
|
||||
],
|
||||
retro: [
|
||||
{ key: 'bg', label: 'Фон', default: '#0c0500' },
|
||||
{ key: 'chroma', label: 'Хром', default: '#c07030' },
|
||||
{ key: 'text', label: 'Текст', default: '#f8d090' },
|
||||
{ key: 'text2', label: 'Текст 2', default: '#9a6020' },
|
||||
],
|
||||
neon: [
|
||||
{ key: 'bg', label: 'Фон', default: '#00000a' },
|
||||
{ key: 'chroma', label: 'Свечение', default: '#de9cfe' },
|
||||
],
|
||||
clean: [
|
||||
{ key: 'text', label: 'Текст', default: '#ffffff' },
|
||||
{ key: 'text2', label: 'Текст 2', default: '#888888' },
|
||||
],
|
||||
y2k: [
|
||||
{ key: 'titleBg', label: 'Заголовок', default: '#1a4090' },
|
||||
{ key: 'bodyBg', label: 'Тело', default: '#c0c8e0' },
|
||||
{ key: 'text', label: 'Текст', default: '#000060' },
|
||||
{ key: 'text2', label: 'Текст 2', default: '#404880' },
|
||||
],
|
||||
lofi: [
|
||||
{ key: 'bg', label: 'Фон', default: '#2a1a0e' },
|
||||
{ key: 'text', label: 'Текст', default: '#fde8c0' },
|
||||
{ key: 'text2', label: 'Текст 2', default: '#e8a060' },
|
||||
],
|
||||
glam: [
|
||||
{ key: 'bg', label: 'Фон', default: '#1c1204' },
|
||||
{ key: 'chroma', label: 'Металл', default: '#d4af37' },
|
||||
{ key: 'text', label: 'Текст', default: '#f0c840' },
|
||||
],
|
||||
matrix: [
|
||||
{ key: 'bg', label: 'Фон', default: '#000800' },
|
||||
{ key: 'chroma', label: 'Фосфор', default: '#00ff32' },
|
||||
],
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, a: number): string {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return hex
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `rgba(${r},${g},${b},${a})`
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return '128,128,128'
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `${r},${g},${b}`
|
||||
}
|
||||
|
||||
export function buildCustomPalette(
|
||||
style: OverlayStyle,
|
||||
data: Record<string, string>,
|
||||
): OverlayPalette {
|
||||
const pal: OverlayPalette = { id: 'custom', name: 'Свой', swatches: [] }
|
||||
|
||||
if (data.text) pal.text = data.text
|
||||
if (data.text2) pal.text2 = data.text2
|
||||
|
||||
if (data.bg) {
|
||||
if (style === 'aero') {
|
||||
pal.bg = `linear-gradient(160deg,${hexToRgba(data.bg, 0.55)} 0%,${hexToRgba(data.bg, 0.38)} 100%)`
|
||||
} else {
|
||||
const alpha = style === 'classic' ? 0.85 : 0.92
|
||||
pal.bg = hexToRgba(data.bg, alpha)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.chroma) {
|
||||
switch (style) {
|
||||
case 'glam':
|
||||
pal.chroma = hexToRgb(data.chroma)
|
||||
break
|
||||
case 'neon':
|
||||
case 'matrix':
|
||||
pal.chroma = data.chroma
|
||||
pal.chroma2 = hexToRgb(data.chroma)
|
||||
if (style === 'matrix') {
|
||||
pal.border = `1px solid ${hexToRgba(data.chroma, 0.38)}`
|
||||
}
|
||||
break
|
||||
case 'retro':
|
||||
pal.chroma = hexToRgba(data.chroma, 0.30)
|
||||
pal.border = `2px solid ${data.chroma}`
|
||||
pal.shadow = `0 0 28px ${hexToRgba(data.chroma, 0.45)},4px 4px 0 rgba(0,0,0,0.6)`
|
||||
break
|
||||
case 'classic':
|
||||
pal.border = `1px solid ${hexToRgba(data.chroma, 0.28)}`
|
||||
pal.chroma = hexToRgba(data.chroma, 0.10)
|
||||
break
|
||||
default:
|
||||
pal.chroma = data.chroma
|
||||
}
|
||||
}
|
||||
|
||||
if (data.titleBg) pal.titleBg = data.titleBg
|
||||
if (data.bodyBg) pal.bodyBg = data.bodyBg
|
||||
|
||||
return pal
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -12,9 +16,25 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user