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

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

23
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
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
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

13
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'rus.hitmotop.com' },
{ protocol: 'https', hostname: 'hitmotop.com' },
],
},
}
export default nextConfig

2186
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
apps/web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "party-mix-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default config

1
apps/web/public/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,29 @@
'use client'
import { useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import Header from '@/components/Header'
import Tabs from '@/components/Tabs'
import type { Tab } from '@/components/Tabs'
import PartyTab from '@/components/PartyTab/PartyTab'
import HistoryTab from '@/components/HistoryTab/HistoryTab'
import ExtraTab from '@/components/ExtraTab/ExtraTab'
export default function Home() {
const [activeTab, setActiveTab] = useState<Tab>('party')
const { history } = usePartyStore()
return (
<main className="max-w-app mx-auto relative z-10">
<Header />
<Tabs
active={activeTab}
historyCount={history.length}
onSwitch={setActiveTab}
/>
{activeTab === 'party' && <PartyTab />}
{activeTab === 'extra' && <ExtraTab />}
{activeTab === 'history' && <HistoryTab />}
</main>
)
}

View File

@@ -0,0 +1,234 @@
'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 = ['#c8ff00', '#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(200,255,0,0.1)', color: '#c8ff00' }}
>
{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 ? '#c8ff00' : 'rgba(200,255,0,0.12)',
color: isLaunched ? '#0a0a0f' : '#c8ff00',
}}
>
{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>
)
}

View File

