feat: nginx reverse proxy, Spotify import, overlay system, UI overhaul

- Add nginx as single entry point: /api/* → backend, /* → web
- NEXT_PUBLIC_API_URL="" so all API calls are relative (go through nginx)
- Add Spotify playlist import (Client Credentials OAuth, up to 500 tracks)
- Add Yandex/Spotify tabbed import UI on /playlists
- Add stream overlay system (SSE + polling fallback, 9 styles)
- Reorganize pages into (main) route group
- Add QueuePanel, VersionsPanel, Toaster components
- Add overlay settings tab in /settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 00:45:53 +03:00
parent 87ba7a0ecf
commit 428548a620
55 changed files with 5934 additions and 2052 deletions

View File

@@ -14,6 +14,8 @@ type Config struct {
DBPass string
DBName string
JWTSecret string
AllowedOrigins string
CookieSecure bool
}
func Load() *Config {
@@ -26,6 +28,8 @@ func Load() *Config {
DBPass: getEnv("DB_PASSWORD", "partymix"),
DBName: getEnv("DB_NAME", "partymix"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3001,http://localhost:3000"),
CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
}
}

View File

@@ -12,6 +12,9 @@ import (
"gorm.io/gorm"
)
const cookieName = "pm_token"
const cookieMaxAge = 60 * 60 * 24 * 30 // 30 days
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
@@ -58,7 +61,7 @@ type loginReq struct {
Password string `json:"password" binding:"required"`
}
func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
func Login(db *gorm.DB, jwtSecret string, cookieSecure bool) gin.HandlerFunc {
return func(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -83,10 +86,15 @@ func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user,
})
c.SetCookie(cookieName, token, cookieMaxAge, "/", "", cookieSecure, true)
c.JSON(http.StatusOK, gin.H{"user": user})
}
}
func Logout(cookieSecure bool) gin.HandlerFunc {
return func(c *gin.Context) {
c.SetCookie(cookieName, "", -1, "/", "", cookieSecure, true)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}

View File

@@ -43,8 +43,13 @@ func parseToken(tokenStr, secret string) (*jwtClaims, error) {
func AuthRequired(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
// Cookie-first, Bearer header as fallback for backwards compat
tokenStr, err := c.Cookie(cookieName)
if err != nil || tokenStr == "" {
header := c.GetHeader("Authorization")
tokenStr := strings.TrimPrefix(header, "Bearer ")
tokenStr = strings.TrimPrefix(header, "Bearer ")
}
if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return

View File

@@ -0,0 +1,175 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type OverlayState struct {
Title string `json:"title"`
Artist string `json:"artist"`
Cover string `json:"cover"`
IsPlaying bool `json:"is_playing"`
Progress float64 `json:"progress"`
Duration float64 `json:"duration"`
Enabled bool `json:"enabled"`
Design string `json:"design"`
Style string `json:"style"`
AccentColor string `json:"accent_color"`
Position string `json:"position"`
Font string `json:"font"`
TextColor string `json:"text_color"`
ShowCover bool `json:"show_cover"`
ShowEq bool `json:"show_eq"`
Palette string `json:"palette"`
CustomBg string `json:"custom_bg"`
CustomText string `json:"custom_text"`
CustomText2 string `json:"custom_text2"`
CustomChroma string `json:"custom_chroma"`
CustomTitleBg string `json:"custom_title_bg"`
CustomBodyBg string `json:"custom_body_bg"`
Margin float64 `json:"margin"`
Scale float64 `json:"scale"`
Opacity float64 `json:"opacity"`
UpdatedAt int64 `json:"updated_at"`
}
type overlayEntry struct {
mu sync.RWMutex
state OverlayState
}
var overlayMap sync.Map // userID -> *overlayEntry
// ── SSE hub ───────────────────────────────────────────────────────────────────
type sseHub struct {
mu sync.RWMutex
clients map[chan OverlayState]struct{}
}
var sseHubs sync.Map // userID -> *sseHub
func getOrCreateOverlayEntry(userID string) *overlayEntry {
v, _ := overlayMap.LoadOrStore(userID, &overlayEntry{
state: OverlayState{Design: "minimal", Enabled: true, Scale: 1, Opacity: 1, Margin: 24},
})
return v.(*overlayEntry)
}
func getOrCreateHub(userID string) *sseHub {
v, _ := sseHubs.LoadOrStore(userID, &sseHub{
clients: make(map[chan OverlayState]struct{}),
})
return v.(*sseHub)
}
func broadcastOverlay(userID string, state OverlayState) {
hub, ok := sseHubs.Load(userID)
if !ok {
return
}
h := hub.(*sseHub)
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.clients {
select {
case ch <- state:
default:
}
}
}
// PUT /api/overlay/state (requires auth)
func PushOverlayState(c *gin.Context) {
userID := currentUserID(c)
var state OverlayState
if err := c.ShouldBindJSON(&state); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
state.UpdatedAt = time.Now().UnixMilli()
entry := getOrCreateOverlayEntry(userID)
entry.mu.Lock()
entry.state = state
entry.mu.Unlock()
broadcastOverlay(userID, state)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/overlay/:token/state (public, fallback polling)
func GetOverlayState(c *gin.Context) {
token := c.Param("token")
v, ok := overlayMap.Load(token)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
entry := v.(*overlayEntry)
entry.mu.RLock()
state := entry.state
entry.mu.RUnlock()
c.JSON(http.StatusOK, state)
}
// GET /api/overlay/:token/stream (SSE, public)
func StreamOverlayState(c *gin.Context) {
token := c.Param("token")
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
// Send current state immediately
if v, ok := overlayMap.Load(token); ok {
entry := v.(*overlayEntry)
entry.mu.RLock()
state := entry.state
entry.mu.RUnlock()
data, _ := json.Marshal(state)
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
} else {
fmt.Fprintf(c.Writer, "event: notfound\ndata: {}\n\n")
}
c.Writer.Flush()
ch := make(chan OverlayState, 8)
hub := getOrCreateHub(token)
hub.mu.Lock()
hub.clients[ch] = struct{}{}
hub.mu.Unlock()
defer func() {
hub.mu.Lock()
delete(hub.clients, ch)
hub.mu.Unlock()
close(ch)
}()
ctx := c.Request.Context()
ticker := time.NewTicker(25 * time.Second)
defer ticker.Stop()
for {
select {
case state, ok := <-ch:
if !ok {
return
}
data, _ := json.Marshal(state)
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
c.Writer.Flush()
case <-ticker.C:
fmt.Fprintf(c.Writer, ": keepalive\n\n")
c.Writer.Flush()
case <-ctx.Done():
return
}
}
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
@@ -378,6 +379,167 @@ func YandexPlaylistHandler(c *gin.Context) {
})
}
// ── Spotify ───────────────────────────────────────────────────────────────────
var (
spotifyTokenMu sync.Mutex
spotifyTokenVal string
spotifyTokenExp time.Time
)
func getSpotifyToken() (string, error) {
spotifyTokenMu.Lock()
defer spotifyTokenMu.Unlock()
if spotifyTokenVal != "" && time.Now().Before(spotifyTokenExp) {
return spotifyTokenVal, nil
}
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientID == "" || clientSecret == "" {
return "", fmt.Errorf("spotify credentials not configured")
}
body := strings.NewReader("grant_type=client_credentials")
req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", body)
if err != nil {
return "", err
}
req.SetBasicAuth(clientID, clientSecret)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var tok struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return "", err
}
spotifyTokenVal = tok.AccessToken
spotifyTokenExp = time.Now().Add(time.Duration(tok.ExpiresIn-30) * time.Second)
return spotifyTokenVal, nil
}
func SpotifyPlaylistHandler(c *gin.Context) {
rawURL := c.Query("url")
if rawURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
return
}
var playlistID string
if strings.HasPrefix(rawURL, "spotify:playlist:") {
playlistID = strings.TrimPrefix(rawURL, "spotify:playlist:")
} else {
u, err := url.Parse(rawURL)
if err != nil || !strings.Contains(u.Host, "spotify.com") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid spotify url"})
return
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i, p := range parts {
if p == "playlist" && i+1 < len(parts) {
playlistID = parts[i+1]
break
}
}
}
if playlistID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "could not extract playlist id"})
return
}
// strip query params from id if present
if idx := strings.IndexAny(playlistID, "?#"); idx >= 0 {
playlistID = playlistID[:idx]
}
token, err := getSpotifyToken()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
doReq := func(apiURL string) ([]byte, error) {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("spotify returned %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// Fetch playlist name
nameBody, err := doReq("https://api.spotify.com/v1/playlists/" + url.PathEscape(playlistID) + "?fields=name")
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
var plMeta struct {
Name string `json:"name"`
}
_ = json.Unmarshal(nameBody, &plMeta)
// Fetch tracks (paginated, up to 500)
type spArtist struct{ Name string `json:"name"` }
type spTrack struct {
Name string `json:"name"`
Artists []spArtist `json:"artists"`
}
type spItem struct {
Track *spTrack `json:"track"`
}
type spPage struct {
Items []spItem `json:"items"`
Next *string `json:"next"`
}
tracks := make([]string, 0, 100)
nextURL := fmt.Sprintf(
"https://api.spotify.com/v1/playlists/%s/tracks?fields=items(track(name,artists(name))),next&limit=100",
url.PathEscape(playlistID),
)
for nextURL != "" && len(tracks) < 500 {
pageBody, err := doReq(nextURL)
if err != nil {
break
}
var page spPage
if err := json.Unmarshal(pageBody, &page); err != nil {
break
}
for _, item := range page.Items {
if item.Track == nil || item.Track.Name == "" {
continue
}
t := item.Track.Name
if len(item.Track.Artists) > 0 && item.Track.Artists[0].Name != "" {
t = item.Track.Artists[0].Name + " — " + item.Track.Name
}
tracks = append(tracks, t)
}
if page.Next != nil {
nextURL = *page.Next
} else {
nextURL = ""
}
}
c.JSON(http.StatusOK, gin.H{
"name": plMeta.Name,
"tracks": tracks,
})
}
func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) {
if redirectCount > 5 {
c.Status(http.StatusInternalServerError)

View File

@@ -52,11 +52,11 @@ type User struct {
type Playlist struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"not null;index" json:"user_id"`
UserID string `gorm:"not null;index;index:idx_playlist_user_created,composite:1" json:"user_id"`
Name string `gorm:"not null" json:"name"`
IsPublic bool `gorm:"default:false" json:"is_public"`
IsPublic bool `gorm:"default:false;index:idx_playlist_public_created,composite:1" json:"is_public"`
Tags string `gorm:"default:''" json:"-"`
CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `gorm:"index:idx_playlist_public_created,composite:2;index:idx_playlist_user_created,composite:2" json:"created_at"`
Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"`
}

View File

@@ -2,22 +2,29 @@ package router
import (
"net/http"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/toyffee/party-mix/internal/config"
"github.com/toyffee/party-mix/internal/handlers"
"gorm.io/gorm"
)
func New(db *gorm.DB, jwtSecret string) *gin.Engine {
func New(db *gorm.DB, cfg *config.Config) *gin.Engine {
r := gin.Default()
origins := strings.Split(cfg.AllowedOrigins, ",")
for i, o := range origins {
origins[i] = strings.TrimSpace(o)
}
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowOrigins: origins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
AllowCredentials: false,
AllowCredentials: true,
}))
r.GET("/health", func(c *gin.Context) {
@@ -31,25 +38,27 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine {
proxy.GET("/img", handlers.ImgProxyHandler)
proxy.GET("/mp3", handlers.MP3ProxyHandler)
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
proxy.GET("/spotify-playlist", handlers.SpotifyPlaylistHandler)
}
auth := r.Group("/api/auth")
{
auth.POST("/register", handlers.Register(db))
auth.POST("/login", handlers.Login(db, jwtSecret))
auth.GET("/me", handlers.AuthRequired(jwtSecret), handlers.Me(db))
auth.POST("/login", handlers.Login(db, cfg.JWTSecret, cfg.CookieSecure))
auth.POST("/logout", handlers.Logout(cfg.CookieSecure))
auth.GET("/me", handlers.AuthRequired(cfg.JWTSecret), handlers.Me(db))
}
r.GET("/api/playlists/public", handlers.GetPublicPlaylists(db))
versions := r.Group("/api/versions", handlers.AuthRequired(jwtSecret))
versions := r.Group("/api/versions", handlers.AuthRequired(cfg.JWTSecret))
{
versions.GET("", handlers.GetVersions(db))
versions.POST("", handlers.SaveVersion(db))
versions.DELETE("", handlers.DeleteVersion(db))
}
playlists := r.Group("/api/playlists", handlers.AuthRequired(jwtSecret))
playlists := r.Group("/api/playlists", handlers.AuthRequired(cfg.JWTSecret))
{
playlists.GET("", handlers.GetPlaylists(db))
playlists.POST("", handlers.CreatePlaylist(db))
@@ -59,6 +68,13 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine {
playlists.POST("/:id/tracks", handlers.AddTrackToPlaylist(db))
}
overlay := r.Group("/api/overlay")
{
overlay.PUT("/state", handlers.AuthRequired(cfg.JWTSecret), handlers.PushOverlayState)
overlay.GET("/:token/state", handlers.GetOverlayState)
overlay.GET("/:token/stream", handlers.StreamOverlayState)
}
remote := r.Group("/api/remote")
{
remote.POST("", handlers.CreateRemoteRoom)

View File

@@ -11,7 +11,7 @@ import (
func main() {
cfg := config.Load()
db := database.Connect(cfg)
r := router.New(db, cfg.JWTSecret)
r := router.New(db, cfg)
log.Printf("Party Mix backend starting on :%s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server failed: %v", err)

68
apps/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,68 @@
events {
worker_connections 1024;
}
http {
# SSE and MP3 streaming — no buffering, long timeouts
upstream backend {
server backend:8080;
}
upstream web {
server web:3000;
}
server {
listen 80;
client_max_body_size 16m;
# SSE overlay stream
location ~ ^/api/overlay/[^/]+/stream {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 120s;
proxy_set_header Connection '';
chunked_transfer_encoding on;
}
# MP3 proxy — large bodies, streaming
location /api/proxy/mp3 {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
# All other API calls
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 30s;
}
# Next.js app (with WebSocket for hot reload in dev)
location / {
proxy_pass http://web;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_read_timeout 60s;
}
}
}

5
apps/web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
*.log
.env*.local

View File

@@ -9,6 +9,7 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS runner

6
apps/web/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,559 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useAuthStore } from '@/store/authStore'
import { getPublicPlaylists, createPlaylist } from '@/lib/authApi'
import type { PublicPlaylist, PlaylistTrack } from '@/types'
import Header from '@/components/Header'
const TAG_PALETTE = ['var(--accent)', '#ff6b9d', '#6bcdff', '#ffb86b', '#b86bff', '#6bffb8']
function tagColor(tag: string): string {
let h = 0
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h)
return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length]
}
type SortMode = 'newest' | 'tracks' | 'alpha'
function TrackList({ tracks }: { tracks: PlaylistTrack[] }) {
return (
<div className="border-t border-white/[0.05] px-4 pt-3 pb-3.5">
<div className="flex flex-col gap-0.5">
{tracks.map((track, i) => (
<div key={track.id} className="flex items-center gap-2.5 py-[3px] group">
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
<span className="text-[12px] text-app-text/75 group-hover:text-app-text truncate transition-colors duration-100">{track.title}</span>
</div>
))}
</div>
</div>
)
}
function PlaylistCard({
pl, onPlay, isLaunched, onFork, isForkDone, isForkLoading, canFork,
}: {
pl: PublicPlaylist
onPlay: () => void
isLaunched: boolean
onFork: () => void
isForkDone: boolean
isForkLoading: boolean
canFork: boolean
}) {
const [expanded, setExpanded] = useState(false)
const tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.13] transition-all duration-200">
<div className="flex items-center gap-3 px-4 py-3.5">
<div
className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center font-display font-extrabold text-[15px] select-none"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
{pl.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="font-display text-[14px] font-bold text-app-text truncate leading-tight">{pl.name}</div>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<span className="text-[11px] font-medium text-accent">{pl.username}</span>
<span className="text-muted/30 text-[11px]">·</span>
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
{tags.map(tag => (
<span
key={tag}
className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
>
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{trackCount > 0 && (
<button
onClick={() => setExpanded(v => !v)}
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all duration-150 cursor-pointer"
title={expanded ? 'Скрыть треки' : 'Показать треки'}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
{canFork && trackCount > 0 && (
<button
onClick={onFork}
disabled={isForkDone || isForkLoading}
className="w-7 h-7 rounded-[8px] flex items-center justify-center transition-all duration-150 cursor-pointer disabled:cursor-default"
style={isForkDone ? { background: 'rgba(107,255,184,0.12)', color: '#6bffb8' } : { color: 'var(--muted)' }}
title={isForkDone ? 'Добавлено!' : 'Скопировать к себе'}
>
{isForkLoading ? (
<div className="w-3 h-3 rounded-full border-[1.5px] border-white/20 border-t-accent animate-spin" />
) : isForkDone ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="hover:text-app-text">
<path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M12 13v4M10 15h4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)}
</button>
)}
<button
onClick={onPlay}
disabled={!trackCount}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: isLaunched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.12)',
color: isLaunched ? '#0a0a0f' : 'var(--accent)',
}}
>
{isLaunched ? '▶ Играет' : '▶ Play'}
</button>
</div>
</div>
{expanded && pl.tracks && pl.tracks.length > 0 && <TrackList tracks={pl.tracks} />}
</div>
)
}
function SortButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
onClick={onClick}
className="text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer whitespace-nowrap"
style={active
? { background: 'rgba(var(--accent-rgb),0.15)', color: 'var(--accent)' }
: { background: 'rgba(255,255,255,0.04)', color: 'var(--muted)' }
}
>
{children}
</button>
)
}
function TagToggleButton({
count, open, onClick,
}: {
count: number
open: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-3 py-2 rounded-[11px] transition-all duration-150 cursor-pointer border whitespace-nowrap shrink-0"
style={count > 0
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.25)' }
: { background: 'rgba(255,255,255,0.04)', color: 'var(--muted)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="7" y1="7" x2="7.01" y2="7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
Теги
{count > 0 && (
<span
className="flex items-center justify-center w-4 h-4 rounded-full text-[10px] font-extrabold leading-none"
style={{ background: 'var(--accent)', color: '#0a0a0f' }}
>
{count}
</span>
)}
<svg width="9" height="9" viewBox="0 0 12 12" fill="none"
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.18s' }}>
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)
}
const TAG_VISIBLE_DEFAULT = 12
function TagPanel({
allTags,
activeTags,
tagCounts,
onToggle,
onClear,
}: {
allTags: string[]
activeTags: Set<string>
tagCounts: Record<string, number>
onToggle: (tag: string) => void
onClear: () => void
}) {
const [tagSearch, setTagSearch] = useState('')
const [showAll, setShowAll] = useState(false)
const count = activeTags.size
const sorted = [...allTags].sort((a, b) => (tagCounts[b] ?? 0) - (tagCounts[a] ?? 0))
const q = tagSearch.toLowerCase().trim()
const matched = q ? sorted.filter(t => t.toLowerCase().includes(q)) : sorted
const visible = q || showAll ? matched : matched.slice(0, TAG_VISIBLE_DEFAULT)
const hiddenCount = matched.length - TAG_VISIBLE_DEFAULT
return (
<div className="p-3 bg-surface border border-white/[0.07] rounded-[14px]">
<div className="flex items-center gap-2 mb-2.5">
<div className="relative flex-1">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="11" height="11" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="text"
value={tagSearch}
onChange={e => setTagSearch(e.target.value)}
placeholder="Найти тег..."
className="w-full font-sans text-[12px] bg-white/[0.04] border border-white/[0.07] rounded-[9px] pl-7 pr-2.5 py-1.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
{tagSearch && (
<button
onClick={() => setTagSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{count > 0 && (
<button
onClick={onClear}
className="text-[11px] font-medium text-muted hover:text-app-text transition-colors cursor-pointer whitespace-nowrap shrink-0"
>
Сбросить
</button>
)}
</div>
{matched.length === 0 ? (
<p className="text-[12px] text-muted text-center py-2">Не найдено</p>
) : (
<>
<div className="flex flex-wrap gap-1.5">
{visible.map(tag => {
const active = activeTags.has(tag)
const color = tagColor(tag)
return (
<button
key={tag}
onClick={() => onToggle(tag)}
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-2.5 py-1.5 rounded-[9px] transition-all duration-150 cursor-pointer"
style={active
? { background: color, color: '#0a0a0f' }
: { background: `${color}14`, color: color, border: `1px solid ${color}30` }
}
>
{active && (
<svg width="9" height="9" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
{tag}
<span className="text-[10px] font-sans font-medium opacity-60 leading-none">
{tagCounts[tag] ?? 0}
</span>
</button>
)
})}
</div>
{!q && hiddenCount > 0 && (
<button
onClick={() => setShowAll(v => !v)}
className="mt-2.5 text-[11px] font-medium text-muted hover:text-app-text transition-colors cursor-pointer"
>
{showAll ? 'Скрыть' : `Ещё ${hiddenCount} тегов`}
</button>
)}
</>
)}
</div>
)
}
export default function CommunityPage() {
const [playlists, setPlaylists] = useState<PublicPlaylist[]>([])
const [loading, setLoading] = useState(true)
const [launched, setLaunched] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [activeTags, setActiveTags] = useState<Set<string>>(new Set())
const [tagsOpen, setTagsOpen] = useState(false)
const [sort, setSort] = useState<SortMode>('newest')
const [forkStates, setForkStates] = useState<Record<string, 'idle' | 'loading' | 'done'>>({})
const { loadPlaylist } = usePartyStore()
const { user } = useAuthStore()
useEffect(() => {
getPublicPlaylists()
.then(setPlaylists)
.catch(() => setPlaylists([]))
.finally(() => setLoading(false))
}, [])
const handlePlay = (pl: PublicPlaylist) => {
const tracks = pl.tracks?.map(t => t.title) ?? []
if (!tracks.length) return
loadPlaylist(tracks)
setLaunched(pl.id)
setTimeout(() => setLaunched(null), 2500)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handlePlayAll = () => {
const tracks = filtered.flatMap(pl => pl.tracks?.map(t => t.title) ?? [])
if (!tracks.length) return
loadPlaylist(tracks)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleFork = useCallback(async (pl: PublicPlaylist) => {
if (!user || forkStates[pl.id] === 'loading' || forkStates[pl.id] === 'done') return
const tracks = pl.tracks?.map(t => t.title) ?? []
if (!tracks.length) return
setForkStates(s => ({ ...s, [pl.id]: 'loading' }))
try {
await createPlaylist(`${pl.name} (от ${pl.username})`, tracks, false, pl.tags ?? [])
setForkStates(s => ({ ...s, [pl.id]: 'done' }))
} catch {
setForkStates(s => ({ ...s, [pl.id]: 'idle' }))
}
}, [user, forkStates])
const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? [])))
const tagCounts = allTags.reduce<Record<string, number>>((acc, tag) => {
acc[tag] = playlists.filter(pl => (pl.tags ?? []).includes(tag)).length
return acc
}, {})
const totalTracks = playlists.reduce((sum, pl) => sum + (pl.tracks?.length ?? 0), 0)
const uniqueAuthors = new Set(playlists.map(pl => pl.username)).size
const filtered = playlists
.filter(pl => {
const q = search.toLowerCase().trim()
const matchesSearch = !q
|| pl.name.toLowerCase().includes(q)
|| pl.username.toLowerCase().includes(q)
|| (pl.tags ?? []).some(t => t.toLowerCase().includes(q))
const matchesTags = activeTags.size === 0 || (pl.tags ?? []).some(t => activeTags.has(t))
return matchesSearch && matchesTags
})
.sort((a, b) => {
if (sort === 'tracks') return (b.tracks?.length ?? 0) - (a.tracks?.length ?? 0)
if (sort === 'alpha') return a.name.localeCompare(b.name, 'ru')
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
const filteredTracks = filtered.reduce((sum, pl) => sum + (pl.tracks?.length ?? 0), 0)
const toggleTag = (tag: string) => {
setActiveTags(prev => {
const next = new Set(prev)
next.has(tag) ? next.delete(tag) : next.add(tag)
return next
})
}
return (
<main className="max-w-app mx-auto">
<Header />
<div className="mb-5">
<div className="flex items-baseline gap-2.5">
<h2 className="font-display text-xl font-extrabold tracking-tight">Сообщество</h2>
{!loading && (
<span className="text-[12px] text-muted font-sans">{playlists.length} плейлистов</span>
)}
</div>
<p className="text-[12px] text-muted mt-0.5">Публичные плейлисты пользователей</p>
</div>
{!loading && playlists.length > 0 && (
<>
{/* Stats bar */}
<div className="flex items-center gap-4 mb-4 px-4 py-2.5 bg-surface border border-white/[0.06] rounded-app">
<div className="flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
<path d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12 0c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-[12px] font-bold text-app-text">{filteredTracks}</span>
<span className="text-[11px] text-muted">треков</span>
</div>
<div className="w-px h-3.5 bg-white/[0.07]" />
<div className="flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<circle cx="9" cy="7" r="4" stroke="currentColor" strokeWidth="1.8" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<span className="text-[12px] font-bold text-app-text">{new Set(filtered.map(pl => pl.username)).size}</span>
<span className="text-[11px] text-muted">{new Set(filtered.map(pl => pl.username)).size === 1 ? 'автор' : new Set(filtered.map(pl => pl.username)).size < 5 ? 'автора' : 'авторов'}</span>
</div>
<div className="w-px h-3.5 bg-white/[0.07]" />
<div className="flex items-center gap-1.5">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
<rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" strokeWidth="1.8" />
<path d="M3 9h18M9 21V9" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<span className="text-[12px] font-bold text-app-text">{filtered.length}</span>
<span className="text-[11px] text-muted">{filtered.length === 1 ? 'плейлист' : filtered.length < 5 ? 'плейлиста' : 'плейлистов'}</span>
</div>
</div>
{/* Search + sort + tags */}
<div className="mb-4 flex flex-col gap-2">
<div className="flex gap-2">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск по названию, автору или тегу..."
className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{allTags.length > 0 && (
<TagToggleButton
count={activeTags.size}
open={tagsOpen}
onClick={() => setTagsOpen(v => !v)}
/>
)}
</div>
{tagsOpen && allTags.length > 0 && (
<TagPanel
allTags={allTags}
activeTags={activeTags}
tagCounts={tagCounts}
onToggle={toggleTag}
onClear={() => setActiveTags(new Set())}
/>
)}
{/* Sort */}
<div className="flex items-center gap-1">
<SortButton active={sort === 'newest'} onClick={() => setSort('newest')}>Новые</SortButton>
<SortButton active={sort === 'tracks'} onClick={() => setSort('tracks')}>По трекам</SortButton>
<SortButton active={sort === 'alpha'} onClick={() => setSort('alpha')}>АЯ</SortButton>
</div>
</div>
{/* Active tag chips */}
{activeTags.size > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3">
{Array.from(activeTags).map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className="flex items-center gap-1 text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
style={{ background: tagColor(tag), color: '#0a0a0f' }}
>
{tag}
<svg width="8" height="8" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
</button>
))}
<button
onClick={() => setActiveTags(new Set())}
className="text-[11px] text-muted hover:text-app-text transition-colors cursor-pointer px-1"
>
Сбросить всё
</button>
</div>
)}
</>
)}
{loading ? (
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
Загрузка...
</div>
) : !filtered.length ? (
<div className="text-center py-14 text-muted">
<div className="text-4xl mb-3 opacity-20">🎵</div>
<p className="text-[13px] font-medium">
{playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
</p>
<p className="text-[12px] mt-1.5 opacity-50">
{playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
</p>
</div>
) : (
<>
<div className="flex items-center justify-between mb-2.5">
<span className="text-[11px] text-muted">
{filtered.length !== playlists.length
? `${filtered.length} из ${playlists.length} · ${filteredTracks} треков`
: `${filtered.length} плейлистов · ${filteredTracks} треков`
}
</span>
<button
onClick={handlePlayAll}
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 3l14 9-14 9V3z" />
</svg>
Играть всё
</button>
</div>
<div className="flex flex-col gap-2.5">
{filtered.map(pl => (
<PlaylistCard
key={pl.id}
pl={pl}
onPlay={() => handlePlay(pl)}
isLaunched={launched === pl.id}
onFork={() => handleFork(pl)}
isForkDone={forkStates[pl.id] === 'done'}
isForkLoading={forkStates[pl.id] === 'loading'}
canFork={!!user && pl.username !== user.username}
/>
))}
</div>
</>
)}
</main>
)
}

View File

@@ -0,0 +1,20 @@
import AuthHydrator from '@/components/AuthHydrator'
import AudioBackground from '@/components/AudioBackground'
import GlobalPlayer from '@/components/GlobalPlayer'
import ThemeApplier from '@/components/ThemeApplier'
import Toaster from '@/components/Toaster'
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<div className="bg-bg min-h-screen">
<ThemeApplier />
<AudioBackground />
<div className="relative pb-[72px] px-4 pt-5 sm:px-4" style={{ zIndex: 1 }}>
<AuthHydrator />
{children}
</div>
<GlobalPlayer />
<Toaster />
</div>
)
}

View File

@@ -31,9 +31,9 @@ export default function LoginPage() {
setError('')
setLoading(true)
try {
const { token, user } = await login(email, password)
setAuth(token, user)
router.push('/')
const user = await login(email, password)
setAuth(user)
router.push('/app')
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally {

View File

@@ -0,0 +1,212 @@
import Link from 'next/link'
const EQ_BARS = [
{ h: 35, d: 0 }, { h: 72, d: 0.12 }, { h: 50, d: 0.23 }, { h: 90, d: 0.06 },
{ h: 55, d: 0.17 }, { h: 82, d: 0.28 }, { h: 44, d: 0.09 }, { h: 96, d: 0.20 },
{ h: 60, d: 0.14 }, { h: 76, d: 0.03 }, { h: 40, d: 0.25 }, { h: 85, d: 0.18 },
{ h: 52, d: 0.07 }, { h: 67, d: 0.30 }, { h: 30, d: 0.11 }, { h: 78, d: 0.22 },
{ h: 48, d: 0.16 }, { h: 88, d: 0.04 }, { h: 62, d: 0.26 }, { h: 38, d: 0.19 },
]
const STEPS = [
{
n: '01',
title: 'Создай вечеринку',
desc: 'Открой плеер, добавь гостей — каждый получает свой цвет',
},
{
n: '02',
title: 'Добавьте треки',
desc: 'Каждый гость пишет своё — плеер сам найдёт и поставит',
},
{
n: '03',
title: 'Включай музыку',
desc: 'Умный шаффл чередует очередь — никто не обделён эфиром',
},
]
const FEATURES = [
{
title: 'Совместная очередь',
desc: 'Каждый гость добавляет треки — никто не обделён эфиром',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1.2" fill="currentColor" stroke="none" />
<circle cx="3" cy="12" r="1.2" fill="currentColor" stroke="none" />
<circle cx="3" cy="18" r="1.2" fill="currentColor" stroke="none" />
</svg>
),
},
{
title: 'Умный шаффл',
desc: 'Честный режим или случайный — музыка для всех, без диктатуры',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 3 21 3 21 8" /><line x1="4" y1="20" x2="21" y2="3" />
<polyline points="21 16 21 21 16 21" /><line x1="15" y1="15" x2="21" y2="21" />
<line x1="4" y1="4" x2="9" y2="9" />
</svg>
),
},
{
title: 'Плейлисты',
desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
),
},
{
title: 'Пульт с телефона',
desc: 'Управляй плеером с любого телефона по QR-ссылке — без установки',
icon: (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" />
<circle cx="12" cy="17" r="1" fill="currentColor" stroke="none" />
</svg>
),
},
]
export default function LandingPage() {
return (
<div className="max-w-app mx-auto min-h-[calc(100vh-40px)] flex flex-col">
{/* Nav */}
<nav className="flex items-center justify-between py-3 mb-2">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-[9px] bg-accent flex items-center justify-center shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
</svg>
</div>
<span className="font-display font-extrabold text-lg tracking-tight">
Party<span className="text-accent">Mix</span>
</span>
</div>
<div className="flex items-center gap-2">
<Link href="/login" className="text-[13px] font-sans text-muted hover:text-app-text transition-colors px-2 py-1">
Войти
</Link>
<Link href="/register" className="text-[13px] font-display font-semibold px-4 py-1.5 bg-surface border border-white/[0.07] rounded-xl text-app-text hover:border-white/20 hover:bg-surface2 transition-all">
Регистрация
</Link>
</div>
</nav>
{/* Hero */}
<section className="flex flex-col items-center text-center pt-10 pb-4 relative">
{/* Glow */}
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[300px] pointer-events-none" aria-hidden="true">
<div className="absolute inset-0 rounded-full blur-3xl" style={{ background: 'rgba(var(--accent-rgb),0.07)' }} />
</div>
{/* EQ */}
<div className="relative flex items-end gap-[4px] mb-7 h-14" aria-hidden="true">
{EQ_BARS.map(({ h, d }, i) => (
<div
key={i}
className="eq-bar"
style={{ height: `${h}%`, animationDelay: `${d}s`, animationDuration: `${0.6 + d * 0.8}s`, width: '3px' }}
/>
))}
</div>
{/* Title */}
<h1 className="font-display font-extrabold leading-none tracking-tight mb-4 text-[68px] sm:text-[96px]">
Party<span className="text-accent">Mix</span>
</h1>
{/* Tagline */}
<p className="font-sans text-base text-muted max-w-[280px] mb-8 leading-relaxed">
Совместные плейлисты для вечеринок.<br />
Каждый гость&nbsp; часть музыки.
</p>
{/* CTA */}
<div className="flex items-center gap-3 flex-wrap justify-center mb-12">
<Link
href="/app"
className="px-7 py-3 font-display font-bold text-sm rounded-xl hover:brightness-110 active:scale-[0.97] transition-all"
style={{ background: 'var(--accent)', color: '#0a0a0f', boxShadow: '0 0 28px rgba(var(--accent-rgb),0.3)' }}
>
Начать вечеринку
</Link>
<Link
href="/login"
className="px-7 py-3 bg-surface border border-white/[0.07] text-app-text font-sans text-sm rounded-xl hover:bg-surface2 hover:border-white/20 active:scale-[0.97] transition-all"
>
Войти
</Link>
</div>
</section>
{/* Steps */}
<section className="py-10">
<p className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted mb-5">Как начать</p>
<div className="flex flex-col gap-4">
{STEPS.map(({ n, title, desc }) => (
<div key={n} className="flex items-start gap-4">
<span
className="text-[11px] font-display font-bold shrink-0 mt-0.5 w-8 h-8 rounded-[8px] flex items-center justify-center"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
{n}
</span>
<div>
<div className="text-[14px] font-display font-bold mb-0.5">{title}</div>
<div className="text-[12px] text-muted leading-relaxed">{desc}</div>
</div>
</div>
))}
</div>
</section>
{/* Divider */}
<div className="border-t border-white/[0.05] mb-8" />
{/* Features */}
<section className="pb-10">
<p className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted mb-5">Возможности</p>
<div className="grid grid-cols-2 gap-3">
{FEATURES.map(({ icon, title, desc }) => (
<div
key={title}
className="bg-surface border border-white/[0.07] rounded-[14px] p-4 hover:bg-surface2 hover:border-white/[0.12] transition-all duration-200"
>
<div
className="w-9 h-9 rounded-[9px] flex items-center justify-center mb-3"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
{icon}
</div>
<h3 className="font-display font-bold text-[13px] mb-1 text-app-text">{title}</h3>
<p className="text-[11px] text-muted font-sans leading-relaxed">{desc}</p>
</div>
))}
</div>
</section>
{/* Footer */}
<footer className="border-t border-white/[0.05] pt-5 pb-4 flex items-center justify-between">
<span className="text-[12px] font-display font-bold tracking-tight text-muted">
Party<span style={{ color: 'var(--accent)' }}>Mix</span>
</span>
<div className="flex items-center gap-4">
<Link href="/search" className="text-[11px] text-muted hover:text-app-text transition-colors">Поиск</Link>
<Link href="/community" className="text-[11px] text-muted hover:text-app-text transition-colors">Сообщество</Link>
<Link href="/app" className="text-[11px] text-muted hover:text-app-text transition-colors">Плеер</Link>
</div>
</footer>
</div>
)
}

View File