@@ -0,0 +1,146 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--border: rgba(255, 255, 255, 0.07);
--accent: #c8ff00;
}
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html {
scroll-behavior: smooth;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
/* ── Audio element ── */
audio {
width: 100%;
height: 34px;
border-radius: 8px;
outline: none;
filter: invert(1) hue-rotate(180deg) saturate(0.5);
}
/* ── EQ bars (now playing indicator) ── */
@keyframes eqAnim {
from {
transform: scaleY(0.3);
}
to {
transform: scaleY(1);
}
}
.eq-bar {
width: 2.5px;
border-radius: 2px;
background: #c8ff00;
animation: eqAnim 0.7s ease-in-out infinite alternate;
height: 100%;
}
.eq-bar:nth-child(2) {
animation-delay: 0.15s;
height: 60%;
}
.eq-bar:nth-child(3) {
animation-delay: 0.3s;
height: 80%;
}
.eq-bar:nth-child(4) {
animation-delay: 0.1s;
height: 50%;
}
/* ── Queue bars (playing indicator) ── */
@keyframes barAnim {
from {
height: 2px;
}
to {
height: 11px;
}
}
.queue-bar {
width: 2.5px;
border-radius: 1px;
background: #c8ff00;
animation: barAnim 0.7s ease-in-out infinite alternate;
}
.queue-bar:nth-child(2) {
animation-delay: 0.15s;
}
.queue-bar:nth-child(3) {
animation-delay: 0.3s;
}
/* ── Cover glow ── */
@keyframes glowPulse {
from {
opacity: 0.4;
}
to {
opacity: 0.9;
}
}
.cover-glow {
position: absolute;
inset: -20px;
border-radius: 50%;
background: radial-gradient(circle, var(--glow-color, rgba(200, 255, 0, 0.25)) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.5s;
filter: blur(12px);
pointer-events: none;
z-index: 0;
}
.cover-playing .cover-glow {
opacity: 1;
animation: glowPulse 1.5s ease-in-out infinite alternate;
}
/* ── Cover pulse ── */
@keyframes coverPulse {
from {
transform: scale(1);
}
to {
transform: scale(1.06);
}
}
.cover-img-playing {
animation: coverPulse 2s ease-in-out infinite alternate;
}
/* ── Drag & drop ── */
.drag-over {
border-top: 2px solid #c8ff00 !important;
}
.dragging {
opacity: 0.4;
}

View File

@@ -0,0 +1,44 @@
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 './globals.css'
const syne = Syne({
subsets: ['latin', 'latin-ext'],
weight: ['400', '700', '800'],
variable: '--font-syne',
})
const dmSans = DM_Sans({
subsets: ['latin'],
weight: ['300', '400', '500'],
variable: '--font-dm-sans',
})
export const metadata: Metadata = {
title: 'Party Mix',
description: 'Совместные плейлисты для вечеринок — музыка с hitmotop.com',
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
}
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">
<AudioBackground />
<div className="relative" style={{ zIndex: 1 }}>
<AuthHydrator />
{children}
</div>
<GlobalPlayer />
</body>
</html>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { useState, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { login } from '@/lib/authApi'
import { useAuthStore } from '@/store/authStore'
import Header from '@/components/Header'
function RegisteredNotice() {
const params = useSearchParams()
if (!params.get('registered')) return null
return (
<div className="text-xs text-[#4ade80] bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.15)] px-3 py-2 rounded-lg mb-4">
Аккаунт создан войдите
</div>
)
}
export default function LoginPage() {
const router = useRouter()
const { setAuth } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const { token, user } = await login(email, password)
setAuth(token, user)
router.push('/')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally {
setLoading(false)
}
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="max-w-sm mx-auto mt-8">
<h2 className="font-display text-xl font-extrabold tracking-tight mb-6">Вход</h2>
<Suspense>
<RegisteredNotice />
</Suspense>
<form onSubmit={handleSubmit} className="bg-surface border border-white/[0.07] rounded-app p-5 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
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 placeholder:text-muted transition-colors"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
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 placeholder:text-muted transition-colors"
/>
</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">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 font-display text-[13px] font-bold tracking-[0.4px] bg-accent border-none rounded-[9px] text-bg flex items-center justify-center gap-2 hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
>
{loading ? (
<div className="w-4 h-4 rounded-full border-2 border-bg/30 border-t-bg animate-spin" />
) : (
'Войти'
)}
</button>
</form>
<p className="text-center text-xs text-muted mt-4">
Нет аккаунта?{' '}
<Link href="/register" className="text-accent hover:underline">
Зарегистрироваться
</Link>
</p>
</div>
</main>
)
}

164
apps/web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,164 @@
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 />
Каждый гость&nbsp; часть музыки.
</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(200,255,0,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>
)
}

View File

@@ -0,0 +1,783 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/authStore'
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 type { Playlist, SearchResult } from '@/types'
import Header from '@/components/Header'
const TAG_PALETTE = ['#c8ff00', '#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 TrackVersionPicker({ title, onPlay }: { title: string; onPlay: (r: SearchResult) => void }) {
const { isSaved, saveVersion, removeVersion } = useVersionStore()
const [results, setResults] = useState<SearchResult[] | null>(null)
useEffect(() => {
searchTracks(title).then(setResults).catch(() => setResults([]))
}, [title])
if (results === null) {
return (
<div className="flex items-center gap-2 px-4 py-2.5 text-muted text-[12px] border-t border-white/[0.05]">
<div className="w-3 h-3 rounded-full border-2 border-surface2 border-t-accent animate-spin shrink-0" />
Ищем версии...
</div>
)
}
if (!results.length) {
return <div className="px-4 py-2.5 text-[12px] text-muted border-t border-white/[0.05]">Версии не найдены</div>
}
return (
<div className="border-t border-white/[0.05]">
{results.map((r, i) => {
const saved = isSaved(title, r)
const hasImg = r.img && !r.img.includes('no-cover')
return (
<div key={i} className="flex items-center gap-2.5 px-4 py-2 hover:bg-surface2 transition-colors border-b border-white/[0.04] last:border-b-0">
<span className="text-[10px] text-muted/40 font-mono w-4 text-right shrink-0">{i + 1}</span>
{hasImg && (
<img src={proxyImgUrl(r.img)} alt="" className="w-7 h-7 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-[12px] text-app-text truncate leading-tight">{r.title}</div>
<div className="text-[10px] text-muted">{r.artist}</div>
</div>
<span className="text-[10px] text-muted/60 font-display shrink-0 hidden sm:block">{r.duration}</span>
<button
onClick={() => saved ? removeVersion(title) : saveVersion(title, r)}
title={saved ? 'Забыть версию' : 'Запомнить эту версию'}
className="w-6 h-6 rounded-md border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: saved ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(200,255,0,0.08)' : 'transparent' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#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={() => onPlay(r)}
className="w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ background: 'rgba(200,255,0,0.12)', border: '1px solid rgba(200,255,0,0.2)' }}
>
<svg width="8" height="8" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)
}
function Toggle({ value, onChange, label }: { value: boolean; onChange: (v: boolean) => void; label: string }) {
return (
<label className="flex items-center gap-2.5 cursor-pointer select-none group" onClick={() => onChange(!value)}>
<div
className="relative w-9 h-5 rounded-full transition-colors duration-200 shrink-0"
style={{
background: value ? 'rgba(200,255,0,0.25)' : 'rgba(255,255,255,0.06)',
border: `1px solid ${value ? 'rgba(200,255,0,0.45)' : 'rgba(255,255,255,0.1)'}`,
}}
>
<div
className="absolute top-[2px] w-4 h-4 rounded-full transition-all duration-200"
style={{ left: value ? 'calc(100% - 18px)' : '2px', background: value ? '#c8ff00' : 'rgba(255,255,255,0.25)' }}
/>
</div>
<span className="text-[12px] text-muted group-hover:text-app-text transition-colors">{label}</span>
</label>
)
}
function TagInput({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
const [input, setInput] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const addTag = (val: string) => {
const tag = val.trim().replace(/,$/, '').trim()
if (tag && !tags.includes(tag) && tags.length < 8) {
onChange([...tags, tag])
}
setInput('')
}
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTag(input)
} else if (e.key === 'Backspace' && !input && tags.length) {
onChange(tags.slice(0, -1))
}
}
return (
<div
className="flex flex-wrap gap-1.5 w-full bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2 cursor-text focus-within:border-accent/35 transition-colors min-h-[42px]"
onClick={() => inputRef.current?.focus()}
>
{tags.map(tag => (
<span
key={tag}
className="flex items-center gap-1 text-[11px] font-display font-bold px-2 py-px rounded-md leading-5"
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
>
{tag}
<button
type="button"
onClick={e => { e.stopPropagation(); onChange(tags.filter(t => t !== tag)) }}
className="opacity-60 hover:opacity-100 transition-opacity cursor-pointer leading-none"
>
×
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
onBlur={() => input.trim() && addTag(input)}
placeholder={tags.length ? '' : 'rock, party, 90s… Enter для добавления'}
className="flex-1 min-w-[120px] bg-transparent text-[12px] text-app-text outline-none placeholder:text-muted"
/>
</div>
)
}
function PlaylistCard({
pl,
onEdit,
onDelete,
}: {
pl: Playlist
onEdit: () => void
onDelete: () => 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 tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0
const playTrack = (title: string, result?: SearchResult) => {
if (result) {
useVersionStore.getState().saveVersion(title, result)
}
loadPlaylist([title])
}
const playAll = () => {
const titles = pl.tracks?.map(t => t.title) ?? []
if (!titles.length) return
loadPlaylist(titles)
setLaunched(true)
setTimeout(() => setLaunched(false), 2500)
}
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">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-display text-[14px] font-bold text-app-text">{pl.name}</span>
{pl.is_public && (
<span className="text-[9px] font-display font-bold tracking-[1px] uppercase px-1.5 py-px rounded-md" style={{ background: 'rgba(200,255,0,0.1)', color: '#c8ff00' }}>
публичный
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<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 cursor-pointer">
<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>
)}
{trackCount > 0 && (
<button onClick={playAll}
className="text-[11px] font-display font-bold px-2.5 py-1.5 rounded-[9px] cursor-pointer transition-all"
style={{ background: launched ? '#c8ff00' : 'rgba(200,255,0,0.12)', color: launched ? '#0a0a0f' : '#c8ff00' }}>
Play
</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">
Изменить
</button>
{confirmDelete ? (
<div className="flex items-center gap-1">
<button onClick={onDelete}
className="text-[11px] px-2.5 py-1.5 border border-[rgba(255,100,100,0.35)] rounded-lg bg-[rgba(255,100,100,0.1)] text-[#ff6b6b] cursor-pointer font-display font-semibold">
Да, удалить
</button>
<button onClick={() => setConfirmDelete(false)}
className="text-[11px] px-2 py-1.5 text-muted hover:text-app-text cursor-pointer">
Отмена
</button>
</div>
) : (
<button onClick={() => setConfirmDelete(true)}
className="text-[11px] px-2.5 py-1.5 border border-[rgba(255,100,100,0.15)] rounded-lg bg-transparent text-[rgba(255,100,100,0.45)] hover:bg-[rgba(255,100,100,0.08)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.3)] transition-all cursor-pointer font-display font-semibold">
Удалить
</button>
)}
</div>
</div>
{expanded && pl.tracks && pl.tracks.length > 0 && (
<div className="border-t border-white/[0.05]">
{pl.tracks.map((track, i) => (
<div key={track.id}>
<div className="flex items-center gap-2.5 px-4 py-2 group hover:bg-surface2 transition-colors">
<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/80 group-hover:text-app-text truncate flex-1 transition-colors">{track.title}</span>
<button
onClick={() => setVersionsFor(versionsFor === track.title ? null : track.title)}
title="Версии трека"
className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-md border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: versionsFor === track.title ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.1)', background: versionsFor === track.title ? 'rgba(200,255,0,0.08)' : 'transparent' }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={versionsFor === track.title ? '#c8ff00' : '#777'} strokeWidth="2">
<path d="M9 18V5l12-2v13" strokeLinecap="round" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</button>
<button
onClick={() => playTrack(track.title)}
title="Воспроизвести"
className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ background: 'rgba(200,255,0,0.12)', border: '1px solid rgba(200,255,0,0.2)' }}>
<svg width="8" height="8" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
{versionsFor === track.title && (
<TrackVersionPicker title={track.title} onPlay={r => playTrack(track.title, r)} />
)}
</div>
))}
</div>
)}
</div>
)
}
function EditForm({
pl,
onSave,
onCancel,
}: {
pl: Playlist
onSave: (name: string, tracks: string[], isPublic: boolean, tags: string[]) => Promise<void>
onCancel: () => void
}) {
const [name, setName] = useState(pl.name)
const [tracks, setTracks] = useState(pl.tracks?.map(t => t.title).join('\n') ?? '')
const [isPublic, setIsPublic] = useState(pl.is_public)
const [tags, setTags] = useState<string[]>(pl.tags ?? [])
const [saving, setSaving] = useState(false)
const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
const handleSave = async () => {
setSaving(true)
await onSave(name.trim(), parseTracks(tracks), isPublic, tags)
setSaving(false)
}
return (
<div className="p-4 bg-surface2/50 border-t border-white/[0.05]">
<input
type="text"
value={name}
onChange={e => setName(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-2 transition-colors"
placeholder="Название плейлиста"
/>
<textarea
value={tracks}
onChange={e => setTracks(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 min-h-[80px] resize-y leading-relaxed mb-2 transition-colors"
placeholder="Треки (по одному на строку)"
/>
<div className="mb-2">
<TagInput tags={tags} onChange={setTags} />
</div>
<div className="mb-3">
<Toggle value={isPublic} onChange={setIsPublic} label="Публичный — виден всем в Сообществе" />
</div>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="flex-1 py-2 font-display text-[12px] font-bold bg-accent border-none rounded-[9px] text-bg hover:opacity-90 cursor-pointer disabled:opacity-50 transition-opacity"
>
{saving ? 'Сохраняем...' : 'Сохранить'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 font-display text-[12px] font-bold bg-transparent border border-white/[0.07] rounded-[9px] text-muted hover:text-app-text hover:border-white/20 cursor-pointer transition-all"
>
Отмена
</button>
</div>
</div>
)
}
function FavoritesCard() {
const { favorites, removeFavorite } = useFavoritesStore()
const { loadPlaylist } = usePartyStore()
const [expanded, setExpanded] = useState(false)
const [launched, setLaunched] = useState(false)
const [versionsFor, setVersionsFor] = useState<string | null>(null)
const playTrack = (title: string, result?: SearchResult) => {
if (result) useVersionStore.getState().saveVersion(title, result)
loadPlaylist([title])
}
const handlePlay = () => {
if (!favorites.length) return
loadPlaylist(favorites)
setLaunched(true)
setTimeout(() => setLaunched(false), 2500)
}
return (
<div className="bg-surface border rounded-app overflow-hidden mb-2.5" style={{ borderColor: 'rgba(200,255,0,0.2)' }}>
<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" style={{ background: 'rgba(200,255,0,0.1)' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="#c8ff00">
<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>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-display text-[14px] font-bold text-app-text">Избранное</span>
<span className="text-[9px] font-display font-bold tracking-[1px] uppercase px-1.5 py-px rounded-md" style={{ background: 'rgba(200,255,0,0.1)', color: '#c8ff00' }}>
локальный
</span>
</div>
<div className="text-[11px] text-muted mt-0.5">
{favorites.length} {favorites.length === 1 ? 'трек' : favorites.length < 5 ? 'трека' : 'треков'}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{favorites.length > 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 cursor-pointer"
>
<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={handlePlay}
disabled={!favorites.length}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: launched ? '#c8ff00' : 'rgba(200,255,0,0.12)', color: launched ? '#0a0a0f' : '#c8ff00' }}
>
{launched ? '▶ Играет' : '▶ Play'}
</button>
</div>
</div>
{expanded && favorites.length > 0 && (
<div className="border-t border-white/[0.05]">
{favorites.map((title, i) => (
<div key={i}>
<div className="flex items-center gap-2.5 px-4 py-2 group hover:bg-surface2 transition-colors">
<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/80 group-hover:text-app-text truncate flex-1 transition-colors">{title}</span>
<button
onClick={() => setVersionsFor(versionsFor === title ? null : title)}
className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-md border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: versionsFor === title ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.1)', background: versionsFor === title ? 'rgba(200,255,0,0.08)' : 'transparent' }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={versionsFor === title ? '#c8ff00' : '#777'} strokeWidth="2">
<path d="M9 18V5l12-2v13" strokeLinecap="round" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</button>
<button
onClick={() => playTrack(title)}
className="opacity-0 group-hover:opacity-100 w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ background: 'rgba(200,255,0,0.12)', border: '1px solid rgba(200,255,0,0.2)' }}>
<svg width="8" height="8" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
<button onClick={() => removeFavorite(title)}
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center text-muted hover:text-[#ff6b6b] transition-all cursor-pointer">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" />
</svg>
</button>
</div>
{versionsFor === title && (
<TrackVersionPicker title={title} onPlay={r => playTrack(title, r)} />
)}
</div>
))}
</div>
)}
</div>
)
}
function YandexImportForm({ 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 fetchYandexPlaylist(importUrl.trim())
if (!data.tracks.length) {
setError('Плейлист пуст или не удалось получить треки')
return
}
setPreview(data)
setPlaylistName(data.name || 'Плейлист из Яндекс.Музыки')
} 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="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="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://music.yandex.ru/playlists/lk...."
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: '#c8ff00' }}>{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 { hydrate: hydrateFavorites } = useFavoritesStore()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [newTracks, setNewTracks] = useState('')
const [newIsPublic, setNewIsPublic] = useState(false)
const [newTags, setNewTags] = useState<string[]>([])
const [saving, setSaving] = useState(false)
const [createError, setCreateError] = useState('')
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [showImport, setShowImport] = useState(false)
useEffect(() => {
hydrateFavorites()
}, [hydrateFavorites])
useEffect(() => {
if (!token) { router.push('/login'); return }
getPlaylists(token)
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false))
}, [token, 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
const tracks = parseTracks(newTracks)
if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return }
setCreateError('')
setSaving(true)
try {
const pl = await createPlaylist(token, newName.trim(), tracks, newIsPublic, newTags)
setPlaylists(prev => [pl, ...prev])
setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([])
setShowForm(false)
} catch (err: unknown) {
setCreateError(err instanceof Error ? err.message : 'Ошибка')
} finally {
setSaving(false)
}
}
const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => {
if (!token) return
try {
const updated = await updatePlaylist(token, 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
setPlaylists(prev => prev.filter(p => p.id !== id))
await deletePlaylist(token, id).catch(() => {})
}
const handleImport = async (name: string, tracks: string[]) => {
if (!token) return
const pl = await createPlaylist(token, name, tracks, false, [])
setPlaylists(prev => [pl, ...prev])
setShowImport(false)
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="font-display text-xl font-extrabold tracking-tight">
Мои плейлисты
{user && <span className="text-muted font-normal text-sm ml-2"> {user.username}</span>}
</h2>
{!loading && (
<p className="text-[12px] text-muted mt-0.5">{playlists.length} плейлистов</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setShowImport(v => !v); setShowForm(false) }}
className="flex items-center gap-1.5 text-[12px] font-display font-bold px-3.5 py-2 rounded-xl transition-all duration-150 cursor-pointer"
style={showImport
? { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--color-muted)' }
: { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--color-app-text)' }
}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z" /><path d="M8 12h8M12 8l4 4-4 4" />
</svg>
Конвертировать
</button>
<button
onClick={() => { setShowForm(v => !v); setShowImport(false) }}
className="flex items-center gap-1.5 text-[12px] font-display font-bold px-3.5 py-2 rounded-xl transition-all duration-150 cursor-pointer"
style={showForm
? { background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.1)', color: 'var(--color-muted)' }
: { background: '#c8ff00', color: '#0a0a0f' }
}
>
{showForm ? (
<>
<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>
Отмена
</>
) : (
<>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
Создать
</>
)}
</button>
</div>
</div>
<FavoritesCard />
{showImport && (
<YandexImportForm
onImport={handleImport}
onClose={() => setShowImport(false)}
/>
)}
{showForm && (
<form onSubmit={handleCreate} className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
<p className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted mb-3">Новый плейлист</p>
<input
type="text"
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Название плейлиста"
required
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 placeholder:text-muted transition-colors mb-2"
/>
<textarea
value={newTracks}
onChange={e => setNewTracks(e.target.value)}
placeholder={'Тараканы — Пойдём на улицу\nPALC — Залип\nДора — Втюрилась'}
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 placeholder:text-muted transition-colors min-h-[90px] resize-y leading-relaxed mb-2"
/>
<div className="mb-2.5">
<p className="text-[11px] text-muted mb-1.5">Теги <span className="opacity-50">(необязательно)</span></p>
<TagInput tags={newTags} onChange={setNewTags} />
</div>
<div className="mb-3">
<Toggle
value={newIsPublic}
onChange={setNewIsPublic}
label={`Публичный — виден всем в ${String.fromCharCode(0x22)}Сообществе${String.fromCharCode(0x22)}`}
/>
</div>
{createError && (
<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-2">
{createError}
</div>
)}
<button
type="submit"
disabled={saving}
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"
>
{saving ? 'Сохраняем...' : 'Создать плейлист'}
</button>
</form>
)}
{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>
) : !playlists.length ? (
<div className="text-center py-14 text-muted">
<div className="text-4xl mb-3 opacity-20">🎵</div>
<p className="text-[13px] font-medium">Нет плейлистов</p>
<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)}
/>
{editingId === pl.id && (
<EditForm
pl={pl}
onSave={(name, tracks, isPublic, tags) => handleUpdate(pl.id, name, tracks, isPublic, tags)}
onCancel={() => setEditingId(null)}
/>
)}
</div>
))}
</div>
)}
</main>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { register } from '@/lib/authApi'
import Header from '@/components/Header'
export default function RegisterPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await register(username, email, password)
router.push('/login?registered=1')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка регистрации')
} finally {
setLoading(false)
}
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="max-w-sm mx-auto mt-8">
<h2 className="font-display text-xl font-extrabold tracking-tight mb-6">Регистрация</h2>
<form onSubmit={handleSubmit} className="bg-surface border border-white/[0.07] rounded-app p-5 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Имя пользователя</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="cooluser"
required
minLength={3}
maxLength={50}
autoComplete="username"
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 placeholder:text-muted transition-colors"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
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 placeholder:text-muted transition-colors"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted font-medium">Пароль</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
minLength={6}
autoComplete="new-password"
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 placeholder:text-muted transition-colors"
/>
</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">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 font-display text-[13px] font-bold tracking-[0.4px] bg-accent border-none rounded-[9px] text-bg flex items-center justify-center gap-2 hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
>
{loading ? (
<div className="w-4 h-4 rounded-full border-2 border-bg/30 border-t-bg animate-spin" />
) : (
'Создать аккаунт'
)}
</button>
</form>
<p className="text-center text-xs text-muted mt-4">
Уже есть аккаунт?{' '}
<Link href="/login" className="text-accent hover:underline">
Войти
</Link>
</p>
</div>
</main>
)
}

View File

@@ -0,0 +1,417 @@
'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(200,255,0,0.1)' : 'transparent', color: tab === 'player' ? '#c8ff00' : '#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(200,255,0,0.1)' : 'transparent', color: tab === 'queue' ? '#c8ff00' : '#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: '#c8ff00' }}
/>
<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(200,255,0,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(200,255,0,0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(200,255,0,0.08)' : 'transparent' }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#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(200,255,0,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>
)
}

View File

@@ -0,0 +1,259 @@
'use client'
import { useRef, useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { searchTracks, proxyImgUrl, proxyMp3Url } from '@/lib/api'
import AddToPlaylist from '@/components/AddToPlaylist'
import Header from '@/components/Header'
import type { SearchResult } from '@/types'
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 saved = isSaved(result.title, result)
const hasImg = result.img && !result.img.includes('no-cover')
return (
<div className="flex items-center gap-3 bg-surface border border-white/[0.07] rounded-app px-3.5 py-2.5 hover:border-white/[0.13] transition-all duration-150 group">
{/* Cover */}
<div className="w-10 h-10 rounded-[8px] bg-surface2 shrink-0 overflow-hidden">
{hasImg ? (
<img
src={proxyImgUrl(result.img)}
alt=""
className="w-full h-full object-cover"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#444" 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>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-app-text truncate leading-tight">{result.title}</div>
<div className="text-[11px] text-muted mt-px truncate">{result.artist}</div>
</div>
{/* Duration */}
{result.duration && (
<span className="text-[11px] text-muted font-display shrink-0 hidden sm:block">{result.duration}</span>
)}
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{/* Save version */}
<button
onClick={() => saved ? removeVersion(result.title) : saveVersion(result.title, result)}
title={saved ? 'Забыть версию' : 'Запомнить эту версию'}
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{
borderColor: saved ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)',
background: saved ? 'rgba(200,255,0,0.08)' : 'transparent',
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#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>
{/* Add to playlist */}
<div className="relative">
<button
ref={addBtnRef}
onClick={() => setPlaylistOpen(v => !v)}
title="Добавить в плейлист"
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{
borderColor: playlistOpen ? 'rgba(200,255,0,0.35)' : 'rgba(255,255,255,0.07)',
background: playlistOpen ? 'rgba(200,255,0,0.06)' : 'transparent',
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={playlistOpen ? '#c8ff00' : '#666'} strokeWidth="2.5" strokeLinecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
{playlistOpen && (
<AddToPlaylist
trackTitle={result.title}
onClose={() => setPlaylistOpen(false)}
anchorRef={addBtnRef as React.RefObject<HTMLElement | null>}
/>
)}
</div>
{/* Favorite */}
<button
onClick={() => toggleFavorite(result.title)}
title={favorited ? 'Убрать из избранного' : 'В избранное'}
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{
borderColor: favorited ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)',
background: favorited ? 'rgba(200,255,0,0.08)' : 'transparent',
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill={favorited ? '#c8ff00' : 'none'} stroke={favorited ? '#c8ff00' : '#555'} 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>
{/* Play */}
<button
onClick={() => onPlay(result)}
title="Воспроизвести"
className="w-7 h-7 rounded-[7px] flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{ background: 'rgba(200,255,0,0.12)', border: '1px solid rgba(200,255,0,0.2)' }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
</div>
)
}
export default function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[] | null>(null)
const [loading, setLoading] = useState(false)
const [lastQuery, setLastQuery] = useState('')
const { loadPlaylist } = usePartyStore()
const inputRef = useRef<HTMLInputElement>(null)
const handleSearch = async (e?: React.FormEvent) => {
e?.preventDefault()
const q = query.trim()
if (!q) return
setLoading(true)
setResults(null)
setLastQuery(q)
try {
const found = await searchTracks(q)
setResults(found)
} catch {
setResults([])
} finally {
setLoading(false)
}
}
const handlePlay = (r: SearchResult) => {
const title = r.artist ? `${r.artist}${r.title}` : r.title
loadPlaylist([title])
}
const handlePlayAll = () => {
if (!results?.length) return
const titles = results.map(r => r.artist ? `${r.artist}${r.title}` : r.title)
loadPlaylist(titles)
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="mb-5">
<h2 className="font-display text-xl font-extrabold tracking-tight">Поиск</h2>
<p className="text-[12px] text-muted mt-0.5">Исполнитель, трек или «Исполнитель Название»</p>
</div>
<form onSubmit={handleSearch} className="flex gap-2 mb-5">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="14" height="14" 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
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Кино, Земфира, Нирвана — Smells Like..."
autoFocus
className="w-full font-sans text-[14px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-10 py-3 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
{query && (
<button
type="button"
onClick={() => { setQuery(''); inputRef.current?.focus() }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
<button
type="submit"
disabled={!query.trim() || loading}
className="px-5 py-3 bg-accent text-bg font-display font-bold text-sm rounded-[11px] hover:brightness-110 active:scale-[0.97] transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer shrink-0"
>
Найти
</button>
</form>
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-14 gap-2.5 text-muted">
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
<span className="text-[13px]">Ищем «{lastQuery}»...</span>
</div>
)}
{/* Results */}
{results !== null && !loading && (
<>
<div className="flex items-center justify-between mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
{results.length ? `${results.length} результатов — «${lastQuery}»` : `Ничего не найдено — «${lastQuery}»`}
</p>
{results.length > 1 && (
<button
onClick={handlePlayAll}
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-3 py-1.5 rounded-[9px] cursor-pointer transition-all"
style={{ background: 'rgba(200,255,0,0.12)', color: '#c8ff00' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
Все в очередь
</button>
)}
</div>
{!results.length ? (
<div className="text-center py-12 text-muted">
<div className="text-4xl mb-3 opacity-20">🔍</div>
<p className="text-[13px]">Попробуйте другой запрос</p>
</div>
) : (
<div className="flex flex-col gap-2">
{results.map((r, i) => (
<ResultCard key={i} result={r} onPlay={handlePlay} />
))}
</div>
)}
</>
)}
{/* Empty state */}
{results === null && !loading && (
<div className="text-center py-14 text-muted">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto mb-3 opacity-20">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" strokeLinecap="round" />
</svg>
<p className="text-[13px] font-medium mb-1">Начните поиск</p>
<p className="text-[12px] opacity-50">Введите исполнителя или название трека</p>
</div>
)}
</main>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useAuthStore } from '@/store/authStore'
import { getPlaylists, addTrackToPlaylist } from '@/lib/authApi'
import type { Playlist } from '@/types'
interface Props {
trackTitle: string
onClose: () => void
anchorRef: React.RefObject<HTMLElement | null>
}
export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) {
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { token } = 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])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (
ref.current && !ref.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)
) onClose()
}
setTimeout(() => document.addEventListener('mousedown', handler), 0)
return () => document.removeEventListener('mousedown', handler)
}, [onClose, anchorRef])
const handleAdd = async (playlist: Playlist) => {
if (!token || added[playlist.id]) return
try {
await addTrackToPlaylist(token, playlist.id, trackTitle)
setAdded(prev => ({ ...prev, [playlist.id]: true }))
} catch {}
}
return (
<div ref={ref} className="absolute z-50 right-0 mt-1 w-56 bg-[#18181f] border border-white/[0.1] rounded-[13px] shadow-[0_8px_32px_rgba(0,0,0,0.5)] overflow-hidden">
<div className="px-3 py-2.5 flex items-center justify-between border-b border-white/[0.06]">
<span className="text-[11px] font-display font-bold text-muted uppercase tracking-[0.5px]">Плейлисты</span>
<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" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" strokeLinecap="round" />
</svg>
</button>
</div>
<button
onClick={() => toggleFavorite(trackTitle)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-surface2 transition-colors cursor-pointer border-b border-white/[0.05]"
>
<div className="w-6 h-6 rounded-md flex items-center justify-center shrink-0" style={{ background: favorited ? 'rgba(200,255,0,0.15)' : 'rgba(255,255,255,0.06)' }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill={favorited ? '#c8ff00' : 'none'} stroke={favorited ? '#c8ff00' : '#666'} 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>
</div>
<span className="text-[12px] font-medium" style={{ color: favorited ? '#c8ff00' : 'var(--color-app-text)' }}>
{favorited ? 'В избранном' : 'В избранное'}
</span>
{favorited && (
<svg className="ml-auto shrink-0" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#c8ff00" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
{token ? (
playlists.length > 0 ? (
<div className="max-h-[180px] overflow-y-auto">
{playlists.map(pl => (
<button
key={pl.id}
onClick={() => handleAdd(pl)}
className="w-full flex items-center gap-2.5 px-3 py-2.5 hover:bg-surface2 transition-colors cursor-pointer border-b border-white/[0.04] last:border-b-0"
>
<div className="w-6 h-6 rounded-md bg-surface2 flex items-center justify-center shrink-0">
<svg className="shrink-0" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#c8ff00" strokeWidth="2.5">
<path d="M9 18V5l12-2v13" strokeLinecap="round" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
<span className="text-[12px] font-medium text-app-text truncate flex-1 text-left">{pl.name}</span>
{added[pl.id] ? (
<svg className="ml-auto shrink-0" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#c8ff00" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg className="ml-auto shrink-0" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#c8ff00" strokeWidth="2.5">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
)}
</button>
))}
</div>
) : (
<div className="px-3 py-3 text-[11px] text-muted text-center">Плейлистов нет</div>
)
) : (
<div className="px-3 py-3 text-[11px] text-muted text-center">Войдите чтобы добавить в плейлист</div>
)}
</div>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import { useEffect, useRef } from 'react'
import { audioState } from '@/lib/audioState'
export default function AudioBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let rafId: number
let smoothBass = 0
let smoothMid = 0
let dataBuf: Uint8Array<ArrayBuffer> | null = null
const resize = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resize()
window.addEventListener('resize', resize)
const draw = () => {
rafId = requestAnimationFrame(draw)
const W = canvas.width
const H = canvas.height
let rawBass = 0
let rawMid = 0
if (audioState.analyser && audioState.isPlaying) {
const binCount = audioState.analyser.frequencyBinCount
if (!dataBuf || dataBuf.length !== binCount) dataBuf = new Uint8Array(new ArrayBuffer(binCount))
audioState.analyser.getByteFrequencyData(dataBuf)
const bassEnd = Math.max(1, Math.ceil(binCount * 0.1))
for (let i = 0; i < bassEnd; i++) rawBass = Math.max(rawBass, dataBuf[i] / 255)
const midStart = bassEnd
const midEnd = Math.ceil(binCount * 0.4)
let midSum = 0
for (let i = midStart; i < midEnd; i++) midSum += dataBuf[i] / 255
rawMid = midSum / Math.max(1, midEnd - midStart)
}
smoothBass += (rawBass - smoothBass) * 0.1
smoothMid += (rawMid - smoothMid) * 0.07
const t = Date.now() / 4500
const breathe = Math.sin(t) * 0.03 + Math.cos(t * 0.7) * 0.015
ctx.clearRect(0, 0, W, H)
ctx.fillStyle = '#0a0a0f'
ctx.fillRect(0, 0, W, H)
const diag = Math.hypot(W, H)
const base = diag * 0.55
// Lime orb — bass driven, top-left
const r1 = base * (0.75 + smoothBass * 0.55 + breathe)
const a1 = 0.055 + smoothBass * 0.07
const g1 = ctx.createRadialGradient(W * 0.18, H * 0.08, 0, W * 0.18, H * 0.08, r1)
g1.addColorStop(0, `rgba(200,255,0,${a1})`)
g1.addColorStop(0.45, `rgba(200,255,0,${a1 * 0.28})`)
g1.addColorStop(1, 'rgba(200,255,0,0)')
ctx.fillStyle = g1
ctx.fillRect(0, 0, W, H)
// Pink orb — mid driven, bottom-right
const r2 = base * (0.65 + smoothMid * 0.45 - breathe * 0.6)
const a2 = 0.045 + smoothMid * 0.055
const g2 = ctx.createRadialGradient(W * 0.82, H * 0.92, 0, W * 0.82, H * 0.92, r2)
g2.addColorStop(0, `rgba(255,60,172,${a2})`)
g2.addColorStop(0.45, `rgba(255,60,172,${a2 * 0.28})`)
g2.addColorStop(1, 'rgba(255,60,172,0)')
ctx.fillStyle = g2
ctx.fillRect(0, 0, W, H)
// Purple orb — combined, center, fades in when playing
const combined = smoothBass * 0.55 + smoothMid * 0.45
if (combined > 0.005) {
const r3 = base * (0.38 + combined * 0.32)
const a3 = combined * 0.035
const g3 = ctx.createRadialGradient(W * 0.5, H * 0.52, 0, W * 0.5, H * 0.52, r3)
g3.addColorStop(0, `rgba(140,100,255,${a3})`)
g3.addColorStop(1, 'rgba(140,100,255,0)')
ctx.fillStyle = g3
ctx.fillRect(0, 0, W, H)
}
}
draw()
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', resize)
}
}, [])
return (
<canvas
ref={canvasRef}
className="fixed inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 0 }}
/>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { useEffect } from 'react'
import { useAuthStore } from '@/store/authStore'
export default function AuthHydrator() {
useEffect(() => {
useAuthStore.getState().hydrate()
}, [])
return null
}

View File

@@ -0,0 +1,698 @@
'use client'
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { proxyImgUrl, proxyMp3Url, searchTracks } from '@/lib/api'
import { useAudioEngine } from '@/hooks/usePlayer'
import { useAudioViz } from '@/hooks/useAudioViz'
import { audioState } from '@/lib/audioState'
import AddToPlaylist from '@/components/AddToPlaylist'
import type { SearchResult } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
interface BottomPlayerProps {
onTrackEnd: (result: SearchResult) => void
}
function formatTime(s: number) {
if (!s || isNaN(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus, reorderQueue, setCurIdx, generateMix, removeFromQueue, addTrackToQueue } =
usePartyStore()
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [coverSrc, setCoverSrc] = useState('')
const [audioMeta, setAudioMeta] = useState({ title: '', artist: '' })
const [results, setResults] = useState<SearchResult[]>([])
const [activeResultIdx, setActiveResultIdx] = useState(0)
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
const [roomId, setRoomId] = useState<string | null>(null)
const [shareOpen, setShareOpen] = useState(false)
const [shareCopied, setShareCopied] = useState(false)
const shareBtnRef = useRef<HTMLButtonElement>(null)
const isPlayingRef = useRef(false)
const audioMetaRef = useRef({ title: '', artist: '' })
const coverSrcRef = useRef('')
const loadingKeyRef = useRef(-1)
const activeResultsRef = useRef<SearchResult[]>([])
const activeResultIdxRef = useRef(0)
const prefetchCacheRef = useRef<Map<string, SearchResult[]>>(new Map())
const prefetchInflightRef = useRef<Map<string, Promise<SearchResult[]>>>(new Map())
const preloadAudioRef = useRef<HTMLAudioElement | null>(null)
useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isPlaying)
useEffect(() => {
const el = new Audio()
el.preload = 'auto'
preloadAudioRef.current = el
return () => { el.src = ''; preloadAudioRef.current = null }
}, [])
const playResult = useCallback(
(resultList: SearchResult[], resIdx: number) => {
const r = resultList[resIdx]
if (!r) return
setActiveResultIdx(resIdx)
activeResultIdxRef.current = resIdx
setAudioMeta({ title: r.title, artist: r.artist })
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
const audio = audioRef.current
if (!audio) return
audio.pause()
audio.src = proxyMp3Url(r.mp3)
audio.load()
resumeContext()
audio.play().catch((err) => {
if (err?.name !== 'AbortError') console.warn('[player] play() failed:', err?.name)
})
},
[audioRef, resumeContext],
)
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
const inflight = prefetchInflightRef.current.get(title)
if (inflight) return inflight
const promise = searchTracks(title)
prefetchInflightRef.current.set(title, promise)
promise.then(r => {
prefetchCacheRef.current.set(title, r)
prefetchInflightRef.current.delete(title)
}).catch(() => {
prefetchInflightRef.current.delete(title)
})
return promise
}, [])
const loadTrack = useCallback(
async (idx: number, key: number) => {
if (idx < 0 || idx >= queue.length) return
loadingKeyRef.current = key
setIsPlaying(false)
setCoverSrc('')
setResults([])
setAudioMeta({ title: '', artist: '' })
setCurrentTime(0)
setDuration(0)
activeResultsRef.current = []
const audio = audioRef.current
if (audio) { audio.pause(); audio.src = '' }
const track = queue[idx]
const hasCache = prefetchCacheRef.current.has(track.title) || prefetchInflightRef.current.has(track.title)
if (!hasCache) setSearchStatus('searching')
const found = await prefetchOrGet(track.title)
if (loadingKeyRef.current !== key) return
activeResultsRef.current = found
setCurrentResults(found)
setResults(found)
if (!found.length) {
setSearchStatus('not-found')
setTimeout(() => {
if (loadingKeyRef.current !== key) return
const s = usePartyStore.getState()
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
}, 2000)
return
}
setSearchStatus('idle')
const saved = getSavedVersion(track.title)
let startIdx = 0
if (saved) {
const si = found.findIndex(r => r.title === saved.title && r.artist === saved.artist)
if (si >= 0) startIdx = si
}
if (found[startIdx]?.img && !found[startIdx].img.includes('no-cover')) {
const proxied = proxyImgUrl(found[startIdx].img)
setCoverSrc(proxied)
updateQueueItemImg(idx, proxied)
}
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
const p = prefetchOrGet(nextTrack.title)
if (offset === 1) {
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 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()
}
}).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],
)
useEffect(() => {
if (queue.length > 0 && curIdx >= 0) {
loadTrack(curIdx, loadKey)
}
}, [loadKey])
// Audio event handlers
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onPlay = () => {
initAudioViz()
resumeContext()
setIsPlaying(true)
isPlayingRef.current = true
audioState.isPlaying = true
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 onEnded = () => {
setIsPlaying(false)
isPlayingRef.current = false
audioState.isPlaying = false
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 onError = () => {
if (!audio.src) return
setTimeout(() => {
resumeContext()
audio.load()
audio.play().catch(() => {})
}, 1500)
}
audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onPause)
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('durationchange', onDuration)
audio.addEventListener('ended', onEnded)
audio.addEventListener('error', onError)
return () => {
audio.removeEventListener('play', onPlay)
audio.removeEventListener('pause', onPause)
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('durationchange', onDuration)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('error', onError)
}
}, [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
useEffect(() => {
if (!roomId) return
const pushState = () => {
const audio = audioRef.current
const { queue: q, curIdx: ci } = usePartyStore.getState()
fetch(`${API_URL}/api/remote/${roomId}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: audioMetaRef.current.title,
artist: audioMetaRef.current.artist,
cover: coverSrcRef.current,
is_playing: isPlayingRef.current,
volume: audio?.volume ?? 1,
progress: audio?.currentTime ?? 0,
duration: audio?.duration ?? 0,
queue_len: q.length,
cur_idx: ci,
versions: activeResultsRef.current.map(r => ({
title: r.title,
artist: r.artist,
duration: r.duration,
img: r.img && !r.img.includes('no-cover') ? proxyImgUrl(r.img) : '',
})),
active_version: activeResultIdxRef.current,
queue: q.map(item => ({
title: item.title,
owner: item.owner,
color_bg: item.color.bg,
color_text: item.color.text,
img: item.img || '',
})),
}),
}).catch(() => {})
}
const pollCommands = () => {
fetch(`${API_URL}/api/remote/${roomId}/commands`)
.then(r => r.ok ? r.json() : [])
.then((cmds: Array<{ cmd: string; value: number; text?: string }>) => {
const a = audioRef.current
for (const c of cmds) {
if (c.cmd === 'play') { if (a) { resumeContext(); a.play().catch(() => {}) } }
else if (c.cmd === 'pause') { if (a) a.pause() }
else if (c.cmd === 'next') { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) }
else if (c.cmd === 'prev') { const s = usePartyStore.getState(); if (s.curIdx > 0) s.setCurIdx(s.curIdx - 1) }
else if (c.cmd === 'volume') { if (a) a.volume = Math.max(0, Math.min(1, c.value)) }
else if (c.cmd === 'seek' && a?.duration) a.currentTime = c.value
else if (c.cmd === 'goto') { const s = usePartyStore.getState(); const i = Math.floor(c.value); if (i >= 0 && i < s.queue.length) s.setCurIdx(i) }
else if (c.cmd === 'remove') { const s = usePartyStore.getState(); const i = Math.floor(c.value); if (i >= 0 && i < s.queue.length) s.removeFromQueue(i) }
else if (c.cmd === 'add' && c.text) { usePartyStore.getState().addTrackToQueue(c.text) }
else if (c.cmd === 'version') { const i = Math.floor(c.value); if (i >= 0 && i < activeResultsRef.current.length) playResultRef.current(activeResultsRef.current, i) }
else if (c.cmd === 'save_version') {
const i = Math.floor(c.value)
const r = activeResultsRef.current[i]
const trackTitle = usePartyStore.getState().queue[usePartyStore.getState().curIdx]?.title
if (r && trackTitle) {
const vs = useVersionStore.getState()
if (vs.isSaved(trackTitle, r)) vs.removeVersion(trackTitle)
else vs.saveVersion(trackTitle, r)
}
}
}
})
.catch(() => {})
}
pushState()
pollCommands()
const ivState = setInterval(pushState, 2000)
const ivCmd = setInterval(pollCommands, 500)
return () => { clearInterval(ivState); clearInterval(ivCmd) }
}, [roomId, audioRef, resumeContext])
// Close panel on outside click
useEffect(() => {
if (!panel) return
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) setPanel(null)
}
setTimeout(() => document.addEventListener('mousedown', handler), 0)
return () => document.removeEventListener('mousedown', handler)
}, [panel])
// Close share popover on outside click
useEffect(() => {
if (!shareOpen) return
const handler = (e: MouseEvent) => {
if (shareBtnRef.current && !shareBtnRef.current.closest('.share-popover-root')?.contains(e.target as Node)) {
setShareOpen(false)
}
}
setTimeout(() => document.addEventListener('mousedown', handler), 0)
return () => document.removeEventListener('mousedown', handler)
}, [shareOpen])
const togglePlay = useCallback(() => {
const audio = audioRef.current
if (!audio) return
if (audio.paused) audio.play().catch(() => {})
else audio.pause()
}, [audioRef])
const prevTrack = () => { const s = usePartyStore.getState(); if (s.curIdx > 0) s.setCurIdx(s.curIdx - 1) }
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 track = queue[Math.max(0, curIdx)] ?? null
const trackTitle = track?.title ?? ''
const favorited = isFavorite(trackTitle)
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
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(200,255,0,0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(200,255,0,0.08)' : 'transparent' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#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(200,255,0,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' ? '#c8ff00' : undefined, background: panel === 'versions' ? 'rgba(200,255,0,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 ? '#c8ff00' : undefined, background: playlistOpen ? 'rgba(200,255,0,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)}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2"
style={{ color: favorited ? '#c8ff00' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? '#c8ff00' : 'none'} stroke={favorited ? '#c8ff00' : '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' ? '#c8ff00' : undefined, background: panel === 'queue' ? 'rgba(200,255,0,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 ? '#c8ff00' : undefined, background: shareOpen ? 'rgba(200,255,0,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 ? '#c8ff00' : undefined }}
>
{shareCopied ? '✓' : 'Копировать'}
</button>
</div>
<p className="text-[10px] text-muted mt-1.5 leading-relaxed">Откройте ссылку на другом устройстве для управления плеером</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>}
</>
)
}

View File

@@ -0,0 +1,183 @@
'use client'
import { useState, useMemo } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { COLORS, initials } from '@/lib/colors'
import type { Color } from '@/types'
export default function ExtraTab() {
const { queue, curIdx, addFairToQueue } = usePartyStore()
const [name, setName] = useState('')
const [tracks, setTracks] = useState('')
const [selectedColor, setSelectedColor] = useState<Color>(COLORS[1])
const [done, setDone] = useState(false)
// Unique participants in the remaining queue
const participants = useMemo(() => {
const seen = new Map<string, Color>()
for (const item of queue.slice(Math.max(curIdx + 1, 0))) {
if (!seen.has(item.owner)) seen.set(item.owner, item.color)
}
return Array.from(seen.entries()).map(([owner, color]) => ({ owner, color }))
}, [queue, curIdx])
// Also include played participants (for same-name additions)
const allParticipants = useMemo(() => {
const seen = new Map<string, Color>()
for (const item of queue) {
if (!seen.has(item.owner)) seen.set(item.owner, item.color)
}
return Array.from(seen.entries()).map(([owner, color]) => ({ owner, color }))
}, [queue])
const selectParticipant = (owner: string, color: Color) => {
setName(owner)
setSelectedColor(color)
}
const parseTracks = (raw: string) =>
raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const n = name.trim()
const t = parseTracks(tracks)
if (!n || !t.length) return
// If existing participant, reuse their color
const existing = allParticipants.find(p => p.owner === n)
const color = existing ? existing.color : selectedColor
addFairToQueue(n, color, t)
setTracks('')
setDone(true)
setTimeout(() => setDone(false), 2000)
}
const remaining = queue.length - Math.max(curIdx + 1, 0)
return (
<div className="animate-fadeUp">
{/* Info about current queue */}
{queue.length > 0 && (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-4">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-3">
Сейчас в очереди
</p>
{participants.length === 0 ? (
<p className="text-[12px] text-muted">Все треки уже сыграны</p>
) : (
<div className="flex flex-col gap-1.5">
{participants.map(({ owner, color }) => {
const count = queue.slice(Math.max(curIdx + 1, 0)).filter(i => i.owner === owner).length
return (
<div key={owner} className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-[11px] font-display font-extrabold shrink-0"
style={{ background: color.bg, color: color.text }}
>
{initials(owner)}
</div>
<span className="text-[13px] text-app-text flex-1">{owner}</span>
<span className="text-[11px] text-muted">{count} треков</span>
</div>
)
})}
</div>
)}
<div className="mt-3 pt-3 border-t border-white/[0.05] text-[11px] text-muted">
Осталось {remaining} {remaining === 1 ? 'трек' : remaining < 5 ? 'трека' : 'треков'} · всего {queue.length}
</div>
</div>
)}
{/* Add tracks form */}
<form onSubmit={handleSubmit} className="bg-surface border border-white/[0.07] rounded-app p-4">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-3">
Добавить треки в очередь
</p>
{/* Quick-pick existing participants */}
{allParticipants.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{allParticipants.map(({ owner, color }) => (
<button
key={owner}
type="button"
onClick={() => selectParticipant(owner, color)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer"
style={name === owner
? { background: color.bg, color: color.text, border: `1px solid ${color.text}40` }
: { background: 'rgba(255,255,255,0.05)', color: 'var(--color-muted)', border: '1px solid rgba(255,255,255,0.07)' }
}
>
<div
className="w-4 h-4 rounded flex items-center justify-center text-[9px] font-extrabold shrink-0"
style={{ background: color.bg, color: color.text }}
>
{initials(owner)}
</div>
{owner}
</button>
))}
</div>
)}
{/* Name input */}
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Имя участника"
required
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 placeholder:text-muted transition-colors mb-2"
/>
{/* Color picker — only for new participants */}
{!allParticipants.find(p => p.owner === name.trim()) && name.trim() && (
<div className="flex items-center gap-2 mb-2">
<span className="text-[11px] text-muted">Цвет:</span>
{COLORS.map((c, i) => (
<button
key={i}
type="button"
onClick={() => setSelectedColor(c)}
className="w-5 h-5 rounded-full transition-all duration-150 cursor-pointer"
style={{
background: c.text,
outline: selectedColor === c ? `2px solid ${c.text}` : 'none',
outlineOffset: '2px',
opacity: selectedColor === c ? 1 : 0.45,
}}
/>
))}
</div>
)}
{/* Tracks textarea */}
<textarea
value={tracks}
onChange={e => setTracks(e.target.value)}
placeholder={'Тараканы — Пойдём на улицу\nPALC — Залип\nДора — Втюрилась'}
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 placeholder:text-muted transition-colors min-h-[100px] resize-y leading-relaxed mb-3"
/>
{/* Preview */}
{parseTracks(tracks).length > 0 && name.trim() && (
<div className="mb-3 px-3 py-2 bg-surface2 rounded-[9px] text-[11px] text-muted">
{parseTracks(tracks).length} треков будут равномерно добавлены в очередь для «{name.trim()}»
</div>
)}
<button
type="submit"
disabled={!name.trim() || parseTracks(tracks).length === 0}
className="w-full py-2.5 font-display text-[13px] font-bold border-none rounded-[9px] transition-all duration-200 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: done ? 'rgba(200,255,0,0.2)' : '#c8ff00', color: done ? '#c8ff00' : '#0a0a0f' }}
>
{done ? '✓ Добавлено в очередь' : '↗ Добавить в очередь'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import { useFavoritesStore } from '@/store/favoritesStore'
import { usePartyStore } from '@/store/partyStore'
export default function FavoritesTab() {
const { favorites, removeFavorite, clearFavorites } = useFavoritesStore()
const { loadPlaylist } = usePartyStore()
if (!favorites.length) {
return (
<div className="animate-fadeUp text-center py-14 text-muted">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mx-auto mb-3 opacity-20">
<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>
<p className="text-sm">Нажмите в плеере, чтобы добавить трек</p>
</div>
)
}
return (
<div className="animate-fadeUp">
<div className="flex items-center justify-between mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
Избранное · {favorites.length}
</p>
<button
onClick={clearFavorites}
className="text-[11px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
>
Очистить всё
</button>
</div>
<div className="flex flex-col gap-2">
{favorites.map((title, i) => (
<div
key={i}
className="bg-surface border border-white/[0.07] rounded-app px-4 py-3 flex items-center gap-3 hover:bg-surface2 hover:border-white/[0.13] transition-all duration-150"
>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
{title}
</div>
</div>
<button
onClick={() => removeFavorite(title)}
title="Убрать из избранного"
className="w-7 h-7 rounded-full border border-white/[0.07] flex items-center justify-center shrink-0 hover:border-[rgba(255,107,107,0.4)] transition-all cursor-pointer text-muted hover:text-[#ff6b6b]"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<button
onClick={() => {
loadPlaylist([title])
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
title="Запустить"
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{
background: 'rgba(200,255,0,0.12)',
borderColor: 'rgba(200,255,0,0.2)',
}}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="#c8ff00"><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { useCallback, useEffect } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { useAuthStore } from '@/store/authStore'
import BottomPlayer from '@/components/BottomPlayer'
import type { SearchResult } from '@/types'
export default function GlobalPlayer() {
const { hydrate: hydrateFavorites } = useFavoritesStore()
const { hydrate: hydrateVersions } = useVersionStore()
const { token } = useAuthStore()
useEffect(() => {
hydrateFavorites()
}, [hydrateFavorites])
// Re-hydrate versions whenever auth changes (null → token = fetch from API)
useEffect(() => {
hydrateVersions()
}, [token, hydrateVersions])
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,
playedAt: new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }),
})
}, [])
return <BottomPlayer onTrackEnd={handleTrackEnd} />
}

View File

@@ -0,0 +1,111 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/authStore'
export default function Header() {
const { user, clearAuth } = useAuthStore()
const router = useRouter()
const pathname = usePathname()
const handleLogout = () => {
clearAuth()
router.push('/')
}
const navLink = (href: string, label: string, icon: React.ReactNode) => {
const active = pathname === href
return (
<Link
href={href}
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(200,255,0,0.12)', borderColor: 'rgba(200,255,0,0.3)', color: '#c8ff00' }
: { background: 'rgba(255,255,255,0.04)', borderColor: 'rgba(255,255,255,0.07)', color: 'var(--color-muted)' }
}
>
{icon}
{label}
</Link>
)
}
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>
</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>
)}
{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>
)}
{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" />
</svg>
)}
<div className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5">
<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>
<button
onClick={handleLogout}
className="flex items-center gap-1 text-[11px] font-display font-semibold text-muted hover:text-[#ff6b6b] transition-colors duration-150 cursor-pointer ml-1 border-l border-white/[0.07] pl-2"
title="Выйти из аккаунта"
>
<svg width="11" height="11" 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>
<span className="hidden sm:inline">Выйти</span>
</button>
</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>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { usePartyStore } from '@/store/partyStore'
import { proxyImgUrl } from '@/lib/api'
export default function HistoryTab() {
const { history, clearHistory } = usePartyStore()
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.07] sm:px-3">
<span className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
Уже сыграло
</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>
{!history.length ? (
<div className="text-center py-12 px-4 text-muted text-[13px]">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="mb-3 opacity-30 mx-auto">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<div>История пуста</div>
<div className="text-[11px] mt-1 opacity-60">Здесь появятся треки которые уже сыграли</div>
</div>
) : (
history.map((item, i) => (
<div
key={i}
className="flex items-center gap-2.5 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 animate-fadeUp sm:px-3"
style={{ animationDelay: `${i * 0.03}s` }}
>
{item.img && !item.img.includes('no-cover') ? (
<img
src={proxyImgUrl(item.img)}
alt=""
className="w-10 h-10 rounded-lg object-cover shrink-0 bg-surface2"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
) : (
<div className="w-10 h-10 rounded-lg bg-surface2 shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
{item.title}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className="text-[10px] px-1.5 py-px rounded"
style={{ background: item.color.bg, color: item.color.text }}
>
{item.owner}
</span>
<span className="text-[10px] text-muted">{item.playedAt}</span>
</div>
</div>
</div>
))
)}
</div>
)
}

View File

@@ -0,0 +1,117 @@
'use client'
import { useState, useEffect } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useAuthStore } from '@/store/authStore'
import { getPlaylists, getPlaylist } from '@/lib/authApi'
import type { Playlist } from '@/types'
export default function AddPersonForm() {
const { addPerson } = usePartyStore()
const { token } = useAuthStore()
const [name, setName] = useState('')
const [tracks, setTracks] = useState('')
const [error, setError] = useState('')
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loadingPlaylist, setLoadingPlaylist] = useState(false)
useEffect(() => {
if (!token) return
getPlaylists(token)
.then(setPlaylists)
.catch(() => {})
}, [token])
const handleSelectPlaylist = async (id: string) => {
if (!token || !id) return
setLoadingPlaylist(true)
try {
const pl = await getPlaylist(token, id)
if (pl.tracks?.length) {
setTracks(pl.tracks.map((t) => t.title).join('\n'))
}
if (!name.trim()) setName(pl.name)
} catch {}
setLoadingPlaylist(false)
}
const handleAdd = () => {
setError('')
if (!name.trim()) return
if (!tracks.trim()) {
setError('Вставьте список треков')
return
}
const parsed = tracks
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 1)
.slice(0, 50)
if (!parsed.length) {
setError('Не нашли треков')
return
}
addPerson(name.trim(), parsed)
setName('')
setTracks('')
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-4 sm:p-3">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя участника"
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 placeholder:text-muted transition-colors"
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
{token && playlists.length > 0 && (
<div className="mt-2">
<select
onChange={(e) => handleSelectPlaylist(e.target.value)}
defaultValue=""
className="w-full font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-muted outline-none focus:border-accent/35 transition-colors cursor-pointer"
>
<option value="" disabled>
{loadingPlaylist ? 'Загружаем...' : '🎵 Выбрать из моих плейлистов'}
</option>
{playlists.map((pl) => (
<option key={pl.id} value={pl.id}>
{pl.name}
</option>
))}
</select>
</div>
)}
<p className="text-xs text-muted mt-2 mb-1.5 leading-relaxed">
Список треков каждый с новой строки. Формат:{' '}
<b className="text-app-text font-medium">Исполнитель Название</b>
</p>
<textarea
value={tracks}
onChange={(e) => setTracks(e.target.value)}
placeholder={'Тараканы — Пойдём на улицу\nPALC — Залип\nДора — Втюрилась\n...'}
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 placeholder:text-muted transition-colors min-h-[80px] resize-y leading-relaxed mt-1"
/>
{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-1.5 rounded-lg mt-1.5">
{error}
</div>
)}
<button
onClick={handleAdd}
className="w-full mt-2.5 py-3 font-display text-[13px] font-bold tracking-[0.4px] bg-surface2 border border-white/[0.07] rounded-[9px] text-app-text flex items-center justify-center gap-1.5 hover:border-accent/30 hover:text-accent transition-all duration-200 cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 5v14M5 12l7-7 7 7" />
</svg>
Добавить участника
</button>
</div>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import { usePartyStore } from '@/store/partyStore'
import PersonCard from './PersonCard'
import AddPersonForm from './AddPersonForm'
export default function PartyTab() {
const { people, shuffleMode, setShuffleMode, generateMix } = usePartyStore()
return (
<div className="animate-fadeUp">
{people.length >= 2 && (
<div className="flex gap-1.5 mb-4 bg-surface border border-white/[0.07] rounded-xl p-1">
<button
onClick={() => setShuffleMode('fair')}
className={`flex-1 py-1.5 text-xs font-display font-semibold tracking-[0.2px] border-none rounded-[7px] cursor-pointer transition-all duration-200 ${
shuffleMode === 'fair' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
По очереди
</button>
<button
onClick={() => setShuffleMode('random')}
className={`flex-1 py-1.5 text-xs font-display font-semibold tracking-[0.2px] border-none rounded-[7px] cursor-pointer transition-all duration-200 ${
shuffleMode === 'random' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
🎲 Случайно
</button>
</div>
)}
<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"
>
<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" />
<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" />
</svg>
Перемешать и включить
</button>
<div className="flex flex-col gap-2.5 mb-4">
{people.map((person, i) => (
<PersonCard key={`${person.name}-${i}`} person={person} index={i} />
))}
</div>
<AddPersonForm />
</div>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
import { useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { initials } from '@/lib/colors'
import type { Person } from '@/types'
interface PersonCardProps {
person: Person
index: number
}
export default function PersonCard({ person, index }: PersonCardProps) {
const { removePerson, queue, curIdx } = usePartyStore()
const [open, setOpen] = useState(false)
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
<div
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2.5 px-4 py-3 cursor-pointer hover:bg-surface2 transition-colors duration-100 select-none sm:px-3"
>
<div
className="w-[30px] h-[30px] rounded-[9px] flex items-center justify-center font-display text-[11px] font-extrabold shrink-0"
style={{ background: person.color.bg, color: person.color.text }}
>
{initials(person.name)}
</div>
<span className="font-display text-[13px] font-bold flex-1 tracking-tight">{person.name}</span>
<span className="text-[11px] text-muted shrink-0">{person.tracks.length} тр.</span>
<span className={`text-muted shrink-0 transition-transform duration-200 text-[11px] ${open ? 'rotate-180' : ''}`}></span>
<button
onClick={(e) => { e.stopPropagation(); removePerson(index) }}
className="w-6 h-6 rounded-[7px] border border-[rgba(255,100,100,0.15)] bg-transparent text-[rgba(255,100,100,0.4)] flex items-center justify-center hover:bg-[rgba(255,100,100,0.1)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.35)] transition-all duration-150 shrink-0 cursor-pointer"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div
className="border-t border-white/[0.07] overflow-hidden transition-all duration-300"
style={{ maxHeight: open ? '500px' : '0' }}
>
<div className="flex flex-wrap gap-1 p-3 sm:p-2.5">
{person.tracks.map((t, ti) => {
const isPlaying =
curIdx >= 0 &&
queue[curIdx] &&
queue[curIdx]._pi === index &&
queue[curIdx]._ti === ti
return (
<span
key={ti}
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-[6px] border max-w-[220px] ${
isPlaying
? 'bg-accent/[0.07] border-accent/25 text-accent font-medium'
: 'border-white/[0.07] bg-surface2 text-muted'
}`}
>
{isPlaying && <div className="w-1 h-1 rounded-full bg-[#4ade80] shrink-0" />}
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-w-[165px]">{t.title}</span>
</span>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,352 @@
'use client'
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { proxyImgUrl, proxyMp3Url, searchTracks } from '@/lib/api'
import { useAudioEngine } from '@/hooks/usePlayer'
import { useAudioViz } from '@/hooks/useAudioViz'
import { audioState } from '@/lib/audioState'
import AddToPlaylist from '@/components/AddToPlaylist'
import type { SearchResult } from '@/types'
interface PlayerCardProps {
onTrackEnd: (result: SearchResult) => void
}
export default function PlayerCard({ onTrackEnd }: PlayerCardProps) {
const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus } =
usePartyStore()
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isVizActive, setIsVizActive] = useState(false)
const [coverSrc, setCoverSrc] = useState('')
const [audioMeta, setAudioMeta] = useState({ title: '', artist: '' })
const [results, setResults] = useState<SearchResult[]>([])
const [resultsOpen, setResultsOpen] = useState(false)
const [activeResultIdx, setActiveResultIdx] = useState(0)
const [audioVisible, setAudioVisible] = useState(false)
const [playlistOpen, setPlaylistOpen] = useState(false)
const playlistBtnRef = useRef<HTMLButtonElement>(null)
const loadingKeyRef = useRef(-1)
const activeResultsRef = useRef<SearchResult[]>([])
const activeResultIdxRef = useRef(0)
const prefetchTitleRef = useRef<string | null>(null)
const prefetchResultsRef = useRef<SearchResult[]>([])
useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isVizActive)
const playResult = useCallback(
(resultList: SearchResult[], resIdx: number) => {
const r = resultList[resIdx]
if (!r) return
setActiveResultIdx(resIdx)
activeResultIdxRef.current = resIdx
setAudioMeta({ title: r.title, artist: r.artist })
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
const audio = audioRef.current
if (!audio) return
audio.pause()
audio.src = proxyMp3Url(r.mp3)
audio.load()
audio.play().catch(() => {})
setAudioVisible(true)
},
[audioRef],
)
const loadTrack = useCallback(
async (idx: number, key: number) => {
if (idx < 0 || idx >= queue.length) return
loadingKeyRef.current = key
setIsVizActive(false)
setCoverSrc('')
setAudioVisible(false)
setResults([])
setResultsOpen(false)
setAudioMeta({ title: '', artist: '' })
activeResultsRef.current = []
const audio = audioRef.current
if (audio) { audio.pause(); audio.src = '' }
const track = queue[idx]
const cached = prefetchTitleRef.current === track.title ? prefetchResultsRef.current : null
prefetchTitleRef.current = null
prefetchResultsRef.current = []
if (!cached) setSearchStatus('searching')
const found = cached ?? await searchTracks(track.title)
if (loadingKeyRef.current !== key) return
activeResultsRef.current = found
setCurrentResults(found)
setResults(found)
if (!found.length) {
setSearchStatus('not-found')
setTimeout(() => {
if (loadingKeyRef.current !== key) return
const s = usePartyStore.getState()
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
}, 4000)
return
}
setSearchStatus('idle')
// Auto-select saved version if exists
const saved = getSavedVersion(track.title)
let startIdx = 0
if (saved) {
const si = found.findIndex(r => r.title === saved.title && r.artist === saved.artist)
if (si >= 0) startIdx = si
}
if (found[startIdx]?.img && !found[startIdx].img.includes('no-cover')) {
const proxied = proxyImgUrl(found[startIdx].img)
setCoverSrc(proxied)
updateQueueItemImg(idx, proxied)
}
playResult(found, startIdx)
const nextTrack = queue[idx + 1]
if (nextTrack) {
const nextTitle = nextTrack.title
if (prefetchTitleRef.current !== nextTitle) {
prefetchTitleRef.current = null
prefetchResultsRef.current = []
searchTracks(nextTitle).then(r => {
prefetchTitleRef.current = nextTitle
prefetchResultsRef.current = r
}).catch(() => {})
}
}
},
[queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion],
)
useEffect(() => {
if (queue.length > 0 && curIdx >= 0) {
loadTrack(curIdx, loadKey)
}
}, [loadKey])
const handlePlay = useCallback(() => {
initAudioViz()
resumeContext()
setIsVizActive(true)
audioState.isPlaying = true
audioState.analyser = analyserRef.current
}, [initAudioViz, resumeContext, analyserRef])
const handlePause = useCallback(() => {
setIsVizActive(false)
audioState.isPlaying = false
}, [])
const handleEnded = useCallback(() => {
setIsVizActive(false)
audioState.isPlaying = false
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)
}, [onTrackEnd])
if (!queue.length) return null
const track = queue[Math.max(0, curIdx)]
const trackTitle = track.title
const favorited = isFavorite(trackTitle)
const prevTrack = () => { const s = usePartyStore.getState(); if (s.curIdx > 0) s.setCurIdx(s.curIdx - 1) }
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) }
return (
<div className="bg-surface border border-white/[0.07] rounded-app mb-4 overflow-hidden animate-fadeUp">
{/* Hero: cover + info */}
<div className="flex relative overflow-hidden border-b border-white/[0.07] min-h-[110px] sm:min-h-[96px]">
<div className={`relative shrink-0 w-[110px] h-[110px] overflow-hidden bg-surface2 sm:w-24 sm:h-24 ${isVizActive ? 'cover-playing' : ''}`}>
<div className="cover-glow" style={{ '--glow-color': `${track.color.text}40` } as React.CSSProperties} />
{coverSrc ? (
<img
src={coverSrc}
alt=""
className={`w-full h-full object-cover block relative z-[1] transition-transform duration-500 ${isVizActive ? 'cover-img-playing' : ''}`}
/>
) : (
<div className="w-full h-full bg-surface2" />
)}
<canvas
ref={canvasRef}
className={`absolute inset-0 w-full h-full pointer-events-none z-[2] transition-opacity duration-300 ${isVizActive ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
<div className="flex-1 px-4 py-3.5 flex flex-col justify-center gap-1 bg-gradient-to-br from-surface2 to-surface min-w-0 sm:px-3">
<div className="text-[10px] font-medium tracking-[1.5px] uppercase text-accent flex items-center gap-1.5">
<div className="flex items-end gap-[2px] h-[10px]">
<div className="eq-bar" /><div className="eq-bar" /><div className="eq-bar" /><div className="eq-bar" />
</div>
Сейчас играет
</div>
<div className="font-display text-[15px] font-extrabold tracking-tight leading-tight whitespace-nowrap overflow-hidden text-ellipsis sm:text-[13px]">
{track.title}
</div>
<span
className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-0.5 rounded-[7px] self-start sm:text-[10px]"
style={{ background: track.color.bg, color: track.color.text }}
>
{track.owner}
</span>
</div>
</div>
{/* Search status */}
{searchStatus === 'searching' && (
<div className="px-4 py-3 flex items-center gap-2.5 text-[13px] text-muted border-b border-white/[0.07] sm:px-3">
<div className="w-3.5 h-3.5 rounded-full border-2 border-surface2 border-t-accent animate-spin shrink-0" />
<span>Ищем...</span>
</div>
)}
{searchStatus === 'not-found' && (
<div className="px-4 py-3 flex items-center gap-2.5 text-[13px] text-muted border-b border-white/[0.07] sm:px-3">
<span>Не найдено пропускаем через 4 секунды...</span>
</div>
)}
{/* Audio player */}
<div style={{ display: audioVisible ? 'block' : 'none' }} className="border-b border-white/[0.07]">
<div className="px-4 py-3 sm:px-3">
<div className="text-xs text-muted mb-1.5 whitespace-nowrap overflow-hidden text-ellipsis">
<span>{audioMeta.title}</span>
<span className="mx-1 opacity-30">·</span>
<span>{audioMeta.artist}</span>
</div>
<audio ref={audioRef} controls preload="auto" crossOrigin="anonymous" onPlay={handlePlay} onPause={handlePause} onEnded={handleEnded} />
</div>
</div>
{/* Version picker */}
{results.length > 1 && (
<div>
<button
onClick={() => setResultsOpen(o => !o)}
className="w-full flex items-center justify-between px-4 py-[7px] text-[10px] font-semibold tracking-[1.2px] uppercase text-muted border-b border-white/[0.07] cursor-pointer hover:bg-surface2 transition-colors duration-100 sm:px-3"
>
<span>Версии трека · выберите</span>
<span className={`text-[10px] transition-transform duration-200 ${resultsOpen ? 'rotate-180' : ''}`}></span>
</button>
<div className="overflow-hidden transition-all duration-300" style={{ maxHeight: resultsOpen ? '600px' : '0' }}>
{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 sm:px-3 ${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>
{/* Save version button */}
<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 duration-150 cursor-pointer hover:border-accent/40"
style={{
borderColor: saved ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)',
background: saved ? 'rgba(200,255,0,0.08)' : 'transparent',
}}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#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>
{/* Play button */}
<button
onClick={(e) => { e.stopPropagation(); playResult(results, i) }}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all duration-150 hover:bg-accent hover:border-accent cursor-pointer ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07] bg-transparent'}`}
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>
</div>
)}
{/* Controls */}
<div className="flex items-center gap-2 px-4 py-2.5 sm:px-3 sm:gap-1.5">
<button onClick={prevTrack} className="w-9 h-9 rounded-[9px] border border-white/[0.07] bg-transparent flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer shrink-0 sm:w-8 sm:h-8">
<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={reloadTrack} className="w-9 h-9 rounded-[9px] border border-white/[0.07] bg-transparent flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer shrink-0 sm:w-8 sm:h-8">
<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>
<button onClick={nextTrack} className="w-9 h-9 rounded-[9px] border border-white/[0.07] bg-transparent flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer shrink-0 sm:w-8 sm:h-8">
<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 className="flex-1 text-center text-xs text-muted font-display sm:text-[11px]">{curIdx + 1} / {queue.length}</div>
{/* Add to playlist */}
<div className="relative">
<button
ref={playlistBtnRef}
onClick={() => setPlaylistOpen(v => !v)}
title="Добавить в плейлист"
className="w-9 h-9 rounded-[9px] border flex items-center justify-center shrink-0 transition-all duration-150 cursor-pointer sm:w-8 sm:h-8"
style={{
borderColor: playlistOpen ? 'rgba(200,255,0,0.35)' : 'rgba(255,255,255,0.07)',
background: playlistOpen ? 'rgba(200,255,0,0.06)' : 'transparent',
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={playlistOpen ? '#c8ff00' : '#666'} 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)}
title={favorited ? 'Убрать из избранного' : 'В избранное'}
className="w-9 h-9 rounded-[9px] border flex items-center justify-center shrink-0 transition-all duration-150 cursor-pointer sm:w-8 sm:h-8"
style={{
borderColor: favorited ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)',
background: favorited ? 'rgba(200,255,0,0.08)' : 'transparent',
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? '#c8ff00' : 'none'} stroke={favorited ? '#c8ff00' : '#555'} 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>
</div>
</div>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
export default function QueueCard() {
const { queue, curIdx, setCurIdx, generateMix, reorderQueue } = usePartyStore()
const [open, setOpen] = useState(false)
const dragSrcIdx = useRef<number | null>(null)
const handleQueueClick = (e: React.MouseEvent, idx: number) => {
if ((e.target as HTMLElement).closest('.drag-handle')) return
setCurIdx(idx)
}
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],
)
if (!queue.length) return null
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden mb-4">
<div
onClick={() => setOpen((o) => !o)}
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface2 transition-colors duration-100 select-none sm:px-3"
>
<div className="flex items-center gap-2">
<span className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
Очередь · {queue.length} треков
</span>
<button
onClick={(e) => { e.stopPropagation(); generateMix() }}
className="px-2.5 py-0.5 text-[11px] border border-white/[0.07] rounded-lg bg-transparent text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer"
>
</button>
</div>
<span className={`text-muted text-[11px] transition-transform duration-200 ${open ? 'rotate-180' : ''}`}></span>
</div>
<div
className="overflow-hidden transition-all duration-300"
style={{ maxHeight: open ? '500px' : '0' }}
>
<div className="flex flex-col max-h-[500px] overflow-y-auto">
{queue.map((item, i) => {
const active = i === curIdx
return (
<div
key={i}
data-idx={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 duration-100 select-none relative sm:px-3"
style={{ background: active ? 'rgba(200,255,0,0.04)' : undefined }}
onClick={(e) => handleQueueClick(e, 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 transition-opacity duration-150 flex items-center touch-none"
title="Перетащить"
>
<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>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useState, useEffect } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { fetchTopCharts } from '@/lib/api'
import type { SearchResult } from '@/types'
function TopChartsCard({ tracks }: { tracks: string[] }) {
const { loadPlaylist } = usePartyStore()
const [launched, setLaunched] = useState(false)
const handlePlay = () => {
loadPlaylist(tracks)
setLaunched(true)
window.scrollTo({ top: 0, behavior: 'smooth' })
setTimeout(() => setLaunched(false), 2500)
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" style={{ opacity: 0.8 }} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse shrink-0" />
<span className="text-[10px] font-display font-bold tracking-[1.3px] uppercase text-accent">
Прямо сейчас
</span>
</div>
<h3 className="font-display font-bold text-[15px] text-app-text leading-tight">Топ чарт</h3>
<p className="text-[11px] text-muted mt-0.5">{tracks.length} треков</p>
</div>
<button
onClick={handlePlay}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 mt-1"
style={{
background: launched ? '#c8ff00' : 'rgba(200,255,0,0.15)',
color: launched ? '#0a0a0f' : '#c8ff00',
}}
>
{launched ? '▶ Играет' : '▶ Слушать'}
</button>
</div>
</div>
)
}
export default function SoloTab() {
const [search, setSearch] = useState('')
const [topTracks, setTopTracks] = useState<string[] | null>(null)
const { loadPlaylist } = usePartyStore()
useEffect(() => {
fetchTopCharts().then((results: SearchResult[]) => {
if (!results.length) { setTopTracks([]); return }
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])
setSearch('')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="animate-fadeUp">
{topTracks === null && (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5 flex items-center gap-2.5 text-muted text-[13px]">
<div className="w-3.5 h-3.5 rounded-full border-2 border-surface2 border-t-accent animate-spin shrink-0" />
Загружаем чарт...
</div>
)}
{topTracks !== null && topTracks.length > 0 && (
<TopChartsCard tracks={topTracks} />
)}
<div className="bg-surface border border-white/[0.07] rounded-app p-4">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-3">
Быстрый поиск
</p>
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
placeholder="Исполнитель — Название трека"
value={search}
onChange={(e) => setSearch(e.target.value)}
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
type="submit"
disabled={!search.trim()}
className="px-4 py-2.5 bg-accent text-bg font-display font-bold text-sm rounded-[9px] hover:brightness-110 active:scale-[0.97] transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
export type Tab = 'party' | 'extra' | 'history'
interface TabsProps {
active: Tab
historyCount: number
onSwitch: (tab: Tab) => void
}
export default function Tabs({ active, historyCount, onSwitch }: TabsProps) {
return (
<div className="flex gap-0 mb-4 bg-surface border border-white/[0.07] rounded-xl p-[3px]">
<button
onClick={() => onSwitch('party')}
className={`flex-1 py-2 px-1 text-[13px] font-display font-bold tracking-[0.2px] rounded-[9px] flex items-center justify-center gap-1.5 transition-all duration-200 cursor-pointer border-none ${
active === 'party' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Вечеринка</span>
</button>
<button
onClick={() => onSwitch('extra')}
className={`flex-1 py-2 px-1 text-[13px] font-display font-bold tracking-[0.2px] rounded-[9px] flex items-center justify-center gap-1.5 transition-all duration-200 cursor-pointer border-none ${
active === 'extra' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
<span>Экстра</span>
</button>
<button
onClick={() => onSwitch('history')}
className={`flex-1 py-2 px-1 text-[13px] font-display font-bold tracking-[0.2px] rounded-[9px] flex items-center justify-center gap-1.5 transition-all duration-200 cursor-pointer border-none ${
active === 'history' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span className="hidden sm:inline">История</span>
{historyCount > 0 && (
<span className="bg-accent text-bg text-[10px] font-extrabold px-1.5 py-px rounded min-w-[18px] text-center">
{historyCount}
</span>
)}
</button>
</div>
)
}

View File

@@ -0,0 +1,64 @@
'use client'
import { useEffect, useRef, RefObject } from 'react'
export function useAudioViz(
canvasRef: RefObject<HTMLCanvasElement | null>,
analyserRef: RefObject<AnalyserNode | null>,
isPlaying: boolean,
) {
const rafRef = useRef<number | null>(null)
useEffect(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (!isPlaying || !analyserRef.current || !canvasRef.current) return
const canvas = canvasRef.current
const analyser = analyserRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
const W = canvas.width
const H = canvas.height
const data = new Uint8Array(analyser.frequencyBinCount)
const draw = () => {
rafRef.current = requestAnimationFrame(draw)
analyser.getByteFrequencyData(data)
ctx.clearRect(0, 0, W, H)
const barCount = data.length
const barW = W / barCount
const maxH = H * 0.55
for (let i = 0; i < barCount; i++) {
const v = data[i] / 255
const bh = v * maxH
const x = i * barW
const hue = 80 + i * 3
const g = ctx.createLinearGradient(0, H, 0, H - bh)
g.addColorStop(0, `hsla(${hue},100%,60%,.9)`)
g.addColorStop(1, `hsla(${hue},100%,80%,.1)`)
ctx.fillStyle = g
ctx.fillRect(x, H - bh, barW * 0.7, bh)
}
const ov = ctx.createLinearGradient(0, 0, 0, H)
ov.addColorStop(0, 'rgba(10,10,15,.3)')
ov.addColorStop(0.5, 'rgba(10,10,15,0)')
ov.addColorStop(1, 'rgba(10,10,15,.6)')
ctx.fillStyle = ov
ctx.fillRect(0, 0, W, H)
}
draw()
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
}
}, [isPlaying, analyserRef, canvasRef])
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useRef, useCallback } from 'react'
export function useAudioEngine() {
const audioRef = useRef<HTMLAudioElement | null>(null)
const audioCtxRef = useRef<AudioContext | null>(null)
const analyserRef = useRef<AnalyserNode | null>(null)
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null)
const initAudioViz = useCallback(() => {
const audio = audioRef.current
if (!audio || typeof window === 'undefined') return
try {
if (!audioCtxRef.current) {
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
const ctx = new AudioCtx()
audioCtxRef.current = ctx
const analyser = ctx.createAnalyser()
analyser.fftSize = 64
analyser.smoothingTimeConstant = 0.8
analyserRef.current = analyser
}
// One MediaElementAudioSourceNode per audio element — never recreate
if (!sourceRef.current) {
const source = audioCtxRef.current.createMediaElementSource(audio)
sourceRef.current = source
source.connect(analyserRef.current!)
analyserRef.current!.connect(audioCtxRef.current.destination)
}
} catch {}
}, [])
const resumeContext = useCallback(() => {
if (audioCtxRef.current?.state === 'suspended') {
audioCtxRef.current.resume()
}
}, [])
return { audioRef, analyserRef, audioCtxRef, initAudioViz, resumeContext }
}

44
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { SearchResult } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
export function proxyImgUrl(url: string): string {
return `${API_URL}/api/proxy/img?url=${encodeURIComponent(url)}`
}
export function proxyMp3Url(url: string): string {
return url.startsWith('/api/proxy') ? `${API_URL}${url}` : url
}
export async function fetchTopCharts(): Promise<SearchResult[]> {
try {
const res = await fetch(`${API_URL}/api/proxy/top`, {
signal: AbortSignal.timeout(12_000),
})
if (!res.ok) return []
return res.json()
} catch {
return []
}
}
export async function fetchYandexPlaylist(yandexUrl: string): Promise<{ name: string; tracks: string[] }> {
const res = await fetch(`${API_URL}/api/proxy/yandex-playlist?url=${encodeURIComponent(yandexUrl)}`, {
signal: AbortSignal.timeout(15_000),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? 'Ошибка загрузки плейлиста')
return data
}
export async function searchTracks(query: string): Promise<SearchResult[]> {
try {
const res = await fetch(`${API_URL}/api/proxy/search?q=${encodeURIComponent(query)}`, {
signal: AbortSignal.timeout(12_000),
})
if (!res.ok) return []
return res.json()
} catch {
return []
}
}

View File

@@ -0,0 +1,4 @@
export const audioState = {
analyser: null as AnalyserNode | null,
isPlaying: false,
}

View File

@@ -0,0 +1,94 @@
import type { User, Playlist, PublicPlaylist } from '@/types'
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}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`)
}
if (res.status === 204) return undefined as 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',
body: JSON.stringify({ username, email, password }),
})
}
export async function login(email: string, password: string): Promise<{ token: string; user: User }> {
return request<{ token: string; user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
}
export async function fetchMe(token: string): Promise<User> {
return request<User>('/api/auth/me', { headers: bearer(token) })
}
export async function getPlaylists(token: string): Promise<Playlist[]> {
return request<Playlist[]>('/api/playlists', { headers: bearer(token) })
}
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 createPlaylist(
token: string,
name: string,
tracks: string[],
isPublic = false,
tags: string[] = [],
): 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[],
isPublic = false,
tags: string[] = [],
): 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> {
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),
})
}

View File

@@ -0,0 +1,33 @@
import type { Color } from '@/types'
export const COLORS: Color[] = [
{ bg: 'rgba(200,255,0,0.1)', text: '#c8ff00' },
{ bg: 'rgba(255,60,172,0.1)', text: '#ff3cac' },
{ bg: 'rgba(0,212,255,0.1)', text: '#00d4ff' },
{ bg: 'rgba(255,165,0,0.1)', text: '#ffa500' },
{ bg: 'rgba(140,100,255,0.1)', text: '#8c64ff' },
{ bg: 'rgba(0,255,140,0.1)', text: '#00ff8c' },
{ bg: 'rgba(255,80,80,0.1)', text: '#ff5050' },
]
export function initials(name: string): string {
return name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
export function hexToRgba(color: string, alpha: number): string {
if (color.startsWith('rgba') || color.startsWith('rgb')) {
return color.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
}
if (color.startsWith('#')) {
const r = parseInt(color.slice(1, 3), 16)
const g = parseInt(color.slice(3, 5), 16)
const b = parseInt(color.slice(5, 7), 16)
return `rgba(${r},${g},${b},${alpha})`
}
return `rgba(200,255,0,${alpha})`
}

View File

@@ -0,0 +1,44 @@
import type { Person, QueueItem } from '@/types'
function shuffleArray<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
export function fairShuffle(people: Person[]): QueueItem[] {
const pools = people.map((p, pi) =>
shuffleArray(
p.tracks.map((t, ti) => ({
title: t.title,
owner: p.name,
color: p.color,
_pi: pi,
_ti: ti,
img: '',
})),
),
)
const order = shuffleArray([...Array(pools.length).keys()])
const result: QueueItem[] = []
const maxLen = Math.max(...pools.map((p) => p.length))
for (let r = 0; r < maxLen; r++) {
for (const pi of order) {
if (r < pools[pi].length) result.push(pools[pi][r])
}
}
return result
}
export function randomShuffle(people: Person[]): QueueItem[] {
const all: QueueItem[] = []
people.forEach((p, pi) =>
p.tracks.forEach((t, ti) =>
all.push({ title: t.title, owner: p.name, color: p.color, _pi: pi, _ti: ti, img: '' }),
),
)
return shuffleArray(all)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
export interface Color {
bg: string
text: string
}
export interface SearchResult {
mp3: string
title: string
artist: string
duration: string
img: string
}
export interface PersonTrack {
title: string
}
export interface Person {
name: string
color: Color
tracks: PersonTrack[]
}
export interface QueueItem {
title: string
owner: string
color: Color
_pi: number
_ti: number
img: string
}
export interface HistoryEntry {
title: string
artist: string
img: string
owner: string
color: Color
playedAt: string
}
export type ShuffleMode = 'fair' | 'random'
export interface User {
id: string
username: string
email: string
created_at: string
}
export interface PlaylistTrack {
id: string
playlist_id: string
title: string
position: number
}
export interface Playlist {
id: string
user_id: string
name: string
is_public: boolean
tags?: string[]
created_at: string
tracks?: PlaylistTrack[]
}
export interface PublicPlaylist extends Playlist {
username: string
}

View File

@@ -0,0 +1,49 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
bg: '#0a0a0f',
surface: '#12121a',
surface2: '#1a1a26',
accent: '#c8ff00',
muted: '#555555',
'app-text': '#f0f0f0',
},
fontFamily: {
sans: ['var(--font-dm-sans)', 'DM Sans', 'sans-serif'],
display: ['var(--font-syne)', 'Syne', 'sans-serif'],
},
borderRadius: {
app: '16px',
},
keyframes: {
fadeUp: {
from: { opacity: '0', transform: 'translateY(6px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
coverPulse: {
from: { transform: 'scale(1)' },
to: { transform: 'scale(1.06)' },
},
glowPulse: {
from: { opacity: '0.4' },
to: { opacity: '0.9' },
},
},
animation: {
fadeUp: 'fadeUp 0.25s ease both',
coverPulse: 'coverPulse 2s ease-in-out infinite alternate',
glowPulse: 'glowPulse 1.5s ease-in-out infinite alternate',
},
maxWidth: {
app: '660px',
},
},
},
plugins: [],
}
export default config

20
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long