@@ -7,7 +7,7 @@ import { useFavoritesStore } from '@/store/favoritesStore'
import { usePartyStore } from '@/store/partyStore'
import { useVersionStore } from '@/store/versionStore'
import { getPlaylists, createPlaylist, updatePlaylist, deletePlaylist } from '@/lib/authApi'
import { searchTracks, proxyImgUrl, fetchYandexPlaylist } from '@/lib/api'
import { searchTracks, proxyImgUrl, fetchYandexPlaylist, fetchSpotifyPlaylist } from '@/lib/api'
import type { Playlist, SearchResult } from '@/types'
import Header from '@/components/Header'
@@ -161,16 +161,21 @@ function PlaylistCard({
pl,
onEdit,
onDelete,
onAddTrack,
}: {
pl: Playlist
onEdit: () => void
onDelete: () => void
onAddTrack: (id: string, title: string) => Promise<void>
}) {
const { loadPlaylist } = usePartyStore()
const [expanded, setExpanded] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const [versionsFor, setVersionsFor] = useState<string | null>(null)
const [launched, setLaunched] = useState(false)
const [addingTrack, setAddingTrack] = useState(false)
const [quickAdd, setQuickAdd] = useState('')
const [quickAdding, setQuickAdding] = useState(false)
const tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0
@@ -201,6 +206,16 @@ function PlaylistCard({
setTimeout(() => setLaunched(false), 2500)
}
const handleQuickAdd = async () => {
const t = quickAdd.trim()
if (!t) return
setQuickAdding(true)
await onAddTrack(pl.id, t)
setQuickAdd('')
setAddingTrack(false)
setQuickAdding(false)
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.11] transition-all duration-200">
<div className="flex items-center gap-3 px-4 py-3.5">
@@ -250,6 +265,14 @@ function PlaylistCard({
</svg>
</button>
)}
<button
onClick={() => setAddingTrack(v => !v)}
title="Добавить трек"
className="w-7 h-7 rounded-[8px] flex items-center justify-center border transition-all cursor-pointer"
style={{ borderColor: addingTrack ? 'rgba(var(--accent-rgb),0.35)' : 'rgba(255,255,255,0.07)', color: addingTrack ? 'var(--accent)' : undefined, background: addingTrack ? 'rgba(var(--accent-rgb),0.06)' : 'transparent' }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
<button onClick={onEdit}
className="text-[11px] px-2.5 py-1.5 border border-white/[0.07] rounded-lg bg-transparent text-muted hover:text-app-text hover:border-white/20 transition-all cursor-pointer font-display font-semibold">
Изменить
@@ -274,6 +297,27 @@ function PlaylistCard({
</div>
</div>
{addingTrack && (
<div className="flex gap-2 px-4 py-2.5 border-t border-white/[0.05] bg-surface2/30">
<input
autoFocus
type="text"
value={quickAdd}
onChange={e => setQuickAdd(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleQuickAdd(); if (e.key === 'Escape') { setAddingTrack(false); setQuickAdd('') } }}
placeholder="Исполнитель — Название"
className="flex-1 text-[12px] bg-surface2 border border-white/[0.07] rounded-[8px] px-3 py-2 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={handleQuickAdd}
disabled={!quickAdd.trim() || quickAdding}
className="px-3 py-2 text-[12px] font-display font-bold rounded-[8px] bg-accent text-bg hover:brightness-110 disabled:opacity-40 cursor-pointer transition-all"
>
{quickAdding ? '...' : 'Добавить'}
</button>
</div>
)}
{expanded && pl.tracks && pl.tracks.length > 0 && (
<div className="border-t border-white/[0.05]">
{pl.tracks.map((track, i) => (
@@ -491,7 +535,7 @@ function FavoritesCard() {
)
}
function YandexImportForm({ onImport, onClose }: {
function YandexImportForm({ onImport }: {
onImport: (name: string, tracks: string[]) => Promise<void>
onClose: () => void
}) {
@@ -533,18 +577,7 @@ function YandexImportForm({ onImport, onClose }: {
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
<div className="flex items-center justify-between mb-3">
<p className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
Импорт из Яндекс.Музыки
</p>
<button onClick={onClose} className="text-muted hover:text-app-text transition-colors cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</button>
</div>
<div className="pt-3">
<div className="flex gap-2 mb-3">
<input
type="url"
@@ -609,12 +642,121 @@ function YandexImportForm({ onImport, onClose }: {
)
}
function SpotifyImportForm({ onImport, onClose }: {
onImport: (name: string, tracks: string[]) => Promise<void>
onClose: () => void
}) {
const [importUrl, setImportUrl] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [preview, setPreview] = useState<{ name: string; tracks: string[] } | null>(null)
const [playlistName, setPlaylistName] = useState('')
const [creating, setCreating] = useState(false)
const handleLoad = async () => {
if (!importUrl.trim()) return
setLoading(true)
setError('')
setPreview(null)
try {
const data = await fetchSpotifyPlaylist(importUrl.trim())
if (!data.tracks.length) {
setError('Плейлист пуст или не удалось получить треки')
return
}
setPreview(data)
setPlaylistName(data.name || 'Плейлист из Spotify')
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки')
} finally {
setLoading(false)
}
}
const handleCreate = async () => {
if (!preview || !playlistName.trim()) return
setCreating(true)
try {
await onImport(playlistName.trim(), preview.tracks)
} finally {
setCreating(false)
}
}
return (
<div className="pt-3">
<div className="flex gap-2 mb-3">
<input
type="url"
value={importUrl}
onChange={e => { setImportUrl(e.target.value); setPreview(null); setError('') }}
onKeyDown={e => e.key === 'Enter' && handleLoad()}
placeholder="https://open.spotify.com/playlist/..."
className="flex-1 font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={handleLoad}
disabled={loading || !importUrl.trim()}
className="px-3.5 py-2.5 font-display text-[12px] font-bold rounded-[9px] border border-white/[0.1] text-muted hover:text-app-text hover:border-white/20 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0 flex items-center gap-1.5"
>
{loading ? (
<><div className="w-3 h-3 rounded-full border-2 border-surface2 border-t-accent animate-spin" /> Загрузка</>
) : 'Загрузить'}
</button>
</div>
{error && (
<div className="text-xs text-[#ff6b6b] bg-[rgba(255,107,107,0.08)] border border-[rgba(255,107,107,0.15)] px-3 py-2 rounded-lg mb-3">
{error}
</div>
)}
{preview && (
<>
<div className="bg-surface2 border border-white/[0.06] rounded-[9px] p-3 mb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-muted">Найдено треков:</span>
<span className="text-[11px] font-display font-bold" style={{ color: 'var(--accent)' }}>{preview.tracks.length}</span>
</div>
<div className="max-h-[140px] overflow-y-auto flex flex-col gap-0.5">
{preview.tracks.map((t, i) => (
<div key={i} className="flex items-center gap-2 py-0.5">
<span className="text-[10px] text-muted/40 font-mono w-4 text-right shrink-0">{i + 1}</span>
<span className="text-[11px] text-app-text/70 truncate">{t}</span>
</div>
))}
</div>
</div>
<input
type="text"
value={playlistName}
onChange={e => setPlaylistName(e.target.value)}
className="w-full font-sans text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 mb-3 transition-colors"
placeholder="Название плейлиста"
/>
<button
onClick={handleCreate}
disabled={creating || !playlistName.trim()}
className="w-full py-2.5 font-display text-[13px] font-bold bg-accent border-none rounded-[9px] text-bg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
>
{creating ? 'Создаём...' : `Создать плейлист (${preview.tracks.length} треков)`}
</button>
</>
)}
</div>
)
}
export default function PlaylistsPage() {
const router = useRouter()
const { token, user } = useAuthStore()
const { user } = useAuthStore()
const { hydrate: hydrateFavorites } = useFavoritesStore()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [loading, setLoading] = useState(true)
const [plSearch, setPlSearch] = useState('')
const [plSort, setPlSort] = useState<'date' | 'name' | 'tracks'>('date')
const [newName, setNewName] = useState('')
const [newTracks, setNewTracks] = useState('')
@@ -626,30 +768,31 @@ export default function PlaylistsPage() {
const [editingId, setEditingId] = useState<string | null>(null)
const [showImport, setShowImport] = useState(false)
const [importSource, setImportSource] = useState<'yandex' | 'spotify'>('yandex')
useEffect(() => {
hydrateFavorites()
}, [hydrateFavorites])
useEffect(() => {
if (!token) { router.push('/login'); return }
getPlaylists(token)
if (!user) { router.push('/login'); return }
getPlaylists()
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false))
}, [token, router])
}, [user, router])
const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
if (!token || !newName.trim()) return
if (!user || !newName.trim()) return
const tracks = parseTracks(newTracks)
if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return }
setCreateError('')
setSaving(true)
try {
const pl = await createPlaylist(token, newName.trim(), tracks, newIsPublic, newTags)
const pl = await createPlaylist(newName.trim(), tracks, newIsPublic, newTags)
setPlaylists(prev => [pl, ...prev])
setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([])
setShowForm(false)
@@ -661,23 +804,39 @@ export default function PlaylistsPage() {
}
const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => {
if (!token) return
if (!user) return
try {
const updated = await updatePlaylist(token, id, name, tracks, isPublic, tags)
const updated = await updatePlaylist(id, name, tracks, isPublic, tags)
setPlaylists(prev => prev.map(p => p.id === id ? updated : p))
setEditingId(null)
} catch {}
}
const handleDelete = async (id: string) => {
if (!token) return
if (!user) return
setPlaylists(prev => prev.filter(p => p.id !== id))
await deletePlaylist(token, id).catch(() => {})
await deletePlaylist(id).catch(() => {})
}
const handleAddTrack = async (id: string, title: string) => {
const pl = playlists.find(p => p.id === id)
if (!pl) return
const existing = pl.tracks?.map(t => t.title) ?? []
const updated = await updatePlaylist(id, pl.name, [...existing, title], pl.is_public, pl.tags ?? [])
setPlaylists(prev => prev.map(p => p.id === id ? updated : p))
}
const filteredPlaylists = playlists
.filter(p => !plSearch.trim() || p.name.toLowerCase().includes(plSearch.toLowerCase()))
.sort((a, b) => {
if (plSort === 'name') return a.name.localeCompare(b.name, 'ru')
if (plSort === 'tracks') return (b.tracks?.length ?? 0) - (a.tracks?.length ?? 0)
return new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime()
})
const handleImport = async (name: string, tracks: string[]) => {
if (!token) return
const pl = await createPlaylist(token, name, tracks, false, [])
if (!user) return
const pl = await createPlaylist(name, tracks, false, [])
setPlaylists(prev => [pl, ...prev])
setShowImport(false)
}
@@ -740,10 +899,33 @@ export default function PlaylistsPage() {
<FavoritesCard />
{showImport && (
<YandexImportForm
onImport={handleImport}
onClose={() => setShowImport(false)}
/>
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-1 bg-surface2 rounded-[9px] p-0.5">
{(['yandex', 'spotify'] as const).map(src => (
<button
key={src}
onClick={() => setImportSource(src)}
className="px-3 py-1 text-[11px] font-display font-bold rounded-[7px] transition-all cursor-pointer"
style={importSource === src
? { background: 'rgba(var(--accent-rgb),0.15)', color: 'var(--accent)' }
: { background: 'transparent', color: 'var(--color-muted)' }}
>
{src === 'yandex' ? 'Яндекс' : 'Spotify'}
</button>
))}
</div>
<button onClick={() => setShowImport(false)} className="text-muted hover:text-app-text transition-colors cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
</button>
</div>
{importSource === 'yandex'
? <YandexImportForm onImport={handleImport} onClose={() => setShowImport(false)} />
: <SpotifyImportForm onImport={handleImport} onClose={() => setShowImport(false)} />
}
</div>
)}
{showForm && (
@@ -801,13 +983,41 @@ export default function PlaylistsPage() {
<p className="text-[12px] mt-1.5 opacity-50">Нажмите «Создать» чтобы добавить первый</p>
</div>
) : (
<>
{/* Search + sort */}
<div className="flex gap-2 mb-3">
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="text"
value={plSearch}
onChange={e => setPlSearch(e.target.value)}
placeholder="Поиск по плейлистам..."
className="w-full text-[12px] bg-surface border border-white/[0.07] rounded-[9px] pl-8 pr-3 py-2 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
</div>
<select
value={plSort}
onChange={e => setPlSort(e.target.value as typeof plSort)}
className="text-[11px] bg-surface border border-white/[0.07] rounded-[9px] px-2.5 py-2 text-muted outline-none cursor-pointer"
>
<option value="date">По дате</option>
<option value="name">По имени</option>
<option value="tracks">По трекам</option>
</select>
</div>
<div className="flex flex-col gap-2.5">
{playlists.map(pl => (
{filteredPlaylists.map(pl => (
<div key={pl.id} className="rounded-app overflow-hidden border border-white/[0.07]">
<PlaylistCard
pl={pl}
onEdit={() => setEditingId(editingId === pl.id ? null : pl.id)}
onDelete={() => handleDelete(pl.id)}
onAddTrack={handleAddTrack}
/>
{editingId === pl.id && (
<EditForm
@@ -818,7 +1028,11 @@ export default function PlaylistsPage() {
)}
</div>
))}
{filteredPlaylists.length === 0 && plSearch && (
<div className="text-center py-8 text-muted text-[13px]">Ничего не найдено</div>
)}
</div>
</>
)}
</main>
)

View File

@@ -0,0 +1,543 @@
'use client'
import { use, useEffect, useRef, useState, useCallback } from 'react'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
interface RemoteQueueItem {
title: string
owner: string
color_bg: string
color_text: string
img?: string
}
interface RemoteVersion {
title: string
artist: string
duration: string
img?: string
}
interface RemoteState {
title: string
artist: string
cover: string
is_playing: boolean
volume: number
progress: number
duration: number
queue_len: number
cur_idx: number
queue: RemoteQueueItem[]
versions: RemoteVersion[]
active_version: number
}
function formatTime(s: number) {
if (!s || isNaN(s)) return '0:00'
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
}
async function cmd(id: string, c: string, value?: number, text?: string) {
await fetch(`${API_URL}/api/remote/${id}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
}).catch(() => {})
}
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [state, setState] = useState<RemoteState | null>(null)
const [notFound, setNotFound] = useState(false)
const [volume, setVolume] = useState(1)
const [addText, setAddText] = useState('')
const [tab, setTab] = useState<'player' | 'queue'>('player')
const [versionsOpen, setVersionsOpen] = useState(false)
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
const [localProgress, setLocalProgress] = useState(0)
const [connected, setConnected] = useState(true)
const [seeking, setSeeking] = useState(false)
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
const lastSuccessRef = useRef(Date.now())
const progressBarRef = useRef<HTMLDivElement>(null)
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
try {
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
if (s) setSavedVersions(JSON.parse(s))
} catch {}
}, [])
useEffect(() => {
let active = true
const poll = async () => {
try {
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
if (res.status === 404) { setNotFound(true); return }
if (!res.ok) return
const data: RemoteState = await res.json()
if (active) {
setState(data)
if (!seeking) {
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
setLocalProgress(data.progress ?? 0)
}
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
lastSuccessRef.current = Date.now()
setConnected(true)
}
} catch {
if (active) setConnected(false)
}
}
poll()
const iv = setInterval(poll, 2000)
return () => { active = false; clearInterval(iv) }
}, [id, seeking])
useEffect(() => {
const iv = setInterval(() => {
if (seeking) return
const { progress, ts, playing } = lastPollRef.current
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
}, 250)
return () => clearInterval(iv)
}, [seeking])
const getSeekPct = useCallback((clientX: number) => {
const bar = progressBarRef.current
if (!bar || !state?.duration) return null
const rect = bar.getBoundingClientRect()
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
}, [state?.duration])
const handleSeekStart = useCallback((clientX: number) => {
const pct = getSeekPct(clientX)
if (pct === null) return
setSeeking(true)
const seekTo = pct * (state?.duration ?? 0)
setLocalProgress(seekTo)
}, [getSeekPct, state?.duration])
const handleSeekMove = useCallback((clientX: number) => {
if (!seeking) return
const pct = getSeekPct(clientX)
if (pct === null) return
setLocalProgress(pct * (state?.duration ?? 0))
}, [seeking, getSeekPct, state?.duration])
const handleSeekEnd = useCallback((clientX: number) => {
if (!seeking) return
const pct = getSeekPct(clientX)
if (pct !== null && state) {
const seekTo = pct * state.duration
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
setLocalProgress(seekTo)
cmd(id, 'seek', seekTo)
}
setSeeking(false)
}, [seeking, getSeekPct, state, id])
useEffect(() => {
const onMouseMove = (e: MouseEvent) => handleSeekMove(e.clientX)
const onMouseUp = (e: MouseEvent) => handleSeekEnd(e.clientX)
const onTouchMove = (e: TouchEvent) => { if (e.touches[0]) handleSeekMove(e.touches[0].clientX) }
const onTouchEnd = (e: TouchEvent) => { const t = e.changedTouches[0]; if (t) handleSeekEnd(t.clientX) }
if (seeking) {
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('touchmove', onTouchMove, { passive: true })
window.addEventListener('touchend', onTouchEnd)
}
return () => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('touchmove', onTouchMove)
window.removeEventListener('touchend', onTouchEnd)
}
}, [seeking, handleSeekMove, handleSeekEnd])
if (notFound) return (
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-4">
<div className="w-16 h-16 rounded-full bg-surface2 flex items-center justify-center mb-2">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
<div>
<p className="text-app-text font-display font-bold text-[15px] mb-1">Сессия не найдена</p>
<p className="text-muted text-[12px]">Ссылка устарела или сессия завершена</p>
</div>
</div>
)
if (!state) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
</div>
)
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
const progressPct = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
const queue = state.queue ?? []
const versions = state.versions ?? []
const trackTitle = queue[state.cur_idx]?.title ?? ''
const currentOwner = queue[state.cur_idx]
const isSavedLocally = (v: RemoteVersion) => {
const s = savedVersions[trackTitle]
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
}
const handleSaveVersion = (i: number) => {
const v = versions[i]
if (!v || !trackTitle) return
const alreadySaved = isSavedLocally(v)
const next = { ...savedVersions }
if (alreadySaved) delete next[trackTitle]
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
setSavedVersions(next)
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
cmd(id, 'save_version', i)
}
return (
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-8">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<span
className="w-1.5 h-1.5 rounded-full shrink-0 transition-all duration-500"
style={{ background: connected ? '#5cc87a' : '#ff6b6b', boxShadow: connected ? '0 0 5px #5cc87a99' : 'none' }}
/>
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
</div>
<div className="flex items-center gap-1 bg-surface2 rounded-[9px] p-0.5">
{(['player', 'queue'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className="px-3 py-1.5 text-[11px] font-display font-bold rounded-[7px] transition-all cursor-pointer flex items-center gap-1"
style={{ background: tab === t ? 'rgba(var(--accent-rgb),0.12)' : 'transparent', color: tab === t ? 'var(--accent)' : '#555' }}
>
{t === 'player' ? 'Плеер' : 'Очередь'}
{t === 'queue' && queue.length > 0 && (
<span className="text-[9px] opacity-60 font-display tabular-nums">{queue.length}</span>
)}
</button>
))}
</div>
</div>
{tab === 'player' && (
<div className="flex flex-col gap-6 items-center flex-1">
{/* Cover */}
<div
className="w-full aspect-square rounded-[24px] overflow-hidden bg-surface2 shrink-0"
style={{ boxShadow: state.cover ? '0 20px 60px rgba(var(--accent-rgb),0.18), 0 8px 20px rgba(0,0,0,0.4)' : '0 8px 20px rgba(0,0,0,0.3)' }}
>
{state.cover ? (
<img src={state.cover} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#222" strokeWidth="1">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
</div>
{/* Track info */}
<div className="w-full">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="font-display text-[20px] font-extrabold tracking-tight leading-tight truncate">
{state.title || '—'}
</div>
{state.artist && (
<div className="text-[13px] text-muted mt-1 truncate">{state.artist}</div>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{currentOwner && (
<span className="text-[10px] px-2 py-0.5 rounded-[5px] font-medium" style={{ background: currentOwner.color_bg, color: currentOwner.color_text }}>
{currentOwner.owner}
</span>
)}
{state.queue_len > 0 && (
<span className="text-[11px] text-muted font-display tabular-nums">{state.cur_idx + 1} / {state.queue_len}</span>
)}
</div>
</div>
</div>
{/* Progress bar */}
<div className="w-full select-none">
<div
ref={progressBarRef}
className="relative h-4 flex items-center cursor-pointer group"
onMouseDown={(e) => { e.preventDefault(); handleSeekStart(e.clientX) }}
onTouchStart={(e) => { if (e.touches[0]) handleSeekStart(e.touches[0].clientX) }}
>
<div className="absolute inset-y-0 flex items-center w-full">
<div className="relative w-full h-1.5 bg-white/[0.08] rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full transition-none"
style={{ width: `${progressPct}%`, background: 'var(--accent)' }}
/>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-white shadow transition-none"
style={{ left: `${progressPct}%`, opacity: seeking ? 1 : 0, transition: seeking ? 'none' : 'opacity 0.15s' }}
/>
</div>
</div>
</div>
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums -mt-0.5">
<span>{formatTime(clampedProgress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between w-full px-2">
<button
onClick={() => cmd(id, 'prev')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
</svg>
</button>
<button
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
className="w-18 h-18 rounded-full flex items-center justify-center active:scale-90 transition-all cursor-pointer hover:brightness-110"
style={{
width: 72,
height: 72,
background: 'var(--accent)',
color: '#0a0a0f',
boxShadow: '0 6px 24px rgba(var(--accent-rgb),0.45)',
}}
>
{state.is_playing ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 3 }}>
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<button
onClick={() => cmd(id, 'next')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12z" /><rect x="16" y="6" width="2" height="12" rx="1" />
</svg>
</button>
</div>
{/* Volume */}
<div className="w-full flex items-center gap-3">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
</svg>
<input
type="range"
min="0"
max="1"
step="0.02"
value={volume}
onChange={(e) => {
const v = parseFloat(e.target.value)
setVolume(v)
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
}}
className="flex-1 cursor-pointer h-1.5"
style={{ accentColor: 'var(--accent)' }}
/>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
</div>
{/* Versions */}
{versions.length > 1 && (
<div className="w-full">
<button
onClick={() => setVersionsOpen(v => !v)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-[10px] bg-surface2 border border-white/[0.07] hover:border-white/[0.12] transition-colors cursor-pointer"
>
<span className="text-[12px] font-display font-bold tracking-[0.5px] text-muted">Версии трека</span>
<span className="flex items-center gap-2">
<span className="text-[11px] text-muted opacity-60">{versions.length}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#555" strokeWidth="2.5" className={`transition-transform duration-200 ${versionsOpen ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
{versionsOpen && (
<div className="mt-1.5 border border-white/[0.07] rounded-[10px] overflow-hidden bg-surface2/50">
{versions.map((v, i) => {
const active = i === state.active_version
const saved = isSavedLocally(v)
return (
<div
key={i}
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0"
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
>
<span className="text-[10px] text-muted w-4 text-right shrink-0 font-display tabular-nums">{i + 1}</span>
{v.img ? (
<img src={v.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
)}
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
<div className="text-[12px] text-app-text truncate">{v.title}</div>
<div className="text-[11px] text-muted mt-px truncate">{v.artist}</div>
</div>
<span className="text-[11px] text-muted shrink-0 font-display tabular-nums">{v.duration}</span>
<button
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ background: active ? 'var(--accent)' : 'transparent', borderColor: active ? 'var(--accent)' : 'rgba(255,255,255,0.07)' }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)}
{tab === 'queue' && (
<div className="flex flex-col gap-3 flex-1">
{/* Add track */}
<div className="flex gap-2">
<input
type="text"
value={addText}
onChange={(e) => setAddText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && addText.trim()) {
cmd(id, 'add', 0, addText.trim())
setAddText('')
}
}}
placeholder="Исполнитель — Название"
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[10px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={() => {
if (!addText.trim()) return
cmd(id, 'add', 0, addText.trim())
setAddText('')
}}
className="shrink-0 w-11 rounded-[10px] flex items-center justify-center bg-accent text-bg font-display font-bold text-[18px] cursor-pointer hover:brightness-110 active:scale-95 transition-all"
style={{ color: '#0a0a0f' }}
>
+
</button>
</div>
{/* Queue list */}
{queue.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center py-16 gap-3">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1.5">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3" cy="6" r="1" fill="#2a2a2a" stroke="none" />
<circle cx="3" cy="12" r="1" fill="#2a2a2a" stroke="none" />
<circle cx="3" cy="18" r="1" fill="#2a2a2a" stroke="none" />
</svg>
<p className="text-[13px] text-muted">Очередь пуста</p>
</div>
) : (
<div className="flex flex-col">
{queue.map((item, i) => {
const active = i === state.cur_idx
return (
<div
key={i}
onClick={() => cmd(id, 'goto', i)}
className="flex items-center gap-2.5 px-2 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer rounded-[8px] transition-colors active:bg-surface2"
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
>
{/* Index / playing indicator */}
<div className="w-5 flex items-center justify-center shrink-0">
{active ? (
<div className="flex items-end gap-[1.5px] h-3.5">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted font-display tabular-nums">{i + 1}</span>
)}
</div>
{/* Cover */}
{item.img ? (
<img src={item.img} alt="" className="w-9 h-9 rounded-[7px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-9 h-9 rounded-[7px] bg-surface2 shrink-0 flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
{/* Title + owner */}
<div className="flex-1 min-w-0">
<span className="text-[13px] text-app-text truncate block leading-tight"
style={{ color: active ? 'var(--accent)' : undefined }}
>{item.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-[4px] font-medium inline-block mt-0.5" style={{ background: item.color_bg, color: item.color_text }}>
{item.owner}
</span>
</div>
{/* Remove */}
<button
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</main>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useRef, useState } from 'react'
import { useRef, useState, useEffect, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
@@ -9,13 +9,31 @@ import AddToPlaylist from '@/components/AddToPlaylist'
import Header from '@/components/Header'
import type { SearchResult } from '@/types'
// Module-level cache: survives navigation, cleared on page refresh
let _cachedQuery = ''
let _cachedResults: SearchResult[] | null = null
const SEARCH_HISTORY_KEY = 'pm_search_history'
const MAX_HISTORY = 8
function getHistory(): string[] {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) ?? '[]') } catch { return [] }
}
function pushHistory(q: string) {
const next = [q, ...getHistory().filter(s => s !== q)].slice(0, MAX_HISTORY)
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) } catch {}
return next
}
function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: SearchResult) => void }) {
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion } = useVersionStore()
const [playlistOpen, setPlaylistOpen] = useState(false)
const addBtnRef = useRef<HTMLButtonElement>(null)
const favorited = isFavorite(result.title)
const favKey = result.artist ? `${result.artist}${result.title}` : result.title
const favorited = isFavorite(favKey)
const saved = isSaved(result.title, result)
const hasImg = result.img && !result.img.includes('no-cover')
@@ -94,7 +112,7 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
{/* Favorite */}
<button
onClick={() => toggleFavorite(result.title)}
onClick={() => toggleFavorite(favKey)}
title={favorited ? 'Убрать из избранного' : 'В избранное'}
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
style={{
@@ -122,28 +140,45 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
}
export default function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[] | null>(null)
const [query, setQuery] = useState(_cachedQuery)
const [results, setResults] = useState<SearchResult[] | null>(_cachedResults)
const [loading, setLoading] = useState(false)
const [lastQuery, setLastQuery] = useState('')
const [lastQuery, setLastQuery] = useState(_cachedQuery)
const [searchHistory, setSearchHistory] = useState<string[]>([])
const { loadPlaylist } = usePartyStore()
const inputRef = useRef<HTMLInputElement>(null)
const handleSearch = async (e?: React.FormEvent) => {
e?.preventDefault()
const q = query.trim()
if (!q) return
useEffect(() => { setSearchHistory(getHistory()) }, [])
const runSearch = useCallback(async (q: string) => {
if (!q.trim()) return
setLoading(true)
setResults(null)
setLastQuery(q)
setSearchHistory(pushHistory(q))
try {
const found = await searchTracks(q)
const raw = await searchTracks(q)
const seen = new Set<string>()
const found = raw.filter(r => {
const key = `${r.artist}|||${r.title}`
if (seen.has(key)) return false
seen.add(key)
return true
})
setResults(found)
_cachedQuery = q
_cachedResults = found
} catch {
setResults([])
_cachedResults = null
} finally {
setLoading(false)
}
}, [])
const handleSearch = (e?: React.FormEvent) => {
e?.preventDefault()
runSearch(query.trim())
}
const handlePlay = (r: SearchResult) => {
@@ -202,6 +237,35 @@ export default function SearchPage() {
</button>
</form>
{/* Search history */}
{!loading && results === null && searchHistory.length > 0 && (
<div className="mb-5">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Недавние</span>
<button
onClick={() => { try { localStorage.removeItem(SEARCH_HISTORY_KEY) } catch {} setSearchHistory([]) }}
className="text-[11px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
>
Очистить
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{searchHistory.map((q) => (
<button
key={q}
onClick={() => { setQuery(q); runSearch(q) }}
className="flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-[9px] bg-surface border border-white/[0.07] text-muted hover:text-app-text hover:border-white/[0.14] transition-all cursor-pointer truncate max-w-[220px]"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="shrink-0 opacity-50">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" strokeLinecap="round" />
</svg>
{q}
</button>
))}
</div>
</div>
)}
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-14 gap-2.5 text-muted">

View File

@@ -0,0 +1,996 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useAuthStore } from '@/store/authStore'
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
import { useBgStore, BG_PRESETS, DEFAULT_FX, type BgMode, type FxConfigs } from '@/store/bgStore'
import { getActiveAccent } from '@/store/themeStore'
import { useOverlayStore, OVERLAY_STYLES, type OverlayDesign, type OverlayStyle, type OverlayPosition } from '@/store/overlayStore'
import { getPalettes, STYLE_CUSTOM_FIELDS } from '@/lib/overlayPalettes'
import { OverlayPreview } from '@/components/OverlayWidget'
import Header from '@/components/Header'
import ColorWheel from '@/components/ColorWheel'
// ── Preview SVGs ─────────────────────────────────────────────────────────────
function OrbsPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-o1" cx="12%" cy="6%" r="60%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.65"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-o2" cx="90%" cy="96%" r="55%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.55"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-o3" cx="50%" cy="50%" r="40%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.35"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<ellipse cx="14" cy="4" rx="72" ry="72" fill="url(#p-o1)"/>
<ellipse cx="108" cy="65" rx="58" ry="58" fill="url(#p-o2)"/>
<ellipse cx="60" cy="34" rx="36" ry="36" fill="url(#p-o3)"/>
</svg>
)
}
function WavesPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<rect width="120" height="68" fill="#0a0a0f"/>
<path d="M0 47 Q15 41 30 47 Q45 53 60 47 Q75 41 90 47 Q105 53 120 47 L120 68 L0 68Z" fill="var(--accent)" opacity="0.16"/>
<path d="M0 55 Q15 49 30 55 Q45 61 60 55 Q75 49 90 55 Q105 61 120 55 L120 68 L0 68Z" fill="var(--accent)" opacity="0.22"/>
<path d="M0 41 Q20 35 40 41 Q60 47 80 41 Q100 35 120 41 L120 68 L0 68Z" fill="rgb(255,60,172)" opacity="0.10"/>
<path d="M0 61 Q20 57 40 61 Q60 65 80 61 Q100 57 120 61 L120 68 L0 68Z" fill="rgb(140,100,255)" opacity="0.14"/>
</svg>
)
}
function ParticlesPreview() {
const d: [number,number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]]
const ln: [number,number][] = [[0,1],[1,2],[0,3],[1,7],[3,5],[2,4],[4,6],[3,7],[7,8],[1,8],[9,0],[9,3]]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
{ln.map(([a,b],i) => <line key={i} x1={d[a][0]} y1={d[a][1]} x2={d[b][0]} y2={d[b][1]} stroke="var(--accent)" strokeWidth="0.6" opacity="0.2"/>)}
{d.map(([x,y],i) => <circle key={i} cx={x} cy={y} r={i%3===0?2:1.3} fill="var(--accent)" opacity={i%2===0?0.7:0.45}/>)}
</svg>
)
}
function AuroraPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-a1" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.55"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-a2" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.45"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-a3" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.40"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<ellipse cx="10" cy="10" rx="80" ry="22" fill="url(#p-a1)"/>
<ellipse cx="66" cy="6" rx="90" ry="16" fill="url(#p-a2)"/>
<ellipse cx="106" cy="19" rx="66" ry="20" fill="url(#p-a3)"/>
<ellipse cx="34" cy="32" rx="85" ry="15" fill="url(#p-a1)" opacity="0.5"/>
<ellipse cx="86" cy="44" rx="60" ry="16" fill="url(#p-a2)" opacity="0.45"/>
</svg>
)
}
function PulsePreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<defs>
<radialGradient id="p-pg" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<circle cx="60" cy="34" r="12" fill="url(#p-pg)"/>
<circle cx="60" cy="34" r="9" fill="none" stroke="var(--accent)" strokeWidth="1.8" opacity="0.75"/>
<circle cx="60" cy="34" r="20" fill="none" stroke="var(--accent)" strokeWidth="1.2" opacity="0.5"/>
<circle cx="60" cy="34" r="30" fill="none" stroke="var(--accent)" strokeWidth="0.9" opacity="0.32"/>
<circle cx="60" cy="34" r="42" fill="none" stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
<circle cx="60" cy="34" r="55" fill="none" stroke="var(--accent)" strokeWidth="0.4" opacity="0.1"/>
</svg>
)
}
function StarsPreview() {
const stars: [number,number,number][] = [
[12,8,1.4],[34,5,0.9],[58,12,1.1],[80,4,1.5],[102,9,0.8],[18,22,1.0],[45,18,1.3],[72,20,0.9],[96,25,1.2],
[8,38,0.8],[28,42,1.1],[55,35,1.4],[78,40,0.9],[108,36,1.0],[22,56,1.2],[50,58,0.8],[75,54,1.3],[100,60,1.0],
[40,28,0.7],[88,14,1.1],[15,48,0.9],[64,48,0.8]
]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<defs>
<radialGradient id="p-neb" cx="35%" cy="45%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.12"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<rect width="120" height="68" fill="url(#p-neb)"/>
{stars.map(([x,y,r],i) => (
<circle key={i} cx={x} cy={y} r={r} fill="var(--accent)" opacity={0.4 + (i % 5) * 0.12}/>
))}
</svg>
)
}
function RainPreview() {
const drops: [number,number,number][] = [
[10,8,20],[25,0,28],[40,15,22],[55,5,25],[70,10,18],[85,2,30],[100,12,24],[112,6,20],
[18,35,22],[33,28,26],[48,40,19],[63,30,24],[78,38,21],[93,25,28],[108,34,20],
]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
{drops.map(([x,y,len],i) => (
<g key={i}>
<line x1={x} y1={y} x2={x} y2={y+len} stroke="var(--accent)" strokeWidth="1.5" opacity={0.15 + (i%4)*0.08}/>
<circle cx={x} cy={y+len} r="1.5" fill="var(--accent)" opacity={0.5 + (i%3)*0.15}/>
</g>
))}
</svg>
)
}
function RaysPreview() {
const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-rg" cx="50%" cy="60%" r="55%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
{rays.map((deg, i) => {
const rad = (deg * Math.PI) / 180
const x2 = 60 + Math.cos(rad) * 90
const y2 = 41 + Math.sin(rad) * 90
return (
<line key={i} x1="60" y1="41" x2={x2} y2={y2}
stroke="var(--accent)" strokeWidth={i%2===0?2.5:1.5} opacity={i%2===0?0.18:0.10}/>
)
})}
<circle cx="60" cy="41" r="14" fill="url(#p-rg)"/>
<circle cx="60" cy="41" r="3" fill="var(--accent)" opacity="0.7"/>
</svg>
)
}
function NonePreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
<line x1="48" y1="34" x2="72" y2="34" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
<line x1="60" y1="22" x2="60" y2="46" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)
}
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
orbs: <OrbsPreview />,
waves: <WavesPreview />,
particles: <ParticlesPreview />,
aurora: <AuroraPreview />,
pulse: <PulsePreview />,
stars: <StarsPreview />,
rain: <RainPreview />,
rays: <RaysPreview />,
none: <NonePreview />,
}
// ── Per-effect slider definitions ─────────────────────────────────────────────
type SliderDef = { key: string; label: string; min: number; max: number; step: number; fmt: (v: number) => string }
const TRAIL_SLIDER: SliderDef = {
key: 'trail', label: 'Шлейф', min: 0, max: 0.95, step: 0.05,
fmt: v => v < 0.02 ? 'Нет' : Math.round(v * 100) + '%',
}
const FX_SLIDERS: Partial<Record<keyof FxConfigs, SliderDef[]>> = {
orbs: [
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
waves: [
{ key: 'amplitude', label: 'Амплитуда', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
particles: [
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'linkDist', label: 'Связи', min: 0.3, max: 2.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
aurora: [
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
pulse: [
{ key: 'sensitivity', label: 'Чувствительность', min: 0, max: 1.0, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ key: 'ringSpeed', label: 'Скорость колец', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
stars: [
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'twinkle', label: 'Мерцание', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
rain: [
{ key: 'drops', label: 'Количество', min: 5, max: 60, step: 1, fmt: v => String(Math.round(v)) },
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
rays: [
{ key: 'count', label: 'Количество', min: 4, max: 16, step: 1, fmt: v => String(Math.round(v)) },
{ key: 'speed', label: 'Скорость', min: 0.2, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
{ key: 'spread', label: 'Ширина', min: 0.3, max: 2.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
TRAIL_SLIDER,
],
}
// ── Chevron icon ──────────────────────────────────────────────────────────────
function Chevron({ open }: { open: boolean }) {
return (
<svg
width="16" height="16" viewBox="0 0 16 16" fill="none"
className="shrink-0 transition-transform duration-200"
style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' }}
>
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
}
// ── Overlay style previews ────────────────────────────────────────────────────
function OverlayStylePreview({ style }: { style: OverlayStyle }) {
const W = 160, H = 90
if (style === 'classic') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#0a0a10"/>
<rect x="10" y="60" width="100" height="22" rx="8" fill="rgba(10,10,16,0.85)" stroke="rgba(255,255,255,0.09)" strokeWidth="0.6"/>
<rect x="18" y="67" width="10" height="10" rx="3" fill="rgba(255,255,255,0.08)"/>
<rect x="34" y="68" width="50" height="3.5" rx="1.5" fill="rgba(255,255,255,0.7)"/>
<rect x="34" y="74" width="32" height="2" rx="1" fill="rgba(255,255,255,0.3)"/>
<rect x="102" y="66" width="2" height="3" rx="1" fill="var(--accent)"/>
<rect x="105" y="64" width="2" height="7" rx="1" fill="var(--accent)"/>
<rect x="108" y="67" width="2" height="4" rx="1" fill="var(--accent)"/>
</svg>
)
if (style === 'aero') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#1a2a3a"/>
<rect x="10" y="62" width="110" height="20" rx="12" fill="rgba(160,210,255,0.4)" stroke="rgba(255,255,255,0.7)" strokeWidth="0.8"/>
<rect x="10" y="62" width="110" height="10" rx="12" fill="rgba(255,255,255,0.3)"/>
<circle cx="24" cy="72" r="7" fill="rgba(255,255,255,0.3)" stroke="rgba(255,255,255,0.6)" strokeWidth="0.6"/>
<rect x="37" y="68" width="48" height="3" rx="1.5" fill="rgba(0,40,120,0.8)"/>
<rect x="37" y="73" width="30" height="2" rx="1" fill="rgba(0,60,160,0.5)"/>
</svg>
)
if (style === 'retro') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#0a0400"/>
<rect x="10" y="55" width="120" height="28" fill="rgba(12,5,0,0.95)" stroke="#b06828" strokeWidth="1.2"/>
<rect x="10" y="55" width="120" height="10" fill="rgba(160,70,10,0.4)"/>
<text x="15" y="63" fontSize="6" fill="#e08030" fontFamily="monospace"> NOW PLAYING</text>
<text x="108" y="63" fontSize="6" fill="#f09040" fontFamily="monospace"> REC</text>
<rect x="15" y="70" width="90" height="3.5" rx="0" fill="rgba(248,208,144,0.8)" fontFamily="monospace"/>
<rect x="15" y="76" width="55" height="2.5" rx="0" fill="rgba(154,96,32,0.7)"/>
<text x="112" y="80" fontSize="5" fill="#7a4810" fontFamily="monospace">00:42</text>
</svg>
)
if (style === 'neon') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#00000f"/>
<rect x="10" y="54" width="120" height="30" rx="6" fill="rgba(0,0,10,0.9)" stroke="rgba(var(--accent-rgb, 222,156,254),0.65)" strokeWidth="0.8"/>
<rect x="10" y="54" width="6" height="6" rx="1" fill="none" stroke="var(--accent)" strokeWidth="0.8"/>
<rect x="124" y="54" width="6" height="6" rx="1" fill="none" stroke="var(--accent)" strokeWidth="0.8"/>
<text x="16" y="64" fontSize="5" fill="rgba(222,156,254,0.5)" fontFamily="monospace" letterSpacing="2"> STREAM</text>
<rect x="16" y="68" width="80" height="3.5" rx="1" fill="var(--accent)" opacity="0.85"/>
<rect x="16" y="74" width="50" height="2" rx="1" fill="rgba(222,156,254,0.45)"/>
</svg>
)
if (style === 'clean') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#111"/>
<rect x="14" y="58" width="3" height="22" rx="1.5" fill="var(--accent)"/>
<rect x="22" y="60" width="95" height="5" rx="2" fill="rgba(255,255,255,0.85)"/>
<rect x="22" y="68" width="60" height="3" rx="1.5" fill="rgba(255,255,255,0.4)"/>
</svg>
)
if (style === 'y2k') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#8090a0"/>
<rect x="15" y="48" width="120" height="36" rx="3" fill="#c8d0e0" stroke="rgba(255,255,255,0.9)" strokeWidth="1"/>
<rect x="15" y="48" width="120" height="12" rx="3" fill="url(#xp)"/>
<defs><linearGradient id="xp" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="#1a4090"/><stop offset="50%" stopColor="#4a80d0"/><stop offset="100%" stopColor="#1a4090"/></linearGradient></defs>
<text x="20" y="57" fontSize="6" fill="white" fontFamily="Tahoma,sans-serif" fontWeight="700">🎵 Party Mix Player</text>
<rect x="120" y="50" width="7" height="6" rx="1" fill="#c0c8d8" stroke="rgba(100,120,160,0.8)" strokeWidth="0.5"/>
<text x="121.5" y="55" fontSize="6" fill="#000" fontWeight="700">×</text>
<rect x="20" y="64" width="100" height="8" rx="1" fill="rgba(255,255,255,0.5)" stroke="#8090b0" strokeWidth="0.5"/>
<rect x="20" y="68" width="45" height="4" rx="0.5" fill="#2060b0"/>
<rect x="20" y="75" width="100" height="5" rx="1" fill="rgba(255,255,255,0.4)" stroke="#8090b0" strokeWidth="0.5"/>
</svg>
)
if (style === 'lofi') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`} style={{ transform: 'rotate(-0.5deg)' }}>
<rect width={W} height={H} fill="#1a0e06"/>
<rect x="12" y="52" width="120" height="34" rx="12" fill="rgba(42,26,14,0.92)" stroke="rgba(240,180,100,0.2)" strokeWidth="0.8"/>
<circle cx="34" cy="69" r="14" fill="radial-gradient(#3a2010,#1a0e06)" stroke="rgba(255,180,80,0.3)" strokeWidth="0.8"/>
<circle cx="34" cy="69" r="14" fill="rgba(30,16,6,0.9)"/>
<circle cx="34" cy="69" r="5" fill="rgba(255,160,60,0.4)"/>
<text x="53" y="62" fontSize="5" fill="rgba(240,180,100,0.5)" fontFamily="serif" fontStyle="italic">now playing</text>
<rect x="53" y="65" width="65" height="3.5" rx="1" fill="rgba(253,232,192,0.8)"/>
<rect x="53" y="71" width="42" height="2.5" rx="1" fill="rgba(240,180,100,0.45)"/>
</svg>
)
if (style === 'glam') return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#0a0800"/>
<rect x="15" y="48" width="120" height="38" rx="10" fill="rgba(22,14,2,0.96)" stroke="rgba(212,175,55,0.55)" strokeWidth="0.8"/>
<rect x="15" y="48" width="120" height="2" rx="1" fill="url(#gld)"/>
<rect x="15" y="84" width="120" height="2" rx="1" fill="url(#gld)"/>
<defs><linearGradient id="gld" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="transparent"/><stop offset="30%" stopColor="rgba(212,175,55,0.9)"/><stop offset="50%" stopColor="#ffd700"/><stop offset="70%" stopColor="rgba(212,175,55,0.9)"/><stop offset="100%" stopColor="transparent"/></linearGradient></defs>
<text x="24" y="56" fontSize="5" fill="rgba(212,175,55,0.5)"></text>
<text x="125" y="56" fontSize="5" fill="rgba(212,175,55,0.5)"></text>
<text x="80" y="63" fontSize="5" fill="rgba(212,175,55,0.45)" textAnchor="middle" letterSpacing="2">NOW PLAYING</text>
<rect x="30" y="65" width="90" height="0.8" fill="rgba(212,175,55,0.3)"/>
<rect x="30" y="69" width="90" height="4" rx="1" fill="rgba(240,200,64,0.8)" textAnchor="middle"/>
<rect x="45" y="75" width="60" height="2.5" rx="1" fill="rgba(200,150,30,0.5)"/>
</svg>
)
// matrix
return (
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
<rect width={W} height={H} fill="#000800"/>
<rect x="10" y="50" width="130" height="34" rx="3" fill="rgba(0,8,0,0.93)" stroke="rgba(0,255,50,0.35)" strokeWidth="0.7"/>
<text x="16" y="61" fontSize="5" fill="rgba(0,200,40,0.5)" fontFamily="monospace">PARTY_MIX@STREAM:~$</text>
<text x="16" y="70" fontSize="7" fill="#00ff32" fontFamily="monospace" fontWeight="700">&gt; Земфира Хочешь?</text>
<text x="16" y="78" fontSize="5" fill="rgba(0,180,30,0.4)" fontFamily="monospace">[LOADING...]</text>
</svg>
)
}
// ── Page ─────────────────────────────────────────────────────────────────────
type SettingsTab = 'appearance' | 'overlay'
const OVERLAY_DESIGNS: { id: OverlayDesign; name: string; desc: string }[] = [
{ id: 'minimal', name: 'Минимальный', desc: 'Маленькая плашка с EQ и названием' },
{ id: 'card', name: 'Карточка', desc: 'Обложка, название и артист' },
{ id: 'bar', name: 'Широкая', desc: 'Полоса во всю ширину снизу' },
]
const OVERLAY_POSITIONS: { id: OverlayPosition; arrow: string; label: string }[] = [
{ id: 'tl', arrow: '↖', label: 'Лево сверху' },
{ id: 'tr', arrow: '↗', label: 'Право сверху' },
{ id: 'bl', arrow: '↙', label: 'Лево снизу' },
{ id: 'br', arrow: '↘', label: 'Право снизу' },
]
const OVERLAY_FONTS: { id: string; name: string; css: string }[] = [
{ id: '', name: 'Авто', css: 'inherit' },
{ id: 'Inter, sans-serif', name: 'Inter', css: 'Inter, sans-serif' },
{ id: 'Space Grotesk, sans-serif', name: 'Space Grotesk',css: 'Space Grotesk, sans-serif' },
{ id: 'Unbounded, sans-serif', name: 'Unbounded', css: 'Unbounded, sans-serif' },
{ id: 'JetBrains Mono, monospace', name: 'JetBrains Mono', css: 'JetBrains Mono, monospace' },
{ id: 'Playfair Display, serif', name: 'Playfair Display', css: 'Playfair Display, serif' },
]
export default function SettingsPage() {
const { user } = useAuthStore()
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
const { bgMode, fxConfigs, setBg, setFxConfig, resetFx } = useBgStore()
const {
enabled: overlayEnabled, design: overlayDesign, style: overlayStyle,
accentColor: overlayAccentColor, position: overlayPosition,
font: overlayFont, textColor: overlayTextColor,
showCover: overlayShowCover, showEq: overlayShowEq,
setEnabled: setOverlayEnabled, setDesign: setOverlayDesign, setStyle: setOverlayStyle,
setAccentColor: setOverlayAccentColor, setPosition: setOverlayPosition,
setFont: setOverlayFont, setTextColor: setOverlayTextColor,
setShowCover: setOverlayShowCover, setShowEq: setOverlayShowEq,
palette: overlayPalette, setPalette: setOverlayPalette,
customPalettes, setCustomPaletteField,
margin: overlayMargin, scale: overlayScale, opacity: overlayOpacity,
setMargin: setOverlayMargin, setScale: setOverlayScale, setOpacity: setOverlayOpacity,
} = useOverlayStore()
const router = useRouter()
const activeAccent = getActiveAccent(accentIdx, customHex)
const [tab, setTab] = useState<SettingsTab>('appearance')
const [showAccent, setShowAccent] = useState(true)
const [showBg, setShowBg] = useState(true)
const [copied, setCopied] = useState(false)
const [activeColorPicker, setActiveColorPicker] = useState<'accent' | 'text' | null>(null)
const [activeCustomField, setActiveCustomField] = useState<string | null>(null)
const [showOvPreview, setShowOvPreview] = useState(true)
const [showOvStyle, setShowOvStyle] = useState(true)
const [showOvPalette, setShowOvPalette] = useState(true)
const [showOvColors, setShowOvColors] = useState(true)
const [showOvFont, setShowOvFont] = useState(true)
const [showOvLayout, setShowOvLayout] = useState(true)
useEffect(() => {
if (!user) router.replace('/login')
}, [user, router])
if (!user) return null
const overlayUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/overlay/${user.id}`
const copyUrl = () => {
navigator.clipboard.writeText(overlayUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
const activeFxMode = bgMode !== 'none' ? bgMode as keyof FxConfigs : null
const fxSliders = activeFxMode ? FX_SLIDERS[activeFxMode] ?? null : null
return (
<main className="max-w-app mx-auto relative z-10">
<Header />
<div className="animate-fadeUp">
<h2 className="font-display text-xl font-extrabold tracking-tight text-app-text mb-4">Настройки</h2>
{/* Tabs */}
<div className="flex gap-1 bg-surface2 rounded-[10px] p-1 mb-4">
{([['appearance', 'Внешний вид'], ['overlay', 'Оверлей']] as [SettingsTab, string][]).map(([id, label]) => (
<button
key={id}
onClick={() => setTab(id)}
className="flex-1 py-2 text-[12px] font-display font-bold rounded-[8px] transition-all cursor-pointer"
style={tab === id
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)' }
: { color: '#555' }
}
>
{label}
</button>
))}
</div>
{/* ── Appearance tab ─────────────────────────────────────────────── */}
{tab === 'appearance' && (
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
Внешний вид
</p>
{/* ── Accent color (collapsible) ─────────────────────────────── */}
<button
onClick={() => setShowAccent(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Акцентный цвет</span>
<Chevron open={showAccent} />
</button>
{showAccent && (
<div className="mb-4">
<div className="flex flex-wrap gap-2 mb-3">
{ACCENT_PRESETS.map((preset, i) => (
<button
key={i}
onClick={() => setAccent(i)}
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
style={accentIdx === i
? { background: `rgba(${preset.rgb},0.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-3 h-3 rounded-full shrink-0"
style={{ background: preset.accent, boxShadow: accentIdx === i ? `0 0 7px ${preset.accent}90` : 'none' }}/>
{preset.name}
</button>
))}
<button
onClick={() => setCustom(customHex)}
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
style={accentIdx === -1
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
style={{
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
}}/>
Свой
</button>
</div>
{accentIdx === -1 && (
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={customHex} onChange={setCustom} />
</div>
)}
</div>
)}
{/* ── Live background (collapsible) ─────────────────────────── */}
<button
onClick={() => setShowBg(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Живой фон</span>
<Chevron open={showBg} />
</button>
{showBg && (
<div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{BG_PRESETS.map((preset) => {
const active = bgMode === preset.id
return (
<button
key={preset.id}
onClick={() => setBg(preset.id)}
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
style={active
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
: { borderColor: 'rgba(255,255,255,0.07)' }
}
>
<div className="w-full aspect-video overflow-hidden">
{BG_PREVIEWS[preset.id]}
</div>
<div className="px-3 py-2" style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}>
<p className="text-[12px] font-display font-bold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
{preset.name}
</p>
<p className="text-[10px] text-muted mt-0.5">{preset.desc}</p>
</div>
</button>
)
})}
</div>
{/* Per-effect config panel */}
{activeFxMode && fxSliders && (
<div className="mt-4 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<div className="flex items-center justify-between mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
Настройки эффекта
</p>
<button
onClick={() => resetFx(activeFxMode)}
className="text-[10px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
>
Сбросить
</button>
</div>
<div className="flex flex-col gap-4">
{fxSliders.map(({ key, label, min, max, step, fmt }) => {
const val = (fxConfigs[activeFxMode] as unknown as Record<string, number>)[key] ?? (DEFAULT_FX[activeFxMode] as unknown as Record<string, number>)[key]
return (
<div key={key}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[12px] text-app-text/70">{label}</span>
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>
{fmt(val)}
</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={val}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(e) => setFxConfig(activeFxMode, { [key]: Number(e.target.value) } as any)}
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
style={{ accentColor: 'var(--accent)' }}
/>
</div>
)
})}
</div>
</div>
)}
</div>
)}
</section>
)}
{/* ── Overlay tab ────────────────────────────────────────────────── */}
{tab === 'overlay' && (
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3 flex flex-col gap-5">
{/* ── Header: enable toggle + URL ──────────────────────────────── */}
<div className="flex items-center gap-3">
<button
onClick={() => setOverlayEnabled(!overlayEnabled)}
className="relative w-11 h-6 rounded-full transition-all duration-200 cursor-pointer shrink-0"
style={{ background: overlayEnabled ? 'var(--accent)' : 'rgba(255,255,255,0.1)' }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all duration-200"
style={{ left: overlayEnabled ? 'calc(100% - 22px)' : '2px' }}
/>
</button>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-app-text">Оверлей для стрима</p>
<p className="text-[10px] font-mono text-muted/50 truncate mt-0.5">{overlayUrl}</p>
</div>
<button
onClick={copyUrl}
title="Скопировать URL"
className="shrink-0 px-3 py-1.5 rounded-[8px] text-[11px] font-display font-bold cursor-pointer transition-all border"
style={copied
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.3)' }
: { background: 'transparent', color: '#666', borderColor: 'rgba(255,255,255,0.08)' }
}
>
{copied ? '✓' : 'URL'}
</button>
</div>
{/* ── Live preview ─────────────────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvPreview(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Превью</span>
<Chevron open={showOvPreview} />
</button>
{showOvPreview && <OverlayPreview />}
</div>
{/* ── Style picker ─────────────────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvStyle(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Стиль</span>
<Chevron open={showOvStyle} />
</button>
{showOvStyle && <div className="grid grid-cols-3 gap-2">
{(Object.entries(OVERLAY_STYLES) as [OverlayStyle, typeof OVERLAY_STYLES[OverlayStyle]][]).map(([id, { name, desc }]) => {
const active = overlayStyle === id
return (
<button
key={id}
onClick={() => { setOverlayStyle(id); setOverlayPalette('default'); setActiveCustomField(null) }}
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
style={active
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
: { borderColor: 'rgba(255,255,255,0.07)' }
}
>
<div className="w-full aspect-video bg-black overflow-hidden">
<OverlayStylePreview style={id} />
</div>
<div
className="px-2 py-1.5"
style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}
>
<p className="text-[11px] font-display font-bold truncate" style={{ color: active ? 'var(--accent)' : '#bbb' }}>{name}</p>
<p className="text-[9px] text-muted mt-0.5 truncate">{desc}</p>
</div>
</button>
)
})}
</div>}
</div>
{/* ── Palette picker ───────────────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvPalette(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Палитра</span>
<Chevron open={showOvPalette} />
</button>
{showOvPalette && <>
<div className="grid grid-cols-2 gap-2 mb-2">
{getPalettes(overlayStyle).map((pal) => {
const active = overlayPalette === pal.id
return (
<button
key={pal.id}
onClick={() => { setOverlayPalette(pal.id); setActiveCustomField(null) }}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer text-left"
style={active
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<div className="flex gap-1 shrink-0">
{pal.swatches.slice(0, 3).map((color, i) => (
<span key={i} className="w-3.5 h-3.5 rounded-full border border-white/10"
style={{ background: color.startsWith('var(') ? 'var(--accent)' : color }} />
))}
</div>
<span className="text-[12px] font-display font-semibold truncate" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
{pal.name}
</span>
</button>
)
})}
{/* Custom palette button */}
{(() => {
const active = overlayPalette === 'custom'
const cp = customPalettes[overlayStyle] ?? {}
const fields = STYLE_CUSTOM_FIELDS[overlayStyle] ?? []
const swatchColors = fields.slice(0, 3).map(f => cp[f.key] ?? f.default)
return (
<button
onClick={() => {
setOverlayPalette('custom')
if (!activeCustomField) setActiveCustomField(fields[0]?.key ?? null)
}}
className="col-span-2 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer text-left"
style={active
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<div className="flex gap-1 shrink-0">
{swatchColors.map((color, i) => (
<span key={i} className="w-3.5 h-3.5 rounded-full border border-white/20" style={{ background: color }} />
))}
</div>
<span className="text-[12px] font-display font-semibold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
Свой
</span>
<span className="ml-auto text-[11px]" style={{ color: active ? 'var(--accent)' : '#555' }}></span>
</button>
)
})()}
</div>
{/* Custom palette editor */}
{overlayPalette === 'custom' && (() => {
const fields = STYLE_CUSTOM_FIELDS[overlayStyle] ?? []
const cp = customPalettes[overlayStyle] ?? {}
const currentField = activeCustomField ?? fields[0]?.key ?? null
const currentValue = currentField
? (cp[currentField] ?? (fields.find(f => f.key === currentField)?.default ?? '#ffffff'))
: '#ffffff'
return (
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<div className="flex gap-1.5 flex-wrap mb-4">
{fields.map((f) => {
const val = cp[f.key] ?? f.default
const isActive = currentField === f.key
return (
<button
key={f.key}
onClick={() => setActiveCustomField(f.key)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[7px] border text-[11px] font-display font-semibold transition-all cursor-pointer"
style={isActive
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.4)', color: 'var(--accent)' }
: { background: 'rgba(255,255,255,0.03)', borderColor: 'rgba(255,255,255,0.07)', color: '#888' }
}
>
<span className="w-3 h-3 rounded-full border border-white/15 shrink-0" style={{ background: val }} />
{f.label}
</button>
)
})}
</div>
{currentField && (
<ColorWheel
value={currentValue}
onChange={(hex) => setCustomPaletteField(overlayStyle, currentField, hex)}
/>
)}
</div>
)
})()}
</>}
</div>
{/* ── Colors: accent + text ─────────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvColors(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Цвета</span>
<Chevron open={showOvColors} />
</button>
{showOvColors && <>
<div className="flex gap-2 mb-2">
{/* Accent color */}
<button
onClick={() => setActiveColorPicker(p => p === 'accent' ? null : 'accent')}
className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer"
style={activeColorPicker === 'accent'
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-4 h-4 rounded-full border border-white/20 shrink-0" style={{ background: overlayAccentColor }} />
<span className="text-[12px] font-display font-semibold" style={{ color: activeColorPicker === 'accent' ? 'var(--accent)' : '#bbb' }}>
Акцент
</span>
<Chevron open={activeColorPicker === 'accent'} />
</button>
{/* Text color */}
<button
onClick={() => setActiveColorPicker(p => p === 'text' ? null : 'text')}
className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer"
style={activeColorPicker === 'text'
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
{overlayTextColor
? <span className="w-4 h-4 rounded-full border border-white/20 shrink-0" style={{ background: overlayTextColor }} />
: <span className="w-4 h-4 rounded-full border border-white/15 shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
<span className="text-[7px] text-muted">A</span>
</span>
}
<span className="text-[12px] font-display font-semibold" style={{ color: activeColorPicker === 'text' ? 'var(--accent)' : '#bbb' }}>
Текст
</span>
{!overlayTextColor && <span className="ml-auto text-[10px] text-muted/50 font-mono">авто</span>}
<Chevron open={activeColorPicker === 'text'} />
</button>
</div>
{activeColorPicker === 'accent' && (
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={overlayAccentColor} onChange={setOverlayAccentColor} />
</div>
)}
{activeColorPicker === 'text' && (
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={overlayTextColor || '#ffffff'} onChange={setOverlayTextColor} />
{overlayTextColor && (
<button
onClick={() => setOverlayTextColor('')}
className="mt-3 text-[11px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
>
Авто (по стилю)
</button>
)}
</div>
)}
</>}
</div>
{/* ── Font: horizontal chips ───────────────────────────────────── */}
<div>
<button
onClick={() => setShowOvFont(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Шрифт</span>
<Chevron open={showOvFont} />
</button>
{showOvFont && <div className="flex gap-1.5 overflow-x-auto pb-0.5 -mx-1 px-1">
{OVERLAY_FONTS.map(({ id, name, css }) => {
const active = overlayFont === id
return (
<button
key={id}
onClick={() => setOverlayFont(id)}
className="flex-none flex flex-col items-center gap-1 px-3 pt-2 pb-2 rounded-[10px] border transition-all duration-150 cursor-pointer min-w-[70px]"
style={active
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span
className="text-[15px] leading-none"
style={{ fontFamily: css, color: active ? 'var(--accent)' : '#aaa', fontWeight: active ? 700 : 400 }}
>
Ag
</span>
<span
className="text-[9px] font-display font-semibold truncate max-w-[64px]"
style={{ color: active ? 'var(--accent)' : '#555' }}
>
{name}
</span>
</button>
)
})}
</div>}
</div>
{/* ── Layout: position + cover/eq ──────────────────────────────── */}
<div>
<button
onClick={() => setShowOvLayout(v => !v)}
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
>
<span className="text-[12px]">Макет</span>
<Chevron open={showOvLayout} />
</button>
{showOvLayout && <div className="flex items-start gap-6">
{/* Position grid */}
<div>
<p className="text-[10px] text-muted mb-2">Позиция</p>
<div className="grid grid-cols-2 gap-1 w-[72px]">
{OVERLAY_POSITIONS.map(({ id, arrow, label }) => {
const active = overlayPosition === id
return (
<button
key={id}
title={label}
onClick={() => setOverlayPosition(id)}
className="flex items-center justify-center h-8 rounded-[7px] text-base transition-all duration-150 cursor-pointer border"
style={active
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
: { background: 'rgba(255,255,255,0.03)', color: '#555', borderColor: 'rgba(255,255,255,0.07)' }
}
>
{arrow}
</button>
)
})}
</div>
</div>
{/* Toggles */}
<div className="flex-1 flex flex-col gap-3 pt-[22px]">
{([
{ label: 'Обложка', val: overlayShowCover, set: setOverlayShowCover },
{ label: 'EQ-анимация', val: overlayShowEq, set: setOverlayShowEq },
] as const).map(({ label, val, set }) => (
<div key={label} className="flex items-center justify-between">
<span className="text-[12px] text-app-text/70">{label}</span>
<button
onClick={() => set(!val)}
className="relative w-10 h-5 rounded-full transition-all duration-200 cursor-pointer shrink-0"
style={{ background: val ? 'var(--accent)' : 'rgba(255,255,255,0.1)' }}
>
<span
className="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all duration-200"
style={{ left: val ? 'calc(100% - 18px)' : '2px' }}
/>
</button>
</div>
))}
</div>
</div>}
{/* Sliders: margin / scale / opacity */}
{showOvLayout && (
<div className="flex flex-col gap-4 mt-4">
{([
{ label: 'Отступ', value: overlayMargin, set: setOverlayMargin, min: 8, max: 64, step: 2, fmt: (v: number) => `${v}px` },
{ label: 'Масштаб', value: overlayScale, set: setOverlayScale, min: 0.5, max: 2, step: 0.05, fmt: (v: number) => `${v.toFixed(2)}×` },
{ label: 'Прозрачность', value: overlayOpacity, set: setOverlayOpacity, min: 0.1, max: 1, step: 0.05, fmt: (v: number) => `${Math.round(v * 100)}%` },
] as const).map(({ label, value, set, min, max, step, fmt }) => (
<div key={label}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[12px] text-app-text/70">{label}</span>
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>{fmt(value)}</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={value}
onChange={(e) => set(Number(e.target.value))}
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
style={{ accentColor: 'var(--accent)' }}
/>
</div>
))}
</div>
)}
</div>
{/* ── Note ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 text-[10px] text-muted/50">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
В OBS: источник «Браузер» вставь URL. CSS: <code className="font-mono">body {'{ background: transparent !important; }'}</code>
</div>
</section>
)}
</div>
</main>
)
}

View File

@@ -1,234 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { getPublicPlaylists } from '@/lib/authApi'
import type { PublicPlaylist, PlaylistTrack } from '@/types'
import Header from '@/components/Header'
const TAG_PALETTE = ['var(--accent)', '#ff6b9d', '#6bcdff', '#ffb86b', '#b86bff', '#6bffb8']
function tagColor(tag: string): string {
let h = 0
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h)
return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length]
}
function TrackList({ tracks }: { tracks: PlaylistTrack[] }) {
return (
<div className="border-t border-white/[0.05] px-4 pt-3 pb-3.5">
<div className="flex flex-col gap-0.5">
{tracks.map((track, i) => (
<div key={track.id} className="flex items-center gap-2.5 py-[3px] group">
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
<span className="text-[12px] text-app-text/75 group-hover:text-app-text truncate transition-colors duration-100">{track.title}</span>
</div>
))}
</div>
</div>
)
}
function PlaylistCard({
pl,
onPlay,
isLaunched,
}: {
pl: PublicPlaylist
onPlay: () => void
isLaunched: boolean
}) {
const [expanded, setExpanded] = useState(false)
const tags = pl.tags ?? []
const trackCount = pl.tracks?.length ?? 0
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.13] transition-all duration-200">
<div className="flex items-center gap-3 px-4 py-3.5">
<div
className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center font-display font-extrabold text-[15px] select-none"
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
>
{pl.username[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="font-display text-[14px] font-bold text-app-text truncate leading-tight">{pl.name}</div>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<span className="text-[11px] font-medium text-accent">{pl.username}</span>
<span className="text-muted/30 text-[11px]">·</span>
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
{tags.map(tag => (
<span
key={tag}
className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
>
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{trackCount > 0 && (
<button
onClick={() => setExpanded(v => !v)}
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all duration-150 cursor-pointer"
title={expanded ? 'Скрыть треки' : 'Показать треки'}
>
<svg
width="11" height="11" viewBox="0 0 12 12" fill="none"
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}
>
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
onClick={onPlay}
disabled={!trackCount}
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: isLaunched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.12)',
color: isLaunched ? '#0a0a0f' : 'var(--accent)',
}}
>
{isLaunched ? '▶ Играет' : '▶ Play'}
</button>
</div>
</div>
{expanded && pl.tracks && pl.tracks.length > 0 && (
<TrackList tracks={pl.tracks} />
)}
</div>
)
}
export default function CommunityPage() {
const [playlists, setPlaylists] = useState<PublicPlaylist[]>([])
const [loading, setLoading] = useState(true)
const [launched, setLaunched] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [activeTag, setActiveTag] = useState<string | null>(null)
const { loadPlaylist } = usePartyStore()
useEffect(() => {
getPublicPlaylists()
.then(setPlaylists)
.catch(() => setPlaylists([]))
.finally(() => setLoading(false))
}, [])
const handlePlay = (pl: PublicPlaylist) => {
const tracks = pl.tracks?.map(t => t.title) ?? []
if (!tracks.length) return
loadPlaylist(tracks)
setLaunched(pl.id)
setTimeout(() => setLaunched(null), 2500)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? [])))
const filtered = playlists.filter(pl => {
const q = search.toLowerCase().trim()
const matchesSearch = !q
|| pl.name.toLowerCase().includes(q)
|| pl.username.toLowerCase().includes(q)
|| (pl.tags ?? []).some(t => t.toLowerCase().includes(q))
const matchesTag = !activeTag || (pl.tags ?? []).includes(activeTag)
return matchesSearch && matchesTag
})
return (
<main className="max-w-app mx-auto">
<Header />
<div className="mb-5">
<div className="flex items-baseline gap-2.5">
<h2 className="font-display text-xl font-extrabold tracking-tight">Сообщество</h2>
{!loading && (
<span className="text-[12px] text-muted font-sans">{playlists.length} плейлистов</span>
)}
</div>
<p className="text-[12px] text-muted mt-0.5">Публичные плейлисты пользователей</p>
</div>
{!loading && playlists.length > 0 && (
<div className="mb-4 flex flex-col gap-2.5">
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск по названию, автору или тегу..."
className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
{allTags.length > 0 && (
<div className="flex gap-1.5 flex-wrap">
{allTags.map(tag => (
<button
key={tag}
onClick={() => setActiveTag(activeTag === tag ? null : tag)}
className="text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
style={activeTag === tag
? { background: tagColor(tag), color: '#0a0a0f' }
: { background: `${tagColor(tag)}15`, color: tagColor(tag), border: `1px solid ${tagColor(tag)}35` }
}
>
{tag}
</button>
))}
</div>
)}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
Загрузка...
</div>
) : !filtered.length ? (
<div className="text-center py-14 text-muted">
<div className="text-4xl mb-3 opacity-20">🎵</div>
<p className="text-[13px] font-medium">
{playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
</p>
<p className="text-[12px] mt-1.5 opacity-50">
{playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
</p>
</div>
) : (
<div className="flex flex-col gap-2.5">
{filtered.map(pl => (
<PlaylistCard
key={pl.id}
pl={pl}
onPlay={() => handlePlay(pl)}
isLaunched={launched === pl.id}
/>
))}
</div>
)}
</main>
)
}

View File

@@ -4,8 +4,8 @@
:root {
--border: rgba(255, 255, 255, 0.07);
--accent: #c8ff00;
--accent-rgb: 200,255,0;
--accent: #de9cfe;
--accent-rgb: 222,156,254;
}
*,

View File

@@ -1,9 +1,5 @@
import type { Metadata, Viewport } from 'next'
import { Syne, DM_Sans } from 'next/font/google'
import AuthHydrator from '@/components/AuthHydrator'
import AudioBackground from '@/components/AudioBackground'
import GlobalPlayer from '@/components/GlobalPlayer'
import ThemeApplier from '@/components/ThemeApplier'
import './globals.css'
const syne = Syne({
@@ -29,17 +25,16 @@ export const viewport: Viewport = {
maximumScale: 1,
}
const accentInitScript = `(function(){try{var P=[['#de9cfe','222,156,254'],['#c8ff00','200,255,0'],['#00D4FF','0,212,255'],['#FF2D78','255,45,120'],['#A855F7','168,85,247'],['#FF6B35','255,107,53'],['#00FFB2','0,255,178']];var idx=parseInt(localStorage.getItem('pm_accent')||'0',10);var a,r;if(idx===-1){a=localStorage.getItem('pm_accent_custom')||'#de9cfe';var h=a.replace('#','');r=parseInt(h.slice(0,2),16)+','+parseInt(h.slice(2,4),16)+','+parseInt(h.slice(4,6),16);}else{var p=P[idx]||P[0];a=p[0];r=p[1];}document.documentElement.style.setProperty('--accent',a);document.documentElement.style.setProperty('--accent-rgb',r);}catch(e){}})();`
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" className={`${syne.variable} ${dmSans.variable}`}>
<body className="font-sans bg-bg text-app-text min-h-screen pb-[72px] px-4 pt-5 sm:px-4">
<ThemeApplier />
<AudioBackground />
<div className="relative" style={{ zIndex: 1 }}>
<AuthHydrator />
<head>
<script dangerouslySetInnerHTML={{ __html: accentInitScript }} />
</head>
<body className="font-sans text-app-text">
{children}
</div>
<GlobalPlayer />
</body>
</html>
)

View File

@@ -0,0 +1,207 @@
'use client'
import { use, useEffect, useRef, useState } from 'react'
import { getPalette, buildCustomPalette } from '@/lib/overlayPalettes'
import { OVERLAY_STYLE_MAP, useLocalProgress, type OverlayWidgetState } from '@/components/OverlayWidget'
import type { OverlayStyle } from '@/store/overlayStore'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
const GFONTS = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;700&family=Unbounded:wght@700;800&family=Playfair+Display:ital,wght@0,700;1,700&display=swap'
type OverlayState = OverlayWidgetState
const ORIGINS: Record<string, string> = {
bl: 'bottom left', br: 'bottom right', tl: 'top left', tr: 'top right',
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function OverlayPage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params)
const [display, setDisplay] = useState<OverlayState | null>(null)
const [notFound, setNotFound] = useState(false)
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevTitle = useRef('')
// Transparent background
useEffect(() => {
const html = document.documentElement
const body = document.body
html.style.cssText = 'background:transparent!important;height:100%;'
body.style.cssText = 'background:transparent!important;min-height:0;height:100%;padding:0;margin:0;overflow:hidden;'
return () => { html.style.cssText = ''; body.style.cssText = '' }
}, [])
// Load Google Fonts
useEffect(() => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = GFONTS
document.head.appendChild(link)
return () => { if (document.head.contains(link)) document.head.removeChild(link) }
}, [])
// Apply accent color
useEffect(() => {
const hex = display?.accent_color
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
document.documentElement.style.setProperty('--accent', hex)
document.documentElement.style.setProperty('--accent-rgb', `${r},${g},${b}`)
}, [display?.accent_color])
// Apply position + scale + opacity via CSS vars
useEffect(() => {
const pos = display?.position || 'bl'
const margin = display?.margin ?? 24
const scale = display?.scale ?? 1
const opacity = display?.opacity ?? 1
const isT = pos[0] === 't', isR = pos[1] === 'r'
const h = document.documentElement
const m = `${margin}px`
h.style.setProperty('--ov-b', isT ? 'auto' : m)
h.style.setProperty('--ov-t', isT ? m : 'auto')
h.style.setProperty('--ov-l', isR ? 'auto' : m)
h.style.setProperty('--ov-r', isR ? m : 'auto')
h.style.setProperty('--ov-scale', String(scale))
h.style.setProperty('--ov-origin', ORIGINS[pos] ?? 'bottom left')
h.style.opacity = String(opacity)
}, [display?.position, display?.margin, display?.scale, display?.opacity])
// Apply font to body
useEffect(() => {
document.body.style.fontFamily = display?.font || ''
}, [display?.font])
// Track-change animation: briefly fade out on new track
useEffect(() => {
if (!display?.title) return
if (display.title === prevTitle.current) return
prevTitle.current = display.title
const html = document.documentElement
html.style.transition = 'opacity 0.18s'
html.style.opacity = '0'
const t = setTimeout(() => {
html.style.opacity = String(display.opacity ?? 1)
}, 180)
return () => clearTimeout(t)
}, [display?.title, display?.opacity])
// SSE connection with polling fallback
useEffect(() => {
let active = true
const clearHide = () => {
if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null }
}
const scheduleHide = (ms: number) => {
if (hideTimer.current) return
hideTimer.current = setTimeout(() => {
if (active) setDisplay(null)
hideTimer.current = null
}, ms)
}
const applyState = (data: OverlayState) => {
if (!active) return
setNotFound(false)
if (!data.enabled) { scheduleHide(500); return }
clearHide()
setDisplay(prev => {
const title = data.title || prev?.title || ''
if (!title) return null
return {
...data,
title,
artist: data.title ? data.artist : (prev?.artist ?? ''),
cover: data.title ? data.cover : (prev?.cover ?? ''),
}
})
}
// Try SSE first
let es: EventSource | null = null
let pollIv: ReturnType<typeof setInterval> | null = null
let sseOk = false
const startPolling = () => {
if (pollIv) return
const poll = async () => {
try {
const res = await fetch(`${API_URL}/api/overlay/${token}/state`)
if (res.status === 404) { setNotFound(true); return }
if (!res.ok) return
const data: OverlayState = await res.json()
applyState(data)
} catch {}
}
poll()
pollIv = setInterval(poll, 2500)
}
try {
es = new EventSource(`${API_URL}/api/overlay/${token}/stream`)
es.onmessage = (e) => {
sseOk = true
const data: OverlayState = JSON.parse(e.data)
applyState(data)
}
es.addEventListener('notfound', () => { setNotFound(true) })
es.onerror = () => {
if (!sseOk) {
// SSE failed before first message → fall back to polling
es?.close()
es = null
startPolling()
}
}
} catch {
startPolling()
}
return () => {
active = false
es?.close()
if (pollIv) clearInterval(pollIv)
clearHide()
}
}, [token])
if (notFound) return (
<div style={{ position: 'fixed', bottom: 20, left: 20, color: 'rgba(255,255,255,0.25)', fontSize: 11, fontFamily: 'monospace' }}>
overlay not found
</div>
)
if (!display) return null
const style = (display.style ?? 'classic') as OverlayStyle
const render = OVERLAY_STYLE_MAP[style] ?? OVERLAY_STYLE_MAP.classic
const palId = display.palette ?? 'default'
const pal = palId === 'custom'
? buildCustomPalette(style, {
bg: display.custom_bg,
text: display.custom_text,
text2: display.custom_text2,
chroma: display.custom_chroma,
titleBg: display.custom_title_bg,
bodyBg: display.custom_body_bg,
})
: getPalette(style, palId)
return <OverlayRenderer display={display} render={render} pal={pal} />
}
// Separate component so hooks (useLocalProgress) run after display is confirmed non-null
function OverlayRenderer({
display, render, pal,
}: {
display: OverlayState
render: (s: OverlayWidgetState, pal: ReturnType<typeof getPalette>) => React.ReactNode
pal: ReturnType<typeof getPalette>
}) {
const progress = useLocalProgress(display.progress, display.duration, display.is_playing, display.updated_at)
const state = { ...display, progress }
return <>{render(state, pal)}</>
}

View File

@@ -1,164 +0,0 @@
import Link from 'next/link'
const EQ_BARS = [
{ h: 35, d: 0 },
{ h: 72, d: 0.12 },
{ h: 50, d: 0.23 },
{ h: 90, d: 0.06 },
{ h: 55, d: 0.17 },
{ h: 82, d: 0.28 },
{ h: 44, d: 0.09 },
{ h: 96, d: 0.20 },
{ h: 60, d: 0.14 },
{ h: 76, d: 0.03 },
{ h: 40, d: 0.25 },
{ h: 85, d: 0.18 },
{ h: 52, d: 0.07 },
{ h: 67, d: 0.30 },
{ h: 30, d: 0.11 },
{ h: 78, d: 0.22 },
{ h: 48, d: 0.16 },
{ h: 88, d: 0.04 },
{ h: 62, d: 0.26 },
{ h: 38, d: 0.19 },
]
const FEATURES = [
{
icon: '🎵',
title: 'Совместная очередь',
desc: 'Каждый гость добавляет свои треки — никто не обделён эфиром',
},
{
icon: '🎲',
title: 'Умный шаффл',
desc: 'По очереди или случайно — два режима миксовки плейлиста',
},
{
icon: '🔍',
title: 'Поиск версий',
desc: 'Автоматически находит нужную версию трека',
},
{
icon: '📋',
title: 'Плейлисты',
desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
},
]
export default function LandingPage() {
return (
<div className="max-w-app mx-auto min-h-[calc(100vh-40px)] flex flex-col">
{/* ── Nav ── */}
<nav className="flex items-center justify-between py-3 mb-2">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-[9px] bg-accent flex items-center justify-center shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
</svg>
</div>
<span className="font-display font-extrabold text-lg tracking-tight">
Party<span className="text-accent">Mix</span>
</span>
</div>
<div className="flex items-center gap-2">
<Link
href="/login"
className="text-[13px] font-sans text-muted hover:text-app-text transition-colors duration-150 px-2 py-1"
>
Войти
</Link>
<Link
href="/register"
className="text-[13px] font-display font-semibold px-4 py-1.5 bg-surface border border-white/[0.07] rounded-xl text-app-text hover:border-white/20 hover:bg-surface2 transition-all duration-150"
>
Регистрация
</Link>
</div>
</nav>
{/* ── Hero ── */}
<section className="flex-1 flex flex-col items-center justify-center text-center py-12 relative">
{/* Ambient glow behind EQ */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[200px] pointer-events-none" aria-hidden="true">
<div className="absolute inset-0 bg-accent/[0.04] rounded-full blur-3xl" />
</div>
{/* EQ visualization */}
<div className="relative flex items-end gap-[4px] mb-8 h-20" aria-hidden="true">
{EQ_BARS.map(({ h, d }, i) => (
<div
key={i}
className="eq-bar"
style={{
height: `${h}%`,
animationDelay: `${d}s`,
animationDuration: `${0.6 + d * 0.8}s`,
width: '3px',
}}
/>
))}
</div>
{/* Title */}
<h1 className="font-display font-extrabold leading-none tracking-tight mb-5 text-[72px] sm:text-[104px]">
Party<span className="text-accent">Mix</span>
</h1>
{/* Tagline */}
<p className="font-sans text-base sm:text-lg text-muted max-w-[320px] mb-10 leading-relaxed">
Совместные плейлисты для вечеринок.
<br />
Каждый гость&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(var(--accent-rgb),0.25)]"
>
Начать вечеринку
</Link>
<Link
href="/login"
className="px-8 py-3 bg-surface border border-white/[0.07] text-app-text font-sans text-sm rounded-xl hover:bg-surface2 hover:border-white/20 active:scale-[0.97] transition-all duration-150"
>
Войти
</Link>
</div>
</section>
{/* ── Divider ── */}
<div className="border-t border-white/[0.05] mb-8" />
{/* ── Features ── */}
<section className="pb-10">
<p className="text-[11px] font-display font-semibold tracking-[1.5px] uppercase text-muted mb-4">
Возможности
</p>
<div className="grid grid-cols-2 gap-3">
{FEATURES.map(({ icon, title, desc }) => (
<div
key={title}
className="group bg-surface border border-white/[0.07] rounded-app p-4 hover:bg-surface2 hover:border-white/[0.12] transition-all duration-200"
>
<span className="text-lg mb-2.5 block">{icon}</span>
<h3 className="font-display font-bold text-[13px] mb-1 text-app-text">{title}</h3>
<p className="text-[12px] text-muted font-sans leading-relaxed">{desc}</p>
</div>
))}
</div>
</section>
{/* ── Footer ── */}
<footer className="pb-4 text-center">
</footer>
</div>
)
}

View File

@@ -1,417 +0,0 @@
'use client'
import { use, useEffect, useRef, useState } from 'react'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
interface RemoteQueueItem {
title: string
owner: string
color_bg: string
color_text: string
img?: string
}
interface RemoteVersion {
title: string
artist: string
duration: string
img?: string
}
interface RemoteState {
title: string
artist: string
cover: string
is_playing: boolean
volume: number
progress: number
duration: number
queue_len: number
cur_idx: number
queue: RemoteQueueItem[]
versions: RemoteVersion[]
active_version: number
}
function formatTime(s: number) {
if (!s || isNaN(s)) return '0:00'
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
}
async function cmd(id: string, c: string, value?: number, text?: string) {
await fetch(`${API_URL}/api/remote/${id}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
}).catch(() => {})
}
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [state, setState] = useState<RemoteState | null>(null)
const [notFound, setNotFound] = useState(false)
const [volume, setVolume] = useState(1)
const [addText, setAddText] = useState('')
const [tab, setTab] = useState<'player' | 'queue'>('player')
const [versionsOpen, setVersionsOpen] = useState(false)
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
const [localProgress, setLocalProgress] = useState(0)
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
useEffect(() => {
try {
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
if (s) setSavedVersions(JSON.parse(s))
} catch {}
}, [])
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
let active = true
const poll = async () => {
try {
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
if (res.status === 404) { setNotFound(true); return }
if (!res.ok) return
const data: RemoteState = await res.json()
if (active) {
setState(data)
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
setLocalProgress(data.progress ?? 0)
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
}
} catch {}
}
poll()
const iv = setInterval(poll, 2000)
return () => { active = false; clearInterval(iv) }
}, [id])
// Local progress interpolation — advance every 250ms when playing
useEffect(() => {
const iv = setInterval(() => {
const { progress, ts, playing } = lastPollRef.current
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
}, 250)
return () => clearInterval(iv)
}, [])
if (notFound) return (
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-3">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
<p className="text-muted text-[13px]">Сессия не найдена или истекла</p>
</div>
)
if (!state) return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
</div>
)
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
const progress = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
const queue = state.queue ?? []
const versions = state.versions ?? []
const trackTitle = queue[state.cur_idx]?.title ?? ''
const isSavedLocally = (v: RemoteVersion) => {
const s = savedVersions[trackTitle]
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
}
const handleSaveVersion = (i: number) => {
const v = versions[i]
if (!v || !trackTitle) return
const alreadySaved = isSavedLocally(v)
const next = { ...savedVersions }
if (alreadySaved) delete next[trackTitle]
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
setSavedVersions(next)
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
cmd(id, 'save_version', i)
}
return (
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-6">
{/* Header + tabs */}
<div className="flex items-center justify-between mb-5">
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
<div className="flex items-center gap-1 bg-surface2 rounded-[8px] p-0.5">
<button
onClick={() => setTab('player')}
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer"
style={{ background: tab === 'player' ? 'rgba(var(--accent-rgb),0.1)' : 'transparent', color: tab === 'player' ? 'var(--accent)' : '#555' }}
>
Плеер
</button>
<button
onClick={() => setTab('queue')}
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer flex items-center gap-1"
style={{ background: tab === 'queue' ? 'rgba(var(--accent-rgb),0.1)' : 'transparent', color: tab === 'queue' ? 'var(--accent)' : '#555' }}
>
Очередь
{queue.length > 0 && <span className="text-[10px] opacity-60">{queue.length}</span>}
</button>
</div>
</div>
{tab === 'player' && (
<div className="flex flex-col gap-7 items-center flex-1">
{/* Cover */}
<div className="w-full aspect-square max-w-[220px] rounded-[20px] overflow-hidden bg-surface2 shadow-2xl">
{state.cover ? (
<img src={state.cover} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
</div>
{/* Track info */}
<div className="w-full text-center px-2">
<div className="font-display text-[18px] font-extrabold tracking-tight truncate leading-tight">
{state.title || '—'}
</div>
{state.artist && (
<div className="text-[13px] text-muted mt-1.5 truncate">{state.artist}</div>
)}
{state.queue_len > 0 && (
<div className="text-[11px] text-muted mt-1 font-display">{state.cur_idx + 1} / {state.queue_len}</div>
)}
</div>
{/* Progress bar */}
<div className="w-full">
<div
className="relative h-1.5 bg-white/[0.07] rounded-full mb-2 cursor-pointer"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
const seekTo = pct * state.duration
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
setLocalProgress(seekTo)
cmd(id, 'seek', seekTo)
}}
>
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${progress}%` }} />
</div>
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums">
<span>{formatTime(clampedProgress)}</span>
<span>{formatTime(state.duration)}</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-8">
<button
onClick={() => cmd(id, 'prev')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
</svg>
</button>
<button
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
className="w-16 h-16 rounded-full flex items-center justify-center bg-accent text-bg active:scale-95 transition-all cursor-pointer hover:brightness-110"
>
{state.is_playing ? (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<button
onClick={() => cmd(id, 'next')}
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 18l8.5-6L6 6v12z"/><rect x="16" y="6" width="2" height="12"/>
</svg>
</button>
</div>
{/* Volume */}
<div className="w-full flex items-center gap-3">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
</svg>
<input
type="range"
min="0"
max="1"
step="0.02"
value={volume}
onChange={(e) => {
const v = parseFloat(e.target.value)
setVolume(v)
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
}}
className="flex-1 cursor-pointer h-1.5"
style={{ accentColor: 'var(--accent)' }}
/>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
</div>
{/* Versions */}
{versions.length > 1 && (
<div className="w-full">
<button
onClick={() => setVersionsOpen(v => !v)}
className="w-full flex items-center justify-between px-3 py-2 rounded-[9px] bg-surface2 border border-white/[0.07] text-[12px] text-muted hover:text-app-text transition-colors cursor-pointer"
>
<span className="font-display font-bold tracking-[0.5px]">Версии трека</span>
<span className="flex items-center gap-1.5">
<span className="text-[11px] opacity-60">{versions.length}</span>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className={`transition-transform ${versionsOpen ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
{versionsOpen && (
<div className="mt-1 border border-white/[0.07] rounded-[9px] overflow-hidden">
{versions.map((v, i) => {
const active = i === state.active_version
const saved = isSavedLocally(v)
return (
<div
key={i}
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0 transition-colors"
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
>
<span className="text-[11px] text-muted w-4 text-right shrink-0 font-display">{i + 1}</span>
{v.img ? (
<img src={v.img} alt="" className="w-8 h-8 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-8 h-8 rounded-[5px] bg-surface2 shrink-0" />
)}
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{v.title}</div>
<div className="text-[11px] text-muted mt-px">{v.artist}</div>
</div>
<span className="text-[11px] text-muted shrink-0 font-display">{v.duration}</span>
<button
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
title={saved ? 'Забыть версию' : 'Запомнить версию'}
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${active ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</div>
)}
{tab === 'queue' && (
<div className="flex flex-col gap-3 flex-1">
{/* Add track */}
<div className="flex gap-2">
<input
type="text"
value={addText}
onChange={(e) => setAddText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && addText.trim()) {
cmd(id, 'add', 0, addText.trim())
setAddText('')
}
}}
placeholder="Исполнитель — Название"
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
/>
<button
onClick={() => {
if (!addText.trim()) return
cmd(id, 'add', 0, addText.trim())
setAddText('')
}}
className="shrink-0 px-3 py-2.5 rounded-[9px] bg-accent text-bg text-[13px] font-display font-bold cursor-pointer hover:brightness-110 active:scale-95 transition-all"
>
+
</button>
</div>
{/* Queue list */}
{queue.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-[13px] text-muted text-center py-10">
Очередь пуста
</div>
) : (
<div className="flex flex-col">
{queue.map((item, i) => {
const active = i === state.cur_idx
return (
<div
key={i}
onClick={() => cmd(id, 'goto', i)}
className="flex items-center gap-2.5 px-1 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer active:bg-surface2 transition-colors rounded-[6px]"
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
>
{active ? (
<div className="flex items-end gap-[1.5px] w-3.5 h-3.5 shrink-0 ml-0.5">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-5 text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img src={item.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color_bg, color: item.color_text }}>
{item.owner}
</span>
<button
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)
})}
</div>
)}
</div>
)}
</main>
)
}

View File

@@ -1,318 +0,0 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { useAuthStore } from '@/store/authStore'
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
import { useBgStore, BG_PRESETS, DEFAULT_RAYS, type BgMode } from '@/store/bgStore'
import { getActiveAccent } from '@/store/themeStore'
import Header from '@/components/Header'
import ColorWheel from '@/components/ColorWheel'
// ── Preview SVGs ─────────────────────────────────────────────────────────────
function OrbsPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-o1" cx="12%" cy="6%" r="60%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.65"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-o2" cx="90%" cy="96%" r="55%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.55"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-o3" cx="50%" cy="50%" r="40%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.35"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<ellipse cx="14" cy="4" rx="72" ry="72" fill="url(#p-o1)"/>
<ellipse cx="108" cy="65" rx="58" ry="58" fill="url(#p-o2)"/>
<ellipse cx="60" cy="34" rx="36" ry="36" fill="url(#p-o3)"/>
</svg>
)
}
function WavesPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<rect width="120" height="68" fill="#0a0a0f"/>
<path d="M0 47 Q15 41 30 47 Q45 53 60 47 Q75 41 90 47 Q105 53 120 47 L120 68 L0 68Z" fill="var(--accent)" opacity="0.16"/>
<path d="M0 55 Q15 49 30 55 Q45 61 60 55 Q75 49 90 55 Q105 61 120 55 L120 68 L0 68Z" fill="var(--accent)" opacity="0.22"/>
<path d="M0 41 Q20 35 40 41 Q60 47 80 41 Q100 35 120 41 L120 68 L0 68Z" fill="rgb(255,60,172)" opacity="0.10"/>
<path d="M0 61 Q20 57 40 61 Q60 65 80 61 Q100 57 120 61 L120 68 L0 68Z" fill="rgb(140,100,255)" opacity="0.14"/>
</svg>
)
}
function ParticlesPreview() {
const d: [number,number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]]
const ln: [number,number][] = [[0,1],[1,2],[0,3],[1,7],[3,5],[2,4],[4,6],[3,7],[7,8],[1,8],[9,0],[9,3]]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
{ln.map(([a,b],i) => <line key={i} x1={d[a][0]} y1={d[a][1]} x2={d[b][0]} y2={d[b][1]} stroke="var(--accent)" strokeWidth="0.6" opacity="0.2"/>)}
{d.map(([x,y],i) => <circle key={i} cx={x} cy={y} r={i%3===0?2:1.3} fill="var(--accent)" opacity={i%2===0?0.7:0.45}/>)}
</svg>
)
}
function AuroraPreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-a1" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.55"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-a2" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.45"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
<radialGradient id="p-a3" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.40"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<ellipse cx="10" cy="10" rx="80" ry="22" fill="url(#p-a1)"/>
<ellipse cx="66" cy="6" rx="90" ry="16" fill="url(#p-a2)"/>
<ellipse cx="106" cy="19" rx="66" ry="20" fill="url(#p-a3)"/>
<ellipse cx="34" cy="32" rx="85" ry="15" fill="url(#p-a1)" opacity="0.5"/>
<ellipse cx="86" cy="44" rx="60" ry="16" fill="url(#p-a2)" opacity="0.45"/>
</svg>
)
}
function PulsePreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<defs>
<radialGradient id="p-pg" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<circle cx="60" cy="34" r="12" fill="url(#p-pg)"/>
<circle cx="60" cy="34" r="9" fill="none" stroke="var(--accent)" strokeWidth="1.8" opacity="0.75"/>
<circle cx="60" cy="34" r="20" fill="none" stroke="var(--accent)" strokeWidth="1.2" opacity="0.5"/>
<circle cx="60" cy="34" r="30" fill="none" stroke="var(--accent)" strokeWidth="0.9" opacity="0.32"/>
<circle cx="60" cy="34" r="42" fill="none" stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
<circle cx="60" cy="34" r="55" fill="none" stroke="var(--accent)" strokeWidth="0.4" opacity="0.1"/>
</svg>
)
}
function StarsPreview() {
const stars: [number,number,number][] = [
[12,8,1.4],[34,5,0.9],[58,12,1.1],[80,4,1.5],[102,9,0.8],[18,22,1.0],[45,18,1.3],[72,20,0.9],[96,25,1.2],
[8,38,0.8],[28,42,1.1],[55,35,1.4],[78,40,0.9],[108,36,1.0],[22,56,1.2],[50,58,0.8],[75,54,1.3],[100,60,1.0],
[40,28,0.7],[88,14,1.1],[15,48,0.9],[64,48,0.8]
]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<defs>
<radialGradient id="p-neb" cx="35%" cy="45%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.12"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
<rect width="120" height="68" fill="url(#p-neb)"/>
{stars.map(([x,y,r],i) => (
<circle key={i} cx={x} cy={y} r={r} fill="var(--accent)" opacity={0.4 + (i % 5) * 0.12}/>
))}
</svg>
)
}
function RainPreview() {
const drops: [number,number,number][] = [
[10,8,20],[25,0,28],[40,15,22],[55,5,25],[70,10,18],[85,2,30],[100,12,24],[112,6,20],
[18,35,22],[33,28,26],[48,40,19],[63,30,24],[78,38,21],[93,25,28],[108,34,20],
]
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
{drops.map(([x,y,len],i) => (
<g key={i}>
<line x1={x} y1={y} x2={x} y2={y+len} stroke="var(--accent)" strokeWidth="1.5" opacity={0.15 + (i%4)*0.08}
style={{background: `linear-gradient(to bottom, transparent, var(--accent))`}}/>
<circle cx={x} cy={y+len} r="1.5" fill="var(--accent)" opacity={0.5 + (i%3)*0.15}/>
</g>
))}
</svg>
)
}
function RaysPreview() {
const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
return (
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="p-rg" cx="50%" cy="60%" r="55%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
</defs>
<rect width="120" height="68" fill="#0a0a0f"/>
{rays.map((deg, i) => {
const rad = (deg * Math.PI) / 180
const x2 = 60 + Math.cos(rad) * 90
const y2 = 41 + Math.sin(rad) * 90
return (
<line key={i} x1="60" y1="41" x2={x2} y2={y2}
stroke="var(--accent)" strokeWidth={i%2===0?2.5:1.5} opacity={i%2===0?0.18:0.10}/>
)
})}
<circle cx="60" cy="41" r="14" fill="url(#p-rg)"/>
<circle cx="60" cy="41" r="3" fill="var(--accent)" opacity="0.7"/>
</svg>
)
}
function NonePreview() {
return (
<svg width="100%" height="100%" viewBox="0 0 120 68">
<rect width="120" height="68" fill="#0a0a0f"/>
<line x1="48" y1="34" x2="72" y2="34" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
<line x1="60" y1="22" x2="60" y2="46" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
)
}
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
orbs: <OrbsPreview />,
waves: <WavesPreview />,
particles: <ParticlesPreview />,
aurora: <AuroraPreview />,
pulse: <PulsePreview />,
stars: <StarsPreview />,
rain: <RainPreview />,
rays: <RaysPreview />,
none: <NonePreview />,
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function SettingsPage() {
const { user } = useAuthStore()
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
const { bgMode, setBg, raysConfig, setRaysConfig } = useBgStore()
const router = useRouter()
const activeAccent = getActiveAccent(accentIdx, customHex)
useEffect(() => {
if (!user) router.replace('/login')
}, [user, router])
if (!user) return null
return (
<main className="max-w-app mx-auto relative z-10">
<Header />
<div className="animate-fadeUp">
<h2 className="font-display text-xl font-extrabold tracking-tight text-app-text mb-5">Настройки</h2>
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
Внешний вид
</p>
{/* Accent color */}
<p className="text-[12px] text-muted mb-2.5">Акцентный цвет</p>
<div className="flex flex-wrap gap-2 mb-4">
{ACCENT_PRESETS.map((preset, i) => (
<button
key={i}
onClick={() => setAccent(i)}
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
style={accentIdx === i
? { background: `rgba(${preset.rgb},0.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-3 h-3 rounded-full shrink-0"
style={{ background: preset.accent, boxShadow: accentIdx === i ? `0 0 7px ${preset.accent}90` : 'none' }}/>
{preset.name}
</button>
))}
{/* Custom color button */}
<button
onClick={() => setCustom(customHex)}
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
style={accentIdx === -1
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
}
>
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
style={{
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
}}/>
Свой
</button>
</div>
{/* Color wheel — shown when custom is selected */}
{accentIdx === -1 && (
<div className="mb-6 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<ColorWheel value={customHex} onChange={setCustom} />
</div>
)}
{accentIdx !== -1 && <div className="mb-2" />}
{/* Live background */}
<p className="text-[12px] text-muted mb-2.5">Живой фон</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{BG_PRESETS.map((preset) => {
const active = bgMode === preset.id
return (
<button
key={preset.id}
onClick={() => setBg(preset.id)}
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
style={active
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
: { borderColor: 'rgba(255,255,255,0.07)' }
}
>
<div className="w-full aspect-video overflow-hidden">
{BG_PREVIEWS[preset.id]}
</div>
<div className="px-3 py-2" style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}>
<p className="text-[12px] font-display font-bold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
{preset.name}
</p>
<p className="text-[10px] text-muted mt-0.5">{preset.desc}</p>
</div>
</button>
)
})}
</div>
{/* Rays config — shown only when rays mode is active */}
{bgMode === 'rays' && (
<div className="mt-4 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
<div className="flex items-center justify-between mb-3">
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
Настройки лучей
</p>
<button
onClick={() => setRaysConfig(DEFAULT_RAYS)}
className="text-[10px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
>
Сбросить
</button>
</div>
<div className="flex flex-col gap-4">
{([
{ key: 'count', label: 'Количество', min: 4, max: 16, step: 1, fmt: (v: number) => String(Math.round(v)) },
{ key: 'speed', label: 'Скорость', min: 0.2, max: 3.0, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
{ key: 'spread', label: 'Ширина', min: 0.3, max: 2.0, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
] as const).map(({ key, label, min, max, step, fmt }) => (
<div key={key}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[12px] text-app-text/70">{label}</span>
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>
{fmt(raysConfig[key])}
</span>
</div>
<input
type="range"
min={min} max={max} step={step}
value={raysConfig[key]}
onChange={(e) => setRaysConfig({ [key]: Number(e.target.value) })}
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
style={{ accentColor: 'var(--accent)' }}
/>
</div>
))}
</div>
</div>
)}
</section>
</div>
</main>
)
}

View File

@@ -1,4 +1,4 @@
'use client'
'use client'
import { useEffect, useRef, useState } from 'react'
import { useFavoritesStore } from '@/store/favoritesStore'
@@ -14,16 +14,16 @@ interface Props {
export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) {
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { token } = useAuthStore()
const { user } = useAuthStore()
const [playlists, setPlaylists] = useState<Playlist[]>([])
const [added, setAdded] = useState<Record<string, boolean>>({})
const ref = useRef<HTMLDivElement>(null)
const favorited = isFavorite(trackTitle)
useEffect(() => {
if (!token) return
getPlaylists(token).then(setPlaylists).catch(() => {})
}, [token])
if (!user) return
getPlaylists().then(setPlaylists).catch(() => {})
}, [user])
useEffect(() => {
const handler = (e: MouseEvent) => {
@@ -37,9 +37,9 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
}, [onClose, anchorRef])
const handleAdd = async (playlist: Playlist) => {
if (!token || added[playlist.id]) return
if (!user || added[playlist.id]) return
try {
await addTrackToPlaylist(token, playlist.id, trackTitle)
await addTrackToPlaylist(playlist.id, trackTitle)
setAdded(prev => ({ ...prev, [playlist.id]: true }))
} catch {}
}
@@ -74,7 +74,7 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
)}
</button>
{token ? (
{user ? (
playlists.length > 0 ? (
<div className="max-h-[180px] overflow-y-auto">
{playlists.map(pl => (

View File

@@ -11,11 +11,11 @@ type Drop = { x: number; y: number; speed: number; len: number; alpha: number }
export default function AudioBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const { bgMode, raysConfig } = useBgStore()
const raysRef = useRef(raysConfig)
const { bgMode, fxConfigs } = useBgStore()
const fxRef = useRef(fxConfigs)
// Keep rays config in sync without restarting the animation loop
useEffect(() => { raysRef.current = raysConfig }, [raysConfig])
// Sync config changes without restarting the animation loop
useEffect(() => { fxRef.current = fxConfigs }, [fxConfigs])
useEffect(() => {
if (bgMode === 'none') return
@@ -28,7 +28,7 @@ export default function AudioBackground() {
let rafId: number
let smoothBass = 0
let smoothMid = 0
let fastBass = 0 // fast tracker for onset/beat detection
let fastBass = 0
let dataBuf: Uint8Array<ArrayBuffer> | null = null
const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight }
@@ -54,35 +54,45 @@ export default function AudioBackground() {
const ac = () => document.documentElement.style.getPropertyValue('--accent-rgb') || '200,255,0'
// ── ORBS — ambient, subtle ────────────────────────────────────────────────
const clear = (W: number, H: number, trail: number) => {
if (trail > 0.005) {
ctx.fillStyle = `rgba(10,10,15,${(1 - trail).toFixed(3)})`
ctx.fillRect(0, 0, W, H)
} else {
ctx.clearRect(0, 0, W, H)
ctx.fillStyle = '#0a0a0f'
ctx.fillRect(0, 0, W, H)
}
}
// ── ORBS ─────────────────────────────────────────────────────────────────
const drawOrbs = () => {
const W = canvas.width, H = canvas.height, a = ac()
const t = Date.now() / 5000
const cfg = fxRef.current.orbs
const t = Date.now() / (5000 / cfg.speed)
const br = Math.sin(t) * 0.04 + Math.cos(t * 0.7) * 0.02
const diag = Math.hypot(W, H), base = diag * 0.62
const bri = cfg.brightness
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
clear(W, H, cfg.trail)
// accent top-left
const r1 = base * (0.78 + smoothBass * 0.45 + br)
const a1 = 0.07 + smoothBass * 0.08
const a1 = (0.07 + smoothBass * 0.08) * bri
const g1 = ctx.createRadialGradient(W * 0.12, H * 0.06, 0, W * 0.12, H * 0.06, r1)
g1.addColorStop(0, `rgba(${a},${a1})`); g1.addColorStop(0.42, `rgba(${a},${a1 * 0.25})`); g1.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H)
// pink bottom-right
const r2 = base * (0.70 + smoothMid * 0.40 - br * 0.5)
const a2 = 0.06 + smoothMid * 0.07
const a2 = (0.06 + smoothMid * 0.07) * bri
const g2 = ctx.createRadialGradient(W * 0.88, H * 0.94, 0, W * 0.88, H * 0.94, r2)
g2.addColorStop(0, `rgba(255,60,172,${a2})`); g2.addColorStop(0.42, `rgba(255,60,172,${a2 * 0.25})`); g2.addColorStop(1, 'rgba(255,60,172,0)')
ctx.fillStyle = g2; ctx.fillRect(0, 0, W, H)
// purple center — only shows with audio
const c = smoothBass * 0.5 + smoothMid * 0.5
if (c > 0.008) {
const r3 = base * (0.40 + c * 0.35)
const g3 = ctx.createRadialGradient(W * 0.5, H * 0.5, 0, W * 0.5, H * 0.5, r3)
g3.addColorStop(0, `rgba(140,100,255,${0.035 + c * 0.06})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
g3.addColorStop(0, `rgba(140,100,255,${(0.035 + c * 0.06) * bri})`); g3.addColorStop(1, 'rgba(140,100,255,0)')
ctx.fillStyle = g3; ctx.fillRect(0, 0, W, H)
}
}
@@ -90,14 +100,16 @@ export default function AudioBackground() {
// ── WAVES ─────────────────────────────────────────────────────────────────
const drawWaves = () => {
const W = canvas.width, H = canvas.height, a = ac()
const t = Date.now() / 1000
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
const cfg = fxRef.current.waves
const t = (Date.now() / 1000) * cfg.speed
const amp = cfg.amplitude
clear(W, H, cfg.trail)
const layers = [
{ y: 0.70, amp: 20 + smoothBass * 60, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${a},` },
{ y: 0.76, amp: 16 + smoothMid * 45, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' },
{ y: 0.81, amp: 24 + smoothBass * 75, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` },
{ y: 0.87, amp: 12 + smoothMid * 35, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' },
{ y: 0.93, amp: 30 + smoothBass * 90, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` },
{ y: 0.70, amp: (20 + smoothBass * 60) * amp, freq: 0.007, ph: t * 0.35, al: 0.12, c: `rgba(${a},` },
{ y: 0.76, amp: (16 + smoothMid * 45) * amp, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' },
{ y: 0.81, amp: (24 + smoothBass * 75) * amp, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` },
{ y: 0.87, amp: (12 + smoothMid * 35) * amp, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' },
{ y: 0.93, amp: (30 + smoothBass * 90) * amp, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` },
]
for (const l of layers) {
const baseY = H * l.y
@@ -119,8 +131,9 @@ export default function AudioBackground() {
}))
const drawParticles = () => {
const W = canvas.width, H = canvas.height, a = ac()
ctx.fillStyle = 'rgba(10,10,15,0.2)'; ctx.fillRect(0, 0, W, H)
const spd = 1 + smoothBass * 3
const cfg = fxRef.current.particles
clear(W, H, cfg.trail)
const spd = (1 + smoothBass * 3) * cfg.speed
for (const p of PTS) {
p.x += p.vx * spd; p.y += p.vy * spd
if (p.x < 0) p.x += 1; if (p.x > 1) p.x -= 1
@@ -133,7 +146,7 @@ export default function AudioBackground() {
ctx.beginPath(); ctx.arc(p.x * W, p.y * H, p.r * (1 + smoothBass * 1.2), 0, Math.PI * 2)
ctx.fillStyle = `rgba(${a},${p.a * (0.7 + smoothMid * 0.3)})`; ctx.fill()
}
const maxD = Math.min(W, H) * 0.12; ctx.lineWidth = 0.5
const maxD = Math.min(W, H) * 0.12 * cfg.linkDist; ctx.lineWidth = 0.5
for (let i = 0; i < PTS.length; i++)
for (let j = i + 1; j < PTS.length; j++) {
const dx = (PTS[i].x - PTS[j].x) * W, dy = (PTS[i].y - PTS[j].y) * H
@@ -145,19 +158,21 @@ export default function AudioBackground() {
}
}
// ── AURORA — subtle shimmer, not a flood ──────────────────────────────────
// ── AURORA ────────────────────────────────────────────────────────────────
const drawAurora = () => {
const W = canvas.width, H = canvas.height, a = ac()
const t = Date.now() / 3500
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
const cfg = fxRef.current.aurora
const t = (Date.now() / 3500) * cfg.speed
const bri = cfg.brightness
clear(W, H, cfg.trail)
const bands = [
{ cx: 0.10, cy: 0.16, w: 0.72, h: 0.22, ph: t * 0.7, al: 0.10 + smoothBass * 0.08, c: a },
{ cx: 0.58, cy: 0.09, w: 0.88, h: 0.18, ph: -t * 0.5, al: 0.08 + smoothMid * 0.07, c: '140,100,255' },
{ cx: 0.85, cy: 0.30, w: 0.62, h: 0.20, ph: t * 0.6, al: 0.09 + smoothBass * 0.07, c: '255,60,172' },
{ cx: 0.30, cy: 0.50, w: 0.78, h: 0.16, ph: -t * 0.8, al: 0.07 + smoothMid * 0.06, c: a },
{ cx: 0.70, cy: 0.64, w: 0.58, h: 0.18, ph: t * 0.5, al: 0.06 + smoothBass * 0.05, c: '140,100,255' },
{ cx: 0.18, cy: 0.78, w: 0.68, h: 0.16, ph: -t * 0.65,al: 0.05 + smoothMid * 0.05, c: '255,60,172' },
{ cx: 0.10, cy: 0.16, w: 0.72, h: 0.22, ph: t * 0.7, al: (0.10 + smoothBass * 0.08) * bri, c: a },
{ cx: 0.58, cy: 0.09, w: 0.88, h: 0.18, ph: -t * 0.5, al: (0.08 + smoothMid * 0.07) * bri, c: '140,100,255' },
{ cx: 0.85, cy: 0.30, w: 0.62, h: 0.20, ph: t * 0.6, al: (0.09 + smoothBass * 0.07) * bri, c: '255,60,172' },
{ cx: 0.30, cy: 0.50, w: 0.78, h: 0.16, ph: -t * 0.8, al: (0.07 + smoothMid * 0.06) * bri, c: a },
{ cx: 0.70, cy: 0.64, w: 0.58, h: 0.18, ph: t * 0.5, al: (0.06 + smoothBass * 0.05) * bri, c: '140,100,255' },
{ cx: 0.18, cy: 0.78, w: 0.68, h: 0.16, ph: -t * 0.65,al: (0.05 + smoothMid * 0.05) * bri, c: '255,60,172' },
]
for (const band of bands) {
const cx = band.cx * W, cy = (band.cy + Math.sin(band.ph) * 0.07) * H
@@ -169,48 +184,68 @@ export default function AudioBackground() {
}
}
// ── PULSE — beat-driven expanding rings ───────────────────────────────────
// ── PULSE — expanding ring waves ──────────────────────────────────────────
const RINGS: Ring[] = []
let prevFast = 0
let lastRingMs = 0
let lastAmbMs = 0
const drawPulse = () => {
const W = canvas.width, H = canvas.height, a = ac()
const cfg = fxRef.current.pulse
const cx = W * 0.5, cy = H * 0.5
const maxR = Math.hypot(W, H) * 0.8
const maxR = Math.hypot(W, H) * 0.72
ctx.fillStyle = 'rgba(10,10,15,0.18)'; ctx.fillRect(0, 0, W, H)
clear(W, H, cfg.trail)
// Beat onset: fastBass rises sharply above smoothBass
const onset = fastBass > smoothBass + 0.10 && fastBass > 0.22 && fastBass > prevFast + 0.04
if (onset) RINGS.push({ r: 10, alpha: 0.7 + fastBass * 0.3, speed: 3 + fastBass * 9 })
prevFast = fastBass
const now = Date.now()
// Slow ambient rings so mode looks alive without audio
if (Math.random() < 0.008 && RINGS.length < 3) RINGS.push({ r: 5, alpha: 0.22, speed: 1.5 })
// Beat-triggered ring
const threshold = 0.45 - cfg.sensitivity * 0.3
if (fastBass > threshold && fastBass > smoothBass + 0.05 && now - lastRingMs > 200) {
RINGS.push({ r: 4, alpha: 1.0, speed: (5 + fastBass * 12) * cfg.ringSpeed })
lastRingMs = now
}
// Ambient ring on timer so it always looks alive even without audio
if (now - lastAmbMs > 1800) {
RINGS.push({ r: 4, alpha: 0.7, speed: 5 * cfg.ringSpeed })
lastAmbMs = now
}
for (let i = RINGS.length - 1; i >= 0; i--) {
const ring = RINGS[i]
ring.r += ring.speed
ring.alpha -= 0.008
if (ring.alpha <= 0 || ring.r > maxR) { RINGS.splice(i, 1); continue }
ring.alpha *= 0.982 // exponential fade — stays bright longer, fades smoothly
if (ring.alpha < 0.015 || ring.r > maxR) { RINGS.splice(i, 1); continue }
const lw = 2 + ring.alpha * 5
// Outer glow
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(${a},${ring.alpha * 0.2})`
ctx.lineWidth = lw * 5; ctx.stroke()
// Main ring
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(${a},${ring.alpha})`
ctx.lineWidth = 1.5 + ring.alpha * 3.5; ctx.stroke()
ctx.lineWidth = lw; ctx.stroke()
// Inner echo
if (ring.r > 40 && ring.alpha > 0.15) {
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.80, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.4})`
ctx.lineWidth = 0.8; ctx.stroke()
// Pink echo
if (ring.r > 30 && ring.alpha > 0.08) {
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.85, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.35})`
ctx.lineWidth = lw * 0.55; ctx.stroke()
}
}
// Persistent center glow
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80 + smoothBass * 120)
cg.addColorStop(0, `rgba(${a},${0.08 + smoothBass * 0.14})`); cg.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)
// Center dot — pulses with bass, small footprint so it doesn't drown rings
const dotR = 14 + smoothBass * 55
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, dotR)
cg.addColorStop(0, `rgba(${a},${0.85 + smoothBass * 0.15})`)
cg.addColorStop(0.45,`rgba(${a},0.25)`)
cg.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = cg
ctx.beginPath(); ctx.arc(cx, cy, dotR, 0, Math.PI * 2); ctx.fill()
}
// ── STARS ─────────────────────────────────────────────────────────────────
@@ -220,22 +255,24 @@ export default function AudioBackground() {
}))
const drawStars = () => {
const W = canvas.width, H = canvas.height, a = ac()
const cfg = fxRef.current.stars
const t = Date.now() / 1000
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
const bri = cfg.brightness
clear(W, H, cfg.trail)
const n1 = ctx.createRadialGradient(W*0.30, H*0.35, 0, W*0.30, H*0.35, W*0.55)
n1.addColorStop(0, `rgba(${a},${0.04+smoothMid*0.04})`); n1.addColorStop(1, `rgba(${a},0)`)
n1.addColorStop(0, `rgba(${a},${(0.04+smoothMid*0.04)*bri})`); n1.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = n1; ctx.fillRect(0, 0, W, H)
for (const s of STARS) {
const tw = (Math.sin(t * s.sp + s.ph) + 1) * 0.5
const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4)
const tw = (Math.sin(t * s.sp * cfg.twinkle + s.ph) + 1) * 0.5
const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4) * bri
const r = s.r * (1 + smoothBass * tw * 1.0)
if (s.r > 1.1) { ctx.beginPath(); ctx.arc(s.x*W, s.y*H, r*2.8, 0, Math.PI*2); ctx.fillStyle = `rgba(${a},${alpha*0.18})`; ctx.fill() }
ctx.beginPath(); ctx.arc(s.x*W, s.y*H, r, 0, Math.PI*2); ctx.fillStyle = `rgba(${a},${alpha})`; ctx.fill()
}
}
// ── RAIN — sparse, soft streaks ───────────────────────────────────────────
const DROPS: Drop[] = Array.from({ length: 30 }, () => ({
// ── RAIN ──────────────────────────────────────────────────────────────────
const DROPS: Drop[] = Array.from({ length: 60 }, () => ({
x: Math.random(), y: Math.random(),
speed: Math.random() * 0.0015 + 0.0008,
len: Math.random() * 0.055 + 0.03,
@@ -243,9 +280,12 @@ export default function AudioBackground() {
}))
const drawRain = () => {
const W = canvas.width, H = canvas.height, a = ac()
ctx.fillStyle = 'rgba(10,10,15,0.12)'; ctx.fillRect(0, 0, W, H)
const spd = 1 + smoothBass * 2.5
for (const d of DROPS) {
const cfg = fxRef.current.rain
const count = Math.round(cfg.drops)
clear(W, H, cfg.trail)
const spd = (1 + smoothBass * 2.5) * cfg.speed
for (let i = 0; i < count && i < DROPS.length; i++) {
const d = DROPS[i]
d.y += d.speed * spd
if (d.y > 1.08) { d.y = -d.len - 0.02; d.x = Math.random() }
const x = d.x * W, y = d.y * H, len = d.len * H * (1 + smoothBass * 0.4)
@@ -256,34 +296,29 @@ export default function AudioBackground() {
}
}
// ── RAYS — tapered beams from center ──────────────────────────────────────
// ── RAYS ──────────────────────────────────────────────────────────────────
const drawRays = () => {
const W = canvas.width, H = canvas.height, a = ac()
const cfg = raysRef.current
const cfg = fxRef.current.rays
const t = (Date.now() / 1000) * cfg.speed * 0.08
const cx = W * 0.5, cy = H * 0.55
const maxR = Math.hypot(W, H) * 1.1
const br = cfg.brightness
const sp = cfg.spread
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
clear(W, H, cfg.trail)
const count = cfg.count
// Primary rays — rotate forward
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 + t
const isMain = i % 2 === 0
const hw = Math.tan((0.055 + smoothBass * 0.035) * sp) * maxR
const al = ((isMain ? 0.12 : 0.07) + smoothBass * 0.10 + smoothMid * 0.03) * br
const ex = cx + Math.cos(angle) * maxR
const ey = cy + Math.sin(angle) * maxR
const px = -Math.sin(angle) * hw
const py = Math.cos(angle) * hw
const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex + px, ey + py); ctx.lineTo(ex - px, ey - py); ctx.closePath()
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
grad.addColorStop(0, `rgba(${a},${al * 2.5})`)
grad.addColorStop(0.12, `rgba(${a},${al})`)
@@ -292,17 +327,14 @@ export default function AudioBackground() {
ctx.fillStyle = grad; ctx.fill()
}
// Secondary rays — counter-rotate, pink tint
const cnt2 = Math.max(2, Math.floor(count / 2))
for (let i = 0; i < cnt2; i++) {
const angle = (i / cnt2) * Math.PI * 2 - t * 0.65 + Math.PI / count
const hw = Math.tan(0.035 * sp) * maxR
const al = (0.05 + smoothMid * 0.06) * br
const ex = cx + Math.cos(angle) * maxR
const ey = cy + Math.sin(angle) * maxR
const px = -Math.sin(angle) * hw
const py = Math.cos(angle) * hw
const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(ex + px, ey + py); ctx.lineTo(ex - px, ey - py); ctx.closePath()
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
@@ -312,7 +344,6 @@ export default function AudioBackground() {
ctx.fillStyle = grad; ctx.fill()
}
// Center glow
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 100 + smoothBass * 140)
cg.addColorStop(0, `rgba(${a},${(0.14 + smoothBass * 0.18) * br})`); cg.addColorStop(1, `rgba(${a},0)`)
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)

View File

@@ -1,4 +1,4 @@
'use client'
'use client'
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
import { usePartyStore } from '@/store/partyStore'
@@ -9,6 +9,9 @@ import { useAudioEngine } from '@/hooks/usePlayer'
import { useAudioViz } from '@/hooks/useAudioViz'
import { audioState } from '@/lib/audioState'
import AddToPlaylist from '@/components/AddToPlaylist'
import QueuePanel from '@/components/Player/QueuePanel'
import VersionsPanel from '@/components/Player/VersionsPanel'
import { useToastStore } from '@/store/toastStore'
import type { SearchResult } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
@@ -25,10 +28,14 @@ function formatTime(s: number) {
}
export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus, reorderQueue, setCurIdx, generateMix, removeFromQueue, addTrackToQueue } =
usePartyStore()
const {
queue, curIdx, loadKey, repeatMode,
updateQueueItemImg, setCurrentResults, setCurrentResult, setSearchStatus, searchStatus,
setCurIdx, setRepeatMode, removeFromQueue, addTrackToQueue,
} = usePartyStore()
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
const showToast = useToastStore((s) => s.show)
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
const canvasRef = useRef<HTMLCanvasElement>(null)
@@ -43,7 +50,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const [panel, setPanel] = useState<'queue' | 'versions' | null>(null)
const [playlistOpen, setPlaylistOpen] = useState(false)
const playlistBtnRef = useRef<HTMLButtonElement>(null)
const dragSrcIdx = useRef<number | null>(null)
const panelRef = useRef<HTMLDivElement>(null)
// Remote control
@@ -62,6 +68,9 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const prefetchInflightRef = useRef<Map<string, Promise<SearchResult[]>>>(new Map())
const preloadAudioRef = useRef<HTMLAudioElement | null>(null)
// Touch swipe → prev/next track
const touchStartX = useRef<number | null>(null)
useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isPlaying)
useEffect(() => {
@@ -77,6 +86,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
if (!r) return
setActiveResultIdx(resIdx)
activeResultIdxRef.current = resIdx
setCurrentResult(r)
setAudioMeta({ title: r.title, artist: r.artist })
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
const audio = audioRef.current
@@ -89,14 +99,12 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
if (err?.name !== 'AbortError') console.warn('[player] play() failed:', err?.name)
})
},
[audioRef, resumeContext],
[audioRef, resumeContext, setCurrentResult],
)
const playResultRef = useRef(playResult)
useEffect(() => { playResultRef.current = playResult }, [playResult])
// Returns cached results immediately, awaits in-flight request, or starts new one.
// Deduplicates parallel requests for the same title.
const prefetchOrGet = useCallback(async (title: string): Promise<SearchResult[]> => {
const cached = prefetchCacheRef.current.get(title)
if (cached) return cached
@@ -142,6 +150,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
if (!found.length) {
setSearchStatus('not-found')
showToast(`Трек не найден: ${track.title}`, 'error')
setTimeout(() => {
if (loadingKeyRef.current !== key) return
const s = usePartyStore.getState()
@@ -167,7 +176,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
playResult(found, startIdx)
// Prefetch next 3 tracks; for N+1 also preload audio into hidden element
for (let offset = 1; offset <= 3; offset++) {
const nextTrack = queue[idx + offset]
if (!nextTrack) continue
@@ -176,29 +184,25 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
p.then(nextResults => {
if (loadingKeyRef.current !== key) return
if (!nextResults.length) return
const saved = getSavedVersion(nextTrack.title)
const si = saved ? nextResults.findIndex(r => r.title === saved.title && r.artist === saved.artist) : -1
const sv = getSavedVersion(nextTrack.title)
const si = sv ? nextResults.findIndex(r => r.title === sv.title && r.artist === sv.artist) : -1
const resIdx = si >= 0 ? si : 0
const preloadEl = preloadAudioRef.current
if (!preloadEl) return
const url = proxyMp3Url(nextResults[resIdx].mp3)
if (preloadEl.src !== url) {
preloadEl.src = url
preloadEl.load()
}
if (preloadEl.src !== url) { preloadEl.src = url; preloadEl.load() }
}).catch(() => {})
} else {
p.catch(() => {})
}
}
// Evict stale cache entries outside the current window
const windowTitles = new Set(queue.slice(Math.max(0, idx - 1), idx + 6).map(t => t.title))
prefetchCacheRef.current.forEach((_, title) => {
if (!windowTitles.has(title)) prefetchCacheRef.current.delete(title)
})
},
[queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion, prefetchOrGet],
[queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion, prefetchOrGet, showToast],
)
useEffect(() => {
@@ -220,8 +224,8 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
audioState.analyser = analyserRef.current
}
const onPause = () => { setIsPlaying(false); isPlayingRef.current = false; audioState.isPlaying = false }
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
const onDuration = () => setDuration(audio.duration || 0)
const onTimeUpdate = () => { setCurrentTime(audio.currentTime); audioState.currentTime = audio.currentTime }
const onDuration = () => { const d = audio.duration || 0; setDuration(d); audioState.duration = d }
const onEnded = () => {
setIsPlaying(false)
isPlayingRef.current = false
@@ -229,15 +233,18 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const r = activeResultsRef.current[activeResultIdxRef.current]
if (r) onTrackEnd(r)
const s = usePartyStore.getState()
const repeat = s.repeatMode
if (repeat === 'one') {
s.setCurIdx(s.curIdx)
} else if (repeat === 'all') {
s.setCurIdx(s.curIdx < s.queue.length - 1 ? s.curIdx + 1 : 0)
} else {
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
}
}
const onError = () => {
if (!audio.src) return
setTimeout(() => {
resumeContext()
audio.load()
audio.play().catch(() => {})
}, 1500)
setTimeout(() => { resumeContext(); audio.load(); audio.play().catch(() => {}) }, 1500)
}
audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onPause)
@@ -255,11 +262,10 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
}
}, [audioRef, analyserRef, initAudioViz, resumeContext, onTrackEnd])
// Keep meta refs in sync for remote
useEffect(() => { audioMetaRef.current = audioMeta }, [audioMeta])
useEffect(() => { coverSrcRef.current = coverSrc }, [coverSrc])
// Remote: push state every 2s, poll commands every 500ms
// Remote: push state every 2s, poll commands every 1.5s
useEffect(() => {
if (!roomId) return
@@ -331,7 +337,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
pushState()
pollCommands()
const ivState = setInterval(pushState, 2000)
const ivCmd = setInterval(pollCommands, 500)
const ivCmd = setInterval(pollCommands, 1500)
return () => { clearInterval(ivState); clearInterval(ivCmd) }
}, [roomId, audioRef, resumeContext])
@@ -360,6 +366,13 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const togglePlay = useCallback(() => {
const audio = audioRef.current
if (!audio) return
if (audio.readyState === 0) {
if (loadingKeyRef.current < 0) {
const s = usePartyStore.getState()
if (s.curIdx >= 0 && s.queue.length > 0) s.setCurIdx(s.curIdx)
}
return
}
if (audio.paused) audio.play().catch(() => {})
else audio.pause()
}, [audioRef])
@@ -368,155 +381,76 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const reloadTrack = () => { const s = usePartyStore.getState(); s.setCurIdx(s.curIdx) }
const nextTrack = () => { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) }
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
dragSrcIdx.current = idx
el.classList.add('dragging')
}, [])
const onDragEnd = useCallback((el: HTMLElement) => {
el.classList.remove('dragging')
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
}, [])
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
e.preventDefault()
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
el.classList.add('drag-over')
}, [])
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
e.preventDefault()
el.classList.remove('drag-over')
if (dragSrcIdx.current === null || dragSrcIdx.current === tgtIdx) return
reorderQueue(dragSrcIdx.current, tgtIdx)
dragSrcIdx.current = null
}, [reorderQueue])
const onTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
}
const onTouchEnd = (e: React.TouchEvent) => {
if (touchStartX.current === null) return
const delta = e.changedTouches[0].clientX - touchStartX.current
touchStartX.current = null
if (delta > 60) prevTrack()
else if (delta < -60) nextTrack()
}
const track = queue[Math.max(0, curIdx)] ?? null
const trackTitle = track?.title ?? ''
const favorited = isFavorite(trackTitle)
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
const repeatLabel = repeatMode === 'none' ? 'Повтор выкл' : repeatMode === 'all' ? 'Повтор всего' : 'Повтор трека'
return (
<>
{/* Audio is always mounted at the same tree position so audioRef never changes and event listeners persist */}
<audio ref={audioRef} preload="auto" crossOrigin="anonymous" style={{ display: 'none' }} />
{(queue.length > 0 && track) && <div className="fixed bottom-0 left-0 right-0 z-50" ref={panelRef}>
{(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')} />
<VersionsPanel
results={results}
activeResultIdx={activeResultIdx}
trackTitle={trackTitle}
onPlay={playResult}
isSaved={isSaved}
saveVersion={saveVersion}
removeVersion={removeVersion}
onClose={() => setPanel(null)}
/>
)}
<div className="flex-1 min-w-0">
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">{r.title}</div>
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
</div>
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
<button
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
title={saved ? 'Забыть версию' : 'Запомнить'}
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); playResult(results, i) }}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)}
{/* Queue panel */}
{panel === 'queue' && (
<div className="overflow-y-auto flex flex-col">
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Очередь · {queue.length}</span>
<button
onClick={() => generateMix()}
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
></button>
</div>
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text"></button>
</div>
<div className="overflow-y-auto">
{queue.map((item, i) => {
const active = i === curIdx
return (
<div
key={i}
draggable
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
onDragStart={(e) => onDragStart(i, e.currentTarget)}
onDragEnd={(e) => onDragEnd(e.currentTarget)}
onDragOver={(e) => onDragOver(e, e.currentTarget)}
onDrop={(e) => onDrop(e, i, e.currentTarget)}
>
<div className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
</svg>
</div>
{active ? (
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img src={item.img} alt="" className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color.bg, color: item.color.text }}>
{item.owner}
</span>
</div>
)
})}
</div>
</div>
<QueuePanel
queue={queue}
curIdx={curIdx}
onClose={() => setPanel(null)}
/>
)}
</div>
)}
{/* Bottom bar */}
<div className="bg-surface border-t border-white/[0.07] px-4 py-2.5">
<div
className="bg-surface border-t border-white/[0.07] px-4 py-2.5"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Progress bar */}
<div className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group" onClick={(e) => {
<div
role="slider"
aria-label="Прогресс трека"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
const audio = audioRef.current
if (audio && duration) audio.currentTime = pct * duration
}}>
}}
>
<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>
@@ -525,49 +459,69 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
{/* 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" />
<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'}`} />
<canvas
ref={canvasRef}
aria-hidden="true"
className={`absolute inset-0 w-full h-full pointer-events-none transition-opacity duration-300 ${isPlaying ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
{searchStatus === 'searching' ? (
<div className="flex items-center gap-1.5 text-[12px] text-muted">
<div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" />
<div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" aria-hidden="true" />
<span>Ищем...</span>
</div>
) : (
<>
<div className="text-[13px] font-display font-bold whitespace-nowrap overflow-hidden text-ellipsis leading-tight">{track.title}</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>
<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">
<div className="text-[10px] text-muted font-display shrink-0 hidden sm:block tabular-nums" aria-label={`${formatTime(currentTime)} из ${formatTime(duration)}`}>
{formatTime(currentTime)}<span className="opacity-40 mx-0.5">/</span>{formatTime(duration)}
</div>
{/* Controls */}
{/* Playback 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">
<button
onClick={prevTrack}
aria-label="Предыдущий трек"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
</button>
<button onClick={togglePlay} className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80">
<button
onClick={togglePlay}
aria-label={isPlaying ? 'Пауза' : 'Воспроизвести'}
className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80"
>
{isPlaying ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
)}
</button>
<button onClick={nextTrack} 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">
<button
onClick={nextTrack}
aria-label="Следующий трек"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
</button>
</div>
@@ -575,28 +529,62 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
{/* 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">
<button
onClick={reloadTrack}
aria-label="Перезагрузить трек"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer hidden sm:flex"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
</button>
{/* Repeat */}
<button
onClick={() => setRepeatMode(repeatMode === 'none' ? 'all' : repeatMode === 'all' ? 'one' : 'none')}
aria-label={repeatLabel}
title={repeatLabel}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hidden sm:flex relative"
style={{
color: repeatMode !== 'none' ? 'var(--accent)' : undefined,
background: repeatMode !== 'none' ? 'rgba(var(--accent-rgb),0.08)' : undefined,
}}
>
{repeatMode === 'one' ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
<text x="10" y="14" fontSize="6" fill="currentColor" stroke="none" fontWeight="bold">1</text>
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
style={{ opacity: repeatMode === 'none' ? 0.4 : 1 }}>
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
)}
</button>
{/* Versions */}
{results.length > 1 && (
<button
onClick={() => setPanel(p => p === 'versions' ? null : 'versions')}
aria-label="Версии трека"
aria-expanded={panel === 'versions'}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: panel === 'versions' ? 'var(--accent)' : undefined, background: panel === 'versions' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
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="Добавить в плейлист"
aria-label="Добавить в плейлист"
aria-expanded={playlistOpen}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: playlistOpen ? 'var(--accent)' : undefined, background: playlistOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
>
@@ -612,9 +600,11 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
/>
)}
</div>
{/* Favorite */}
<button
onClick={() => toggleFavorite(trackTitle)}
aria-label={favorited ? 'Убрать из избранного' : 'В избранное'}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2"
style={{ color: favorited ? 'var(--accent)' : undefined }}
>
@@ -622,18 +612,21 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</button>
{/* Queue */}
<button
onClick={() => setPanel(p => p === 'queue' ? null : 'queue')}
aria-label="Очередь"
aria-expanded={panel === 'queue'}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: panel === 'queue' ? 'var(--accent)' : undefined, background: panel === 'queue' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
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
@@ -650,7 +643,8 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
}
if (id) setShareOpen(v => !v)
}}
title="Пульт управления"
aria-label="Пульт управления"
aria-expanded={shareOpen}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: shareOpen || roomId ? 'var(--accent)' : undefined, background: shareOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
>
@@ -667,6 +661,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
<div className="flex gap-1.5">
<input
readOnly
aria-label="Ссылка на пульт"
value={typeof window !== 'undefined' ? `${window.location.origin}/remote/${roomId}` : `/remote/${roomId}`}
className="flex-1 min-w-0 text-[11px] bg-surface2 border border-white/[0.07] rounded-[7px] px-2 py-1.5 text-muted outline-none font-mono truncate"
/>
@@ -678,6 +673,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
setTimeout(() => setShareCopied(false), 2000)
}
}}
aria-label="Копировать ссылку на пульт"
className="shrink-0 px-2 py-1.5 text-[11px] rounded-[7px] border border-white/[0.07] hover:bg-surface2 transition-all cursor-pointer font-display"
style={{ color: shareCopied ? 'var(--accent)' : undefined }}
>
@@ -691,8 +687,8 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
</div>
</div>
</div>
</div>}
</div>
)}
</>
)
}

View File

@@ -1,26 +1,90 @@
'use client'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { useAuthStore } from '@/store/authStore'
import { useOverlayStore } from '@/store/overlayStore'
import { audioState } from '@/lib/audioState'
import BottomPlayer from '@/components/BottomPlayer'
import type { SearchResult } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
export default function GlobalPlayer() {
const { hydrate: hydrateFavorites } = useFavoritesStore()
const { hydrate: hydrateVersions } = useVersionStore()
const { token } = useAuthStore()
const { user } = useAuthStore()
const lastPushedRef = useRef('')
useEffect(() => { hydrateFavorites() }, [hydrateFavorites])
useEffect(() => { hydrateVersions() }, [user, hydrateVersions])
const pushOverlay = useCallback(async (result: SearchResult | null) => {
if (!user) return
const { enabled, design, style, accentColor, position, font, textColor, showCover, showEq, palette, customPalettes, margin, scale, opacity } = useOverlayStore.getState()
const cp = customPalettes[style] ?? {}
const isPlaying = audioState.isPlaying
const key = `${result?.title}|${isPlaying}|${enabled}|${design}|${style}|${accentColor}|${position}|${font}|${textColor}|${showCover}|${showEq}|${palette}|${JSON.stringify(cp)}|${margin}|${scale}|${opacity}`
if (key === lastPushedRef.current) return
lastPushedRef.current = key
try {
await fetch(`${API_URL}/api/overlay/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: result?.title ?? '',
artist: result?.artist ?? '',
cover: result?.img ?? '',
is_playing: isPlaying,
progress: audioState.currentTime,
duration: audioState.duration,
enabled,
design,
style,
accent_color: accentColor,
position,
font,
text_color: textColor,
show_cover: showCover,
show_eq: showEq,
palette,
custom_bg: cp.bg ?? '',
custom_text: cp.text ?? '',
custom_text2: cp.text2 ?? '',
custom_chroma: cp.chroma ?? '',
custom_title_bg: cp.titleBg ?? '',
custom_body_bg: cp.bodyBg ?? '',
margin,
scale,
opacity,
}),
})
} catch {}
}, [user])
// Initial push on mount (and whenever user changes)
useEffect(() => {
pushOverlay(usePartyStore.getState().currentResult ?? null)
}, [pushOverlay])
useEffect(() => {
hydrateFavorites()
}, [hydrateFavorites])
return usePartyStore.subscribe((state, prev) => {
const result = state.currentResult ?? null
const prevResult = prev.currentResult ?? null
if (result?.title !== prevResult?.title || state.curIdx !== prev.curIdx) {
pushOverlay(result)
}
})
}, [pushOverlay])
// Re-hydrate versions whenever auth changes (null → token = fetch from API)
useEffect(() => {
hydrateVersions()
}, [token, hydrateVersions])
return useOverlayStore.subscribe(() => {
pushOverlay(usePartyStore.getState().currentResult ?? null)
})
}, [pushOverlay])
const handleTrackEnd = useCallback((result: SearchResult) => {
const { queue, curIdx, addToHistory } = usePartyStore.getState()

View File

@@ -10,8 +10,10 @@ export default function Header() {
const router = useRouter()
const pathname = usePathname()
const [open, setOpen] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
@@ -22,8 +24,12 @@ export default function Header() {
return () => document.removeEventListener('mousedown', handler)
}, [open])
// Close mobile menu on navigation
useEffect(() => { setMenuOpen(false) }, [pathname])
const handleLogout = () => {
setOpen(false)
setMenuOpen(false)
clearAuth()
router.push('/')
}
@@ -33,6 +39,8 @@ export default function Header() {
return (
<Link
href={href}
aria-label={label}
aria-current={active ? 'page' : undefined}
className="flex items-center gap-1.5 text-[12px] font-display font-semibold px-3 py-1.5 rounded-xl border transition-all duration-150 hidden sm:flex"
style={active
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.3)', color: 'var(--accent)' }
@@ -45,10 +53,56 @@ export default function Header() {
)
}
const mobileNavLink = (href: string, label: string, icon: React.ReactNode) => {
const active = pathname === href
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">
<Link
href={href}
onClick={() => setMenuOpen(false)}
aria-current={active ? 'page' : undefined}
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150"
style={active
? { color: 'var(--accent)', background: 'rgba(var(--accent-rgb),0.06)' }
: { color: 'var(--color-muted)' }
}
>
{icon}
{label}
</Link>
)
}
const searchIcon = (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
const communityIcon = (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" />
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
const playlistsIcon = (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
const settingsIcon = (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
return (
<div className="relative mb-5">
<div className="flex items-center gap-2.5 pb-5 border-b border-white/[0.07]">
<Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline" aria-label="Party Mix — главная">
<div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
@@ -59,37 +113,22 @@ export default function Header() {
</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>
)}
{navLink('/search', 'Поиск', searchIcon)}
{navLink('/community', 'Сообщество', communityIcon)}
{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>
)}
{navLink('/playlists', 'Плейлисты', playlistsIcon)}
{/* User badge with dropdown */}
<div className="relative" ref={dropdownRef}>
<div className="relative hidden sm:block" ref={dropdownRef}>
<button
onClick={() => setOpen(v => !v)}
aria-label={`Меню пользователя ${user.username}`}
aria-expanded={open}
className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5 cursor-pointer transition-colors duration-150 hover:border-white/[0.14]"
>
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0">
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0" aria-hidden="true">
<span className="text-[10px] font-display font-extrabold text-accent">
{user.username[0].toUpperCase()}
</span>
@@ -97,6 +136,7 @@ export default function Header() {
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none"
aria-hidden="true"
className="text-muted transition-transform duration-200 ml-0.5"
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
@@ -109,24 +149,18 @@ export default function Header() {
className="absolute right-0 top-full mt-1.5 w-52 bg-surface border border-white/[0.09] rounded-xl shadow-xl z-50 py-1 overflow-hidden"
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
>
{/* Settings link */}
<Link
href="/settings"
onClick={() => setOpen(false)}
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-app-text hover:bg-white/[0.04] transition-all duration-150"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{settingsIcon}
Настройки
</Link>
<div className="border-t border-white/[0.07] mx-1 my-0.5" />
{/* Logout */}
<button
onClick={handleLogout}
aria-label="Выйти из аккаунта"
className="w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted hover:text-[#ff6b6b] hover:bg-white/[0.04] transition-all duration-150 cursor-pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
@@ -144,19 +178,88 @@ export default function Header() {
<>
<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"
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5 hidden sm:block"
>
Войти
</Link>
<Link
href="/register"
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150 hidden sm:block"
>
Регистрация
</Link>
</>
)}
{/* Hamburger — mobile only */}
<button
onClick={() => setMenuOpen(v => !v)}
aria-label={menuOpen ? 'Закрыть меню' : 'Открыть меню'}
aria-expanded={menuOpen}
className="w-8 h-8 flex items-center justify-center rounded-[8px] sm:hidden text-muted hover:text-app-text hover:bg-surface2 transition-all cursor-pointer"
>
{menuOpen ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
</svg>
)}
</button>
</div>
</div>
{/* Mobile menu drawer */}
{menuOpen && (
<div
className="absolute left-0 right-0 top-full bg-surface border border-white/[0.09] rounded-[12px] z-40 overflow-hidden shadow-xl flex flex-col sm:hidden"
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
>
{mobileNavLink('/search', 'Поиск', searchIcon)}
{mobileNavLink('/community', 'Сообщество', communityIcon)}
{user && mobileNavLink('/playlists', 'Плейлисты', playlistsIcon)}
<div className="border-t border-white/[0.07] mx-2 my-1" />
{user ? (
<>
{mobileNavLink('/settings', 'Настройки', settingsIcon)}
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150 cursor-pointer w-full text-left"
style={{ color: '#ff6b6b' }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
Выйти
</button>
</>
) : (
<>
<Link
href="/login"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150"
style={{ color: 'var(--color-muted)' }}
>
Войти
</Link>
<Link
href="/register"
onClick={() => setMenuOpen(false)}
className="mx-3 mb-2 flex items-center justify-center py-2.5 text-[14px] font-display font-semibold bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
>
Регистрация
</Link>
</>
)}
</div>
)}
</div>
)
}

View File

@@ -1,10 +1,32 @@
'use client'
import { useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useAuthStore } from '@/store/authStore'
import { useToastStore } from '@/store/toastStore'
import { createPlaylist } from '@/lib/authApi'
import { proxyImgUrl } from '@/lib/api'
export default function HistoryTab() {
const { history, clearHistory } = usePartyStore()
const { user } = useAuthStore()
const showToast = useToastStore((s) => s.show)
const [saving, setSaving] = useState(false)
const handleSavePlaylist = async () => {
if (!history.length) return
setSaving(true)
try {
const tracks = [...history].reverse().map((h) => h.title)
const name = `История ${new Date().toLocaleDateString('ru', { day: 'numeric', month: 'short' })}`
await createPlaylist(name, tracks)
showToast('Плейлист сохранён', 'success')
} catch {
showToast('Не удалось сохранить плейлист', 'error')
} finally {
setSaving(false)
}
}
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
@@ -13,12 +35,23 @@ export default function HistoryTab() {
Уже сыграло
</span>
{history.length > 0 && (
<div className="flex items-center gap-2">
{user && (
<button
onClick={handleSavePlaylist}
disabled={saving}
className="text-[11px] px-2.5 py-0.5 border border-accent/20 rounded-[7px] text-accent/60 hover:bg-accent/08 hover:text-accent hover:border-accent/35 transition-all duration-150 cursor-pointer font-sans disabled:opacity-40"
>
{saving ? 'Сохраняем...' : '↓ В плейлист'}
</button>
)}
<button
onClick={clearHistory}
className="text-[11px] px-2.5 py-0.5 border border-[rgba(255,100,100,0.2)] rounded-[7px] bg-transparent text-[rgba(255,100,100,0.5)] hover:bg-[rgba(255,100,100,0.08)] hover:text-[#ff6b6b] hover:border-[rgba(255,100,100,0.35)] transition-all duration-150 cursor-pointer font-sans"
>
Очистить
</button>
</div>
)}
</div>

View File

@@ -0,0 +1,489 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { getPalette, buildCustomPalette, type OverlayPalette } from '@/lib/overlayPalettes'
import { useOverlayStore, type OverlayStyle } from '@/store/overlayStore'
import { usePartyStore } from '@/store/partyStore'
// Shared state type used by both the overlay page and the settings preview
export interface OverlayWidgetState {
title: string
artist: string
cover: string
is_playing: boolean
progress: number
duration: number
enabled: boolean
design: string
style: string
accent_color: string
position: string
font: string
text_color: string
show_cover: boolean
show_eq: boolean
palette: string
custom_bg: string
custom_text: string
custom_text2: string
custom_chroma: string
custom_title_bg: string
custom_body_bg: string
margin: number
scale: number
opacity: number
updated_at: number
}
// ── Helpers ───────────────────────────────────────────────────────────────────
export function useCover(src: string) {
const [err, setErr] = useState(false)
useEffect(() => setErr(false), [src])
return { src: err ? '' : src, onError: () => setErr(true) }
}
export function EqBars({ color, size = 14, playing = true }: { color: string; size?: number; playing?: boolean }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2.5, height: size, flexShrink: 0 }}>
{[65, 100, 50, 85, 40].map((h, i) => (
<div key={i} className="eq-bar" style={{ height: `${h}%`, width: 2.5, background: color, borderRadius: 2, animationDelay: `${i * 0.13}s`, animationPlayState: playing ? 'running' : 'paused' }} />
))}
</div>
)
}
function MusicIcon() {
return (
<svg width="55%" height="55%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
</svg>
)
}
function ProgressBar({ progress, duration, color, style: barStyle }: { progress: number; duration: number; color: string; style?: React.CSSProperties }) {
const pct = duration > 0 ? Math.min(100, (progress / duration) * 100) : 0
if (!duration) return null
return (
<div style={{ height: 2, background: 'rgba(255,255,255,0.1)', borderRadius: 1, overflow: 'hidden', ...barStyle }}>
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 1, transition: 'width 1s linear' }} />
</div>
)
}
// position: fixed → in the overlay page, fixed to viewport.
// In the preview box (which has transform:translateZ(0)), fixed children are contained inside.
// --ov-scale is set by the overlay page per the user's scale setting.
// transform-origin follows --ov-origin (set based on position).
export const OVERLAY_POS: React.CSSProperties = {
position: 'fixed',
bottom: 'var(--ov-b)' as string,
top: 'var(--ov-t)' as string,
left: 'var(--ov-l)' as string,
right: 'var(--ov-r)' as string,
transform: 'scale(var(--ov-scale,1))',
transformOrigin: 'var(--ov-origin, bottom left)',
}
// ── 1. Классика ────────────────────────────────────────────────────────────────
export function ClassicStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const cover = useCover(s.cover)
const bg = pal.bg ?? 'rgba(10,10,16,0.82)'
const border = pal.border ?? '1px solid rgba(255,255,255,0.09)'
const shadow = pal.shadow ?? '0 8px 32px rgba(0,0,0,0.55)'
const text = s.text_color || pal.text || '#fff'
const text2 = pal.text2 ?? 'rgba(255,255,255,0.45)'
const coverBg = pal.chroma ?? 'rgba(255,255,255,0.06)'
return (
<div style={{ ...OVERLAY_POS, display: 'flex', flexDirection: 'column', gap: 0, padding: '10px 16px 10px 10px', borderRadius: 16, background: bg, backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', border, boxShadow: shadow, maxWidth: 340 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{s.show_cover !== false && (
<div style={{ width: 44, height: 44, borderRadius: 10, overflow: 'hidden', background: coverBg, flexShrink: 0 }}>
{cover.src
? <img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}><MusicIcon /></div>
}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
</div>
{s.show_eq !== false && <EqBars color="var(--accent)" playing={s.is_playing} />}
</div>
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ marginTop: 8 }} />
</div>
)
}
// ── 2. Фрутигер Аеро ──────────────────────────────────────────────────────────
export function AeroStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const cover = useCover(s.cover)
const bg = pal.bg ?? 'linear-gradient(160deg,rgba(190,230,255,0.55) 0%,rgba(120,185,255,0.42) 100%)'
const border = pal.border ?? '1.5px solid rgba(255,255,255,0.72)'
const shadow = pal.shadow ?? '0 4px 20px rgba(80,160,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)'
const text = s.text_color || pal.text || '#003a6e'
const text2 = pal.text2 ?? 'rgba(0,60,130,0.60)'
return (
<div style={{ ...OVERLAY_POS, display: 'flex', flexDirection: 'column', borderRadius: 50, background: bg, backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)', border, boxShadow: shadow, maxWidth: 340, overflow: 'hidden', position: 'fixed' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '55%', background: 'linear-gradient(180deg,rgba(255,255,255,0.38) 0%,rgba(255,255,255,0) 100%)', borderRadius: '50px 50px 0 0', pointerEvents: 'none' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 20px 8px 8px' }}>
{s.show_cover !== false && (
<div style={{ width: 42, height: 42, borderRadius: '50%', overflow: 'hidden', background: 'rgba(255,255,255,0.25)', flexShrink: 0, border: '2px solid rgba(255,255,255,0.6)', boxShadow: '0 2px 8px rgba(0,80,180,0.2)' }}>
{cover.src
? <img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(0,80,160,0.5)' }}><MusicIcon /></div>
}
</div>
)}
<div style={{ flex: 1, minWidth: 0, position: 'relative' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '-0.02em' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
</div>
{s.show_eq !== false && <EqBars color="var(--accent)" size={12} playing={s.is_playing} />}
</div>
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ borderRadius: 0, margin: '0 0 0 0' }} />
</div>
)
}
// ── 3. Ретро ──────────────────────────────────────────────────────────────────
export function RetroStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const [blink, setBlink] = useState(true)
useEffect(() => { const iv = setInterval(() => setBlink(b => !b), 800); return () => clearInterval(iv) }, [])
const now = new Date()
const ts = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`
const bg = pal.bg ?? 'rgba(12,5,0,0.94)'
const border = pal.border ?? '2px solid #c07030'
const shadow = pal.shadow ?? '0 0 28px rgba(180,90,20,0.45),4px 4px 0 rgba(0,0,0,0.6)'
const chromaA = pal.chroma ?? 'rgba(180,100,20,0.30)'
const text = s.text_color || pal.text || '#f8d090'
const text2 = pal.text2 ?? '#9a6020'
const blinkColor = pal.chroma?.startsWith('#') ? pal.chroma : 'var(--accent)'
const pct = s.duration > 0 ? Math.min(100, (s.progress / s.duration) * 100) : 0
return (
<div style={{ ...OVERLAY_POS, width: 310, fontFamily: s.font || 'monospace', background: bg, border, boxShadow: shadow, overflow: 'hidden' }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px)', pointerEvents: 'none', zIndex: 2 }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: chromaA, borderBottom: `1px solid ${blinkColor}44` }}>
<span style={{ fontSize: 10, color: blinkColor, letterSpacing: '0.1em' }}> NOW PLAYING</span>
<span style={{ fontSize: 10, color: blink ? blinkColor : 'transparent', letterSpacing: '0.05em' }}> REC</span>
</div>
<div style={{ padding: '10px 12px 8px', position: 'relative', zIndex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: text, letterSpacing: '0.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 3, letterSpacing: '0.05em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
{s.duration > 0 && (
<div style={{ marginTop: 8, height: 3, background: 'rgba(255,255,255,0.1)', borderRadius: 0 }}>
<div style={{ height: '100%', width: `${pct}%`, background: blinkColor, transition: 'width 1s linear' }} />
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 4 }}>
<span style={{ fontSize: 9, color: text2, fontFamily: 'monospace' }}>{ts}</span>
</div>
</div>
</div>
)
}
// ── 4. Неон ───────────────────────────────────────────────────────────────────
export function NeonStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const bg = pal.bg ?? 'rgba(0,0,10,0.9)'
const vars = pal.chroma && pal.chroma2
? { '--accent': pal.chroma, '--accent-rgb': pal.chroma2 } as React.CSSProperties
: {}
const text = s.text_color || 'var(--accent)'
return (
<div style={{ ...vars, ...OVERLAY_POS, padding: '12px 18px', background: bg, backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', border: '1px solid rgba(var(--accent-rgb),0.6)', boxShadow: '0 0 28px rgba(var(--accent-rgb),0.3),inset 0 0 20px rgba(var(--accent-rgb),0.04)', borderRadius: 8, maxWidth: 340 }}>
{[['0','0'],['0','auto'],['auto','0'],['auto','auto']].map(([t,b], i) => (
<div key={i} style={{ position: 'absolute', top: t === '0' ? -1 : 'auto', bottom: b === 'auto' ? 'auto' : -1, left: i < 2 ? -1 : 'auto', right: i < 2 ? 'auto' : -1, width: 8, height: 8, border: '2px solid var(--accent)', borderRadius: 1 }} />
))}
<div style={{ fontSize: 10, color: 'rgba(var(--accent-rgb),0.5)', letterSpacing: '0.2em', marginBottom: 6, fontFamily: 'monospace' }}> STREAM</div>
<div style={{ fontSize: 16, fontWeight: 800, color: text, letterSpacing: '0.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', textShadow: '0 0 12px rgba(var(--accent-rgb),0.8)' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: 'rgba(var(--accent-rgb),0.5)', marginTop: 4, letterSpacing: '0.06em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ flex: 1, background: 'rgba(var(--accent-rgb),0.15)' }} />
{(!s.duration) && <div style={{ flex: 1, height: 1, background: 'linear-gradient(90deg,rgba(var(--accent-rgb),0.6),transparent)' }} />}
{s.show_eq !== false && <EqBars color="var(--accent)" size={10} playing={s.is_playing} />}
</div>
</div>
)
}
// ── 5. Минимализм ─────────────────────────────────────────────────────────────
export function CleanStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const text = s.text_color || pal.text || '#ffffff'
const text2 = pal.text2 ?? 'rgba(255,255,255,0.55)'
return (
<div style={{ ...OVERLAY_POS }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 4, height: 32, background: 'var(--accent)', borderRadius: 2, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 22, fontWeight: 800, color: text, lineHeight: 1.1, letterSpacing: '-0.03em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 400, textShadow: '0 2px 16px rgba(0,0,0,0.8)' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 13, color: text2, marginTop: 3, letterSpacing: '-0.01em', textShadow: '0 2px 8px rgba(0,0,0,0.8)' }}>{s.artist}</div>}
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ marginTop: 6, width: 200 }} />
</div>
</div>
</div>
)
}
// ── 6. Y2K ────────────────────────────────────────────────────────────────────
export function Y2kStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const titleBg = pal.titleBg ?? 'linear-gradient(90deg,#1a4090 0%,#4a80d0 40%,#1a4090 100%)'
const bodyBg = pal.bodyBg ?? 'linear-gradient(160deg,#dce4f0,#c0c8e0)'
const text = s.text_color || pal.text || '#000060'
const text2 = pal.text2 ?? '#404880'
const pct = s.duration > 0 ? Math.min(100, (s.progress / s.duration) * 100) : 42
return (
<div style={{ ...OVERLAY_POS, width: 300, fontFamily: s.font || 'Tahoma, Arial, sans-serif', boxShadow: '3px 3px 0 rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.9)', border: '2px solid #7080b0', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '3px 4px 3px 6px', background: titleBg, userSelect: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 9 }}>🎵</span>
<span style={{ fontSize: 11, color: '#fff', fontWeight: 700, letterSpacing: '0.01em' }}>Party Mix Player</span>
</div>
<div style={{ display: 'flex', gap: 2 }}>
{['_','□','×'].map(c => (
<div key={c} style={{ width: 16, height: 14, background: 'linear-gradient(180deg,#d0d8e8,#9aa8c0)', border: '1px solid #607090', borderRadius: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, color: '#000', fontWeight: 700, cursor: 'default' }}>{c}</div>
))}
</div>
</div>
<div style={{ background: bodyBg, padding: '10px 12px 12px' }}>
<div style={{ background: 'rgba(255,255,255,0.5)', border: '1px solid #8090b0', borderRadius: 2, padding: '6px 10px', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 10, color: text2, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
</div>
<div style={{ height: 14, background: 'rgba(255,255,255,0.4)', border: '1px solid #8090b0', borderRadius: 2, overflow: 'hidden', boxShadow: 'inset 1px 1px 2px rgba(0,0,0,0.15)' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'linear-gradient(180deg,var(--accent),rgba(var(--accent-rgb),0.65))', borderRadius: 1, transition: 'width 1s linear' }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 3 }}>
<span style={{ fontSize: 9, color: text2 }}> {s.is_playing ? 'Playing' : 'Paused'}</span>
<span style={{ fontSize: 9, color: text2 }}>WMP 9.0</span>
</div>
</div>
</div>
)
}
// ── 7. Ло-фай ─────────────────────────────────────────────────────────────────
export function LofiStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const cover = useCover(s.cover)
const bg = pal.bg ?? 'rgba(42,26,14,0.92)'
const text = s.text_color || pal.text || '#fde8c0'
const text2 = pal.text2 ?? 'rgba(var(--accent-rgb),0.60)'
return (
<div style={{ ...OVERLAY_POS, display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px', borderRadius: 18, background: bg, backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', border: '1px solid rgba(var(--accent-rgb),0.2)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', transform: `rotate(-0.8deg) scale(var(--ov-scale,1))`, maxWidth: 320 }}>
{s.show_cover !== false && (
<div style={{ width: 52, height: 52, borderRadius: '50%', flexShrink: 0, background: 'radial-gradient(circle at center,#3a2010 0%,#1a0e06 40%,#2a1808 60%,#1a0e06 100%)', border: '2px solid rgba(var(--accent-rgb),0.3)', boxShadow: '0 2px 12px rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-radial-gradient(circle at center,transparent 0%,transparent 14%,rgba(255,255,255,0.03) 14.5%,transparent 15%)', borderRadius: '50%' }} />
{cover.src
? <div style={{ width: 22, height: 22, borderRadius: '50%', overflow: 'hidden', zIndex: 1 }}><img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" /></div>
: <div style={{ width: 10, height: 10, borderRadius: '50%', background: 'rgba(var(--accent-rgb),0.5)', zIndex: 1 }} />
}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.55)', letterSpacing: '0.15em', marginBottom: 4, fontStyle: 'italic' }}>now playing</div>
<div style={{ fontSize: 14, fontWeight: 600, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontStyle: 'italic', letterSpacing: '0.01em' }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 3, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontStyle: 'italic' }}>{s.artist}</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 }}>
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ flex: 1, background: 'rgba(var(--accent-rgb),0.2)' }} />
{!s.duration && <div style={{ flex: 1, height: 1, background: 'rgba(var(--accent-rgb),0.25)' }} />}
{s.show_eq !== false && <EqBars color="var(--accent)" size={10} playing={s.is_playing} />}
</div>
</div>
</div>
)
}
// ── 8. Гламур ─────────────────────────────────────────────────────────────────
export function GlamStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const bg = pal.bg ?? 'linear-gradient(145deg,rgba(28,18,4,0.95),rgba(16,10,2,0.95))'
const chroma = pal.chroma ?? '212,175,55'
const text = s.text_color || pal.text || `rgb(${chroma})`
const text2 = pal.text2 ?? `rgba(${chroma},0.65)`
const metalG = `linear-gradient(90deg,transparent,rgba(${chroma},0.8) 30%,rgb(${chroma}) 50%,rgba(${chroma},0.8) 70%,transparent)`
return (
<div style={{ ...OVERLAY_POS, padding: '16px 20px', background: bg, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', border: `1px solid rgba(${chroma},0.45)`, boxShadow: `0 8px 40px rgba(0,0,0,0.7),0 0 0 1px rgba(${chroma},0.2),inset 0 1px 0 rgba(${chroma},0.15)`, borderRadius: 14, maxWidth: 340, overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: metalG }} />
<div style={{ position: 'absolute', top: 6, left: 6, fontSize: 10, color: `rgba(${chroma},0.55)` }}></div>
<div style={{ position: 'absolute', top: 6, right: 6, fontSize: 10, color: `rgba(${chroma},0.55)` }}></div>
<div style={{ textAlign: 'center', fontSize: 8, color: `rgba(${chroma},0.5)`, letterSpacing: '0.3em', marginBottom: 8, textTransform: 'uppercase' }}>Now Playing</div>
<div style={{ height: 1, background: `linear-gradient(90deg,transparent,rgba(${chroma},0.4),transparent)`, marginBottom: 10 }} />
<div style={{ fontSize: 16, fontWeight: 700, color: text, textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '0.02em', textShadow: `0 0 20px rgba(${chroma},0.4)` }}>{s.title || '—'}</div>
{s.artist && <div style={{ fontSize: 11, color: text2, textAlign: 'center', marginTop: 5, letterSpacing: '0.08em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
<div style={{ height: 1, background: `linear-gradient(90deg,transparent,rgba(${chroma},0.4),transparent)`, marginTop: 10 }} />
{s.show_eq !== false && <div style={{ textAlign: 'center', marginTop: 6 }}><EqBars color={`rgb(${chroma})`} size={10} playing={s.is_playing} /></div>}
<ProgressBar progress={s.progress} duration={s.duration} color={`rgb(${chroma})`} style={{ marginTop: 8, background: `rgba(${chroma},0.15)` }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: metalG }} />
</div>
)
}
// ── 9. Матрица ────────────────────────────────────────────────────────────────
export function MatrixStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
const [cursor, setCursor] = useState(true)
const [dots, setDots] = useState(0)
useEffect(() => {
const iv = setInterval(() => { setCursor(c => !c); setDots(d => (d + 1) % 4) }, 530)
return () => clearInterval(iv)
}, [])
const bg = pal.bg ?? 'rgba(0,8,0,0.93)'
const border = pal.border ?? '1px solid rgba(0,255,50,0.35)'
const vars = pal.chroma && pal.chroma2
? { '--accent': pal.chroma, '--accent-rgb': pal.chroma2 } as React.CSSProperties
: {}
const text = s.text_color || pal.chroma || '#00ff32'
return (
<div style={{ ...vars, ...OVERLAY_POS, padding: '12px 16px', background: bg, backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', border, boxShadow: '0 0 24px rgba(var(--accent-rgb),0.15),inset 0 0 12px rgba(var(--accent-rgb),0.03)', borderRadius: 4, maxWidth: 360, fontFamily: s.font || '"Courier New",Courier,monospace' }}>
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.5)', letterSpacing: '0.08em', marginBottom: 6 }}>PARTY_MIX@STREAM:~$ play --now</div>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<span style={{ fontSize: 12, color: 'rgba(var(--accent-rgb),0.6)', marginRight: 6 }}>&gt;</span>
<span style={{ fontSize: 15, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 280, textShadow: '0 0 8px rgba(var(--accent-rgb),0.6)' }}>{s.title || 'NULL'}</span>
<span style={{ color: text, opacity: cursor ? 1 : 0, marginLeft: 2, textShadow: '0 0 6px rgba(var(--accent-rgb),0.8)' }}></span>
</div>
{s.artist && <div style={{ fontSize: 11, color: 'rgba(var(--accent-rgb),0.55)', marginTop: 3, letterSpacing: '0.05em' }}>{`:: artist="${s.artist}"`}</div>}
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.4)', marginTop: 6, letterSpacing: '0.05em' }}>{`[LOADING${'.'.repeat(dots)}]`}</div>
</div>
)
}
// ── Style map ─────────────────────────────────────────────────────────────────
export const OVERLAY_STYLE_MAP: Record<string, (s: OverlayWidgetState, pal: OverlayPalette) => React.ReactNode> = {
classic: (s, pal) => <ClassicStyle s={s} pal={pal} />,
aero: (s, pal) => <AeroStyle s={s} pal={pal} />,
retro: (s, pal) => <RetroStyle s={s} pal={pal} />,
neon: (s, pal) => <NeonStyle s={s} pal={pal} />,
clean: (s, pal) => <CleanStyle s={s} pal={pal} />,
y2k: (s, pal) => <Y2kStyle s={s} pal={pal} />,
lofi: (s, pal) => <LofiStyle s={s} pal={pal} />,
glam: (s, pal) => <GlamStyle s={s} pal={pal} />,
matrix: (s, pal) => <MatrixStyle s={s} pal={pal} />,
}
// ── Settings preview ──────────────────────────────────────────────────────────
export function OverlayPreview() {
const {
style: overlayStyle,
palette: overlayPalette,
customPalettes,
accentColor,
textColor,
showCover,
showEq,
font,
} = useOverlayStore()
const currentResult = usePartyStore(s => s.currentResult)
const cp = customPalettes[overlayStyle] ?? {}
const pal = overlayPalette === 'custom'
? buildCustomPalette(overlayStyle as OverlayStyle, {
bg: cp.bg ?? '',
text: cp.text ?? '',
text2: cp.text2 ?? '',
chroma: cp.chroma ?? '',
titleBg: cp.titleBg ?? '',
bodyBg: cp.bodyBg ?? '',
})
: getPalette(overlayStyle as OverlayStyle, overlayPalette)
const hex = /^#[0-9a-fA-F]{6}$/.test(accentColor) ? accentColor : '#de9cfe'
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
// Use real track if playing, otherwise mock
const mockState: OverlayWidgetState = {
title: currentResult?.title ?? 'Название трека',
artist: currentResult?.artist ?? 'Артист',
cover: currentResult?.img ?? '',
is_playing: true,
progress: 0,
duration: 0,
enabled: true,
design: 'minimal',
style: overlayStyle,
accent_color: hex,
position: 'bl',
font,
text_color: textColor,
show_cover: showCover,
show_eq: showEq,
palette: overlayPalette,
custom_bg: cp.bg ?? '',
custom_text: cp.text ?? '',
custom_text2: cp.text2 ?? '',
custom_chroma: cp.chroma ?? '',
custom_title_bg: cp.titleBg ?? '',
custom_body_bg: cp.bodyBg ?? '',
margin: 24,
scale: 1,
opacity: 1,
updated_at: 0,
}
const render = OVERLAY_STYLE_MAP[overlayStyle] ?? OVERLAY_STYLE_MAP.classic
// Re-key on style change to replay enter animation
const animKey = overlayStyle + overlayPalette
return (
<div
style={{
position: 'relative',
height: 160,
borderRadius: 12,
overflow: 'hidden',
// transform creates a new containing block for position:fixed children
transform: 'translateZ(0)',
'--accent': hex,
'--accent-rgb': `${r},${g},${b}`,
'--ov-b': '20px',
'--ov-t': 'auto',
'--ov-l': '20px',
'--ov-r': 'auto',
'--ov-scale': '1',
'--ov-origin': 'bottom left',
} as React.CSSProperties}
>
{/* "stream" background */}
<div style={{ position: 'absolute', inset: 0, background: 'rgba(8,8,12,0.97)' }} />
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(255,255,255,0.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.022) 1px,transparent 1px)', backgroundSize: '22px 22px' }} />
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse at 15% 80%,rgba(${r},${g},${b},0.10) 0%,transparent 55%)` }} />
<div key={animKey}>{render(mockState, pal)}</div>
</div>
)
}
// ── Local progress hook (for overlay page) ────────────────────────────────────
export function useLocalProgress(serverProgress: number, serverDuration: number, isPlaying: boolean, updatedAt: number) {
const [progress, setProgress] = useState(serverProgress)
const startRef = useRef({ t: Date.now(), p: serverProgress })
useEffect(() => {
startRef.current = { t: Date.now(), p: serverProgress }
setProgress(serverProgress)
}, [serverProgress, updatedAt])
useEffect(() => {
if (!isPlaying || !serverDuration) return
const iv = setInterval(() => {
const elapsed = (Date.now() - startRef.current.t) / 1000
setProgress(Math.min(serverDuration, startRef.current.p + elapsed))
}, 500)
return () => clearInterval(iv)
}, [isPlaying, serverDuration])
return progress
}

View File

@@ -8,7 +8,7 @@ import type { Playlist } from '@/types'
export default function AddPersonForm() {
const { addPerson } = usePartyStore()
const { token } = useAuthStore()
const { user } = useAuthStore()
const [name, setName] = useState('')
const [tracks, setTracks] = useState('')
@@ -18,17 +18,17 @@ export default function AddPersonForm() {
const [loadingPlaylist, setLoadingPlaylist] = useState(false)
useEffect(() => {
if (!token) return
getPlaylists(token)
if (!user) return
getPlaylists()
.then(setPlaylists)
.catch(() => {})
}, [token])
}, [user])
const handleSelectPlaylist = async (id: string) => {
if (!token || !id) return
if (!user || !id) return
setLoadingPlaylist(true)
try {
const pl = await getPlaylist(token, id)
const pl = await getPlaylist(id)
if (pl.tracks?.length) {
setTracks(pl.tracks.map((t) => t.title).join('\n'))
}
@@ -69,7 +69,7 @@ export default function AddPersonForm() {
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
{token && playlists.length > 0 && (
{user && playlists.length > 0 && (
<div className="mt-2">
<select
onChange={(e) => handleSelectPlaylist(e.target.value)}

View File

@@ -1,11 +1,23 @@
'use client'
import { useState } from 'react'
import { usePartyStore } from '@/store/partyStore'
import PersonCard from './PersonCard'
import AddPersonForm from './AddPersonForm'
export default function PartyTab() {
const { people, shuffleMode, setShuffleMode, generateMix } = usePartyStore()
const [hint, setHint] = useState(false)
const handleGenerate = () => {
if (!people.length) {
setHint(true)
setTimeout(() => setHint(false), 2500)
return
}
setHint(false)
generateMix()
}
return (
<div className="animate-fadeUp">
@@ -31,14 +43,8 @@ export default function PartyTab() {
)}
<button
onClick={() => {
if (!people.length) {
alert('Добавьте участников!')
return
}
generateMix()
}}
className="w-full py-3.5 mb-4 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
onClick={handleGenerate}
className="w-full py-3.5 mb-1.5 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 3 21 3 21 8" />
@@ -49,7 +55,17 @@ export default function PartyTab() {
Перемешать и включить
</button>
<div className="flex flex-col gap-2.5 mb-4">
<div className={`overflow-hidden transition-all duration-300 ${hint ? 'max-h-10 mb-2.5' : 'max-h-0 mb-0'}`}>
<div className="flex items-center gap-2 text-[12px] text-[#ffb86b] bg-[rgba(255,184,107,0.08)] border border-[rgba(255,184,107,0.18)] px-3 py-1.5 rounded-[9px]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="shrink-0">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<path d="M12 8v4M12 16h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
Сначала добавьте участников ниже
</div>
</div>
<div className="flex flex-col gap-2.5 mb-4 mt-2.5">
{people.map((person, i) => (
<PersonCard key={`${person.name}-${i}`} person={person} index={i} />
))}
@@ -59,3 +75,4 @@ export default function PartyTab() {
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { useRef, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
import type { QueueItem } from '@/types'
interface Props {
queue: QueueItem[]
curIdx: number
onClose: () => void
}
export default function QueuePanel({ queue, curIdx, onClose }: Props) {
const { setCurIdx, generateMix, reorderQueue } = usePartyStore()
const dragSrc = useRef<number | null>(null)
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
dragSrc.current = idx
el.classList.add('dragging')
}, [])
const onDragEnd = useCallback((el: HTMLElement) => {
el.classList.remove('dragging')
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
}, [])
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
e.preventDefault()
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
el.classList.add('drag-over')
}, [])
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
e.preventDefault()
el.classList.remove('drag-over')
if (dragSrc.current === null || dragSrc.current === tgtIdx) return
reorderQueue(dragSrc.current, tgtIdx)
dragSrc.current = null
}, [reorderQueue])
return (
<div className="overflow-y-auto flex flex-col">
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">
Очередь · {queue.length}
</span>
<button
onClick={() => generateMix()}
aria-label="Пересортировать очередь"
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
></button>
</div>
<button
onClick={onClose}
aria-label="Закрыть очередь"
className="text-muted text-[11px] cursor-pointer hover:text-app-text"
></button>
</div>
<div className="overflow-y-auto">
{queue.map((item, i) => {
const active = i === curIdx
return (
<div
key={i}
draggable
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
onDragStart={(e) => onDragStart(i, e.currentTarget)}
onDragEnd={(e) => onDragEnd(e.currentTarget)}
onDragOver={(e) => onDragOver(e, e.currentTarget)}
onDrop={(e) => onDrop(e, i, e.currentTarget)}
>
<div
className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none"
aria-hidden="true"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
</svg>
</div>
{active ? (
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0" aria-label="Играет">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img
src={item.img}
alt=""
className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
) : (
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
{item.title}
</span>
<span
className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium"
style={{ background: item.color.bg, color: item.color.text }}
>
{item.owner}
</span>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import { proxyImgUrl } from '@/lib/api'
import type { SearchResult } from '@/types'
interface Props {
results: SearchResult[]
activeResultIdx: number
trackTitle: string
onPlay: (results: SearchResult[], i: number) => void
isSaved: (title: string, r: SearchResult) => boolean
saveVersion: (title: string, r: SearchResult) => void
removeVersion: (title: string) => void
onClose: () => void
}
export default function VersionsPanel({
results, activeResultIdx, trackTitle, onPlay, isSaved, saveVersion, removeVersion, onClose,
}: Props) {
return (
<div className="overflow-y-auto">
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Версии трека</span>
<button
onClick={onClose}
aria-label="Закрыть версии"
className="text-muted text-[11px] cursor-pointer hover:text-app-text"
></button>
</div>
{results.map((r, i) => {
const saved = isSaved(trackTitle, r)
return (
<div
key={i}
onClick={() => onPlay(results, i)}
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
>
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
{r.img && !r.img.includes('no-cover') && (
<img
src={proxyImgUrl(r.img)}
alt=""
className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
)}
<div className="flex-1 min-w-0">
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">
{r.title}
</div>
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
</div>
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
<button
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
aria-label={saved ? 'Забыть версию' : 'Запомнить версию'}
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
style={{
borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)',
background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent',
}}
>
<svg
width="10" height="10" viewBox="0 0 24 24"
fill={saved ? 'var(--accent)' : 'none'}
stroke={saved ? 'var(--accent)' : '#555'}
strokeWidth="2"
>
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onPlay(results, i) }}
aria-label={i === activeResultIdx ? 'Воспроизводится' : 'Воспроизвести эту версию'}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}>
<path d="M8 5v14l11-7z" />
</svg>
</button>
</div>
)
})}
</div>
)
}

View File

@@ -1,10 +1,28 @@
'use client'
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { fetchTopCharts } from '@/lib/api'
import type { SearchResult } from '@/types'
const SEARCH_HISTORY_KEY = 'pm_search_history'
const MAX_HISTORY = 8
function getSearchHistory(): string[] {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) ?? '[]') } catch { return [] }
}
function addToSearchHistory(q: string) {
const prev = getSearchHistory().filter((s) => s !== q)
const next = [q, ...prev].slice(0, MAX_HISTORY)
try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) } catch {}
}
function clearSearchHistory() {
try { localStorage.removeItem(SEARCH_HISTORY_KEY) } catch {}
}
function TopChartsCard({ tracks }: { tracks: string[] }) {
const { loadPlaylist } = usePartyStore()
const [launched, setLaunched] = useState(false)
@@ -48,24 +66,34 @@ function TopChartsCard({ tracks }: { tracks: string[] }) {
export default function SoloTab() {
const [search, setSearch] = useState('')
const [topTracks, setTopTracks] = useState<string[] | null>(null)
const [searchHistory, setSearchHistory] = useState<string[]>([])
const { loadPlaylist } = usePartyStore()
useEffect(() => {
setSearchHistory(getSearchHistory())
fetchTopCharts().then((results: SearchResult[]) => {
if (!results.length) { setTopTracks([]); return }
setTopTracks(
results.map((r) => (r.artist ? `${r.artist}${r.title}` : r.title))
)
setTopTracks(results.map((r) => (r.artist ? `${r.artist}${r.title}` : r.title)))
})
}, [])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
const q = search.trim()
if (!q) return
loadPlaylist([q])
const doSearch = useCallback((q: string) => {
if (!q.trim()) return
addToSearchHistory(q.trim())
setSearchHistory(getSearchHistory())
loadPlaylist([q.trim()])
setSearch('')
window.scrollTo({ top: 0, behavior: 'smooth' })
}, [loadPlaylist])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
doSearch(search)
}
const handleClearHistory = () => {
clearSearchHistory()
setSearchHistory([])
}
return (
@@ -100,6 +128,31 @@ export default function SoloTab() {
</button>
</form>
{searchHistory.length > 0 && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] text-muted font-display uppercase tracking-[1px]">Недавние</span>
<button
onClick={handleClearHistory}
className="text-[10px] text-muted hover:text-[#ff6b6b] transition-colors cursor-pointer"
>
Очистить
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{searchHistory.map((q) => (
<button
key={q}
onClick={() => doSearch(q)}
className="text-[11px] px-2.5 py-1 rounded-[7px] bg-surface2 text-muted hover:text-app-text hover:bg-white/[0.07] transition-all cursor-pointer truncate max-w-[200px]"
>
{q}
</button>
))}
</div>
</div>
)}
</div>
</div>
)

View File

@@ -0,0 +1,38 @@
'use client'
import { useToastStore } from '@/store/toastStore'
export default function Toaster() {
const { toasts, dismiss } = useToastStore()
if (!toasts.length) return null
return (
<div className="fixed bottom-[84px] left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 z-[9999] pointer-events-none">
{toasts.map((t) => (
<div
key={t.id}
onClick={() => dismiss(t.id)}
className="pointer-events-auto flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-[13px] font-sans shadow-xl cursor-pointer animate-fadeUp"
style={{
background: t.type === 'error' ? 'rgba(255,80,80,0.12)' : t.type === 'success' ? 'rgba(80,200,120,0.12)' : 'rgba(255,255,255,0.08)',
border: `1px solid ${t.type === 'error' ? 'rgba(255,80,80,0.25)' : t.type === 'success' ? 'rgba(80,200,120,0.25)' : 'rgba(255,255,255,0.1)'}`,
color: t.type === 'error' ? '#ff6b6b' : t.type === 'success' ? '#5cc87a' : '#ccc',
backdropFilter: 'blur(12px)',
}}
>
{t.type === 'error' && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="shrink-0">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)}
{t.type === 'success' && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="shrink-0">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
{t.message}
</div>
))}
</div>
)
}

View File

@@ -31,6 +31,15 @@ export async function fetchYandexPlaylist(yandexUrl: string): Promise<{ name: st
return data
}
export async function fetchSpotifyPlaylist(spotifyUrl: string): Promise<{ name: string; tracks: string[] }> {
const res = await fetch(`${API_URL}/api/proxy/spotify-playlist?url=${encodeURIComponent(spotifyUrl)}`, {
signal: AbortSignal.timeout(20_000),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? 'Ошибка загрузки Spotify плейлиста')
return data
}
export async function searchTracks(query: string): Promise<SearchResult[]> {
try {
const res = await fetch(`${API_URL}/api/proxy/search?q=${encodeURIComponent(query)}`, {

View File

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

View File

@@ -4,9 +4,22 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
credentials: 'include',
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
})
if (res.status === 401) {
if (typeof window !== 'undefined') {
const pub = ['/login', '/register', '/']
const isPublic = pub.includes(window.location.pathname) || window.location.pathname.startsWith('/remote/')
if (!isPublic) {
window.location.href = '/login'
}
}
throw new Error('Unauthorized')
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`)
@@ -15,10 +28,6 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return res.json()
}
function bearer(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` }
}
export async function register(username: string, email: string, password: string): Promise<User> {
return request<User>('/api/auth/register', {
method: 'POST',
@@ -26,31 +35,35 @@ export async function register(username: string, email: string, password: string
})
}
export async function login(email: string, password: string): Promise<{ token: string; user: User }> {
return request<{ token: string; user: User }>('/api/auth/login', {
export async function login(email: string, password: string): Promise<User> {
const res = await request<{ user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
return res.user
}
export async function fetchMe(token: string): Promise<User> {
return request<User>('/api/auth/me', { headers: bearer(token) })
export async function logout(): Promise<void> {
await request<void>('/api/auth/logout', { method: 'POST' }).catch(() => {})
}
export async function getPlaylists(token: string): Promise<Playlist[]> {
return request<Playlist[]>('/api/playlists', { headers: bearer(token) })
export async function fetchMe(): Promise<User> {
return request<User>('/api/auth/me')
}
export async function getPlaylists(): Promise<Playlist[]> {
return request<Playlist[]>('/api/playlists')
}
export async function getPublicPlaylists(): Promise<PublicPlaylist[]> {
return request<PublicPlaylist[]>('/api/playlists/public')
}
export async function getPlaylist(token: string, id: string): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`, { headers: bearer(token) })
export async function getPlaylist(id: string): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`)
}
export async function createPlaylist(
token: string,
name: string,
tracks: string[],
isPublic = false,
@@ -58,13 +71,11 @@ export async function createPlaylist(
): Promise<Playlist> {
return request<Playlist>('/api/playlists', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
})
}
export async function updatePlaylist(
token: string,
id: string,
name: string,
tracks: string[],
@@ -73,22 +84,17 @@ export async function updatePlaylist(
): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`, {
method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
})
}
export async function addTrackToPlaylist(token: string, playlistId: string, title: string): Promise<void> {
export async function addTrackToPlaylist(playlistId: string, title: string): Promise<void> {
return request<void>(`/api/playlists/${playlistId}/tracks`, {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ title }),
})
}
export async function deletePlaylist(token: string, id: string): Promise<void> {
return request<void>(`/api/playlists/${id}`, {
method: 'DELETE',
headers: bearer(token),
})
export async function deletePlaylist(id: string): Promise<void> {
return request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
}

View File

@@ -0,0 +1,368 @@
import type { OverlayStyle } from '@/store/overlayStore'
export interface OverlayPalette {
id: string
name: string
swatches: string[] // preview dots (2-3 hex/rgba)
bg?: string // panel background (color or gradient)
border?: string // full CSS border declaration
shadow?: string // box-shadow
text?: string // primary text color
text2?: string // secondary text color
chroma?: string // characteristic color: retro border hex, neon/matrix phosphor hex, glam metal rgb-string
chroma2?: string // for RGB string "r,g,b" when chroma is hex (used for --accent-rgb override)
titleBg?: string // Y2K titlebar gradient
bodyBg?: string // Y2K body gradient
}
const PALETTES: Record<OverlayStyle, OverlayPalette[]> = {
classic: [
{
id: 'default', name: 'Тёмный', swatches: ['#0a0a10', '#ffffff', '#888888'],
bg: 'rgba(10,10,16,0.82)', border: '1px solid rgba(255,255,255,0.09)',
shadow: '0 8px 32px rgba(0,0,0,0.55)',
text: '#ffffff', text2: 'rgba(255,255,255,0.45)', chroma: 'rgba(255,255,255,0.06)',
},
{
id: 'warm', name: 'Тёплый', swatches: ['#160c06', '#ffeedd', '#dca850'],
bg: 'rgba(22,12,6,0.86)', border: '1px solid rgba(220,168,80,0.20)',
shadow: '0 8px 32px rgba(20,8,0,0.6)',
text: '#ffeedd', text2: 'rgba(255,200,120,0.50)', chroma: 'rgba(220,168,80,0.10)',
},
{
id: 'ocean', name: 'Морской', swatches: ['#040a1a', '#ddeeff', '#3c82ff'],
bg: 'rgba(4,10,26,0.88)', border: '1px solid rgba(60,130,255,0.22)',
shadow: '0 8px 32px rgba(0,10,40,0.6)',
text: '#ddeeff', text2: 'rgba(100,170,255,0.50)', chroma: 'rgba(60,130,255,0.08)',
},
{
id: 'frost', name: 'Белый', swatches: ['#ffffff', '#111111', '#bbbbbb'],
bg: 'rgba(255,255,255,0.90)', border: '1px solid rgba(0,0,0,0.09)',
shadow: '0 8px 32px rgba(0,0,0,0.15)',
text: '#111111', text2: 'rgba(0,0,0,0.42)', chroma: 'rgba(0,0,0,0.05)',
},
],
aero: [
{
id: 'default', name: 'Небо', swatches: ['#bee6ff', '#78b9ff', '#003a6e'],
bg: 'linear-gradient(160deg,rgba(190,230,255,0.55) 0%,rgba(120,185,255,0.42) 100%)',
border: '1.5px solid rgba(255,255,255,0.72)',
shadow: '0 4px 20px rgba(80,160,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
text: '#003a6e', text2: 'rgba(0,60,130,0.60)',
},
{
id: 'rose', name: 'Роза', swatches: ['#ffc8d7', '#ff87af', '#6e0030'],
bg: 'linear-gradient(160deg,rgba(255,200,215,0.55) 0%,rgba(255,135,175,0.42) 100%)',
border: '1.5px solid rgba(255,255,255,0.72)',
shadow: '0 4px 20px rgba(255,100,160,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
text: '#6e0030', text2: 'rgba(130,0,60,0.60)',
},
{
id: 'mint', name: 'Мята', swatches: ['#b4ffd2', '#50dc9b', '#003a20'],
bg: 'linear-gradient(160deg,rgba(180,255,210,0.55) 0%,rgba(80,220,155,0.42) 100%)',
border: '1.5px solid rgba(255,255,255,0.72)',
shadow: '0 4px 20px rgba(60,200,130,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
text: '#003a20', text2: 'rgba(0,80,50,0.60)',
},
{
id: 'lavender', name: 'Лаванда', swatches: ['#dcc8ff', '#9b7dff', '#3a006e'],
bg: 'linear-gradient(160deg,rgba(220,200,255,0.55) 0%,rgba(155,125,255,0.42) 100%)',
border: '1.5px solid rgba(255,255,255,0.72)',
shadow: '0 4px 20px rgba(130,80,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
text: '#3a006e', text2: 'rgba(70,0,140,0.55)',
},
],
retro: [
{
id: 'default', name: 'Янтарь', swatches: ['#0c0500', '#c07030', '#f8d090'],
bg: 'rgba(12,5,0,0.94)', border: '2px solid #c07030',
shadow: '0 0 28px rgba(180,90,20,0.45),4px 4px 0 rgba(0,0,0,0.6)',
text: '#f8d090', text2: '#9a6020', chroma: 'rgba(180,100,20,0.30)',
},
{
id: 'phosphor', name: 'Фосфор', swatches: ['#000c04', '#30c060', '#90f8b0'],
bg: 'rgba(0,12,4,0.94)', border: '2px solid #30c060',
shadow: '0 0 28px rgba(20,160,60,0.45),4px 4px 0 rgba(0,0,0,0.6)',
text: '#90f8b0', text2: '#207840', chroma: 'rgba(20,160,60,0.28)',
},
{
id: 'crt', name: 'CRT синий', swatches: ['#000412', '#3060c0', '#90b8f8'],
bg: 'rgba(0,4,18,0.94)', border: '2px solid #3060c0',
shadow: '0 0 28px rgba(30,80,200,0.45),4px 4px 0 rgba(0,0,0,0.6)',
text: '#90b8f8', text2: '#204880', chroma: 'rgba(30,80,200,0.30)',
},
{
id: 'blood', name: 'Красный', swatches: ['#0e0202', '#c02828', '#f89090'],
bg: 'rgba(14,2,2,0.94)', border: '2px solid #c02828',
shadow: '0 0 28px rgba(180,28,20,0.45),4px 4px 0 rgba(0,0,0,0.6)',
text: '#f89090', text2: '#802020', chroma: 'rgba(180,28,20,0.28)',
},
],
neon: [
{
id: 'default', name: 'Акцент', swatches: ['#00000a', 'var(--accent)', 'var(--accent)'],
bg: 'rgba(0,0,10,0.90)',
},
{
id: 'cyan', name: 'Голубой', swatches: ['#00040c', '#00e5ff', '#00e5ff'],
bg: 'rgba(0,4,12,0.92)', chroma: '#00e5ff', chroma2: '0,229,255',
},
{
id: 'magenta', name: 'Пурпур', swatches: ['#0a0008', '#ff00cc', '#ff00cc'],
bg: 'rgba(10,0,8,0.92)', chroma: '#ff00cc', chroma2: '255,0,204',
},
{
id: 'lime', name: 'Лайм', swatches: ['#000a04', '#00ff88', '#00ff88'],
bg: 'rgba(0,10,4,0.92)', chroma: '#00ff88', chroma2: '0,255,136',
},
],
clean: [
{
id: 'default', name: 'Стандарт', swatches: ['transparent', '#ffffff', 'var(--accent)'],
},
{
id: 'shadow', name: 'С тенью', swatches: ['transparent', '#ffffff', '#666666'],
text: '#ffffff',
},
{
id: 'dark-text', name: 'Тёмный', swatches: ['transparent', '#111111', '#333333'],
text: '#111111', text2: 'rgba(0,0,0,0.50)',
},
{
id: 'warm-text', name: 'Тёплый', swatches: ['transparent', '#ffeedd', '#ddaa66'],
text: '#ffeedd', text2: 'rgba(255,200,120,0.60)',
},
],
y2k: [
{
id: 'default', name: 'Лунный', swatches: ['#c8d0e0', '#1a4090', '#000060'],
titleBg: 'linear-gradient(90deg,#1a4090 0%,#4a80d0 40%,#1a4090 100%)',
bodyBg: 'linear-gradient(160deg,#dce4f0,#c0c8e0)',
text: '#000060', text2: '#404880',
},
{
id: 'rose', name: 'Розовый', swatches: ['#f0d4dc', '#901440', '#600020'],
titleBg: 'linear-gradient(90deg,#901440 0%,#d04080 40%,#901440 100%)',
bodyBg: 'linear-gradient(160deg,#f0d4dc,#d0b0c0)',
text: '#600020', text2: '#804060',
},
{
id: 'dark', name: 'Тёмный', swatches: ['#c0c0c8', '#484848', '#000020'],
titleBg: 'linear-gradient(90deg,#282828 0%,#484848 40%,#282828 100%)',
bodyBg: 'linear-gradient(160deg,#c0c0c8,#a0a0b0)',
text: '#000020', text2: '#303040',
},
{
id: 'forest', name: 'Лесной', swatches: ['#d4ecd8', '#106020', '#004010'],
titleBg: 'linear-gradient(90deg,#106020 0%,#308040 40%,#106020 100%)',
bodyBg: 'linear-gradient(160deg,#d4ecd8,#b8d8c0)',
text: '#004010', text2: '#306040',
},
],
lofi: [
{
id: 'default', name: 'Коричневый', swatches: ['#2a1a0e', '#fde8c0', '#e8a060'],
bg: 'rgba(42,26,14,0.92)',
text: '#fde8c0', text2: 'rgba(var(--accent-rgb),0.60)',
},
{
id: 'night', name: 'Ночной', swatches: ['#0c1020', '#c0d4f8', '#7090d8'],
bg: 'rgba(12,16,32,0.92)',
text: '#c0d4f8', text2: 'rgba(var(--accent-rgb),0.60)',
},
{
id: 'forest', name: 'Лесной', swatches: ['#0c1c10', '#c8f0c8', '#60c870'],
bg: 'rgba(12,28,16,0.92)',
text: '#c8f0c8', text2: 'rgba(var(--accent-rgb),0.60)',
},
{
id: 'plum', name: 'Сливовый', swatches: ['#1c0c20', '#e8c0f8', '#c060d8'],
bg: 'rgba(28,12,32,0.92)',
text: '#e8c0f8', text2: 'rgba(var(--accent-rgb),0.60)',
},
],
glam: [
{
id: 'default', name: 'Золото', swatches: ['#1c1204', '#d4af37', '#f0c840'],
bg: 'linear-gradient(145deg,rgba(28,18,4,0.95),rgba(16,10,2,0.95))',
chroma: '212,175,55', text: '#f0c840', text2: 'rgba(200,150,30,0.65)',
},
{
id: 'silver', name: 'Серебро', swatches: ['#101214', '#c0c8d2', '#e0e8f0'],
bg: 'linear-gradient(145deg,rgba(16,18,20,0.96),rgba(10,12,14,0.96))',
chroma: '192,200,210', text: '#e0e8f0', text2: 'rgba(160,170,180,0.65)',
},
{
id: 'rose-gold', name: 'Розовое золото', swatches: ['#160e0e', '#dc9e8e', '#f0c8b8'],
bg: 'linear-gradient(145deg,rgba(22,14,14,0.95),rgba(14,8,8,0.95))',
chroma: '220,158,142', text: '#f0c8b8', text2: 'rgba(200,140,120,0.65)',
},
{
id: 'emerald', name: 'Изумруд', swatches: ['#08140e', '#32b478', '#80f8c0'],
bg: 'linear-gradient(145deg,rgba(8,20,14,0.95),rgba(4,12,8,0.95))',
chroma: '50,180,120', text: '#80f8c0', text2: 'rgba(40,160,100,0.65)',
},
],
matrix: [
{
id: 'default', name: 'Зелёный', swatches: ['#000800', '#00ff32', '#00ff32'],
bg: 'rgba(0,8,0,0.93)', chroma: '#00ff32', chroma2: '0,255,50',
border: '1px solid rgba(0,255,50,0.35)',
},
{
id: 'cyan', name: 'Голубой', swatches: ['#00040c', '#00e5ff', '#00e5ff'],
bg: 'rgba(0,4,12,0.93)', chroma: '#00e5ff', chroma2: '0,229,255',
border: '1px solid rgba(0,229,255,0.35)',
},
{
id: 'amber', name: 'Янтарь', swatches: ['#0c0800', '#ffb400', '#ffb400'],
bg: 'rgba(12,8,0,0.93)', chroma: '#ffb400', chroma2: '255,180,0',
border: '1px solid rgba(255,180,0,0.35)',
},
{
id: 'violet', name: 'Пурпур', swatches: ['#08000c', '#cc00ff', '#cc00ff'],
bg: 'rgba(8,0,12,0.93)', chroma: '#cc00ff', chroma2: '204,0,255',
border: '1px solid rgba(204,0,255,0.35)',
},
],
}
export function getPalettes(style: OverlayStyle): OverlayPalette[] {
return PALETTES[style] ?? PALETTES.classic
}
export function getPalette(style: OverlayStyle, paletteId: string): OverlayPalette {
const list = getPalettes(style)
return list.find(p => p.id === paletteId) ?? list[0]
}
// ── Custom palette ────────────────────────────────────────────────────────────
export interface ColorFieldDef {
key: string
label: string
default: string // hex default for the color picker
}
export const STYLE_CUSTOM_FIELDS: Record<OverlayStyle, ColorFieldDef[]> = {
classic: [
{ key: 'bg', label: 'Фон', default: '#0a0a10' },
{ key: 'chroma', label: 'Рамка', default: '#ffffff' },
{ key: 'text', label: 'Текст', default: '#ffffff' },
{ key: 'text2', label: 'Текст 2', default: '#888888' },
],
aero: [
{ key: 'bg', label: 'Цвет', default: '#bee6ff' },
{ key: 'text', label: 'Текст', default: '#003a6e' },
{ key: 'text2', label: 'Текст 2', default: '#004488' },
],
retro: [
{ key: 'bg', label: 'Фон', default: '#0c0500' },
{ key: 'chroma', label: 'Хром', default: '#c07030' },
{ key: 'text', label: 'Текст', default: '#f8d090' },
{ key: 'text2', label: 'Текст 2', default: '#9a6020' },
],
neon: [
{ key: 'bg', label: 'Фон', default: '#00000a' },
{ key: 'chroma', label: 'Свечение', default: '#de9cfe' },
],
clean: [
{ key: 'text', label: 'Текст', default: '#ffffff' },
{ key: 'text2', label: 'Текст 2', default: '#888888' },
],
y2k: [
{ key: 'titleBg', label: 'Заголовок', default: '#1a4090' },
{ key: 'bodyBg', label: 'Тело', default: '#c0c8e0' },
{ key: 'text', label: 'Текст', default: '#000060' },
{ key: 'text2', label: 'Текст 2', default: '#404880' },
],
lofi: [
{ key: 'bg', label: 'Фон', default: '#2a1a0e' },
{ key: 'text', label: 'Текст', default: '#fde8c0' },
{ key: 'text2', label: 'Текст 2', default: '#e8a060' },
],
glam: [
{ key: 'bg', label: 'Фон', default: '#1c1204' },
{ key: 'chroma', label: 'Металл', default: '#d4af37' },
{ key: 'text', label: 'Текст', default: '#f0c840' },
],
matrix: [
{ key: 'bg', label: 'Фон', default: '#000800' },
{ key: 'chroma', label: 'Фосфор', default: '#00ff32' },
],
}
function hexToRgba(hex: string, a: number): string {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return hex
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r},${g},${b},${a})`
}
function hexToRgb(hex: string): string {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return '128,128,128'
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `${r},${g},${b}`
}
export function buildCustomPalette(
style: OverlayStyle,
data: Record<string, string>,
): OverlayPalette {
const pal: OverlayPalette = { id: 'custom', name: 'Свой', swatches: [] }
if (data.text) pal.text = data.text
if (data.text2) pal.text2 = data.text2
if (data.bg) {
if (style === 'aero') {
pal.bg = `linear-gradient(160deg,${hexToRgba(data.bg, 0.55)} 0%,${hexToRgba(data.bg, 0.38)} 100%)`
} else {
const alpha = style === 'classic' ? 0.85 : 0.92
pal.bg = hexToRgba(data.bg, alpha)
}
}
if (data.chroma) {
switch (style) {
case 'glam':
pal.chroma = hexToRgb(data.chroma)
break
case 'neon':
case 'matrix':
pal.chroma = data.chroma
pal.chroma2 = hexToRgb(data.chroma)
if (style === 'matrix') {
pal.border = `1px solid ${hexToRgba(data.chroma, 0.38)}`
}
break
case 'retro':
pal.chroma = hexToRgba(data.chroma, 0.30)
pal.border = `2px solid ${data.chroma}`
pal.shadow = `0 0 28px ${hexToRgba(data.chroma, 0.45)},4px 4px 0 rgba(0,0,0,0.6)`
break
case 'classic':
pal.border = `1px solid ${hexToRgba(data.chroma, 0.28)}`
pal.chroma = hexToRgba(data.chroma, 0.10)
break
default:
pal.chroma = data.chroma
}
}
if (data.titleBg) pal.titleBg = data.titleBg
if (data.bodyBg) pal.bodyBg = data.bodyBg
return pal
}

View File

@@ -2,49 +2,53 @@
import { create } from 'zustand'
import type { User } from '@/types'
import { fetchMe } from '@/lib/authApi'
import { fetchMe, logout } from '@/lib/authApi'
const TOKEN_KEY = 'pm_token'
const USER_KEY = 'pm_user'
interface AuthStore {
token: string | null
user: User | null
setAuth: (token: string, user: User) => void
setAuth: (user: User) => void
clearAuth: () => void
hydrate: () => Promise<void>
}
export const useAuthStore = create<AuthStore>((set, get) => ({
token: null,
export const useAuthStore = create<AuthStore>((set) => ({
user: null,
setAuth: (token, user) => {
setAuth: (user) => {
if (typeof window !== 'undefined') {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
set({ token, user })
set({ user })
},
clearAuth: () => {
clearAuth: async () => {
await logout()
if (typeof window !== 'undefined') {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
// Remove legacy token key if present
localStorage.removeItem('pm_token')
}
set({ token: null, user: null })
set({ user: null })
},
hydrate: async () => {
if (typeof window === 'undefined') return
const token = localStorage.getItem(TOKEN_KEY)
if (!token) return
// Optimistically restore user from cache for instant UI
const cached = localStorage.getItem(USER_KEY)
if (cached) {
try { set({ user: JSON.parse(cached) }) } catch {}
}
// Verify with server via cookie
try {
const user = await fetchMe(token)
set({ token, user })
const user = await fetchMe()
localStorage.setItem(USER_KEY, JSON.stringify(user))
set({ user })
} catch {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
localStorage.removeItem('pm_token')
set({ user: null })
}
},
}))

View File

@@ -18,23 +18,50 @@ export const BG_PRESETS: BgPreset[] = [
{ id: 'none', name: 'Нет', desc: 'Чистый фон' },
]
export interface RaysConfig {
count: number // 416
speed: number // 0.23.0 (rotation speed multiplier)
brightness: number // 0.32.5
spread: number // 0.32.0 (ray width multiplier)
export interface OrbsConfig { brightness: number; speed: number; trail: number }
export interface WavesConfig { amplitude: number; speed: number; trail: number }
export interface ParticlesConfig { speed: number; linkDist: number; trail: number }
export interface AuroraConfig { brightness: number; speed: number; trail: number }
export interface PulseConfig { sensitivity: number; ringSpeed: number; trail: number }
export interface StarsConfig { brightness: number; twinkle: number; trail: number }
export interface RainConfig { drops: number; speed: number; trail: number }
export interface RaysConfig { count: number; speed: number; brightness: number; spread: number; trail: number }
export interface FxConfigs {
orbs: OrbsConfig
waves: WavesConfig
particles: ParticlesConfig
aurora: AuroraConfig
pulse: PulseConfig
stars: StarsConfig
rain: RainConfig
rays: RaysConfig
}
export const DEFAULT_RAYS: RaysConfig = { count: 9, speed: 1, brightness: 1, spread: 1 }
export const DEFAULT_ORBS: OrbsConfig = { brightness: 1, speed: 1, trail: 0 }
export const DEFAULT_WAVES: WavesConfig = { amplitude: 1, speed: 1, trail: 0 }
export const DEFAULT_PARTICLES: ParticlesConfig = { speed: 1, linkDist: 1, trail: 0 }
export const DEFAULT_AURORA: AuroraConfig = { brightness: 1, speed: 1, trail: 0 }
export const DEFAULT_PULSE: PulseConfig = { sensitivity: 0.5, ringSpeed: 1, trail: 0 }
export const DEFAULT_STARS: StarsConfig = { brightness: 1, twinkle: 1, trail: 0 }
export const DEFAULT_RAIN: RainConfig = { drops: 30, speed: 1, trail: 0 }
export const DEFAULT_RAYS: RaysConfig = { count: 9, speed: 1, brightness: 1, spread: 1, trail: 0 }
export const DEFAULT_FX: FxConfigs = {
orbs: DEFAULT_ORBS, waves: DEFAULT_WAVES, particles: DEFAULT_PARTICLES,
aurora: DEFAULT_AURORA, pulse: DEFAULT_PULSE, stars: DEFAULT_STARS,
rain: DEFAULT_RAIN, rays: DEFAULT_RAYS,
}
const KEY_BG = 'pm_bg'
const KEY_RAYS = 'pm_rays'
const KEY_FX = 'pm_fx'
interface BgStore {
bgMode: BgMode
raysConfig: RaysConfig
fxConfigs: FxConfigs
setBg: (mode: BgMode) => void
setRaysConfig: (cfg: Partial<RaysConfig>) => void
setFxConfig: <M extends keyof FxConfigs>(mode: M, cfg: Partial<FxConfigs[M]>) => void
resetFx: (mode: keyof FxConfigs) => void
}
export const useBgStore = create<BgStore>((set, get) => ({
@@ -43,18 +70,34 @@ export const useBgStore = create<BgStore>((set, get) => ({
const saved = localStorage.getItem(KEY_BG) as BgMode | null
return saved && BG_PRESETS.some(p => p.id === saved) ? saved : 'orbs'
})(),
raysConfig: (() => {
if (typeof window === 'undefined') return DEFAULT_RAYS
try { return { ...DEFAULT_RAYS, ...JSON.parse(localStorage.getItem(KEY_RAYS) || '{}') } }
catch { return DEFAULT_RAYS }
fxConfigs: (() => {
if (typeof window === 'undefined') return DEFAULT_FX
try {
const saved = JSON.parse(localStorage.getItem(KEY_FX) || '{}')
return {
orbs: { ...DEFAULT_ORBS, ...(saved.orbs ?? {}) },
waves: { ...DEFAULT_WAVES, ...(saved.waves ?? {}) },
particles: { ...DEFAULT_PARTICLES, ...(saved.particles ?? {}) },
aurora: { ...DEFAULT_AURORA, ...(saved.aurora ?? {}) },
pulse: { ...DEFAULT_PULSE, ...(saved.pulse ?? {}) },
stars: { ...DEFAULT_STARS, ...(saved.stars ?? {}) },
rain: { ...DEFAULT_RAIN, ...(saved.rain ?? {}) },
rays: { ...DEFAULT_RAYS, ...(saved.rays ?? {}) },
}
} catch { return DEFAULT_FX }
})(),
setBg: (mode) => {
if (typeof window !== 'undefined') localStorage.setItem(KEY_BG, mode)
set({ bgMode: mode })
},
setRaysConfig: (cfg) => {
const next = { ...get().raysConfig, ...cfg }
if (typeof window !== 'undefined') localStorage.setItem(KEY_RAYS, JSON.stringify(next))
set({ raysConfig: next })
setFxConfig: (mode, cfg) => {
const next: FxConfigs = { ...get().fxConfigs, [mode]: { ...get().fxConfigs[mode], ...cfg } }
if (typeof window !== 'undefined') localStorage.setItem(KEY_FX, JSON.stringify(next))
set({ fxConfigs: next })
},
resetFx: (mode) => {
const next: FxConfigs = { ...get().fxConfigs, [mode]: DEFAULT_FX[mode] }
if (typeof window !== 'undefined') localStorage.setItem(KEY_FX, JSON.stringify(next))
set({ fxConfigs: next })
},
}))

View File

@@ -0,0 +1,215 @@
'use client'
import { create } from 'zustand'
const KEY_ENABLED = 'pm_overlay_enabled'
const KEY_DESIGN = 'pm_overlay_design'
const KEY_STYLE = 'pm_overlay_style'
const KEY_ACCENT = 'pm_overlay_accent'
const KEY_POSITION = 'pm_overlay_position'
const KEY_FONT = 'pm_overlay_font'
const KEY_TEXTCOLOR = 'pm_overlay_textcolor'
const KEY_SHOWCVR = 'pm_overlay_showcvr'
const KEY_SHOWEQ = 'pm_overlay_showeq'
const KEY_PALETTE = 'pm_overlay_palette'
const KEY_CUSTPALETTES = 'pm_overlay_custpalettes'
const KEY_MARGIN = 'pm_overlay_margin'
const KEY_SCALE = 'pm_overlay_scale'
const KEY_OPACITY = 'pm_overlay_opacity'
export type OverlayDesign = 'minimal' | 'card' | 'bar'
export type OverlayStyle =
| 'classic' | 'aero' | 'retro' | 'neon' | 'clean'
| 'y2k' | 'lofi' | 'glam' | 'matrix'
export type OverlayPosition = 'bl' | 'br' | 'tl' | 'tr'
export interface StyleConfig {
bg: string
blur: string
border: string
shadow: string
radius: string
text: string
text2: string
eqColor: string
fontStyle: 'normal' | 'italic'
fontWeight: number
letterSpacing: string
}
export const OVERLAY_STYLES: Record<OverlayStyle, { name: string; desc: string; cfg: StyleConfig }> = {
classic: {
name: 'Классика', desc: 'Тёмное стекло, нейтральный',
cfg: {
bg: 'rgba(10,10,15,0.82)', blur: '20px',
border: '1px solid rgba(255,255,255,0.08)',
shadow: '0 8px 32px rgba(0,0,0,0.5)',
radius: '16px', text: '#ffffff', text2: 'rgba(255,255,255,0.45)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 600, letterSpacing: '0',
},
},
aero: {
name: 'Фрутигер Аеро', desc: 'Глянцевый XP-стиль',
cfg: {
bg: 'linear-gradient(145deg,rgba(200,235,255,0.55) 0%,rgba(140,195,255,0.4) 100%)',
blur: '24px',
border: '1.5px solid rgba(255,255,255,0.75)',
shadow: '0 4px 20px rgba(80,160,255,0.35),inset 0 1px 0 rgba(255,255,255,0.6)',
radius: '22px', text: '#003a6e', text2: 'rgba(0,60,120,0.6)',
eqColor: '#0077cc', fontStyle: 'normal', fontWeight: 700, letterSpacing: '-0.02em',
},
},
retro: {
name: 'Ретро', desc: 'VHS / кассетная эстетика',
cfg: {
bg: 'rgba(18,8,2,0.93)',
blur: '0px',
border: '2px solid #b06828',
shadow: '0 0 24px rgba(180,100,20,0.45),4px 4px 0 rgba(0,0,0,0.5)',
radius: '4px', text: '#f8d090', text2: '#9a6020',
eqColor: '#e08030', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.05em',
},
},
neon: {
name: 'Неон', desc: 'Киберпанк, светящиеся линии',
cfg: {
bg: 'rgba(0,0,10,0.9)',
blur: '8px',
border: '1px solid rgba(var(--accent-rgb),0.55)',
shadow: '0 0 24px rgba(var(--accent-rgb),0.35),inset 0 0 16px rgba(var(--accent-rgb),0.06)',
radius: '8px', text: 'var(--accent)', text2: 'rgba(var(--accent-rgb),0.55)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 800, letterSpacing: '0.04em',
},
},
clean: {
name: 'Минимализм', desc: 'Только текст, без фона',
cfg: {
bg: 'transparent',
blur: '0px',
border: 'none',
shadow: 'none',
radius: '0', text: '#ffffff', text2: 'rgba(255,255,255,0.5)',
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 600, letterSpacing: '0',
},
},
y2k: {
name: 'Y2K', desc: 'Хром, ранние 2000-е',
cfg: {
bg: 'linear-gradient(160deg,#d8d8e8 0%,#b0b8d0 50%,#c8cce0 100%)',
blur: '4px',
border: '2px solid rgba(255,255,255,0.9)',
shadow: '3px 3px 0 rgba(0,0,80,0.25),inset 0 1px 0 rgba(255,255,255,0.8)',
radius: '6px', text: '#000060', text2: '#444488',
eqColor: '#0044cc', fontStyle: 'normal', fontWeight: 700, letterSpacing: '-0.01em',
},
},
lofi: {
name: 'Ло-фай', desc: 'Тёплые тона, уютная атмосфера',
cfg: {
bg: 'rgba(38,24,12,0.9)',
blur: '14px',
border: '1px solid rgba(240,180,100,0.18)',
shadow: '0 6px 28px rgba(0,0,0,0.45)',
radius: '14px', text: '#fde8c0', text2: 'rgba(240,180,100,0.55)',
eqColor: '#e8a060', fontStyle: 'italic', fontWeight: 500, letterSpacing: '0.01em',
},
},
glam: {
name: 'Гламур', desc: 'Золото, роскошь',
cfg: {
bg: 'linear-gradient(145deg,rgba(28,18,4,0.93) 0%,rgba(18,12,2,0.93) 100%)',
blur: '16px',
border: '1px solid rgba(212,175,55,0.45)',
shadow: '0 8px 32px rgba(0,0,0,0.65),0 0 0 1px rgba(212,175,55,0.2)',
radius: '14px', text: '#f0c840', text2: 'rgba(200,150,30,0.65)',
eqColor: '#d4af37', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.02em',
},
},
matrix: {
name: 'Матрица', desc: 'Зелёный терминал, хакер',
cfg: {
bg: 'rgba(0,8,0,0.92)',
blur: '4px',
border: '1px solid rgba(0,255,50,0.3)',
shadow: '0 0 20px rgba(0,255,50,0.18)',
radius: '4px', text: '#00ff32', text2: 'rgba(0,200,30,0.55)',
eqColor: '#00ff32', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.08em',
},
},
}
interface OverlayStore {
enabled: boolean
design: OverlayDesign
style: OverlayStyle
accentColor: string
position: OverlayPosition
font: string
textColor: string
showCover: boolean
showEq: boolean
palette: string
customPalettes: Record<string, Record<string, string>>
margin: number
scale: number
opacity: number
setEnabled: (v: boolean) => void
setDesign: (d: OverlayDesign) => void
setStyle: (s: OverlayStyle) => void
setAccentColor: (c: string) => void
setPosition: (p: OverlayPosition) => void
setFont: (f: string) => void
setTextColor: (c: string) => void
setShowCover: (v: boolean) => void
setShowEq: (v: boolean) => void
setPalette: (p: string) => void
setCustomPaletteField: (style: string, field: string, value: string) => void
setMargin: (v: number) => void
setScale: (v: number) => void
setOpacity: (v: number) => void
}
const ls = (key: string) => (typeof window !== 'undefined' ? localStorage.getItem(key) : null)
const lsSet = (key: string, v: string) => { if (typeof window !== 'undefined') localStorage.setItem(key, v) }
export const useOverlayStore = create<OverlayStore>((set) => ({
enabled: ls(KEY_ENABLED) !== 'false',
design: (ls(KEY_DESIGN) ?? 'minimal') as OverlayDesign,
style: (ls(KEY_STYLE) ?? 'classic') as OverlayStyle,
accentColor: ls(KEY_ACCENT) ?? '#de9cfe',
position: (ls(KEY_POSITION) ?? 'bl') as OverlayPosition,
font: ls(KEY_FONT) ?? '',
textColor: ls(KEY_TEXTCOLOR) ?? '',
showCover: ls(KEY_SHOWCVR) !== 'false',
showEq: ls(KEY_SHOWEQ) !== 'false',
palette: ls(KEY_PALETTE) ?? 'default',
customPalettes: (() => { try { return JSON.parse(ls(KEY_CUSTPALETTES) ?? '{}') } catch { return {} } })(),
margin: Number(ls(KEY_MARGIN) ?? 24),
scale: Number(ls(KEY_SCALE) ?? 1),
opacity: Number(ls(KEY_OPACITY) ?? 1),
setEnabled: (v) => { lsSet(KEY_ENABLED, String(v)); set({ enabled: v }) },
setDesign: (d) => { lsSet(KEY_DESIGN, d); set({ design: d }) },
setStyle: (s) => { lsSet(KEY_STYLE, s); set({ style: s }) },
setAccentColor: (c) => { lsSet(KEY_ACCENT, c); set({ accentColor: c }) },
setPosition: (p) => { lsSet(KEY_POSITION, p); set({ position: p }) },
setFont: (f) => { lsSet(KEY_FONT, f); set({ font: f }) },
setTextColor: (c) => { lsSet(KEY_TEXTCOLOR, c); set({ textColor: c }) },
setShowCover: (v) => { lsSet(KEY_SHOWCVR, String(v)); set({ showCover: v }) },
setShowEq: (v) => { lsSet(KEY_SHOWEQ, String(v)); set({ showEq: v }) },
setPalette: (p) => { lsSet(KEY_PALETTE, p); set({ palette: p }) },
setCustomPaletteField: (style, field, value) => {
set((state) => {
const next = {
...state.customPalettes,
[style]: { ...(state.customPalettes[style] ?? {}), [field]: value },
}
lsSet(KEY_CUSTPALETTES, JSON.stringify(next))
return { customPalettes: next }
})
},
setMargin: (v) => { lsSet(KEY_MARGIN, String(v)); set({ margin: v }) },
setScale: (v) => { lsSet(KEY_SCALE, String(v)); set({ scale: v }) },
setOpacity: (v) => { lsSet(KEY_OPACITY, String(v)); set({ opacity: v }) },
}))

View File

@@ -1,18 +1,25 @@
'use client'
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import type { Person, QueueItem, SearchResult, HistoryEntry, ShuffleMode, Color } from '@/types'
import { COLORS } from '@/lib/colors'
import { fairShuffle, randomShuffle } from '@/lib/shuffle'
const HISTORY_LIMIT = 100
export type RepeatMode = 'none' | 'one' | 'all'
interface PartyStore {
people: Person[]
queue: QueueItem[]
curIdx: number
loadKey: number
shuffleMode: ShuffleMode
repeatMode: RepeatMode
history: HistoryEntry[]
currentResults: SearchResult[]
currentResult: SearchResult | null
searchStatus: 'idle' | 'searching' | 'not-found'
addPerson: (name: string, tracks: string[]) => void
@@ -27,20 +34,26 @@ interface PartyStore {
updateQueueItemImg: (idx: number, img: string) => void
reorderQueue: (fromIdx: number, toIdx: number) => void
setCurrentResults: (results: SearchResult[]) => void
setCurrentResult: (r: SearchResult | null) => void
setSearchStatus: (status: PartyStore['searchStatus']) => void
setShuffleMode: (mode: ShuffleMode) => void
setRepeatMode: (mode: RepeatMode) => void
addToHistory: (entry: HistoryEntry) => void
clearHistory: () => void
}
export const usePartyStore = create<PartyStore>((set, get) => ({
export const usePartyStore = create<PartyStore>()(
persist(
(set, get) => ({
people: [],
queue: [],
curIdx: -1,
loadKey: 0,
shuffleMode: 'fair',
repeatMode: 'none',
history: [],
currentResults: [],
currentResult: null,
searchStatus: 'idle',
addPerson: (name, tracks) => {
@@ -79,7 +92,6 @@ export const usePartyStore = create<PartyStore>((set, 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) {
@@ -90,7 +102,6 @@ export const usePartyStore = create<PartyStore>((set, get) => ({
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: '',
}))
@@ -100,7 +111,6 @@ export const usePartyStore = create<PartyStore>((set, get) => ({
}
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[] = []
@@ -111,7 +121,6 @@ export const usePartyStore = create<PartyStore>((set, get) => ({
}
const newQueue = [...played, ...interleaved]
// If nothing was playing yet, start from 0
const newCurIdx = curIdx < 0 ? 0 : curIdx
set((state) => ({
queue: newQueue,
@@ -187,14 +196,33 @@ export const usePartyStore = create<PartyStore>((set, get) => ({
},
setCurrentResults: (results) => set({ currentResults: results }),
setCurrentResult: (r) => set({ currentResult: r }),
setSearchStatus: (searchStatus) => set({ searchStatus }),
setShuffleMode: (shuffleMode) => set({ shuffleMode }),
setRepeatMode: (repeatMode) => set({ repeatMode }),
addToHistory: (entry) => {
set((state) => ({ history: [entry, ...state.history] }))
set((state) => ({
history: [entry, ...state.history].slice(0, HISTORY_LIMIT),
}))
},
clearHistory: () => set({ history: [] }),
}))
}),
{
name: 'pm_party',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
people: state.people,
queue: state.queue,
curIdx: state.curIdx,
shuffleMode: state.shuffleMode,
repeatMode: state.repeatMode,
history: state.history,
}),
}
)
)

View File

@@ -9,6 +9,7 @@ export interface AccentPreset {
}
export const ACCENT_PRESETS: AccentPreset[] = [
{ name: 'Лаванда', accent: '#de9cfe', rgb: '222,156,254' },
{ name: 'Лайм', accent: '#c8ff00', rgb: '200,255,0' },
{ name: 'Синий', accent: '#00D4FF', rgb: '0,212,255' },
{ name: 'Розовый', accent: '#FF2D78', rgb: '255,45,120' },

View File

@@ -0,0 +1,27 @@
'use client'
import { create } from 'zustand'
interface Toast {
id: number
message: string
type: 'error' | 'success' | 'info'
}
interface ToastStore {
toasts: Toast[]
show: (message: string, type?: Toast['type']) => void
dismiss: (id: number) => void
}
let nextId = 0
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
show: (message, type = 'info') => {
const id = ++nextId
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }))
setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 3500)
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}))

View File

@@ -3,6 +3,7 @@
import { create } from 'zustand'
import type { SearchResult } from '@/types'
import { useAuthStore } from '@/store/authStore'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
const STORAGE_KEY = 'pm_versions'
@@ -35,16 +36,12 @@ function saveLocal(v: Record<string, SavedVersion>) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(v)) } catch {}
}
function getToken(): string | null {
return useAuthStore.getState().token
}
async function apiFetch(path: string, opts?: RequestInit): Promise<Response | null> {
const token = getToken()
if (!token) return null
if (!useAuthStore.getState().user) return null
return fetch(`${API_URL}${path}`, {
credentials: 'include',
...opts,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...opts?.headers },
headers: { 'Content-Type': 'application/json', ...opts?.headers },
}).catch(() => null)
}

View File

@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -12,9 +16,25 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -29,8 +29,9 @@ services:
GIN_MODE: release
PORT: 8080
JWT_SECRET: ${JWT_SECRET}
ports:
- "8081:8080"
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost}
SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID:-}
SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET:-}
depends_on:
postgres:
condition: service_healthy
@@ -41,14 +42,21 @@ services:
context: ./apps/web
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
ports:
- "3001:3000"
NEXT_PUBLIC_API_URL: ""
depends_on:
- backend
restart: unless-stopped
nginx:
image: nginx:alpine
volumes:
- ./apps/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "${APP_PORT:-80}:80"
depends_on:
- web
- backend
restart: unless-stopped
volumes:
postgres_data: