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:
@@ -7,25 +7,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
DBHost string
|
DBHost string
|
||||||
DBPort string
|
DBPort string
|
||||||
DBUser string
|
DBUser string
|
||||||
DBPass string
|
DBPass string
|
||||||
DBName string
|
DBName string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
AllowedOrigins string
|
||||||
|
CookieSecure bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
return &Config{
|
return &Config{
|
||||||
Port: getEnv("PORT", "8080"),
|
Port: getEnv("PORT", "8080"),
|
||||||
DBHost: getEnv("DB_HOST", "localhost"),
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
DBPort: getEnv("DB_PORT", "5432"),
|
DBPort: getEnv("DB_PORT", "5432"),
|
||||||
DBUser: getEnv("DB_USER", "partymix"),
|
DBUser: getEnv("DB_USER", "partymix"),
|
||||||
DBPass: getEnv("DB_PASSWORD", "partymix"),
|
DBPass: getEnv("DB_PASSWORD", "partymix"),
|
||||||
DBName: getEnv("DB_NAME", "partymix"),
|
DBName: getEnv("DB_NAME", "partymix"),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
|
||||||
|
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3001,http://localhost:3000"),
|
||||||
|
CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const cookieName = "pm_token"
|
||||||
|
const cookieMaxAge = 60 * 60 * 24 * 30 // 30 days
|
||||||
|
|
||||||
type registerReq struct {
|
type registerReq struct {
|
||||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
@@ -58,7 +61,7 @@ type loginReq struct {
|
|||||||
Password string `json:"password" binding:"required"`
|
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) {
|
return func(c *gin.Context) {
|
||||||
var req loginReq
|
var req loginReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -83,10 +86,15 @@ func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.SetCookie(cookieName, token, cookieMaxAge, "/", "", cookieSecure, true)
|
||||||
"token": token,
|
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||||
"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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,13 @@ func parseToken(tokenStr, secret string) (*jwtClaims, error) {
|
|||||||
|
|
||||||
func AuthRequired(jwtSecret string) gin.HandlerFunc {
|
func AuthRequired(jwtSecret string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
header := c.GetHeader("Authorization")
|
// Cookie-first, Bearer header as fallback for backwards compat
|
||||||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
tokenStr, err := c.Cookie(cookieName)
|
||||||
|
if err != nil || tokenStr == "" {
|
||||||
|
header := c.GetHeader("Authorization")
|
||||||
|
tokenStr = strings.TrimPrefix(header, "Bearer ")
|
||||||
|
}
|
||||||
|
|
||||||
if tokenStr == "" {
|
if tokenStr == "" {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
|
|||||||
175
apps/backend/internal/handlers/overlay.go
Normal file
175
apps/backend/internal/handlers/overlay.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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) {
|
func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) {
|
||||||
if redirectCount > 5 {
|
if redirectCount > 5 {
|
||||||
c.Status(http.StatusInternalServerError)
|
c.Status(http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ type User struct {
|
|||||||
|
|
||||||
type Playlist struct {
|
type Playlist struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
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"`
|
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:"-"`
|
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"`
|
Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,29 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/toyffee/party-mix/internal/config"
|
||||||
"github.com/toyffee/party-mix/internal/handlers"
|
"github.com/toyffee/party-mix/internal/handlers"
|
||||||
"gorm.io/gorm"
|
"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()
|
r := gin.Default()
|
||||||
|
|
||||||
|
origins := strings.Split(cfg.AllowedOrigins, ",")
|
||||||
|
for i, o := range origins {
|
||||||
|
origins[i] = strings.TrimSpace(o)
|
||||||
|
}
|
||||||
|
|
||||||
r.Use(cors.New(cors.Config{
|
r.Use(cors.New(cors.Config{
|
||||||
AllowAllOrigins: true,
|
AllowOrigins: origins,
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||||||
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
|
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
|
||||||
AllowCredentials: false,
|
AllowCredentials: true,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
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("/img", handlers.ImgProxyHandler)
|
||||||
proxy.GET("/mp3", handlers.MP3ProxyHandler)
|
proxy.GET("/mp3", handlers.MP3ProxyHandler)
|
||||||
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
|
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
|
||||||
|
proxy.GET("/spotify-playlist", handlers.SpotifyPlaylistHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := r.Group("/api/auth")
|
auth := r.Group("/api/auth")
|
||||||
{
|
{
|
||||||
auth.POST("/register", handlers.Register(db))
|
auth.POST("/register", handlers.Register(db))
|
||||||
auth.POST("/login", handlers.Login(db, jwtSecret))
|
auth.POST("/login", handlers.Login(db, cfg.JWTSecret, cfg.CookieSecure))
|
||||||
auth.GET("/me", handlers.AuthRequired(jwtSecret), handlers.Me(db))
|
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))
|
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.GET("", handlers.GetVersions(db))
|
||||||
versions.POST("", handlers.SaveVersion(db))
|
versions.POST("", handlers.SaveVersion(db))
|
||||||
versions.DELETE("", handlers.DeleteVersion(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.GET("", handlers.GetPlaylists(db))
|
||||||
playlists.POST("", handlers.CreatePlaylist(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))
|
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 := r.Group("/api/remote")
|
||||||
{
|
{
|
||||||
remote.POST("", handlers.CreateRemoteRoom)
|
remote.POST("", handlers.CreateRemoteRoom)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
db := database.Connect(cfg)
|
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)
|
log.Printf("Party Mix backend starting on :%s", cfg.Port)
|
||||||
if err := r.Run(":" + cfg.Port); err != nil {
|
if err := r.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatalf("server failed: %v", err)
|
log.Fatalf("server failed: %v", err)
|
||||||
|
|||||||
68
apps/nginx/nginx.conf
Normal file
68
apps/nginx/nginx.conf
Normal 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
5
apps/web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.env*.local
|
||||||
@@ -9,6 +9,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|||||||
6
apps/web/next-env.d.ts
vendored
Normal file
6
apps/web/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
559
apps/web/src/app/(main)/community/page.tsx
Normal file
559
apps/web/src/app/(main)/community/page.tsx
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { getPublicPlaylists, createPlaylist } from '@/lib/authApi'
|
||||||
|
import type { PublicPlaylist, PlaylistTrack } from '@/types'
|
||||||
|
import Header from '@/components/Header'
|
||||||
|
|
||||||
|
const TAG_PALETTE = ['var(--accent)', '#ff6b9d', '#6bcdff', '#ffb86b', '#b86bff', '#6bffb8']
|
||||||
|
|
||||||
|
function tagColor(tag: string): string {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h)
|
||||||
|
return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortMode = 'newest' | 'tracks' | 'alpha'
|
||||||
|
|
||||||
|
function TrackList({ tracks }: { tracks: PlaylistTrack[] }) {
|
||||||
|
return (
|
||||||
|
<div className="border-t border-white/[0.05] px-4 pt-3 pb-3.5">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{tracks.map((track, i) => (
|
||||||
|
<div key={track.id} className="flex items-center gap-2.5 py-[3px] group">
|
||||||
|
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
|
||||||
|
<span className="text-[12px] text-app-text/75 group-hover:text-app-text truncate transition-colors duration-100">{track.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaylistCard({
|
||||||
|
pl, onPlay, isLaunched, onFork, isForkDone, isForkLoading, canFork,
|
||||||
|
}: {
|
||||||
|
pl: PublicPlaylist
|
||||||
|
onPlay: () => void
|
||||||
|
isLaunched: boolean
|
||||||
|
onFork: () => void
|
||||||
|
isForkDone: boolean
|
||||||
|
isForkLoading: boolean
|
||||||
|
canFork: boolean
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const tags = pl.tags ?? []
|
||||||
|
const trackCount = pl.tracks?.length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.13] transition-all duration-200">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center font-display font-extrabold text-[15px] select-none"
|
||||||
|
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{pl.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-display text-[14px] font-bold text-app-text truncate leading-tight">{pl.name}</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||||
|
<span className="text-[11px] font-medium text-accent">{pl.username}</span>
|
||||||
|
<span className="text-muted/30 text-[11px]">·</span>
|
||||||
|
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
|
||||||
|
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{trackCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(v => !v)}
|
||||||
|
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all duration-150 cursor-pointer"
|
||||||
|
title={expanded ? 'Скрыть треки' : 'Показать треки'}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"
|
||||||
|
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}>
|
||||||
|
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canFork && trackCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onFork}
|
||||||
|
disabled={isForkDone || isForkLoading}
|
||||||
|
className="w-7 h-7 rounded-[8px] flex items-center justify-center transition-all duration-150 cursor-pointer disabled:cursor-default"
|
||||||
|
style={isForkDone ? { background: 'rgba(107,255,184,0.12)', color: '#6bffb8' } : { color: 'var(--muted)' }}
|
||||||
|
title={isForkDone ? 'Добавлено!' : 'Скопировать к себе'}
|
||||||
|
>
|
||||||
|
{isForkLoading ? (
|
||||||
|
<div className="w-3 h-3 rounded-full border-[1.5px] border-white/20 border-t-accent animate-spin" />
|
||||||
|
) : isForkDone ? (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" className="hover:text-app-text">
|
||||||
|
<path d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
<path d="M12 13v4M10 15h4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onPlay}
|
||||||
|
disabled={!trackCount}
|
||||||
|
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
background: isLaunched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.12)',
|
||||||
|
color: isLaunched ? '#0a0a0f' : 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLaunched ? '▶ Играет' : '▶ Play'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && pl.tracks && pl.tracks.length > 0 && <TrackList tracks={pl.tracks} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer whitespace-nowrap"
|
||||||
|
style={active
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.15)', color: 'var(--accent)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.04)', color: 'var(--muted)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagToggleButton({
|
||||||
|
count, open, onClick,
|
||||||
|
}: {
|
||||||
|
count: number
|
||||||
|
open: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-3 py-2 rounded-[11px] transition-all duration-150 cursor-pointer border whitespace-nowrap shrink-0"
|
||||||
|
style={count > 0
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.25)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.04)', color: 'var(--muted)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<line x1="7" y1="7" x2="7.01" y2="7" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
Теги
|
||||||
|
{count > 0 && (
|
||||||
|
<span
|
||||||
|
className="flex items-center justify-center w-4 h-4 rounded-full text-[10px] font-extrabold leading-none"
|
||||||
|
style={{ background: 'var(--accent)', color: '#0a0a0f' }}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<svg width="9" height="9" viewBox="0 0 12 12" fill="none"
|
||||||
|
style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.18s' }}>
|
||||||
|
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TAG_VISIBLE_DEFAULT = 12
|
||||||
|
|
||||||
|
function TagPanel({
|
||||||
|
allTags,
|
||||||
|
activeTags,
|
||||||
|
tagCounts,
|
||||||
|
onToggle,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
allTags: string[]
|
||||||
|
activeTags: Set<string>
|
||||||
|
tagCounts: Record<string, number>
|
||||||
|
onToggle: (tag: string) => void
|
||||||
|
onClear: () => void
|
||||||
|
}) {
|
||||||
|
const [tagSearch, setTagSearch] = useState('')
|
||||||
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
const count = activeTags.size
|
||||||
|
|
||||||
|
const sorted = [...allTags].sort((a, b) => (tagCounts[b] ?? 0) - (tagCounts[a] ?? 0))
|
||||||
|
|
||||||
|
const q = tagSearch.toLowerCase().trim()
|
||||||
|
const matched = q ? sorted.filter(t => t.toLowerCase().includes(q)) : sorted
|
||||||
|
const visible = q || showAll ? matched : matched.slice(0, TAG_VISIBLE_DEFAULT)
|
||||||
|
const hiddenCount = matched.length - TAG_VISIBLE_DEFAULT
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-surface border border-white/[0.07] rounded-[14px]">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="11" height="11" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagSearch}
|
||||||
|
onChange={e => setTagSearch(e.target.value)}
|
||||||
|
placeholder="Найти тег..."
|
||||||
|
className="w-full font-sans text-[12px] bg-white/[0.04] border border-white/[0.07] rounded-[9px] pl-7 pr-2.5 py-1.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
|
||||||
|
/>
|
||||||
|
{tagSearch && (
|
||||||
|
<button
|
||||||
|
onClick={() => setTagSearch('')}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{count > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="text-[11px] font-medium text-muted hover:text-app-text transition-colors cursor-pointer whitespace-nowrap shrink-0"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matched.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-muted text-center py-2">Не найдено</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{visible.map(tag => {
|
||||||
|
const active = activeTags.has(tag)
|
||||||
|
const color = tagColor(tag)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => onToggle(tag)}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-2.5 py-1.5 rounded-[9px] transition-all duration-150 cursor-pointer"
|
||||||
|
style={active
|
||||||
|
? { background: color, color: '#0a0a0f' }
|
||||||
|
: { background: `${color}14`, color: color, border: `1px solid ${color}30` }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{tag}
|
||||||
|
<span className="text-[10px] font-sans font-medium opacity-60 leading-none">
|
||||||
|
{tagCounts[tag] ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!q && hiddenCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(v => !v)}
|
||||||
|
className="mt-2.5 text-[11px] font-medium text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{showAll ? 'Скрыть' : `Ещё ${hiddenCount} тегов`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommunityPage() {
|
||||||
|
const [playlists, setPlaylists] = useState<PublicPlaylist[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [launched, setLaunched] = useState<string | null>(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [activeTags, setActiveTags] = useState<Set<string>>(new Set())
|
||||||
|
const [tagsOpen, setTagsOpen] = useState(false)
|
||||||
|
const [sort, setSort] = useState<SortMode>('newest')
|
||||||
|
const [forkStates, setForkStates] = useState<Record<string, 'idle' | 'loading' | 'done'>>({})
|
||||||
|
const { loadPlaylist } = usePartyStore()
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getPublicPlaylists()
|
||||||
|
.then(setPlaylists)
|
||||||
|
.catch(() => setPlaylists([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePlay = (pl: PublicPlaylist) => {
|
||||||
|
const tracks = pl.tracks?.map(t => t.title) ?? []
|
||||||
|
if (!tracks.length) return
|
||||||
|
loadPlaylist(tracks)
|
||||||
|
setLaunched(pl.id)
|
||||||
|
setTimeout(() => setLaunched(null), 2500)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayAll = () => {
|
||||||
|
const tracks = filtered.flatMap(pl => pl.tracks?.map(t => t.title) ?? [])
|
||||||
|
if (!tracks.length) return
|
||||||
|
loadPlaylist(tracks)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFork = useCallback(async (pl: PublicPlaylist) => {
|
||||||
|
if (!user || forkStates[pl.id] === 'loading' || forkStates[pl.id] === 'done') return
|
||||||
|
const tracks = pl.tracks?.map(t => t.title) ?? []
|
||||||
|
if (!tracks.length) return
|
||||||
|
setForkStates(s => ({ ...s, [pl.id]: 'loading' }))
|
||||||
|
try {
|
||||||
|
await createPlaylist(`${pl.name} (от ${pl.username})`, tracks, false, pl.tags ?? [])
|
||||||
|
setForkStates(s => ({ ...s, [pl.id]: 'done' }))
|
||||||
|
} catch {
|
||||||
|
setForkStates(s => ({ ...s, [pl.id]: 'idle' }))
|
||||||
|
}
|
||||||
|
}, [user, forkStates])
|
||||||
|
|
||||||
|
const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? [])))
|
||||||
|
|
||||||
|
const tagCounts = allTags.reduce<Record<string, number>>((acc, tag) => {
|
||||||
|
acc[tag] = playlists.filter(pl => (pl.tags ?? []).includes(tag)).length
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const totalTracks = playlists.reduce((sum, pl) => sum + (pl.tracks?.length ?? 0), 0)
|
||||||
|
const uniqueAuthors = new Set(playlists.map(pl => pl.username)).size
|
||||||
|
|
||||||
|
const filtered = playlists
|
||||||
|
.filter(pl => {
|
||||||
|
const q = search.toLowerCase().trim()
|
||||||
|
const matchesSearch = !q
|
||||||
|
|| pl.name.toLowerCase().includes(q)
|
||||||
|
|| pl.username.toLowerCase().includes(q)
|
||||||
|
|| (pl.tags ?? []).some(t => t.toLowerCase().includes(q))
|
||||||
|
const matchesTags = activeTags.size === 0 || (pl.tags ?? []).some(t => activeTags.has(t))
|
||||||
|
return matchesSearch && matchesTags
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (sort === 'tracks') return (b.tracks?.length ?? 0) - (a.tracks?.length ?? 0)
|
||||||
|
if (sort === 'alpha') return a.name.localeCompare(b.name, 'ru')
|
||||||
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredTracks = filtered.reduce((sum, pl) => sum + (pl.tracks?.length ?? 0), 0)
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
setActiveTags(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(tag) ? next.delete(tag) : next.add(tag)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-app mx-auto">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="mb-5">
|
||||||
|
<div className="flex items-baseline gap-2.5">
|
||||||
|
<h2 className="font-display text-xl font-extrabold tracking-tight">Сообщество</h2>
|
||||||
|
{!loading && (
|
||||||
|
<span className="text-[12px] text-muted font-sans">{playlists.length} плейлистов</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[12px] text-muted mt-0.5">Публичные плейлисты пользователей</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && playlists.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="flex items-center gap-4 mb-4 px-4 py-2.5 bg-surface border border-white/[0.06] rounded-app">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
|
||||||
|
<path d="M9 19V6l12-3v13M9 19c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zm12 0c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[12px] font-bold text-app-text">{filteredTracks}</span>
|
||||||
|
<span className="text-[11px] text-muted">треков</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-3.5 bg-white/[0.07]" />
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
<circle cx="9" cy="7" r="4" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[12px] font-bold text-app-text">{new Set(filtered.map(pl => pl.username)).size}</span>
|
||||||
|
<span className="text-[11px] text-muted">{new Set(filtered.map(pl => pl.username)).size === 1 ? 'автор' : new Set(filtered.map(pl => pl.username)).size < 5 ? 'автора' : 'авторов'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-3.5 bg-white/[0.07]" />
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="text-accent shrink-0">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<path d="M3 9h18M9 21V9" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[12px] font-bold text-app-text">{filtered.length}</span>
|
||||||
|
<span className="text-[11px] text-muted">{filtered.length === 1 ? 'плейлист' : filtered.length < 5 ? 'плейлиста' : 'плейлистов'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + sort + tags */}
|
||||||
|
<div className="mb-4 flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Поиск по названию, автору или тегу..."
|
||||||
|
className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<TagToggleButton
|
||||||
|
count={activeTags.size}
|
||||||
|
open={tagsOpen}
|
||||||
|
onClick={() => setTagsOpen(v => !v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tagsOpen && allTags.length > 0 && (
|
||||||
|
<TagPanel
|
||||||
|
allTags={allTags}
|
||||||
|
activeTags={activeTags}
|
||||||
|
tagCounts={tagCounts}
|
||||||
|
onToggle={toggleTag}
|
||||||
|
onClear={() => setActiveTags(new Set())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<SortButton active={sort === 'newest'} onClick={() => setSort('newest')}>Новые</SortButton>
|
||||||
|
<SortButton active={sort === 'tracks'} onClick={() => setSort('tracks')}>По трекам</SortButton>
|
||||||
|
<SortButton active={sort === 'alpha'} onClick={() => setSort('alpha')}>А–Я</SortButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active tag chips */}
|
||||||
|
{activeTags.size > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{Array.from(activeTags).map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => toggleTag(tag)}
|
||||||
|
className="flex items-center gap-1 text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
|
||||||
|
style={{ background: tagColor(tag), color: '#0a0a0f' }}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<svg width="8" height="8" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTags(new Set())}
|
||||||
|
className="text-[11px] text-muted hover:text-app-text transition-colors cursor-pointer px-1"
|
||||||
|
>
|
||||||
|
Сбросить всё
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
|
||||||
|
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
) : !filtered.length ? (
|
||||||
|
<div className="text-center py-14 text-muted">
|
||||||
|
<div className="text-4xl mb-3 opacity-20">🎵</div>
|
||||||
|
<p className="text-[13px] font-medium">
|
||||||
|
{playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] mt-1.5 opacity-50">
|
||||||
|
{playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
{filtered.length !== playlists.length
|
||||||
|
? `${filtered.length} из ${playlists.length} · ${filteredTracks} треков`
|
||||||
|
: `${filtered.length} плейлистов · ${filteredTracks} треков`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handlePlayAll}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
|
||||||
|
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M5 3l14 9-14 9V3z" />
|
||||||
|
</svg>
|
||||||
|
Играть всё
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{filtered.map(pl => (
|
||||||
|
<PlaylistCard
|
||||||
|
key={pl.id}
|
||||||
|
pl={pl}
|
||||||
|
onPlay={() => handlePlay(pl)}
|
||||||
|
isLaunched={launched === pl.id}
|
||||||
|
onFork={() => handleFork(pl)}
|
||||||
|
isForkDone={forkStates[pl.id] === 'done'}
|
||||||
|
isForkLoading={forkStates[pl.id] === 'loading'}
|
||||||
|
canFork={!!user && pl.username !== user.username}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
apps/web/src/app/(main)/layout.tsx
Normal file
20
apps/web/src/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import AuthHydrator from '@/components/AuthHydrator'
|
||||||
|
import AudioBackground from '@/components/AudioBackground'
|
||||||
|
import GlobalPlayer from '@/components/GlobalPlayer'
|
||||||
|
import ThemeApplier from '@/components/ThemeApplier'
|
||||||
|
import Toaster from '@/components/Toaster'
|
||||||
|
|
||||||
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-bg min-h-screen">
|
||||||
|
<ThemeApplier />
|
||||||
|
<AudioBackground />
|
||||||
|
<div className="relative pb-[72px] px-4 pt-5 sm:px-4" style={{ zIndex: 1 }}>
|
||||||
|
<AuthHydrator />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<GlobalPlayer />
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,9 +31,9 @@ export default function LoginPage() {
|
|||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const { token, user } = await login(email, password)
|
const user = await login(email, password)
|
||||||
setAuth(token, user)
|
setAuth(user)
|
||||||
router.push('/')
|
router.push('/app')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : 'Ошибка входа')
|
setError(err instanceof Error ? err.message : 'Ошибка входа')
|
||||||
} finally {
|
} finally {
|
||||||
212
apps/web/src/app/(main)/page.tsx
Normal file
212
apps/web/src/app/(main)/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
const EQ_BARS = [
|
||||||
|
{ h: 35, d: 0 }, { h: 72, d: 0.12 }, { h: 50, d: 0.23 }, { h: 90, d: 0.06 },
|
||||||
|
{ h: 55, d: 0.17 }, { h: 82, d: 0.28 }, { h: 44, d: 0.09 }, { h: 96, d: 0.20 },
|
||||||
|
{ h: 60, d: 0.14 }, { h: 76, d: 0.03 }, { h: 40, d: 0.25 }, { h: 85, d: 0.18 },
|
||||||
|
{ h: 52, d: 0.07 }, { h: 67, d: 0.30 }, { h: 30, d: 0.11 }, { h: 78, d: 0.22 },
|
||||||
|
{ h: 48, d: 0.16 }, { h: 88, d: 0.04 }, { h: 62, d: 0.26 }, { h: 38, d: 0.19 },
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{
|
||||||
|
n: '01',
|
||||||
|
title: 'Создай вечеринку',
|
||||||
|
desc: 'Открой плеер, добавь гостей — каждый получает свой цвет',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: '02',
|
||||||
|
title: 'Добавьте треки',
|
||||||
|
desc: 'Каждый гость пишет своё — плеер сам найдёт и поставит',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: '03',
|
||||||
|
title: 'Включай музыку',
|
||||||
|
desc: 'Умный шаффл чередует очередь — никто не обделён эфиром',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
title: 'Совместная очередь',
|
||||||
|
desc: 'Каждый гость добавляет треки — никто не обделён эфиром',
|
||||||
|
icon: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18" />
|
||||||
|
<circle cx="3" cy="6" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="3" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="3" cy="18" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Умный шаффл',
|
||||||
|
desc: 'Честный режим или случайный — музыка для всех, без диктатуры',
|
||||||
|
icon: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="16 3 21 3 21 8" /><line x1="4" y1="20" x2="21" y2="3" />
|
||||||
|
<polyline points="21 16 21 21 16 21" /><line x1="15" y1="15" x2="21" y2="21" />
|
||||||
|
<line x1="4" y1="4" x2="9" y2="9" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Плейлисты',
|
||||||
|
desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
|
||||||
|
icon: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||||
|
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Пульт с телефона',
|
||||||
|
desc: 'Управляй плеером с любого телефона по QR-ссылке — без установки',
|
||||||
|
icon: (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="5" y="2" width="14" height="20" rx="2" />
|
||||||
|
<circle cx="12" cy="17" r="1" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-app mx-auto min-h-[calc(100vh-40px)] flex flex-col">
|
||||||
|
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex items-center justify-between py-3 mb-2">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-[9px] bg-accent flex items-center justify-center shrink-0">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
|
||||||
|
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-extrabold text-lg tracking-tight">
|
||||||
|
Party<span className="text-accent">Mix</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/login" className="text-[13px] font-sans text-muted hover:text-app-text transition-colors px-2 py-1">
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
<Link href="/register" className="text-[13px] font-display font-semibold px-4 py-1.5 bg-surface border border-white/[0.07] rounded-xl text-app-text hover:border-white/20 hover:bg-surface2 transition-all">
|
||||||
|
Регистрация
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="flex flex-col items-center text-center pt-10 pb-4 relative">
|
||||||
|
{/* Glow */}
|
||||||
|
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[300px] pointer-events-none" aria-hidden="true">
|
||||||
|
<div className="absolute inset-0 rounded-full blur-3xl" style={{ background: 'rgba(var(--accent-rgb),0.07)' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* EQ */}
|
||||||
|
<div className="relative flex items-end gap-[4px] mb-7 h-14" aria-hidden="true">
|
||||||
|
{EQ_BARS.map(({ h, d }, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="eq-bar"
|
||||||
|
style={{ height: `${h}%`, animationDelay: `${d}s`, animationDuration: `${0.6 + d * 0.8}s`, width: '3px' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="font-display font-extrabold leading-none tracking-tight mb-4 text-[68px] sm:text-[96px]">
|
||||||
|
Party<span className="text-accent">Mix</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<p className="font-sans text-base text-muted max-w-[280px] mb-8 leading-relaxed">
|
||||||
|
Совместные плейлисты для вечеринок.<br />
|
||||||
|
Каждый гость — часть музыки.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap justify-center mb-12">
|
||||||
|
<Link
|
||||||
|
href="/app"
|
||||||
|
className="px-7 py-3 font-display font-bold text-sm rounded-xl hover:brightness-110 active:scale-[0.97] transition-all"
|
||||||
|
style={{ background: 'var(--accent)', color: '#0a0a0f', boxShadow: '0 0 28px rgba(var(--accent-rgb),0.3)' }}
|
||||||
|
>
|
||||||
|
Начать вечеринку →
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="px-7 py-3 bg-surface border border-white/[0.07] text-app-text font-sans text-sm rounded-xl hover:bg-surface2 hover:border-white/20 active:scale-[0.97] transition-all"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<section className="py-10">
|
||||||
|
<p className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted mb-5">Как начать</p>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{STEPS.map(({ n, title, desc }) => (
|
||||||
|
<div key={n} className="flex items-start gap-4">
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-display font-bold shrink-0 mt-0.5 w-8 h-8 rounded-[8px] flex items-center justify-center"
|
||||||
|
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-[14px] font-display font-bold mb-0.5">{title}</div>
|
||||||
|
<div className="text-[12px] text-muted leading-relaxed">{desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-white/[0.05] mb-8" />
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section className="pb-10">
|
||||||
|
<p className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted mb-5">Возможности</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{FEATURES.map(({ icon, title, desc }) => (
|
||||||
|
<div
|
||||||
|
key={title}
|
||||||
|
className="bg-surface border border-white/[0.07] rounded-[14px] p-4 hover:bg-surface2 hover:border-white/[0.12] transition-all duration-200"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-[9px] flex items-center justify-center mb-3"
|
||||||
|
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="font-display font-bold text-[13px] mb-1 text-app-text">{title}</h3>
|
||||||
|
<p className="text-[11px] text-muted font-sans leading-relaxed">{desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-white/[0.05] pt-5 pb-4 flex items-center justify-between">
|
||||||
|
<span className="text-[12px] font-display font-bold tracking-tight text-muted">
|
||||||
|
Party<span style={{ color: 'var(--accent)' }}>Mix</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/search" className="text-[11px] text-muted hover:text-app-text transition-colors">Поиск</Link>
|
||||||
|
<Link href="/community" className="text-[11px] text-muted hover:text-app-text transition-colors">Сообщество</Link>
|
||||||
|
<Link href="/app" className="text-[11px] text-muted hover:text-app-text transition-colors">Плеер</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { useFavoritesStore } from '@/store/favoritesStore'
|
|||||||
import { usePartyStore } from '@/store/partyStore'
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
import { useVersionStore } from '@/store/versionStore'
|
import { useVersionStore } from '@/store/versionStore'
|
||||||
import { getPlaylists, createPlaylist, updatePlaylist, deletePlaylist } from '@/lib/authApi'
|
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 type { Playlist, SearchResult } from '@/types'
|
||||||
import Header from '@/components/Header'
|
import Header from '@/components/Header'
|
||||||
|
|
||||||
@@ -161,16 +161,21 @@ function PlaylistCard({
|
|||||||
pl,
|
pl,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onAddTrack,
|
||||||
}: {
|
}: {
|
||||||
pl: Playlist
|
pl: Playlist
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
onAddTrack: (id: string, title: string) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const { loadPlaylist } = usePartyStore()
|
const { loadPlaylist } = usePartyStore()
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
const [versionsFor, setVersionsFor] = useState<string | null>(null)
|
const [versionsFor, setVersionsFor] = useState<string | null>(null)
|
||||||
const [launched, setLaunched] = useState(false)
|
const [launched, setLaunched] = useState(false)
|
||||||
|
const [addingTrack, setAddingTrack] = useState(false)
|
||||||
|
const [quickAdd, setQuickAdd] = useState('')
|
||||||
|
const [quickAdding, setQuickAdding] = useState(false)
|
||||||
const tags = pl.tags ?? []
|
const tags = pl.tags ?? []
|
||||||
const trackCount = pl.tracks?.length ?? 0
|
const trackCount = pl.tracks?.length ?? 0
|
||||||
|
|
||||||
@@ -201,6 +206,16 @@ function PlaylistCard({
|
|||||||
setTimeout(() => setLaunched(false), 2500)
|
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 (
|
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="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.11] transition-all duration-200">
|
||||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||||
@@ -250,6 +265,14 @@ function PlaylistCard({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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}
|
<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">
|
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>
|
||||||
</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 && (
|
{expanded && pl.tracks && pl.tracks.length > 0 && (
|
||||||
<div className="border-t border-white/[0.05]">
|
<div className="border-t border-white/[0.05]">
|
||||||
{pl.tracks.map((track, i) => (
|
{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>
|
onImport: (name: string, tracks: string[]) => Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
@@ -533,18 +577,7 @@ function YandexImportForm({ onImport, onClose }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
|
<div className="pt-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<p className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
|
|
||||||
Импорт из Яндекс.Музыки
|
|
||||||
</p>
|
|
||||||
<button onClick={onClose} className="text-muted hover:text-app-text transition-colors cursor-pointer">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mb-3">
|
<div className="flex gap-2 mb-3">
|
||||||
<input
|
<input
|
||||||
type="url"
|
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() {
|
export default function PlaylistsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { token, user } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const { hydrate: hydrateFavorites } = useFavoritesStore()
|
const { hydrate: hydrateFavorites } = useFavoritesStore()
|
||||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [plSearch, setPlSearch] = useState('')
|
||||||
|
const [plSort, setPlSort] = useState<'date' | 'name' | 'tracks'>('date')
|
||||||
|
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [newTracks, setNewTracks] = useState('')
|
const [newTracks, setNewTracks] = useState('')
|
||||||
@@ -626,30 +768,31 @@ export default function PlaylistsPage() {
|
|||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [showImport, setShowImport] = useState(false)
|
const [showImport, setShowImport] = useState(false)
|
||||||
|
const [importSource, setImportSource] = useState<'yandex' | 'spotify'>('yandex')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hydrateFavorites()
|
hydrateFavorites()
|
||||||
}, [hydrateFavorites])
|
}, [hydrateFavorites])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) { router.push('/login'); return }
|
if (!user) { router.push('/login'); return }
|
||||||
getPlaylists(token)
|
getPlaylists()
|
||||||
.then(setPlaylists)
|
.then(setPlaylists)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [token, router])
|
}, [user, router])
|
||||||
|
|
||||||
const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
|
const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1)
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!token || !newName.trim()) return
|
if (!user || !newName.trim()) return
|
||||||
const tracks = parseTracks(newTracks)
|
const tracks = parseTracks(newTracks)
|
||||||
if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return }
|
if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return }
|
||||||
setCreateError('')
|
setCreateError('')
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const pl = await createPlaylist(token, newName.trim(), tracks, newIsPublic, newTags)
|
const pl = await createPlaylist(newName.trim(), tracks, newIsPublic, newTags)
|
||||||
setPlaylists(prev => [pl, ...prev])
|
setPlaylists(prev => [pl, ...prev])
|
||||||
setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([])
|
setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([])
|
||||||
setShowForm(false)
|
setShowForm(false)
|
||||||
@@ -661,23 +804,39 @@ export default function PlaylistsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => {
|
const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => {
|
||||||
if (!token) return
|
if (!user) return
|
||||||
try {
|
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))
|
setPlaylists(prev => prev.map(p => p.id === id ? updated : p))
|
||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!token) return
|
if (!user) return
|
||||||
setPlaylists(prev => prev.filter(p => p.id !== id))
|
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[]) => {
|
const handleImport = async (name: string, tracks: string[]) => {
|
||||||
if (!token) return
|
if (!user) return
|
||||||
const pl = await createPlaylist(token, name, tracks, false, [])
|
const pl = await createPlaylist(name, tracks, false, [])
|
||||||
setPlaylists(prev => [pl, ...prev])
|
setPlaylists(prev => [pl, ...prev])
|
||||||
setShowImport(false)
|
setShowImport(false)
|
||||||
}
|
}
|
||||||
@@ -740,10 +899,33 @@ export default function PlaylistsPage() {
|
|||||||
<FavoritesCard />
|
<FavoritesCard />
|
||||||
|
|
||||||
{showImport && (
|
{showImport && (
|
||||||
<YandexImportForm
|
<div className="bg-surface border border-white/[0.07] rounded-app p-4 mb-5">
|
||||||
onImport={handleImport}
|
<div className="flex items-center justify-between mb-3">
|
||||||
onClose={() => setShowImport(false)}
|
<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 && (
|
{showForm && (
|
||||||
@@ -801,24 +983,56 @@ export default function PlaylistsPage() {
|
|||||||
<p className="text-[12px] mt-1.5 opacity-50">Нажмите «Создать» чтобы добавить первый</p>
|
<p className="text-[12px] mt-1.5 opacity-50">Нажмите «Создать» чтобы добавить первый</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2.5">
|
<>
|
||||||
{playlists.map(pl => (
|
{/* Search + sort */}
|
||||||
<div key={pl.id} className="rounded-app overflow-hidden border border-white/[0.07]">
|
<div className="flex gap-2 mb-3">
|
||||||
<PlaylistCard
|
<div className="relative flex-1">
|
||||||
pl={pl}
|
<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">
|
||||||
onEdit={() => setEditingId(editingId === pl.id ? null : pl.id)}
|
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||||
onDelete={() => handleDelete(pl.id)}
|
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={plSearch}
|
||||||
|
onChange={e => setPlSearch(e.target.value)}
|
||||||
|
placeholder="Поиск по плейлистам..."
|
||||||
|
className="w-full text-[12px] bg-surface border border-white/[0.07] rounded-[9px] pl-8 pr-3 py-2 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
|
||||||
/>
|
/>
|
||||||
{editingId === pl.id && (
|
|
||||||
<EditForm
|
|
||||||
pl={pl}
|
|
||||||
onSave={(name, tracks, isPublic, tags) => handleUpdate(pl.id, name, tracks, isPublic, tags)}
|
|
||||||
onCancel={() => setEditingId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<select
|
||||||
</div>
|
value={plSort}
|
||||||
|
onChange={e => setPlSort(e.target.value as typeof plSort)}
|
||||||
|
className="text-[11px] bg-surface border border-white/[0.07] rounded-[9px] px-2.5 py-2 text-muted outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="date">По дате</option>
|
||||||
|
<option value="name">По имени</option>
|
||||||
|
<option value="tracks">По трекам</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{filteredPlaylists.map(pl => (
|
||||||
|
<div key={pl.id} className="rounded-app overflow-hidden border border-white/[0.07]">
|
||||||
|
<PlaylistCard
|
||||||
|
pl={pl}
|
||||||
|
onEdit={() => setEditingId(editingId === pl.id ? null : pl.id)}
|
||||||
|
onDelete={() => handleDelete(pl.id)}
|
||||||
|
onAddTrack={handleAddTrack}
|
||||||
|
/>
|
||||||
|
{editingId === pl.id && (
|
||||||
|
<EditForm
|
||||||
|
pl={pl}
|
||||||
|
onSave={(name, tracks, isPublic, tags) => handleUpdate(pl.id, name, tracks, isPublic, tags)}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filteredPlaylists.length === 0 && plSearch && (
|
||||||
|
<div className="text-center py-8 text-muted text-[13px]">Ничего не найдено</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
543
apps/web/src/app/(main)/remote/[id]/page.tsx
Normal file
543
apps/web/src/app/(main)/remote/[id]/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use, useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
|
interface RemoteQueueItem {
|
||||||
|
title: string
|
||||||
|
owner: string
|
||||||
|
color_bg: string
|
||||||
|
color_text: string
|
||||||
|
img?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteVersion {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
duration: string
|
||||||
|
img?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteState {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
cover: string
|
||||||
|
is_playing: boolean
|
||||||
|
volume: number
|
||||||
|
progress: number
|
||||||
|
duration: number
|
||||||
|
queue_len: number
|
||||||
|
cur_idx: number
|
||||||
|
queue: RemoteQueueItem[]
|
||||||
|
versions: RemoteVersion[]
|
||||||
|
active_version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(s: number) {
|
||||||
|
if (!s || isNaN(s)) return '0:00'
|
||||||
|
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmd(id: string, c: string, value?: number, text?: string) {
|
||||||
|
await fetch(`${API_URL}/api/remote/${id}/command`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params)
|
||||||
|
const [state, setState] = useState<RemoteState | null>(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
const [volume, setVolume] = useState(1)
|
||||||
|
const [addText, setAddText] = useState('')
|
||||||
|
const [tab, setTab] = useState<'player' | 'queue'>('player')
|
||||||
|
const [versionsOpen, setVersionsOpen] = useState(false)
|
||||||
|
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
|
||||||
|
const [localProgress, setLocalProgress] = useState(0)
|
||||||
|
const [connected, setConnected] = useState(true)
|
||||||
|
const [seeking, setSeeking] = useState(false)
|
||||||
|
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
|
||||||
|
const lastSuccessRef = useRef(Date.now())
|
||||||
|
const progressBarRef = useRef<HTMLDivElement>(null)
|
||||||
|
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
|
||||||
|
if (s) setSavedVersions(JSON.parse(s))
|
||||||
|
} catch {}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
|
||||||
|
if (res.status === 404) { setNotFound(true); return }
|
||||||
|
if (!res.ok) return
|
||||||
|
const data: RemoteState = await res.json()
|
||||||
|
if (active) {
|
||||||
|
setState(data)
|
||||||
|
if (!seeking) {
|
||||||
|
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
|
||||||
|
setLocalProgress(data.progress ?? 0)
|
||||||
|
}
|
||||||
|
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
|
||||||
|
lastSuccessRef.current = Date.now()
|
||||||
|
setConnected(true)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (active) setConnected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
const iv = setInterval(poll, 2000)
|
||||||
|
return () => { active = false; clearInterval(iv) }
|
||||||
|
}, [id, seeking])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
if (seeking) return
|
||||||
|
const { progress, ts, playing } = lastPollRef.current
|
||||||
|
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
|
||||||
|
}, 250)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [seeking])
|
||||||
|
|
||||||
|
const getSeekPct = useCallback((clientX: number) => {
|
||||||
|
const bar = progressBarRef.current
|
||||||
|
if (!bar || !state?.duration) return null
|
||||||
|
const rect = bar.getBoundingClientRect()
|
||||||
|
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||||
|
}, [state?.duration])
|
||||||
|
|
||||||
|
const handleSeekStart = useCallback((clientX: number) => {
|
||||||
|
const pct = getSeekPct(clientX)
|
||||||
|
if (pct === null) return
|
||||||
|
setSeeking(true)
|
||||||
|
const seekTo = pct * (state?.duration ?? 0)
|
||||||
|
setLocalProgress(seekTo)
|
||||||
|
}, [getSeekPct, state?.duration])
|
||||||
|
|
||||||
|
const handleSeekMove = useCallback((clientX: number) => {
|
||||||
|
if (!seeking) return
|
||||||
|
const pct = getSeekPct(clientX)
|
||||||
|
if (pct === null) return
|
||||||
|
setLocalProgress(pct * (state?.duration ?? 0))
|
||||||
|
}, [seeking, getSeekPct, state?.duration])
|
||||||
|
|
||||||
|
const handleSeekEnd = useCallback((clientX: number) => {
|
||||||
|
if (!seeking) return
|
||||||
|
const pct = getSeekPct(clientX)
|
||||||
|
if (pct !== null && state) {
|
||||||
|
const seekTo = pct * state.duration
|
||||||
|
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
|
||||||
|
setLocalProgress(seekTo)
|
||||||
|
cmd(id, 'seek', seekTo)
|
||||||
|
}
|
||||||
|
setSeeking(false)
|
||||||
|
}, [seeking, getSeekPct, state, id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMouseMove = (e: MouseEvent) => handleSeekMove(e.clientX)
|
||||||
|
const onMouseUp = (e: MouseEvent) => handleSeekEnd(e.clientX)
|
||||||
|
const onTouchMove = (e: TouchEvent) => { if (e.touches[0]) handleSeekMove(e.touches[0].clientX) }
|
||||||
|
const onTouchEnd = (e: TouchEvent) => { const t = e.changedTouches[0]; if (t) handleSeekEnd(t.clientX) }
|
||||||
|
if (seeking) {
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
|
window.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||||
|
window.addEventListener('touchend', onTouchEnd)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
window.removeEventListener('touchmove', onTouchMove)
|
||||||
|
window.removeEventListener('touchend', onTouchEnd)
|
||||||
|
}
|
||||||
|
}, [seeking, handleSeekMove, handleSeekEnd])
|
||||||
|
|
||||||
|
if (notFound) return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-surface2 flex items-center justify-center mb-2">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
|
||||||
|
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-app-text font-display font-bold text-[15px] mb-1">Сессия не найдена</p>
|
||||||
|
<p className="text-muted text-[12px]">Ссылка устарела или сессия завершена</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!state) return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
|
||||||
|
const progressPct = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
|
||||||
|
const queue = state.queue ?? []
|
||||||
|
const versions = state.versions ?? []
|
||||||
|
const trackTitle = queue[state.cur_idx]?.title ?? ''
|
||||||
|
const currentOwner = queue[state.cur_idx]
|
||||||
|
|
||||||
|
const isSavedLocally = (v: RemoteVersion) => {
|
||||||
|
const s = savedVersions[trackTitle]
|
||||||
|
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveVersion = (i: number) => {
|
||||||
|
const v = versions[i]
|
||||||
|
if (!v || !trackTitle) return
|
||||||
|
const alreadySaved = isSavedLocally(v)
|
||||||
|
const next = { ...savedVersions }
|
||||||
|
if (alreadySaved) delete next[trackTitle]
|
||||||
|
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
|
||||||
|
setSavedVersions(next)
|
||||||
|
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
|
||||||
|
cmd(id, 'save_version', i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-8">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full shrink-0 transition-all duration-500"
|
||||||
|
style={{ background: connected ? '#5cc87a' : '#ff6b6b', boxShadow: connected ? '0 0 5px #5cc87a99' : 'none' }}
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 bg-surface2 rounded-[9px] p-0.5">
|
||||||
|
{(['player', 'queue'] as const).map(t => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className="px-3 py-1.5 text-[11px] font-display font-bold rounded-[7px] transition-all cursor-pointer flex items-center gap-1"
|
||||||
|
style={{ background: tab === t ? 'rgba(var(--accent-rgb),0.12)' : 'transparent', color: tab === t ? 'var(--accent)' : '#555' }}
|
||||||
|
>
|
||||||
|
{t === 'player' ? 'Плеер' : 'Очередь'}
|
||||||
|
{t === 'queue' && queue.length > 0 && (
|
||||||
|
<span className="text-[9px] opacity-60 font-display tabular-nums">{queue.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'player' && (
|
||||||
|
<div className="flex flex-col gap-6 items-center flex-1">
|
||||||
|
|
||||||
|
{/* Cover */}
|
||||||
|
<div
|
||||||
|
className="w-full aspect-square rounded-[24px] overflow-hidden bg-surface2 shrink-0"
|
||||||
|
style={{ boxShadow: state.cover ? '0 20px 60px rgba(var(--accent-rgb),0.18), 0 8px 20px rgba(0,0,0,0.4)' : '0 8px 20px rgba(0,0,0,0.3)' }}
|
||||||
|
>
|
||||||
|
{state.cover ? (
|
||||||
|
<img src={state.cover} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="#222" strokeWidth="1">
|
||||||
|
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track info */}
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-display text-[20px] font-extrabold tracking-tight leading-tight truncate">
|
||||||
|
{state.title || '—'}
|
||||||
|
</div>
|
||||||
|
{state.artist && (
|
||||||
|
<div className="text-[13px] text-muted mt-1 truncate">{state.artist}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
|
{currentOwner && (
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-[5px] font-medium" style={{ background: currentOwner.color_bg, color: currentOwner.color_text }}>
|
||||||
|
{currentOwner.owner}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.queue_len > 0 && (
|
||||||
|
<span className="text-[11px] text-muted font-display tabular-nums">{state.cur_idx + 1} / {state.queue_len}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full select-none">
|
||||||
|
<div
|
||||||
|
ref={progressBarRef}
|
||||||
|
className="relative h-4 flex items-center cursor-pointer group"
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); handleSeekStart(e.clientX) }}
|
||||||
|
onTouchStart={(e) => { if (e.touches[0]) handleSeekStart(e.touches[0].clientX) }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 flex items-center w-full">
|
||||||
|
<div className="relative w-full h-1.5 bg-white/[0.08] rounded-full">
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 rounded-full transition-none"
|
||||||
|
style={{ width: `${progressPct}%`, background: 'var(--accent)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-white shadow transition-none"
|
||||||
|
style={{ left: `${progressPct}%`, opacity: seeking ? 1 : 0, transition: seeking ? 'none' : 'opacity 0.15s' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums -mt-0.5">
|
||||||
|
<span>{formatTime(clampedProgress)}</span>
|
||||||
|
<span>{formatTime(state.duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-between w-full px-2">
|
||||||
|
<button
|
||||||
|
onClick={() => cmd(id, 'prev')}
|
||||||
|
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
|
||||||
|
className="w-18 h-18 rounded-full flex items-center justify-center active:scale-90 transition-all cursor-pointer hover:brightness-110"
|
||||||
|
style={{
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: '#0a0a0f',
|
||||||
|
boxShadow: '0 6px 24px rgba(var(--accent-rgb),0.45)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.is_playing ? (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<rect x="6" y="4" width="4" height="16" rx="1" /><rect x="14" y="4" width="4" height="16" rx="1" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 3 }}>
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => cmd(id, 'next')}
|
||||||
|
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-90 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 18l8.5-6L6 6v12z" /><rect x="16" y="6" width="2" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="w-full flex items-center gap-3">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.02"
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = parseFloat(e.target.value)
|
||||||
|
setVolume(v)
|
||||||
|
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
|
||||||
|
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
|
||||||
|
}}
|
||||||
|
className="flex-1 cursor-pointer h-1.5"
|
||||||
|
style={{ accentColor: 'var(--accent)' }}
|
||||||
|
/>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
||||||
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||||
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Versions */}
|
||||||
|
{versions.length > 1 && (
|
||||||
|
<div className="w-full">
|
||||||
|
<button
|
||||||
|
onClick={() => setVersionsOpen(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-[10px] bg-surface2 border border-white/[0.07] hover:border-white/[0.12] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px] font-display font-bold tracking-[0.5px] text-muted">Версии трека</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-muted opacity-60">{versions.length}</span>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#555" strokeWidth="2.5" className={`transition-transform duration-200 ${versionsOpen ? 'rotate-180' : ''}`}>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{versionsOpen && (
|
||||||
|
<div className="mt-1.5 border border-white/[0.07] rounded-[10px] overflow-hidden bg-surface2/50">
|
||||||
|
{versions.map((v, i) => {
|
||||||
|
const active = i === state.active_version
|
||||||
|
const saved = isSavedLocally(v)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0"
|
||||||
|
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-muted w-4 text-right shrink-0 font-display tabular-nums">{i + 1}</span>
|
||||||
|
{v.img ? (
|
||||||
|
<img src={v.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
|
||||||
|
<div className="text-[12px] text-app-text truncate">{v.title}</div>
|
||||||
|
<div className="text-[11px] text-muted mt-px truncate">{v.artist}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-muted shrink-0 font-display tabular-nums">{v.duration}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
|
||||||
|
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
|
||||||
|
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
|
||||||
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
|
||||||
|
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
|
||||||
|
style={{ background: active ? 'var(--accent)' : 'transparent', borderColor: active ? 'var(--accent)' : 'rgba(255,255,255,0.07)' }}
|
||||||
|
>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'queue' && (
|
||||||
|
<div className="flex flex-col gap-3 flex-1">
|
||||||
|
|
||||||
|
{/* Add track */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addText}
|
||||||
|
onChange={(e) => setAddText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && addText.trim()) {
|
||||||
|
cmd(id, 'add', 0, addText.trim())
|
||||||
|
setAddText('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Исполнитель — Название"
|
||||||
|
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[10px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!addText.trim()) return
|
||||||
|
cmd(id, 'add', 0, addText.trim())
|
||||||
|
setAddText('')
|
||||||
|
}}
|
||||||
|
className="shrink-0 w-11 rounded-[10px] flex items-center justify-center bg-accent text-bg font-display font-bold text-[18px] cursor-pointer hover:brightness-110 active:scale-95 transition-all"
|
||||||
|
style={{ color: '#0a0a0f' }}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue list */}
|
||||||
|
{queue.length === 0 ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-center py-16 gap-3">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1.5">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18" />
|
||||||
|
<circle cx="3" cy="6" r="1" fill="#2a2a2a" stroke="none" />
|
||||||
|
<circle cx="3" cy="12" r="1" fill="#2a2a2a" stroke="none" />
|
||||||
|
<circle cx="3" cy="18" r="1" fill="#2a2a2a" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-[13px] text-muted">Очередь пуста</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{queue.map((item, i) => {
|
||||||
|
const active = i === state.cur_idx
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => cmd(id, 'goto', i)}
|
||||||
|
className="flex items-center gap-2.5 px-2 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer rounded-[8px] transition-colors active:bg-surface2"
|
||||||
|
style={{ background: active ? 'rgba(var(--accent-rgb),0.05)' : undefined }}
|
||||||
|
>
|
||||||
|
{/* Index / playing indicator */}
|
||||||
|
<div className="w-5 flex items-center justify-center shrink-0">
|
||||||
|
{active ? (
|
||||||
|
<div className="flex items-end gap-[1.5px] h-3.5">
|
||||||
|
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-muted font-display tabular-nums">{i + 1}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover */}
|
||||||
|
{item.img ? (
|
||||||
|
<img src={item.img} alt="" className="w-9 h-9 rounded-[7px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9 rounded-[7px] bg-surface2 shrink-0 flex items-center justify-center">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
|
||||||
|
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title + owner */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-[13px] text-app-text truncate block leading-tight"
|
||||||
|
style={{ color: active ? 'var(--accent)' : undefined }}
|
||||||
|
>{item.title}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-[4px] font-medium inline-block mt-0.5" style={{ background: item.color_bg, color: item.color_text }}>
|
||||||
|
{item.owner}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
import { usePartyStore } from '@/store/partyStore'
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
import { useFavoritesStore } from '@/store/favoritesStore'
|
import { useFavoritesStore } from '@/store/favoritesStore'
|
||||||
import { useVersionStore } from '@/store/versionStore'
|
import { useVersionStore } from '@/store/versionStore'
|
||||||
@@ -9,13 +9,31 @@ import AddToPlaylist from '@/components/AddToPlaylist'
|
|||||||
import Header from '@/components/Header'
|
import Header from '@/components/Header'
|
||||||
import type { SearchResult } from '@/types'
|
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 }) {
|
function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: SearchResult) => void }) {
|
||||||
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
||||||
const { isSaved, saveVersion, removeVersion } = useVersionStore()
|
const { isSaved, saveVersion, removeVersion } = useVersionStore()
|
||||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||||
const addBtnRef = useRef<HTMLButtonElement>(null)
|
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 saved = isSaved(result.title, result)
|
||||||
const hasImg = result.img && !result.img.includes('no-cover')
|
const hasImg = result.img && !result.img.includes('no-cover')
|
||||||
|
|
||||||
@@ -94,7 +112,7 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
|
|||||||
|
|
||||||
{/* Favorite */}
|
{/* Favorite */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleFavorite(result.title)}
|
onClick={() => toggleFavorite(favKey)}
|
||||||
title={favorited ? 'Убрать из избранного' : 'В избранное'}
|
title={favorited ? 'Убрать из избранного' : 'В избранное'}
|
||||||
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
|
className="w-7 h-7 rounded-[7px] border flex items-center justify-center transition-all duration-150 cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
@@ -122,28 +140,45 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState(_cachedQuery)
|
||||||
const [results, setResults] = useState<SearchResult[] | null>(null)
|
const [results, setResults] = useState<SearchResult[] | null>(_cachedResults)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [lastQuery, setLastQuery] = useState('')
|
const [lastQuery, setLastQuery] = useState(_cachedQuery)
|
||||||
|
const [searchHistory, setSearchHistory] = useState<string[]>([])
|
||||||
const { loadPlaylist } = usePartyStore()
|
const { loadPlaylist } = usePartyStore()
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const handleSearch = async (e?: React.FormEvent) => {
|
useEffect(() => { setSearchHistory(getHistory()) }, [])
|
||||||
e?.preventDefault()
|
|
||||||
const q = query.trim()
|
const runSearch = useCallback(async (q: string) => {
|
||||||
if (!q) return
|
if (!q.trim()) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setResults(null)
|
setResults(null)
|
||||||
setLastQuery(q)
|
setLastQuery(q)
|
||||||
|
setSearchHistory(pushHistory(q))
|
||||||
try {
|
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)
|
setResults(found)
|
||||||
|
_cachedQuery = q
|
||||||
|
_cachedResults = found
|
||||||
} catch {
|
} catch {
|
||||||
setResults([])
|
setResults([])
|
||||||
|
_cachedResults = null
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearch = (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
runSearch(query.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePlay = (r: SearchResult) => {
|
const handlePlay = (r: SearchResult) => {
|
||||||
@@ -202,6 +237,35 @@ export default function SearchPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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 */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-14 gap-2.5 text-muted">
|
<div className="flex items-center justify-center py-14 gap-2.5 text-muted">
|
||||||
996
apps/web/src/app/(main)/settings/page.tsx
Normal file
996
apps/web/src/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
||||||
|
import { useBgStore, BG_PRESETS, DEFAULT_FX, type BgMode, type FxConfigs } from '@/store/bgStore'
|
||||||
|
import { getActiveAccent } from '@/store/themeStore'
|
||||||
|
import { useOverlayStore, OVERLAY_STYLES, type OverlayDesign, type OverlayStyle, type OverlayPosition } from '@/store/overlayStore'
|
||||||
|
import { getPalettes, STYLE_CUSTOM_FIELDS } from '@/lib/overlayPalettes'
|
||||||
|
import { OverlayPreview } from '@/components/OverlayWidget'
|
||||||
|
import Header from '@/components/Header'
|
||||||
|
import ColorWheel from '@/components/ColorWheel'
|
||||||
|
|
||||||
|
// ── Preview SVGs ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OrbsPreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="p-o1" cx="12%" cy="6%" r="60%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.65"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="p-o2" cx="90%" cy="96%" r="55%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.55"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="p-o3" cx="50%" cy="50%" r="40%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.35"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<ellipse cx="14" cy="4" rx="72" ry="72" fill="url(#p-o1)"/>
|
||||||
|
<ellipse cx="108" cy="65" rx="58" ry="58" fill="url(#p-o2)"/>
|
||||||
|
<ellipse cx="60" cy="34" rx="36" ry="36" fill="url(#p-o3)"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WavesPreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<path d="M0 47 Q15 41 30 47 Q45 53 60 47 Q75 41 90 47 Q105 53 120 47 L120 68 L0 68Z" fill="var(--accent)" opacity="0.16"/>
|
||||||
|
<path d="M0 55 Q15 49 30 55 Q45 61 60 55 Q75 49 90 55 Q105 61 120 55 L120 68 L0 68Z" fill="var(--accent)" opacity="0.22"/>
|
||||||
|
<path d="M0 41 Q20 35 40 41 Q60 47 80 41 Q100 35 120 41 L120 68 L0 68Z" fill="rgb(255,60,172)" opacity="0.10"/>
|
||||||
|
<path d="M0 61 Q20 57 40 61 Q60 65 80 61 Q100 57 120 61 L120 68 L0 68Z" fill="rgb(140,100,255)" opacity="0.14"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticlesPreview() {
|
||||||
|
const d: [number,number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]]
|
||||||
|
const ln: [number,number][] = [[0,1],[1,2],[0,3],[1,7],[3,5],[2,4],[4,6],[3,7],[7,8],[1,8],[9,0],[9,3]]
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
{ln.map(([a,b],i) => <line key={i} x1={d[a][0]} y1={d[a][1]} x2={d[b][0]} y2={d[b][1]} stroke="var(--accent)" strokeWidth="0.6" opacity="0.2"/>)}
|
||||||
|
{d.map(([x,y],i) => <circle key={i} cx={x} cy={y} r={i%3===0?2:1.3} fill="var(--accent)" opacity={i%2===0?0.7:0.45}/>)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuroraPreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="p-a1" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.55"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="p-a2" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.45"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
|
||||||
|
<radialGradient id="p-a3" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.40"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<ellipse cx="10" cy="10" rx="80" ry="22" fill="url(#p-a1)"/>
|
||||||
|
<ellipse cx="66" cy="6" rx="90" ry="16" fill="url(#p-a2)"/>
|
||||||
|
<ellipse cx="106" cy="19" rx="66" ry="20" fill="url(#p-a3)"/>
|
||||||
|
<ellipse cx="34" cy="32" rx="85" ry="15" fill="url(#p-a1)" opacity="0.5"/>
|
||||||
|
<ellipse cx="86" cy="44" rx="60" ry="16" fill="url(#p-a2)" opacity="0.45"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PulsePreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="p-pg" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<circle cx="60" cy="34" r="12" fill="url(#p-pg)"/>
|
||||||
|
<circle cx="60" cy="34" r="9" fill="none" stroke="var(--accent)" strokeWidth="1.8" opacity="0.75"/>
|
||||||
|
<circle cx="60" cy="34" r="20" fill="none" stroke="var(--accent)" strokeWidth="1.2" opacity="0.5"/>
|
||||||
|
<circle cx="60" cy="34" r="30" fill="none" stroke="var(--accent)" strokeWidth="0.9" opacity="0.32"/>
|
||||||
|
<circle cx="60" cy="34" r="42" fill="none" stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
|
||||||
|
<circle cx="60" cy="34" r="55" fill="none" stroke="var(--accent)" strokeWidth="0.4" opacity="0.1"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StarsPreview() {
|
||||||
|
const stars: [number,number,number][] = [
|
||||||
|
[12,8,1.4],[34,5,0.9],[58,12,1.1],[80,4,1.5],[102,9,0.8],[18,22,1.0],[45,18,1.3],[72,20,0.9],[96,25,1.2],
|
||||||
|
[8,38,0.8],[28,42,1.1],[55,35,1.4],[78,40,0.9],[108,36,1.0],[22,56,1.2],[50,58,0.8],[75,54,1.3],[100,60,1.0],
|
||||||
|
[40,28,0.7],[88,14,1.1],[15,48,0.9],[64,48,0.8]
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="p-neb" cx="35%" cy="45%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.12"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<rect width="120" height="68" fill="url(#p-neb)"/>
|
||||||
|
{stars.map(([x,y,r],i) => (
|
||||||
|
<circle key={i} cx={x} cy={y} r={r} fill="var(--accent)" opacity={0.4 + (i % 5) * 0.12}/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RainPreview() {
|
||||||
|
const drops: [number,number,number][] = [
|
||||||
|
[10,8,20],[25,0,28],[40,15,22],[55,5,25],[70,10,18],[85,2,30],[100,12,24],[112,6,20],
|
||||||
|
[18,35,22],[33,28,26],[48,40,19],[63,30,24],[78,38,21],[93,25,28],[108,34,20],
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
{drops.map(([x,y,len],i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<line x1={x} y1={y} x2={x} y2={y+len} stroke="var(--accent)" strokeWidth="1.5" opacity={0.15 + (i%4)*0.08}/>
|
||||||
|
<circle cx={x} cy={y+len} r="1.5" fill="var(--accent)" opacity={0.5 + (i%3)*0.15}/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RaysPreview() {
|
||||||
|
const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="p-rg" cx="50%" cy="60%" r="55%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
{rays.map((deg, i) => {
|
||||||
|
const rad = (deg * Math.PI) / 180
|
||||||
|
const x2 = 60 + Math.cos(rad) * 90
|
||||||
|
const y2 = 41 + Math.sin(rad) * 90
|
||||||
|
return (
|
||||||
|
<line key={i} x1="60" y1="41" x2={x2} y2={y2}
|
||||||
|
stroke="var(--accent)" strokeWidth={i%2===0?2.5:1.5} opacity={i%2===0?0.18:0.10}/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<circle cx="60" cy="41" r="14" fill="url(#p-rg)"/>
|
||||||
|
<circle cx="60" cy="41" r="3" fill="var(--accent)" opacity="0.7"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NonePreview() {
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
||||||
|
<rect width="120" height="68" fill="#0a0a0f"/>
|
||||||
|
<line x1="48" y1="34" x2="72" y2="34" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<line x1="60" y1="22" x2="60" y2="46" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
|
||||||
|
orbs: <OrbsPreview />,
|
||||||
|
waves: <WavesPreview />,
|
||||||
|
particles: <ParticlesPreview />,
|
||||||
|
aurora: <AuroraPreview />,
|
||||||
|
pulse: <PulsePreview />,
|
||||||
|
stars: <StarsPreview />,
|
||||||
|
rain: <RainPreview />,
|
||||||
|
rays: <RaysPreview />,
|
||||||
|
none: <NonePreview />,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-effect slider definitions ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SliderDef = { key: string; label: string; min: number; max: number; step: number; fmt: (v: number) => string }
|
||||||
|
|
||||||
|
const TRAIL_SLIDER: SliderDef = {
|
||||||
|
key: 'trail', label: 'Шлейф', min: 0, max: 0.95, step: 0.05,
|
||||||
|
fmt: v => v < 0.02 ? 'Нет' : Math.round(v * 100) + '%',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FX_SLIDERS: Partial<Record<keyof FxConfigs, SliderDef[]>> = {
|
||||||
|
orbs: [
|
||||||
|
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
TRAIL_SLIDER,
|
||||||
|
],
|
||||||
|
waves: [
|
||||||
|
{ key: 'amplitude', label: 'Амплитуда', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
TRAIL_SLIDER,
|
||||||
|
],
|
||||||
|
particles: [
|
||||||
|
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
{ key: 'linkDist', label: 'Связи', min: 0.3, max: 2.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
TRAIL_SLIDER,
|
||||||
|
],
|
||||||
|
aurora: [
|
||||||
|
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
TRAIL_SLIDER,
|
||||||
|
],
|
||||||
|
pulse: [
|
||||||
|
{ key: 'sensitivity', label: 'Чувствительность', min: 0, max: 1.0, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
|
||||||
|
{ key: 'ringSpeed', label: 'Скорость колец', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
TRAIL_SLIDER,
|
||||||
|
],
|
||||||
|
stars: [
|
||||||
|
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
{ key: 'twinkle', label: 'Мерцание', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
TRAIL_SLIDER,
|
||||||
|
],
|
||||||
|
rain: [
|
||||||
|
{ key: 'drops', label: 'Количество', min: 5, max: 60, step: 1, fmt: v => String(Math.round(v)) },
|
||||||
|
{ key: 'speed', label: 'Скорость', min: 0.3, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
TRAIL_SLIDER,
|
||||||
|
],
|
||||||
|
rays: [
|
||||||
|
{ key: 'count', label: 'Количество', min: 4, max: 16, step: 1, fmt: v => String(Math.round(v)) },
|
||||||
|
{ key: 'speed', label: 'Скорость', min: 0.2, max: 3.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
{ key: 'spread', label: 'Ширина', min: 0.3, max: 2.0, step: 0.1, fmt: v => v.toFixed(1) + 'x' },
|
||||||
|
TRAIL_SLIDER,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chevron icon ──────────────────────────────────────────────────────────────
|
||||||
|
function Chevron({ open }: { open: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16" height="16" viewBox="0 0 16 16" fill="none"
|
||||||
|
className="shrink-0 transition-transform duration-200"
|
||||||
|
style={{ transform: open ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
||||||
|
>
|
||||||
|
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overlay style previews ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function OverlayStylePreview({ style }: { style: OverlayStyle }) {
|
||||||
|
const W = 160, H = 90
|
||||||
|
if (style === 'classic') return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<rect width={W} height={H} fill="#0a0a10"/>
|
||||||
|
<rect x="10" y="60" width="100" height="22" rx="8" fill="rgba(10,10,16,0.85)" stroke="rgba(255,255,255,0.09)" strokeWidth="0.6"/>
|
||||||
|
<rect x="18" y="67" width="10" height="10" rx="3" fill="rgba(255,255,255,0.08)"/>
|
||||||
|
<rect x="34" y="68" width="50" height="3.5" rx="1.5" fill="rgba(255,255,255,0.7)"/>
|
||||||
|
<rect x="34" y="74" width="32" height="2" rx="1" fill="rgba(255,255,255,0.3)"/>
|
||||||
|
<rect x="102" y="66" width="2" height="3" rx="1" fill="var(--accent)"/>
|
||||||
|
<rect x="105" y="64" width="2" height="7" rx="1" fill="var(--accent)"/>
|
||||||
|
<rect x="108" y="67" width="2" height="4" rx="1" fill="var(--accent)"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
if (style === 'aero') return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<rect width={W} height={H} fill="#1a2a3a"/>
|
||||||
|
<rect x="10" y="62" width="110" height="20" rx="12" fill="rgba(160,210,255,0.4)" stroke="rgba(255,255,255,0.7)" strokeWidth="0.8"/>
|
||||||
|
<rect x="10" y="62" width="110" height="10" rx="12" fill="rgba(255,255,255,0.3)"/>
|
||||||
|
<circle cx="24" cy="72" r="7" fill="rgba(255,255,255,0.3)" stroke="rgba(255,255,255,0.6)" strokeWidth="0.6"/>
|
||||||
|
<rect x="37" y="68" width="48" height="3" rx="1.5" fill="rgba(0,40,120,0.8)"/>
|
||||||
|
<rect x="37" y="73" width="30" height="2" rx="1" fill="rgba(0,60,160,0.5)"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
if (style === 'retro') return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<rect width={W} height={H} fill="#0a0400"/>
|
||||||
|
<rect x="10" y="55" width="120" height="28" fill="rgba(12,5,0,0.95)" stroke="#b06828" strokeWidth="1.2"/>
|
||||||
|
<rect x="10" y="55" width="120" height="10" fill="rgba(160,70,10,0.4)"/>
|
||||||
|
<text x="15" y="63" fontSize="6" fill="#e08030" fontFamily="monospace">▶ NOW PLAYING</text>
|
||||||
|
<text x="108" y="63" fontSize="6" fill="#f09040" fontFamily="monospace">● REC</text>
|
||||||
|
<rect x="15" y="70" width="90" height="3.5" rx="0" fill="rgba(248,208,144,0.8)" fontFamily="monospace"/>
|
||||||
|
<rect x="15" y="76" width="55" height="2.5" rx="0" fill="rgba(154,96,32,0.7)"/>
|
||||||
|
<text x="112" y="80" fontSize="5" fill="#7a4810" fontFamily="monospace">00:42</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
if (style === 'neon') return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<rect width={W} height={H} fill="#00000f"/>
|
||||||
|
<rect x="10" y="54" width="120" height="30" rx="6" fill="rgba(0,0,10,0.9)" stroke="rgba(var(--accent-rgb, 222,156,254),0.65)" strokeWidth="0.8"/>
|
||||||
|
<rect x="10" y="54" width="6" height="6" rx="1" fill="none" stroke="var(--accent)" strokeWidth="0.8"/>
|
||||||
|
<rect x="124" y="54" width="6" height="6" rx="1" fill="none" stroke="var(--accent)" strokeWidth="0.8"/>
|
||||||
|
<text x="16" y="64" fontSize="5" fill="rgba(222,156,254,0.5)" fontFamily="monospace" letterSpacing="2">◈ STREAM</text>
|
||||||
|
<rect x="16" y="68" width="80" height="3.5" rx="1" fill="var(--accent)" opacity="0.85"/>
|
||||||
|
<rect x="16" y="74" width="50" height="2" rx="1" fill="rgba(222,156,254,0.45)"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
if (style === 'clean') return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<rect width={W} height={H} fill="#111"/>
|
||||||
|
<rect x="14" y="58" width="3" height="22" rx="1.5" fill="var(--accent)"/>
|
||||||
|
<rect x="22" y="60" width="95" height="5" rx="2" fill="rgba(255,255,255,0.85)"/>
|
||||||
|
<rect x="22" y="68" width="60" height="3" rx="1.5" fill="rgba(255,255,255,0.4)"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
if (style === 'y2k') return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<rect width={W} height={H} fill="#8090a0"/>
|
||||||
|
<rect x="15" y="48" width="120" height="36" rx="3" fill="#c8d0e0" stroke="rgba(255,255,255,0.9)" strokeWidth="1"/>
|
||||||
|
<rect x="15" y="48" width="120" height="12" rx="3" fill="url(#xp)"/>
|
||||||
|
<defs><linearGradient id="xp" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="#1a4090"/><stop offset="50%" stopColor="#4a80d0"/><stop offset="100%" stopColor="#1a4090"/></linearGradient></defs>
|
||||||
|
<text x="20" y="57" fontSize="6" fill="white" fontFamily="Tahoma,sans-serif" fontWeight="700">🎵 Party Mix Player</text>
|
||||||
|
<rect x="120" y="50" width="7" height="6" rx="1" fill="#c0c8d8" stroke="rgba(100,120,160,0.8)" strokeWidth="0.5"/>
|
||||||
|
<text x="121.5" y="55" fontSize="6" fill="#000" fontWeight="700">×</text>
|
||||||
|
<rect x="20" y="64" width="100" height="8" rx="1" fill="rgba(255,255,255,0.5)" stroke="#8090b0" strokeWidth="0.5"/>
|
||||||
|
<rect x="20" y="68" width="45" height="4" rx="0.5" fill="#2060b0"/>
|
||||||
|
<rect x="20" y="75" width="100" height="5" rx="1" fill="rgba(255,255,255,0.4)" stroke="#8090b0" strokeWidth="0.5"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
if (style === 'lofi') return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`} style={{ transform: 'rotate(-0.5deg)' }}>
|
||||||
|
<rect width={W} height={H} fill="#1a0e06"/>
|
||||||
|
<rect x="12" y="52" width="120" height="34" rx="12" fill="rgba(42,26,14,0.92)" stroke="rgba(240,180,100,0.2)" strokeWidth="0.8"/>
|
||||||
|
<circle cx="34" cy="69" r="14" fill="radial-gradient(#3a2010,#1a0e06)" stroke="rgba(255,180,80,0.3)" strokeWidth="0.8"/>
|
||||||
|
<circle cx="34" cy="69" r="14" fill="rgba(30,16,6,0.9)"/>
|
||||||
|
<circle cx="34" cy="69" r="5" fill="rgba(255,160,60,0.4)"/>
|
||||||
|
<text x="53" y="62" fontSize="5" fill="rgba(240,180,100,0.5)" fontFamily="serif" fontStyle="italic">now playing</text>
|
||||||
|
<rect x="53" y="65" width="65" height="3.5" rx="1" fill="rgba(253,232,192,0.8)"/>
|
||||||
|
<rect x="53" y="71" width="42" height="2.5" rx="1" fill="rgba(240,180,100,0.45)"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
if (style === 'glam') return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<rect width={W} height={H} fill="#0a0800"/>
|
||||||
|
<rect x="15" y="48" width="120" height="38" rx="10" fill="rgba(22,14,2,0.96)" stroke="rgba(212,175,55,0.55)" strokeWidth="0.8"/>
|
||||||
|
<rect x="15" y="48" width="120" height="2" rx="1" fill="url(#gld)"/>
|
||||||
|
<rect x="15" y="84" width="120" height="2" rx="1" fill="url(#gld)"/>
|
||||||
|
<defs><linearGradient id="gld" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="transparent"/><stop offset="30%" stopColor="rgba(212,175,55,0.9)"/><stop offset="50%" stopColor="#ffd700"/><stop offset="70%" stopColor="rgba(212,175,55,0.9)"/><stop offset="100%" stopColor="transparent"/></linearGradient></defs>
|
||||||
|
<text x="24" y="56" fontSize="5" fill="rgba(212,175,55,0.5)">✦</text>
|
||||||
|
<text x="125" y="56" fontSize="5" fill="rgba(212,175,55,0.5)">✦</text>
|
||||||
|
<text x="80" y="63" fontSize="5" fill="rgba(212,175,55,0.45)" textAnchor="middle" letterSpacing="2">NOW PLAYING</text>
|
||||||
|
<rect x="30" y="65" width="90" height="0.8" fill="rgba(212,175,55,0.3)"/>
|
||||||
|
<rect x="30" y="69" width="90" height="4" rx="1" fill="rgba(240,200,64,0.8)" textAnchor="middle"/>
|
||||||
|
<rect x="45" y="75" width="60" height="2.5" rx="1" fill="rgba(200,150,30,0.5)"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
// matrix
|
||||||
|
return (
|
||||||
|
<svg width="100%" height="100%" viewBox={`0 0 ${W} ${H}`}>
|
||||||
|
<rect width={W} height={H} fill="#000800"/>
|
||||||
|
<rect x="10" y="50" width="130" height="34" rx="3" fill="rgba(0,8,0,0.93)" stroke="rgba(0,255,50,0.35)" strokeWidth="0.7"/>
|
||||||
|
<text x="16" y="61" fontSize="5" fill="rgba(0,200,40,0.5)" fontFamily="monospace">PARTY_MIX@STREAM:~$</text>
|
||||||
|
<text x="16" y="70" fontSize="7" fill="#00ff32" fontFamily="monospace" fontWeight="700">> Земфира — Хочешь?▌</text>
|
||||||
|
<text x="16" y="78" fontSize="5" fill="rgba(0,180,30,0.4)" fontFamily="monospace">[LOADING...]</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SettingsTab = 'appearance' | 'overlay'
|
||||||
|
|
||||||
|
const OVERLAY_DESIGNS: { id: OverlayDesign; name: string; desc: string }[] = [
|
||||||
|
{ id: 'minimal', name: 'Минимальный', desc: 'Маленькая плашка с EQ и названием' },
|
||||||
|
{ id: 'card', name: 'Карточка', desc: 'Обложка, название и артист' },
|
||||||
|
{ id: 'bar', name: 'Широкая', desc: 'Полоса во всю ширину снизу' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OVERLAY_POSITIONS: { id: OverlayPosition; arrow: string; label: string }[] = [
|
||||||
|
{ id: 'tl', arrow: '↖', label: 'Лево сверху' },
|
||||||
|
{ id: 'tr', arrow: '↗', label: 'Право сверху' },
|
||||||
|
{ id: 'bl', arrow: '↙', label: 'Лево снизу' },
|
||||||
|
{ id: 'br', arrow: '↘', label: 'Право снизу' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OVERLAY_FONTS: { id: string; name: string; css: string }[] = [
|
||||||
|
{ id: '', name: 'Авто', css: 'inherit' },
|
||||||
|
{ id: 'Inter, sans-serif', name: 'Inter', css: 'Inter, sans-serif' },
|
||||||
|
{ id: 'Space Grotesk, sans-serif', name: 'Space Grotesk',css: 'Space Grotesk, sans-serif' },
|
||||||
|
{ id: 'Unbounded, sans-serif', name: 'Unbounded', css: 'Unbounded, sans-serif' },
|
||||||
|
{ id: 'JetBrains Mono, monospace', name: 'JetBrains Mono', css: 'JetBrains Mono, monospace' },
|
||||||
|
{ id: 'Playfair Display, serif', name: 'Playfair Display', css: 'Playfair Display, serif' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
|
||||||
|
const { bgMode, fxConfigs, setBg, setFxConfig, resetFx } = useBgStore()
|
||||||
|
const {
|
||||||
|
enabled: overlayEnabled, design: overlayDesign, style: overlayStyle,
|
||||||
|
accentColor: overlayAccentColor, position: overlayPosition,
|
||||||
|
font: overlayFont, textColor: overlayTextColor,
|
||||||
|
showCover: overlayShowCover, showEq: overlayShowEq,
|
||||||
|
setEnabled: setOverlayEnabled, setDesign: setOverlayDesign, setStyle: setOverlayStyle,
|
||||||
|
setAccentColor: setOverlayAccentColor, setPosition: setOverlayPosition,
|
||||||
|
setFont: setOverlayFont, setTextColor: setOverlayTextColor,
|
||||||
|
setShowCover: setOverlayShowCover, setShowEq: setOverlayShowEq,
|
||||||
|
palette: overlayPalette, setPalette: setOverlayPalette,
|
||||||
|
customPalettes, setCustomPaletteField,
|
||||||
|
margin: overlayMargin, scale: overlayScale, opacity: overlayOpacity,
|
||||||
|
setMargin: setOverlayMargin, setScale: setOverlayScale, setOpacity: setOverlayOpacity,
|
||||||
|
} = useOverlayStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const activeAccent = getActiveAccent(accentIdx, customHex)
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<SettingsTab>('appearance')
|
||||||
|
const [showAccent, setShowAccent] = useState(true)
|
||||||
|
const [showBg, setShowBg] = useState(true)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [activeColorPicker, setActiveColorPicker] = useState<'accent' | 'text' | null>(null)
|
||||||
|
const [activeCustomField, setActiveCustomField] = useState<string | null>(null)
|
||||||
|
const [showOvPreview, setShowOvPreview] = useState(true)
|
||||||
|
const [showOvStyle, setShowOvStyle] = useState(true)
|
||||||
|
const [showOvPalette, setShowOvPalette] = useState(true)
|
||||||
|
const [showOvColors, setShowOvColors] = useState(true)
|
||||||
|
const [showOvFont, setShowOvFont] = useState(true)
|
||||||
|
const [showOvLayout, setShowOvLayout] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) router.replace('/login')
|
||||||
|
}, [user, router])
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const overlayUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/overlay/${user.id}`
|
||||||
|
|
||||||
|
const copyUrl = () => {
|
||||||
|
navigator.clipboard.writeText(overlayUrl).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeFxMode = bgMode !== 'none' ? bgMode as keyof FxConfigs : null
|
||||||
|
const fxSliders = activeFxMode ? FX_SLIDERS[activeFxMode] ?? null : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-app mx-auto relative z-10">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="animate-fadeUp">
|
||||||
|
<h2 className="font-display text-xl font-extrabold tracking-tight text-app-text mb-4">Настройки</h2>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 bg-surface2 rounded-[10px] p-1 mb-4">
|
||||||
|
{([['appearance', 'Внешний вид'], ['overlay', 'Оверлей']] as [SettingsTab, string][]).map(([id, label]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setTab(id)}
|
||||||
|
className="flex-1 py-2 text-[12px] font-display font-bold rounded-[8px] transition-all cursor-pointer"
|
||||||
|
style={tab === id
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)' }
|
||||||
|
: { color: '#555' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Appearance tab ─────────────────────────────────────────────── */}
|
||||||
|
{tab === 'appearance' && (
|
||||||
|
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3">
|
||||||
|
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
|
||||||
|
Внешний вид
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── Accent color (collapsible) ─────────────────────────────── */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAccent(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px]">Акцентный цвет</span>
|
||||||
|
<Chevron open={showAccent} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAccent && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{ACCENT_PRESETS.map((preset, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setAccent(i)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
||||||
|
style={accentIdx === i
|
||||||
|
? { background: `rgba(${preset.rgb},0.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
|
||||||
|
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style={{ background: preset.accent, boxShadow: accentIdx === i ? `0 0 7px ${preset.accent}90` : 'none' }}/>
|
||||||
|
{preset.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setCustom(customHex)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
||||||
|
style={accentIdx === -1
|
||||||
|
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
|
||||||
|
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
|
||||||
|
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
|
||||||
|
}}/>
|
||||||
|
Свой
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accentIdx === -1 && (
|
||||||
|
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||||
|
<ColorWheel value={customHex} onChange={setCustom} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Live background (collapsible) ─────────────────────────── */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBg(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px]">Живой фон</span>
|
||||||
|
<Chevron open={showBg} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showBg && (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{BG_PRESETS.map((preset) => {
|
||||||
|
const active = bgMode === preset.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => setBg(preset.id)}
|
||||||
|
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
|
||||||
|
style={active
|
||||||
|
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
|
||||||
|
: { borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="w-full aspect-video overflow-hidden">
|
||||||
|
{BG_PREVIEWS[preset.id]}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2" style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}>
|
||||||
|
<p className="text-[12px] font-display font-bold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
||||||
|
{preset.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted mt-0.5">{preset.desc}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-effect config panel */}
|
||||||
|
{activeFxMode && fxSliders && (
|
||||||
|
<div className="mt-4 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
|
||||||
|
Настройки эффекта
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => resetFx(activeFxMode)}
|
||||||
|
className="text-[10px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{fxSliders.map(({ key, label, min, max, step, fmt }) => {
|
||||||
|
const val = (fxConfigs[activeFxMode] as unknown as Record<string, number>)[key] ?? (DEFAULT_FX[activeFxMode] as unknown as Record<string, number>)[key]
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-[12px] text-app-text/70">{label}</span>
|
||||||
|
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>
|
||||||
|
{fmt(val)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min} max={max} step={step}
|
||||||
|
value={val}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onChange={(e) => setFxConfig(activeFxMode, { [key]: Number(e.target.value) } as any)}
|
||||||
|
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
|
||||||
|
style={{ accentColor: 'var(--accent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Overlay tab ────────────────────────────────────────────────── */}
|
||||||
|
{tab === 'overlay' && (
|
||||||
|
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3 flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* ── Header: enable toggle + URL ──────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setOverlayEnabled(!overlayEnabled)}
|
||||||
|
className="relative w-11 h-6 rounded-full transition-all duration-200 cursor-pointer shrink-0"
|
||||||
|
style={{ background: overlayEnabled ? 'var(--accent)' : 'rgba(255,255,255,0.1)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all duration-200"
|
||||||
|
style={{ left: overlayEnabled ? 'calc(100% - 22px)' : '2px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[13px] font-medium text-app-text">Оверлей для стрима</p>
|
||||||
|
<p className="text-[10px] font-mono text-muted/50 truncate mt-0.5">{overlayUrl}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={copyUrl}
|
||||||
|
title="Скопировать URL"
|
||||||
|
className="shrink-0 px-3 py-1.5 rounded-[8px] text-[11px] font-display font-bold cursor-pointer transition-all border"
|
||||||
|
style={copied
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.3)' }
|
||||||
|
: { background: 'transparent', color: '#666', borderColor: 'rgba(255,255,255,0.08)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied ? '✓' : 'URL'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Live preview ─────────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOvPreview(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px]">Превью</span>
|
||||||
|
<Chevron open={showOvPreview} />
|
||||||
|
</button>
|
||||||
|
{showOvPreview && <OverlayPreview />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Style picker ─────────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOvStyle(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px]">Стиль</span>
|
||||||
|
<Chevron open={showOvStyle} />
|
||||||
|
</button>
|
||||||
|
{showOvStyle && <div className="grid grid-cols-3 gap-2">
|
||||||
|
{(Object.entries(OVERLAY_STYLES) as [OverlayStyle, typeof OVERLAY_STYLES[OverlayStyle]][]).map(([id, { name, desc }]) => {
|
||||||
|
const active = overlayStyle === id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => { setOverlayStyle(id); setOverlayPalette('default'); setActiveCustomField(null) }}
|
||||||
|
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
|
||||||
|
style={active
|
||||||
|
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
|
||||||
|
: { borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="w-full aspect-video bg-black overflow-hidden">
|
||||||
|
<OverlayStylePreview style={id} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="px-2 py-1.5"
|
||||||
|
style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<p className="text-[11px] font-display font-bold truncate" style={{ color: active ? 'var(--accent)' : '#bbb' }}>{name}</p>
|
||||||
|
<p className="text-[9px] text-muted mt-0.5 truncate">{desc}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Palette picker ───────────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOvPalette(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px]">Палитра</span>
|
||||||
|
<Chevron open={showOvPalette} />
|
||||||
|
</button>
|
||||||
|
{showOvPalette && <>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||||
|
{getPalettes(overlayStyle).map((pal) => {
|
||||||
|
const active = overlayPalette === pal.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pal.id}
|
||||||
|
onClick={() => { setOverlayPalette(pal.id); setActiveCustomField(null) }}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer text-left"
|
||||||
|
style={active
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{pal.swatches.slice(0, 3).map((color, i) => (
|
||||||
|
<span key={i} className="w-3.5 h-3.5 rounded-full border border-white/10"
|
||||||
|
style={{ background: color.startsWith('var(') ? 'var(--accent)' : color }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px] font-display font-semibold truncate" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
||||||
|
{pal.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Custom palette button */}
|
||||||
|
{(() => {
|
||||||
|
const active = overlayPalette === 'custom'
|
||||||
|
const cp = customPalettes[overlayStyle] ?? {}
|
||||||
|
const fields = STYLE_CUSTOM_FIELDS[overlayStyle] ?? []
|
||||||
|
const swatchColors = fields.slice(0, 3).map(f => cp[f.key] ?? f.default)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOverlayPalette('custom')
|
||||||
|
if (!activeCustomField) setActiveCustomField(fields[0]?.key ?? null)
|
||||||
|
}}
|
||||||
|
className="col-span-2 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer text-left"
|
||||||
|
style={active
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
{swatchColors.map((color, i) => (
|
||||||
|
<span key={i} className="w-3.5 h-3.5 rounded-full border border-white/20" style={{ background: color }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px] font-display font-semibold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
||||||
|
Свой
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[11px]" style={{ color: active ? 'var(--accent)' : '#555' }}>✏️</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom palette editor */}
|
||||||
|
{overlayPalette === 'custom' && (() => {
|
||||||
|
const fields = STYLE_CUSTOM_FIELDS[overlayStyle] ?? []
|
||||||
|
const cp = customPalettes[overlayStyle] ?? {}
|
||||||
|
const currentField = activeCustomField ?? fields[0]?.key ?? null
|
||||||
|
const currentValue = currentField
|
||||||
|
? (cp[currentField] ?? (fields.find(f => f.key === currentField)?.default ?? '#ffffff'))
|
||||||
|
: '#ffffff'
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||||
|
<div className="flex gap-1.5 flex-wrap mb-4">
|
||||||
|
{fields.map((f) => {
|
||||||
|
const val = cp[f.key] ?? f.default
|
||||||
|
const isActive = currentField === f.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
onClick={() => setActiveCustomField(f.key)}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[7px] border text-[11px] font-display font-semibold transition-all cursor-pointer"
|
||||||
|
style={isActive
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.4)', color: 'var(--accent)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.03)', borderColor: 'rgba(255,255,255,0.07)', color: '#888' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="w-3 h-3 rounded-full border border-white/15 shrink-0" style={{ background: val }} />
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{currentField && (
|
||||||
|
<ColorWheel
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(hex) => setCustomPaletteField(overlayStyle, currentField, hex)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Colors: accent + text ─────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOvColors(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px]">Цвета</span>
|
||||||
|
<Chevron open={showOvColors} />
|
||||||
|
</button>
|
||||||
|
{showOvColors && <>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
{/* Accent color */}
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveColorPicker(p => p === 'accent' ? null : 'accent')}
|
||||||
|
className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer"
|
||||||
|
style={activeColorPicker === 'accent'
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="w-4 h-4 rounded-full border border-white/20 shrink-0" style={{ background: overlayAccentColor }} />
|
||||||
|
<span className="text-[12px] font-display font-semibold" style={{ color: activeColorPicker === 'accent' ? 'var(--accent)' : '#bbb' }}>
|
||||||
|
Акцент
|
||||||
|
</span>
|
||||||
|
<Chevron open={activeColorPicker === 'accent'} />
|
||||||
|
</button>
|
||||||
|
{/* Text color */}
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveColorPicker(p => p === 'text' ? null : 'text')}
|
||||||
|
className="flex-1 flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border transition-all duration-150 cursor-pointer"
|
||||||
|
style={activeColorPicker === 'text'
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{overlayTextColor
|
||||||
|
? <span className="w-4 h-4 rounded-full border border-white/20 shrink-0" style={{ background: overlayTextColor }} />
|
||||||
|
: <span className="w-4 h-4 rounded-full border border-white/15 shrink-0 flex items-center justify-center" style={{ background: 'rgba(255,255,255,0.06)' }}>
|
||||||
|
<span className="text-[7px] text-muted">A</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span className="text-[12px] font-display font-semibold" style={{ color: activeColorPicker === 'text' ? 'var(--accent)' : '#bbb' }}>
|
||||||
|
Текст
|
||||||
|
</span>
|
||||||
|
{!overlayTextColor && <span className="ml-auto text-[10px] text-muted/50 font-mono">авто</span>}
|
||||||
|
<Chevron open={activeColorPicker === 'text'} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeColorPicker === 'accent' && (
|
||||||
|
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||||
|
<ColorWheel value={overlayAccentColor} onChange={setOverlayAccentColor} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeColorPicker === 'text' && (
|
||||||
|
<div className="p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
||||||
|
<ColorWheel value={overlayTextColor || '#ffffff'} onChange={setOverlayTextColor} />
|
||||||
|
{overlayTextColor && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOverlayTextColor('')}
|
||||||
|
className="mt-3 text-[11px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Авто (по стилю)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Font: horizontal chips ───────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOvFont(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px]">Шрифт</span>
|
||||||
|
<Chevron open={showOvFont} />
|
||||||
|
</button>
|
||||||
|
{showOvFont && <div className="flex gap-1.5 overflow-x-auto pb-0.5 -mx-1 px-1">
|
||||||
|
{OVERLAY_FONTS.map(({ id, name, css }) => {
|
||||||
|
const active = overlayFont === id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setOverlayFont(id)}
|
||||||
|
className="flex-none flex flex-col items-center gap-1 px-3 pt-2 pb-2 rounded-[10px] border transition-all duration-150 cursor-pointer min-w-[70px]"
|
||||||
|
style={active
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.08)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.02)', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-[15px] leading-none"
|
||||||
|
style={{ fontFamily: css, color: active ? 'var(--accent)' : '#aaa', fontWeight: active ? 700 : 400 }}
|
||||||
|
>
|
||||||
|
Ag
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-display font-semibold truncate max-w-[64px]"
|
||||||
|
style={{ color: active ? 'var(--accent)' : '#555' }}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Layout: position + cover/eq ──────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOvLayout(v => !v)}
|
||||||
|
className="flex items-center justify-between w-full mb-2.5 text-muted hover:text-app-text/70 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-[12px]">Макет</span>
|
||||||
|
<Chevron open={showOvLayout} />
|
||||||
|
</button>
|
||||||
|
{showOvLayout && <div className="flex items-start gap-6">
|
||||||
|
{/* Position grid */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted mb-2">Позиция</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1 w-[72px]">
|
||||||
|
{OVERLAY_POSITIONS.map(({ id, arrow, label }) => {
|
||||||
|
const active = overlayPosition === id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
title={label}
|
||||||
|
onClick={() => setOverlayPosition(id)}
|
||||||
|
className="flex items-center justify-center h-8 rounded-[7px] text-base transition-all duration-150 cursor-pointer border"
|
||||||
|
style={active
|
||||||
|
? { background: 'rgba(var(--accent-rgb),0.12)', color: 'var(--accent)', borderColor: 'rgba(var(--accent-rgb),0.35)' }
|
||||||
|
: { background: 'rgba(255,255,255,0.03)', color: '#555', borderColor: 'rgba(255,255,255,0.07)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{arrow}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles */}
|
||||||
|
<div className="flex-1 flex flex-col gap-3 pt-[22px]">
|
||||||
|
{([
|
||||||
|
{ label: 'Обложка', val: overlayShowCover, set: setOverlayShowCover },
|
||||||
|
{ label: 'EQ-анимация', val: overlayShowEq, set: setOverlayShowEq },
|
||||||
|
] as const).map(({ label, val, set }) => (
|
||||||
|
<div key={label} className="flex items-center justify-between">
|
||||||
|
<span className="text-[12px] text-app-text/70">{label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => set(!val)}
|
||||||
|
className="relative w-10 h-5 rounded-full transition-all duration-200 cursor-pointer shrink-0"
|
||||||
|
style={{ background: val ? 'var(--accent)' : 'rgba(255,255,255,0.1)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all duration-200"
|
||||||
|
style={{ left: val ? 'calc(100% - 18px)' : '2px' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{/* Sliders: margin / scale / opacity */}
|
||||||
|
{showOvLayout && (
|
||||||
|
<div className="flex flex-col gap-4 mt-4">
|
||||||
|
{([
|
||||||
|
{ label: 'Отступ', value: overlayMargin, set: setOverlayMargin, min: 8, max: 64, step: 2, fmt: (v: number) => `${v}px` },
|
||||||
|
{ label: 'Масштаб', value: overlayScale, set: setOverlayScale, min: 0.5, max: 2, step: 0.05, fmt: (v: number) => `${v.toFixed(2)}×` },
|
||||||
|
{ label: 'Прозрачность', value: overlayOpacity, set: setOverlayOpacity, min: 0.1, max: 1, step: 0.05, fmt: (v: number) => `${Math.round(v * 100)}%` },
|
||||||
|
] as const).map(({ label, value, set, min, max, step, fmt }) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-[12px] text-app-text/70">{label}</span>
|
||||||
|
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>{fmt(value)}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min} max={max} step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => set(Number(e.target.value))}
|
||||||
|
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
|
||||||
|
style={{ accentColor: 'var(--accent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Note ─────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-muted/50">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
В OBS: источник «Браузер» → вставь URL. CSS: <code className="font-mono">body {'{ background: transparent !important; }'}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { usePartyStore } from '@/store/partyStore'
|
|
||||||
import { getPublicPlaylists } from '@/lib/authApi'
|
|
||||||
import type { PublicPlaylist, PlaylistTrack } from '@/types'
|
|
||||||
import Header from '@/components/Header'
|
|
||||||
|
|
||||||
const TAG_PALETTE = ['var(--accent)', '#ff6b9d', '#6bcdff', '#ffb86b', '#b86bff', '#6bffb8']
|
|
||||||
|
|
||||||
function tagColor(tag: string): string {
|
|
||||||
let h = 0
|
|
||||||
for (let i = 0; i < tag.length; i++) h = tag.charCodeAt(i) + ((h << 5) - h)
|
|
||||||
return TAG_PALETTE[Math.abs(h) % TAG_PALETTE.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrackList({ tracks }: { tracks: PlaylistTrack[] }) {
|
|
||||||
return (
|
|
||||||
<div className="border-t border-white/[0.05] px-4 pt-3 pb-3.5">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
{tracks.map((track, i) => (
|
|
||||||
<div key={track.id} className="flex items-center gap-2.5 py-[3px] group">
|
|
||||||
<span className="text-[11px] text-muted/40 font-mono w-4 shrink-0 text-right select-none">{i + 1}</span>
|
|
||||||
<span className="text-[12px] text-app-text/75 group-hover:text-app-text truncate transition-colors duration-100">{track.title}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlaylistCard({
|
|
||||||
pl,
|
|
||||||
onPlay,
|
|
||||||
isLaunched,
|
|
||||||
}: {
|
|
||||||
pl: PublicPlaylist
|
|
||||||
onPlay: () => void
|
|
||||||
isLaunched: boolean
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const tags = pl.tags ?? []
|
|
||||||
const trackCount = pl.tracks?.length ?? 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden hover:border-white/[0.13] transition-all duration-200">
|
|
||||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
|
||||||
<div
|
|
||||||
className="w-9 h-9 rounded-[10px] shrink-0 flex items-center justify-center font-display font-extrabold text-[15px] select-none"
|
|
||||||
style={{ background: 'rgba(var(--accent-rgb),0.1)', color: 'var(--accent)' }}
|
|
||||||
>
|
|
||||||
{pl.username[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-display text-[14px] font-bold text-app-text truncate leading-tight">{pl.name}</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
|
||||||
<span className="text-[11px] font-medium text-accent">{pl.username}</span>
|
|
||||||
<span className="text-muted/30 text-[11px]">·</span>
|
|
||||||
<span className="text-[11px] text-muted">{trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'}</span>
|
|
||||||
{tags.map(tag => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="text-[10px] font-display font-bold px-1.5 py-px rounded-md leading-none"
|
|
||||||
style={{ background: `${tagColor(tag)}18`, color: tagColor(tag) }}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
{trackCount > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(v => !v)}
|
|
||||||
className="w-7 h-7 rounded-[8px] flex items-center justify-center text-muted hover:text-app-text hover:bg-white/[0.05] transition-all duration-150 cursor-pointer"
|
|
||||||
title={expanded ? 'Скрыть треки' : 'Показать треки'}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="11" height="11" viewBox="0 0 12 12" fill="none"
|
|
||||||
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}
|
|
||||||
>
|
|
||||||
<path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onPlay}
|
|
||||||
disabled={!trackCount}
|
|
||||||
className="text-[12px] font-display font-bold px-3 py-1.5 rounded-[9px] transition-all duration-200 cursor-pointer whitespace-nowrap shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
style={{
|
|
||||||
background: isLaunched ? 'var(--accent)' : 'rgba(var(--accent-rgb),0.12)',
|
|
||||||
color: isLaunched ? '#0a0a0f' : 'var(--accent)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLaunched ? '▶ Играет' : '▶ Play'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && pl.tracks && pl.tracks.length > 0 && (
|
|
||||||
<TrackList tracks={pl.tracks} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CommunityPage() {
|
|
||||||
const [playlists, setPlaylists] = useState<PublicPlaylist[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [launched, setLaunched] = useState<string | null>(null)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
|
||||||
const { loadPlaylist } = usePartyStore()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getPublicPlaylists()
|
|
||||||
.then(setPlaylists)
|
|
||||||
.catch(() => setPlaylists([]))
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handlePlay = (pl: PublicPlaylist) => {
|
|
||||||
const tracks = pl.tracks?.map(t => t.title) ?? []
|
|
||||||
if (!tracks.length) return
|
|
||||||
loadPlaylist(tracks)
|
|
||||||
setLaunched(pl.id)
|
|
||||||
setTimeout(() => setLaunched(null), 2500)
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? [])))
|
|
||||||
|
|
||||||
const filtered = playlists.filter(pl => {
|
|
||||||
const q = search.toLowerCase().trim()
|
|
||||||
const matchesSearch = !q
|
|
||||||
|| pl.name.toLowerCase().includes(q)
|
|
||||||
|| pl.username.toLowerCase().includes(q)
|
|
||||||
|| (pl.tags ?? []).some(t => t.toLowerCase().includes(q))
|
|
||||||
const matchesTag = !activeTag || (pl.tags ?? []).includes(activeTag)
|
|
||||||
return matchesSearch && matchesTag
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="max-w-app mx-auto">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<div className="mb-5">
|
|
||||||
<div className="flex items-baseline gap-2.5">
|
|
||||||
<h2 className="font-display text-xl font-extrabold tracking-tight">Сообщество</h2>
|
|
||||||
{!loading && (
|
|
||||||
<span className="text-[12px] text-muted font-sans">{playlists.length} плейлистов</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-[12px] text-muted mt-0.5">Публичные плейлисты пользователей</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!loading && playlists.length > 0 && (
|
|
||||||
<div className="mb-4 flex flex-col gap-2.5">
|
|
||||||
<div className="relative">
|
|
||||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" width="13" height="13" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="Поиск по названию, автору или тегу..."
|
|
||||||
className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors"
|
|
||||||
/>
|
|
||||||
{search && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearch('')}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-app-text transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allTags.length > 0 && (
|
|
||||||
<div className="flex gap-1.5 flex-wrap">
|
|
||||||
{allTags.map(tag => (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
onClick={() => setActiveTag(activeTag === tag ? null : tag)}
|
|
||||||
className="text-[11px] font-display font-bold px-2.5 py-1 rounded-lg transition-all duration-150 cursor-pointer"
|
|
||||||
style={activeTag === tag
|
|
||||||
? { background: tagColor(tag), color: '#0a0a0f' }
|
|
||||||
: { background: `${tagColor(tag)}15`, color: tagColor(tag), border: `1px solid ${tagColor(tag)}35` }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-14 text-muted text-sm gap-2.5">
|
|
||||||
<div className="w-4 h-4 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
) : !filtered.length ? (
|
|
||||||
<div className="text-center py-14 text-muted">
|
|
||||||
<div className="text-4xl mb-3 opacity-20">🎵</div>
|
|
||||||
<p className="text-[13px] font-medium">
|
|
||||||
{playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'}
|
|
||||||
</p>
|
|
||||||
<p className="text-[12px] mt-1.5 opacity-50">
|
|
||||||
{playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
{filtered.map(pl => (
|
|
||||||
<PlaylistCard
|
|
||||||
key={pl.id}
|
|
||||||
pl={pl}
|
|
||||||
onPlay={() => handlePlay(pl)}
|
|
||||||
isLaunched={launched === pl.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--border: rgba(255, 255, 255, 0.07);
|
--border: rgba(255, 255, 255, 0.07);
|
||||||
--accent: #c8ff00;
|
--accent: #de9cfe;
|
||||||
--accent-rgb: 200,255,0;
|
--accent-rgb: 222,156,254;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import { Syne, DM_Sans } from 'next/font/google'
|
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'
|
import './globals.css'
|
||||||
|
|
||||||
const syne = Syne({
|
const syne = Syne({
|
||||||
@@ -29,17 +25,16 @@ export const viewport: Viewport = {
|
|||||||
maximumScale: 1,
|
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 }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru" className={`${syne.variable} ${dmSans.variable}`}>
|
<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">
|
<head>
|
||||||
<ThemeApplier />
|
<script dangerouslySetInnerHTML={{ __html: accentInitScript }} />
|
||||||
<AudioBackground />
|
</head>
|
||||||
<div className="relative" style={{ zIndex: 1 }}>
|
<body className="font-sans text-app-text">
|
||||||
<AuthHydrator />
|
{children}
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<GlobalPlayer />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
207
apps/web/src/app/overlay/[token]/page.tsx
Normal file
207
apps/web/src/app/overlay/[token]/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { use, useEffect, useRef, useState } from 'react'
|
||||||
|
import { getPalette, buildCustomPalette } from '@/lib/overlayPalettes'
|
||||||
|
import { OVERLAY_STYLE_MAP, useLocalProgress, type OverlayWidgetState } from '@/components/OverlayWidget'
|
||||||
|
import type { OverlayStyle } from '@/store/overlayStore'
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||||
|
const GFONTS = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;700&family=Unbounded:wght@700;800&family=Playfair+Display:ital,wght@0,700;1,700&display=swap'
|
||||||
|
|
||||||
|
type OverlayState = OverlayWidgetState
|
||||||
|
|
||||||
|
const ORIGINS: Record<string, string> = {
|
||||||
|
bl: 'bottom left', br: 'bottom right', tl: 'top left', tr: 'top right',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function OverlayPage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = use(params)
|
||||||
|
const [display, setDisplay] = useState<OverlayState | null>(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const prevTitle = useRef('')
|
||||||
|
|
||||||
|
// Transparent background
|
||||||
|
useEffect(() => {
|
||||||
|
const html = document.documentElement
|
||||||
|
const body = document.body
|
||||||
|
html.style.cssText = 'background:transparent!important;height:100%;'
|
||||||
|
body.style.cssText = 'background:transparent!important;min-height:0;height:100%;padding:0;margin:0;overflow:hidden;'
|
||||||
|
return () => { html.style.cssText = ''; body.style.cssText = '' }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load Google Fonts
|
||||||
|
useEffect(() => {
|
||||||
|
const link = document.createElement('link')
|
||||||
|
link.rel = 'stylesheet'
|
||||||
|
link.href = GFONTS
|
||||||
|
document.head.appendChild(link)
|
||||||
|
return () => { if (document.head.contains(link)) document.head.removeChild(link) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Apply accent color
|
||||||
|
useEffect(() => {
|
||||||
|
const hex = display?.accent_color
|
||||||
|
if (!hex || !/^#[0-9a-fA-F]{6}$/.test(hex)) return
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
document.documentElement.style.setProperty('--accent', hex)
|
||||||
|
document.documentElement.style.setProperty('--accent-rgb', `${r},${g},${b}`)
|
||||||
|
}, [display?.accent_color])
|
||||||
|
|
||||||
|
// Apply position + scale + opacity via CSS vars
|
||||||
|
useEffect(() => {
|
||||||
|
const pos = display?.position || 'bl'
|
||||||
|
const margin = display?.margin ?? 24
|
||||||
|
const scale = display?.scale ?? 1
|
||||||
|
const opacity = display?.opacity ?? 1
|
||||||
|
const isT = pos[0] === 't', isR = pos[1] === 'r'
|
||||||
|
const h = document.documentElement
|
||||||
|
const m = `${margin}px`
|
||||||
|
h.style.setProperty('--ov-b', isT ? 'auto' : m)
|
||||||
|
h.style.setProperty('--ov-t', isT ? m : 'auto')
|
||||||
|
h.style.setProperty('--ov-l', isR ? 'auto' : m)
|
||||||
|
h.style.setProperty('--ov-r', isR ? m : 'auto')
|
||||||
|
h.style.setProperty('--ov-scale', String(scale))
|
||||||
|
h.style.setProperty('--ov-origin', ORIGINS[pos] ?? 'bottom left')
|
||||||
|
h.style.opacity = String(opacity)
|
||||||
|
}, [display?.position, display?.margin, display?.scale, display?.opacity])
|
||||||
|
|
||||||
|
// Apply font to body
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.fontFamily = display?.font || ''
|
||||||
|
}, [display?.font])
|
||||||
|
|
||||||
|
// Track-change animation: briefly fade out on new track
|
||||||
|
useEffect(() => {
|
||||||
|
if (!display?.title) return
|
||||||
|
if (display.title === prevTitle.current) return
|
||||||
|
prevTitle.current = display.title
|
||||||
|
const html = document.documentElement
|
||||||
|
html.style.transition = 'opacity 0.18s'
|
||||||
|
html.style.opacity = '0'
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
html.style.opacity = String(display.opacity ?? 1)
|
||||||
|
}, 180)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [display?.title, display?.opacity])
|
||||||
|
|
||||||
|
// SSE connection with polling fallback
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
const clearHide = () => {
|
||||||
|
if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null }
|
||||||
|
}
|
||||||
|
const scheduleHide = (ms: number) => {
|
||||||
|
if (hideTimer.current) return
|
||||||
|
hideTimer.current = setTimeout(() => {
|
||||||
|
if (active) setDisplay(null)
|
||||||
|
hideTimer.current = null
|
||||||
|
}, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyState = (data: OverlayState) => {
|
||||||
|
if (!active) return
|
||||||
|
setNotFound(false)
|
||||||
|
if (!data.enabled) { scheduleHide(500); return }
|
||||||
|
clearHide()
|
||||||
|
setDisplay(prev => {
|
||||||
|
const title = data.title || prev?.title || ''
|
||||||
|
if (!title) return null
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
title,
|
||||||
|
artist: data.title ? data.artist : (prev?.artist ?? ''),
|
||||||
|
cover: data.title ? data.cover : (prev?.cover ?? ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try SSE first
|
||||||
|
let es: EventSource | null = null
|
||||||
|
let pollIv: ReturnType<typeof setInterval> | null = null
|
||||||
|
let sseOk = false
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollIv) return
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/overlay/${token}/state`)
|
||||||
|
if (res.status === 404) { setNotFound(true); return }
|
||||||
|
if (!res.ok) return
|
||||||
|
const data: OverlayState = await res.json()
|
||||||
|
applyState(data)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
pollIv = setInterval(poll, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
es = new EventSource(`${API_URL}/api/overlay/${token}/stream`)
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
sseOk = true
|
||||||
|
const data: OverlayState = JSON.parse(e.data)
|
||||||
|
applyState(data)
|
||||||
|
}
|
||||||
|
es.addEventListener('notfound', () => { setNotFound(true) })
|
||||||
|
es.onerror = () => {
|
||||||
|
if (!sseOk) {
|
||||||
|
// SSE failed before first message → fall back to polling
|
||||||
|
es?.close()
|
||||||
|
es = null
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
es?.close()
|
||||||
|
if (pollIv) clearInterval(pollIv)
|
||||||
|
clearHide()
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
if (notFound) return (
|
||||||
|
<div style={{ position: 'fixed', bottom: 20, left: 20, color: 'rgba(255,255,255,0.25)', fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
|
overlay not found
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
if (!display) return null
|
||||||
|
|
||||||
|
const style = (display.style ?? 'classic') as OverlayStyle
|
||||||
|
const render = OVERLAY_STYLE_MAP[style] ?? OVERLAY_STYLE_MAP.classic
|
||||||
|
const palId = display.palette ?? 'default'
|
||||||
|
const pal = palId === 'custom'
|
||||||
|
? buildCustomPalette(style, {
|
||||||
|
bg: display.custom_bg,
|
||||||
|
text: display.custom_text,
|
||||||
|
text2: display.custom_text2,
|
||||||
|
chroma: display.custom_chroma,
|
||||||
|
titleBg: display.custom_title_bg,
|
||||||
|
bodyBg: display.custom_body_bg,
|
||||||
|
})
|
||||||
|
: getPalette(style, palId)
|
||||||
|
|
||||||
|
return <OverlayRenderer display={display} render={render} pal={pal} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate component so hooks (useLocalProgress) run after display is confirmed non-null
|
||||||
|
function OverlayRenderer({
|
||||||
|
display, render, pal,
|
||||||
|
}: {
|
||||||
|
display: OverlayState
|
||||||
|
render: (s: OverlayWidgetState, pal: ReturnType<typeof getPalette>) => React.ReactNode
|
||||||
|
pal: ReturnType<typeof getPalette>
|
||||||
|
}) {
|
||||||
|
const progress = useLocalProgress(display.progress, display.duration, display.is_playing, display.updated_at)
|
||||||
|
const state = { ...display, progress }
|
||||||
|
return <>{render(state, pal)}</>
|
||||||
|
}
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
const EQ_BARS = [
|
|
||||||
{ h: 35, d: 0 },
|
|
||||||
{ h: 72, d: 0.12 },
|
|
||||||
{ h: 50, d: 0.23 },
|
|
||||||
{ h: 90, d: 0.06 },
|
|
||||||
{ h: 55, d: 0.17 },
|
|
||||||
{ h: 82, d: 0.28 },
|
|
||||||
{ h: 44, d: 0.09 },
|
|
||||||
{ h: 96, d: 0.20 },
|
|
||||||
{ h: 60, d: 0.14 },
|
|
||||||
{ h: 76, d: 0.03 },
|
|
||||||
{ h: 40, d: 0.25 },
|
|
||||||
{ h: 85, d: 0.18 },
|
|
||||||
{ h: 52, d: 0.07 },
|
|
||||||
{ h: 67, d: 0.30 },
|
|
||||||
{ h: 30, d: 0.11 },
|
|
||||||
{ h: 78, d: 0.22 },
|
|
||||||
{ h: 48, d: 0.16 },
|
|
||||||
{ h: 88, d: 0.04 },
|
|
||||||
{ h: 62, d: 0.26 },
|
|
||||||
{ h: 38, d: 0.19 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const FEATURES = [
|
|
||||||
{
|
|
||||||
icon: '🎵',
|
|
||||||
title: 'Совместная очередь',
|
|
||||||
desc: 'Каждый гость добавляет свои треки — никто не обделён эфиром',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🎲',
|
|
||||||
title: 'Умный шаффл',
|
|
||||||
desc: 'По очереди или случайно — два режима миксовки плейлиста',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '🔍',
|
|
||||||
title: 'Поиск версий',
|
|
||||||
desc: 'Автоматически находит нужную версию трека',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '📋',
|
|
||||||
title: 'Плейлисты',
|
|
||||||
desc: 'Сохраняй сеты для разных компаний и запускай одним кликом',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function LandingPage() {
|
|
||||||
return (
|
|
||||||
<div className="max-w-app mx-auto min-h-[calc(100vh-40px)] flex flex-col">
|
|
||||||
|
|
||||||
{/* ── Nav ── */}
|
|
||||||
<nav className="flex items-center justify-between py-3 mb-2">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="w-8 h-8 rounded-[9px] bg-accent flex items-center justify-center shrink-0">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
|
|
||||||
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span className="font-display font-extrabold text-lg tracking-tight">
|
|
||||||
Party<span className="text-accent">Mix</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="text-[13px] font-sans text-muted hover:text-app-text transition-colors duration-150 px-2 py-1"
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/register"
|
|
||||||
className="text-[13px] font-display font-semibold px-4 py-1.5 bg-surface border border-white/[0.07] rounded-xl text-app-text hover:border-white/20 hover:bg-surface2 transition-all duration-150"
|
|
||||||
>
|
|
||||||
Регистрация
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* ── Hero ── */}
|
|
||||||
<section className="flex-1 flex flex-col items-center justify-center text-center py-12 relative">
|
|
||||||
|
|
||||||
{/* Ambient glow behind EQ */}
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[200px] pointer-events-none" aria-hidden="true">
|
|
||||||
<div className="absolute inset-0 bg-accent/[0.04] rounded-full blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* EQ visualization */}
|
|
||||||
<div className="relative flex items-end gap-[4px] mb-8 h-20" aria-hidden="true">
|
|
||||||
{EQ_BARS.map(({ h, d }, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="eq-bar"
|
|
||||||
style={{
|
|
||||||
height: `${h}%`,
|
|
||||||
animationDelay: `${d}s`,
|
|
||||||
animationDuration: `${0.6 + d * 0.8}s`,
|
|
||||||
width: '3px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<h1 className="font-display font-extrabold leading-none tracking-tight mb-5 text-[72px] sm:text-[104px]">
|
|
||||||
Party<span className="text-accent">Mix</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Tagline */}
|
|
||||||
<p className="font-sans text-base sm:text-lg text-muted max-w-[320px] mb-10 leading-relaxed">
|
|
||||||
Совместные плейлисты для вечеринок.
|
|
||||||
<br />
|
|
||||||
Каждый гость — часть музыки.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* CTA buttons */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap justify-center">
|
|
||||||
<Link
|
|
||||||
href="/app"
|
|
||||||
className="px-8 py-3 bg-accent text-bg font-display font-bold text-sm rounded-xl hover:brightness-110 active:scale-[0.97] transition-all duration-150 shadow-[0_0_24px_rgba(var(--accent-rgb),0.25)]"
|
|
||||||
>
|
|
||||||
Начать вечеринку →
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="px-8 py-3 bg-surface border border-white/[0.07] text-app-text font-sans text-sm rounded-xl hover:bg-surface2 hover:border-white/20 active:scale-[0.97] transition-all duration-150"
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Divider ── */}
|
|
||||||
<div className="border-t border-white/[0.05] mb-8" />
|
|
||||||
|
|
||||||
{/* ── Features ── */}
|
|
||||||
<section className="pb-10">
|
|
||||||
<p className="text-[11px] font-display font-semibold tracking-[1.5px] uppercase text-muted mb-4">
|
|
||||||
Возможности
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{FEATURES.map(({ icon, title, desc }) => (
|
|
||||||
<div
|
|
||||||
key={title}
|
|
||||||
className="group bg-surface border border-white/[0.07] rounded-app p-4 hover:bg-surface2 hover:border-white/[0.12] transition-all duration-200"
|
|
||||||
>
|
|
||||||
<span className="text-lg mb-2.5 block">{icon}</span>
|
|
||||||
<h3 className="font-display font-bold text-[13px] mb-1 text-app-text">{title}</h3>
|
|
||||||
<p className="text-[12px] text-muted font-sans leading-relaxed">{desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
|
||||||
<footer className="pb-4 text-center">
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,417 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { use, useEffect, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
|
||||||
|
|
||||||
interface RemoteQueueItem {
|
|
||||||
title: string
|
|
||||||
owner: string
|
|
||||||
color_bg: string
|
|
||||||
color_text: string
|
|
||||||
img?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoteVersion {
|
|
||||||
title: string
|
|
||||||
artist: string
|
|
||||||
duration: string
|
|
||||||
img?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RemoteState {
|
|
||||||
title: string
|
|
||||||
artist: string
|
|
||||||
cover: string
|
|
||||||
is_playing: boolean
|
|
||||||
volume: number
|
|
||||||
progress: number
|
|
||||||
duration: number
|
|
||||||
queue_len: number
|
|
||||||
cur_idx: number
|
|
||||||
queue: RemoteQueueItem[]
|
|
||||||
versions: RemoteVersion[]
|
|
||||||
active_version: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(s: number) {
|
|
||||||
if (!s || isNaN(s)) return '0:00'
|
|
||||||
return `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cmd(id: string, c: string, value?: number, text?: string) {
|
|
||||||
await fetch(`${API_URL}/api/remote/${id}/command`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ cmd: c, value: value ?? 0, text }),
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RemotePage({ params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = use(params)
|
|
||||||
const [state, setState] = useState<RemoteState | null>(null)
|
|
||||||
const [notFound, setNotFound] = useState(false)
|
|
||||||
const [volume, setVolume] = useState(1)
|
|
||||||
const [addText, setAddText] = useState('')
|
|
||||||
const [tab, setTab] = useState<'player' | 'queue'>('player')
|
|
||||||
const [versionsOpen, setVersionsOpen] = useState(false)
|
|
||||||
const [savedVersions, setSavedVersions] = useState<Record<string, { title: string; artist: string; duration: string }>>({})
|
|
||||||
const [localProgress, setLocalProgress] = useState(0)
|
|
||||||
const lastPollRef = useRef<{ progress: number; ts: number; playing: boolean }>({ progress: 0, ts: Date.now(), playing: false })
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const s = typeof window !== 'undefined' ? localStorage.getItem('pm_versions') : null
|
|
||||||
if (s) setSavedVersions(JSON.parse(s))
|
|
||||||
} catch {}
|
|
||||||
}, [])
|
|
||||||
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_URL}/api/remote/${id}/state`)
|
|
||||||
if (res.status === 404) { setNotFound(true); return }
|
|
||||||
if (!res.ok) return
|
|
||||||
const data: RemoteState = await res.json()
|
|
||||||
if (active) {
|
|
||||||
setState(data)
|
|
||||||
lastPollRef.current = { progress: data.progress ?? 0, ts: Date.now(), playing: data.is_playing }
|
|
||||||
setLocalProgress(data.progress ?? 0)
|
|
||||||
setVolume(v => Math.abs(v - (data.volume ?? 1)) > 0.05 ? (data.volume ?? 1) : v)
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
poll()
|
|
||||||
const iv = setInterval(poll, 2000)
|
|
||||||
return () => { active = false; clearInterval(iv) }
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
// Local progress interpolation — advance every 250ms when playing
|
|
||||||
useEffect(() => {
|
|
||||||
const iv = setInterval(() => {
|
|
||||||
const { progress, ts, playing } = lastPollRef.current
|
|
||||||
if (playing) setLocalProgress(progress + (Date.now() - ts) / 1000)
|
|
||||||
}, 250)
|
|
||||||
return () => clearInterval(iv)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (notFound) return (
|
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center text-center px-6 gap-3">
|
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="1.5">
|
|
||||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-muted text-[13px]">Сессия не найдена или истекла</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!state) return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="w-5 h-5 rounded-full border-2 border-surface2 border-t-accent animate-spin" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const clampedProgress = Math.min(localProgress, state.duration || localProgress)
|
|
||||||
const progress = state.duration > 0 ? (clampedProgress / state.duration) * 100 : 0
|
|
||||||
const queue = state.queue ?? []
|
|
||||||
const versions = state.versions ?? []
|
|
||||||
const trackTitle = queue[state.cur_idx]?.title ?? ''
|
|
||||||
|
|
||||||
const isSavedLocally = (v: RemoteVersion) => {
|
|
||||||
const s = savedVersions[trackTitle]
|
|
||||||
return !!s && s.title === v.title && s.artist === v.artist && s.duration === v.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveVersion = (i: number) => {
|
|
||||||
const v = versions[i]
|
|
||||||
if (!v || !trackTitle) return
|
|
||||||
const alreadySaved = isSavedLocally(v)
|
|
||||||
const next = { ...savedVersions }
|
|
||||||
if (alreadySaved) delete next[trackTitle]
|
|
||||||
else next[trackTitle] = { title: v.title, artist: v.artist, duration: v.duration }
|
|
||||||
setSavedVersions(next)
|
|
||||||
try { localStorage.setItem('pm_versions', JSON.stringify(next)) } catch {}
|
|
||||||
cmd(id, 'save_version', i)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen flex flex-col max-w-sm mx-auto px-4 pt-5 pb-6">
|
|
||||||
|
|
||||||
{/* Header + tabs */}
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<span className="text-[11px] font-display font-bold tracking-[1.5px] uppercase text-muted">Party Mix · Пульт</span>
|
|
||||||
<div className="flex items-center gap-1 bg-surface2 rounded-[8px] p-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setTab('player')}
|
|
||||||
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer"
|
|
||||||
style={{ background: tab === 'player' ? 'rgba(var(--accent-rgb),0.1)' : 'transparent', color: tab === 'player' ? 'var(--accent)' : '#555' }}
|
|
||||||
>
|
|
||||||
Плеер
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTab('queue')}
|
|
||||||
className="px-3 py-1 text-[11px] font-display font-bold rounded-[6px] transition-all cursor-pointer flex items-center gap-1"
|
|
||||||
style={{ background: tab === 'queue' ? 'rgba(var(--accent-rgb),0.1)' : 'transparent', color: tab === 'queue' ? 'var(--accent)' : '#555' }}
|
|
||||||
>
|
|
||||||
Очередь
|
|
||||||
{queue.length > 0 && <span className="text-[10px] opacity-60">{queue.length}</span>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === 'player' && (
|
|
||||||
<div className="flex flex-col gap-7 items-center flex-1">
|
|
||||||
{/* Cover */}
|
|
||||||
<div className="w-full aspect-square max-w-[220px] rounded-[20px] overflow-hidden bg-surface2 shadow-2xl">
|
|
||||||
{state.cover ? (
|
|
||||||
<img src={state.cover} alt="" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#2a2a2a" strokeWidth="1">
|
|
||||||
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Track info */}
|
|
||||||
<div className="w-full text-center px-2">
|
|
||||||
<div className="font-display text-[18px] font-extrabold tracking-tight truncate leading-tight">
|
|
||||||
{state.title || '—'}
|
|
||||||
</div>
|
|
||||||
{state.artist && (
|
|
||||||
<div className="text-[13px] text-muted mt-1.5 truncate">{state.artist}</div>
|
|
||||||
)}
|
|
||||||
{state.queue_len > 0 && (
|
|
||||||
<div className="text-[11px] text-muted mt-1 font-display">{state.cur_idx + 1} / {state.queue_len}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="w-full">
|
|
||||||
<div
|
|
||||||
className="relative h-1.5 bg-white/[0.07] rounded-full mb-2 cursor-pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const pct = (e.clientX - rect.left) / rect.width
|
|
||||||
const seekTo = pct * state.duration
|
|
||||||
lastPollRef.current = { progress: seekTo, ts: Date.now(), playing: state.is_playing }
|
|
||||||
setLocalProgress(seekTo)
|
|
||||||
cmd(id, 'seek', seekTo)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-[11px] text-muted font-display tabular-nums">
|
|
||||||
<span>{formatTime(clampedProgress)}</span>
|
|
||||||
<span>{formatTime(state.duration)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<button
|
|
||||||
onClick={() => cmd(id, 'prev')}
|
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => cmd(id, state.is_playing ? 'pause' : 'play')}
|
|
||||||
className="w-16 h-16 rounded-full flex items-center justify-center bg-accent text-bg active:scale-95 transition-all cursor-pointer hover:brightness-110"
|
|
||||||
>
|
|
||||||
{state.is_playing ? (
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => cmd(id, 'next')}
|
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center text-muted hover:text-app-text active:scale-95 transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M6 18l8.5-6L6 6v12z"/><rect x="16" y="6" width="2" height="12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Volume */}
|
|
||||||
<div className="w-full flex items-center gap-3">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
|
||||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.02"
|
|
||||||
value={volume}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = parseFloat(e.target.value)
|
|
||||||
setVolume(v)
|
|
||||||
if (volumeDebounce.current) clearTimeout(volumeDebounce.current)
|
|
||||||
volumeDebounce.current = setTimeout(() => cmd(id, 'volume', v), 150)
|
|
||||||
}}
|
|
||||||
className="flex-1 cursor-pointer h-1.5"
|
|
||||||
style={{ accentColor: 'var(--accent)' }}
|
|
||||||
/>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-muted shrink-0">
|
|
||||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
||||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Versions */}
|
|
||||||
{versions.length > 1 && (
|
|
||||||
<div className="w-full">
|
|
||||||
<button
|
|
||||||
onClick={() => setVersionsOpen(v => !v)}
|
|
||||||
className="w-full flex items-center justify-between px-3 py-2 rounded-[9px] bg-surface2 border border-white/[0.07] text-[12px] text-muted hover:text-app-text transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<span className="font-display font-bold tracking-[0.5px]">Версии трека</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[11px] opacity-60">{versions.length}</span>
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className={`transition-transform ${versionsOpen ? 'rotate-180' : ''}`}>
|
|
||||||
<polyline points="6 9 12 15 18 9" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{versionsOpen && (
|
|
||||||
<div className="mt-1 border border-white/[0.07] rounded-[9px] overflow-hidden">
|
|
||||||
{versions.map((v, i) => {
|
|
||||||
const active = i === state.active_version
|
|
||||||
const saved = isSavedLocally(v)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex items-center gap-2.5 px-3 py-2.5 border-b border-white/[0.05] last:border-b-0 transition-colors"
|
|
||||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
|
|
||||||
>
|
|
||||||
<span className="text-[11px] text-muted w-4 text-right shrink-0 font-display">{i + 1}</span>
|
|
||||||
{v.img ? (
|
|
||||||
<img src={v.img} alt="" className="w-8 h-8 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 rounded-[5px] bg-surface2 shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}>
|
|
||||||
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{v.title}</div>
|
|
||||||
<div className="text-[11px] text-muted mt-px">{v.artist}</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] text-muted shrink-0 font-display">{v.duration}</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleSaveVersion(i) }}
|
|
||||||
title={saved ? 'Забыть версию' : 'Запомнить версию'}
|
|
||||||
className="w-7 h-7 rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer"
|
|
||||||
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
|
|
||||||
>
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
|
|
||||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { cmd(id, 'version', i); setVersionsOpen(false) }}
|
|
||||||
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${active ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
|
|
||||||
style={{ width: 26, height: 26 }}
|
|
||||||
>
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill={active ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'queue' && (
|
|
||||||
<div className="flex flex-col gap-3 flex-1">
|
|
||||||
{/* Add track */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={addText}
|
|
||||||
onChange={(e) => setAddText(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && addText.trim()) {
|
|
||||||
cmd(id, 'add', 0, addText.trim())
|
|
||||||
setAddText('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Исполнитель — Название"
|
|
||||||
className="flex-1 min-w-0 text-[13px] bg-surface2 border border-white/[0.07] rounded-[9px] px-3 py-2.5 text-app-text outline-none focus:border-accent/35 placeholder:text-muted transition-colors"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (!addText.trim()) return
|
|
||||||
cmd(id, 'add', 0, addText.trim())
|
|
||||||
setAddText('')
|
|
||||||
}}
|
|
||||||
className="shrink-0 px-3 py-2.5 rounded-[9px] bg-accent text-bg text-[13px] font-display font-bold cursor-pointer hover:brightness-110 active:scale-95 transition-all"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Queue list */}
|
|
||||||
{queue.length === 0 ? (
|
|
||||||
<div className="flex-1 flex items-center justify-center text-[13px] text-muted text-center py-10">
|
|
||||||
Очередь пуста
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{queue.map((item, i) => {
|
|
||||||
const active = i === state.cur_idx
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
onClick={() => cmd(id, 'goto', i)}
|
|
||||||
className="flex items-center gap-2.5 px-1 py-2.5 border-b border-white/[0.05] last:border-b-0 cursor-pointer active:bg-surface2 transition-colors rounded-[6px]"
|
|
||||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
|
|
||||||
>
|
|
||||||
{active ? (
|
|
||||||
<div className="flex items-end gap-[1.5px] w-3.5 h-3.5 shrink-0 ml-0.5">
|
|
||||||
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] text-muted w-5 text-right shrink-0 font-display">{i + 1}</span>
|
|
||||||
)}
|
|
||||||
{item.img ? (
|
|
||||||
<img src={item.img} alt="" className="w-8 h-8 rounded-[6px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
|
||||||
) : (
|
|
||||||
<div className="w-8 h-8 rounded-[6px] bg-surface2 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color_bg, color: item.color_text }}>
|
|
||||||
{item.owner}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); cmd(id, 'remove', i) }}
|
|
||||||
className="w-7 h-7 rounded-full flex items-center justify-center text-muted hover:text-[#ff6b6b] hover:bg-[rgba(255,107,107,0.08)] transition-all cursor-pointer shrink-0"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { useAuthStore } from '@/store/authStore'
|
|
||||||
import { useThemeStore, ACCENT_PRESETS } from '@/store/themeStore'
|
|
||||||
import { useBgStore, BG_PRESETS, DEFAULT_RAYS, type BgMode } from '@/store/bgStore'
|
|
||||||
import { getActiveAccent } from '@/store/themeStore'
|
|
||||||
import Header from '@/components/Header'
|
|
||||||
import ColorWheel from '@/components/ColorWheel'
|
|
||||||
|
|
||||||
// ── Preview SVGs ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function OrbsPreview() {
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="p-o1" cx="12%" cy="6%" r="60%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.65"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="p-o2" cx="90%" cy="96%" r="55%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.55"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="p-o3" cx="50%" cy="50%" r="40%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.35"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
<ellipse cx="14" cy="4" rx="72" ry="72" fill="url(#p-o1)"/>
|
|
||||||
<ellipse cx="108" cy="65" rx="58" ry="58" fill="url(#p-o2)"/>
|
|
||||||
<ellipse cx="60" cy="34" rx="36" ry="36" fill="url(#p-o3)"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WavesPreview() {
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
<path d="M0 47 Q15 41 30 47 Q45 53 60 47 Q75 41 90 47 Q105 53 120 47 L120 68 L0 68Z" fill="var(--accent)" opacity="0.16"/>
|
|
||||||
<path d="M0 55 Q15 49 30 55 Q45 61 60 55 Q75 49 90 55 Q105 61 120 55 L120 68 L0 68Z" fill="var(--accent)" opacity="0.22"/>
|
|
||||||
<path d="M0 41 Q20 35 40 41 Q60 47 80 41 Q100 35 120 41 L120 68 L0 68Z" fill="rgb(255,60,172)" opacity="0.10"/>
|
|
||||||
<path d="M0 61 Q20 57 40 61 Q60 65 80 61 Q100 57 120 61 L120 68 L0 68Z" fill="rgb(140,100,255)" opacity="0.14"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ParticlesPreview() {
|
|
||||||
const d: [number,number][] = [[18,14],[52,10],[88,22],[32,38],[72,48],[14,54],[86,56],[50,28],[68,8],[30,20]]
|
|
||||||
const ln: [number,number][] = [[0,1],[1,2],[0,3],[1,7],[3,5],[2,4],[4,6],[3,7],[7,8],[1,8],[9,0],[9,3]]
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
{ln.map(([a,b],i) => <line key={i} x1={d[a][0]} y1={d[a][1]} x2={d[b][0]} y2={d[b][1]} stroke="var(--accent)" strokeWidth="0.6" opacity="0.2"/>)}
|
|
||||||
{d.map(([x,y],i) => <circle key={i} cx={x} cy={y} r={i%3===0?2:1.3} fill="var(--accent)" opacity={i%2===0?0.7:0.45}/>)}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuroraPreview() {
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="p-a1" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.55"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="p-a2" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(140,100,255)" stopOpacity="0.45"/><stop offset="100%" stopColor="rgb(140,100,255)" stopOpacity="0"/></radialGradient>
|
|
||||||
<radialGradient id="p-a3" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="rgb(255,60,172)" stopOpacity="0.40"/><stop offset="100%" stopColor="rgb(255,60,172)" stopOpacity="0"/></radialGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
<ellipse cx="10" cy="10" rx="80" ry="22" fill="url(#p-a1)"/>
|
|
||||||
<ellipse cx="66" cy="6" rx="90" ry="16" fill="url(#p-a2)"/>
|
|
||||||
<ellipse cx="106" cy="19" rx="66" ry="20" fill="url(#p-a3)"/>
|
|
||||||
<ellipse cx="34" cy="32" rx="85" ry="15" fill="url(#p-a1)" opacity="0.5"/>
|
|
||||||
<ellipse cx="86" cy="44" rx="60" ry="16" fill="url(#p-a2)" opacity="0.45"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PulsePreview() {
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="p-pg" cx="50%" cy="50%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.35"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
<circle cx="60" cy="34" r="12" fill="url(#p-pg)"/>
|
|
||||||
<circle cx="60" cy="34" r="9" fill="none" stroke="var(--accent)" strokeWidth="1.8" opacity="0.75"/>
|
|
||||||
<circle cx="60" cy="34" r="20" fill="none" stroke="var(--accent)" strokeWidth="1.2" opacity="0.5"/>
|
|
||||||
<circle cx="60" cy="34" r="30" fill="none" stroke="var(--accent)" strokeWidth="0.9" opacity="0.32"/>
|
|
||||||
<circle cx="60" cy="34" r="42" fill="none" stroke="var(--accent)" strokeWidth="0.6" opacity="0.18"/>
|
|
||||||
<circle cx="60" cy="34" r="55" fill="none" stroke="var(--accent)" strokeWidth="0.4" opacity="0.1"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StarsPreview() {
|
|
||||||
const stars: [number,number,number][] = [
|
|
||||||
[12,8,1.4],[34,5,0.9],[58,12,1.1],[80,4,1.5],[102,9,0.8],[18,22,1.0],[45,18,1.3],[72,20,0.9],[96,25,1.2],
|
|
||||||
[8,38,0.8],[28,42,1.1],[55,35,1.4],[78,40,0.9],[108,36,1.0],[22,56,1.2],[50,58,0.8],[75,54,1.3],[100,60,1.0],
|
|
||||||
[40,28,0.7],[88,14,1.1],[15,48,0.9],[64,48,0.8]
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="p-neb" cx="35%" cy="45%" r="50%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.12"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
<rect width="120" height="68" fill="url(#p-neb)"/>
|
|
||||||
{stars.map(([x,y,r],i) => (
|
|
||||||
<circle key={i} cx={x} cy={y} r={r} fill="var(--accent)" opacity={0.4 + (i % 5) * 0.12}/>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RainPreview() {
|
|
||||||
const drops: [number,number,number][] = [
|
|
||||||
[10,8,20],[25,0,28],[40,15,22],[55,5,25],[70,10,18],[85,2,30],[100,12,24],[112,6,20],
|
|
||||||
[18,35,22],[33,28,26],[48,40,19],[63,30,24],[78,38,21],[93,25,28],[108,34,20],
|
|
||||||
]
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
{drops.map(([x,y,len],i) => (
|
|
||||||
<g key={i}>
|
|
||||||
<line x1={x} y1={y} x2={x} y2={y+len} stroke="var(--accent)" strokeWidth="1.5" opacity={0.15 + (i%4)*0.08}
|
|
||||||
style={{background: `linear-gradient(to bottom, transparent, var(--accent))`}}/>
|
|
||||||
<circle cx={x} cy={y+len} r="1.5" fill="var(--accent)" opacity={0.5 + (i%3)*0.15}/>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function RaysPreview() {
|
|
||||||
const rays = Array.from({length: 9}, (_,i) => (i/9)*360)
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68" preserveAspectRatio="xMidYMid slice">
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="p-rg" cx="50%" cy="60%" r="55%"><stop offset="0%" stopColor="var(--accent)" stopOpacity="0.5"/><stop offset="100%" stopColor="var(--accent)" stopOpacity="0"/></radialGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
{rays.map((deg, i) => {
|
|
||||||
const rad = (deg * Math.PI) / 180
|
|
||||||
const x2 = 60 + Math.cos(rad) * 90
|
|
||||||
const y2 = 41 + Math.sin(rad) * 90
|
|
||||||
return (
|
|
||||||
<line key={i} x1="60" y1="41" x2={x2} y2={y2}
|
|
||||||
stroke="var(--accent)" strokeWidth={i%2===0?2.5:1.5} opacity={i%2===0?0.18:0.10}/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
<circle cx="60" cy="41" r="14" fill="url(#p-rg)"/>
|
|
||||||
<circle cx="60" cy="41" r="3" fill="var(--accent)" opacity="0.7"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NonePreview() {
|
|
||||||
return (
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 120 68">
|
|
||||||
<rect width="120" height="68" fill="#0a0a0f"/>
|
|
||||||
<line x1="48" y1="34" x2="72" y2="34" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
|
|
||||||
<line x1="60" y1="22" x2="60" y2="46" stroke="#2a2a35" strokeWidth="1.5" strokeLinecap="round"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const BG_PREVIEWS: Record<BgMode, React.ReactNode> = {
|
|
||||||
orbs: <OrbsPreview />,
|
|
||||||
waves: <WavesPreview />,
|
|
||||||
particles: <ParticlesPreview />,
|
|
||||||
aurora: <AuroraPreview />,
|
|
||||||
pulse: <PulsePreview />,
|
|
||||||
stars: <StarsPreview />,
|
|
||||||
rain: <RainPreview />,
|
|
||||||
rays: <RaysPreview />,
|
|
||||||
none: <NonePreview />,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
|
||||||
const { user } = useAuthStore()
|
|
||||||
const { accentIdx, customHex, setAccent, setCustom } = useThemeStore()
|
|
||||||
const { bgMode, setBg, raysConfig, setRaysConfig } = useBgStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const activeAccent = getActiveAccent(accentIdx, customHex)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) router.replace('/login')
|
|
||||||
}, [user, router])
|
|
||||||
|
|
||||||
if (!user) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="max-w-app mx-auto relative z-10">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<div className="animate-fadeUp">
|
|
||||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-app-text mb-5">Настройки</h2>
|
|
||||||
|
|
||||||
<section className="bg-surface border border-white/[0.07] rounded-app p-5 mb-3">
|
|
||||||
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted mb-4">
|
|
||||||
Внешний вид
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Accent color */}
|
|
||||||
<p className="text-[12px] text-muted mb-2.5">Акцентный цвет</p>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
|
||||||
{ACCENT_PRESETS.map((preset, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => setAccent(i)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
|
||||||
style={accentIdx === i
|
|
||||||
? { background: `rgba(${preset.rgb},0.12)`, color: preset.accent, borderColor: `${preset.accent}55` }
|
|
||||||
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style={{ background: preset.accent, boxShadow: accentIdx === i ? `0 0 7px ${preset.accent}90` : 'none' }}/>
|
|
||||||
{preset.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{/* Custom color button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setCustom(customHex)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-[9px] text-[12px] font-display font-semibold transition-all duration-150 cursor-pointer border"
|
|
||||||
style={accentIdx === -1
|
|
||||||
? { background: `rgba(${activeAccent.rgb},0.12)`, color: activeAccent.accent, borderColor: `${activeAccent.accent}55` }
|
|
||||||
: { background: 'rgba(255,255,255,0.03)', color: '#666', borderColor: 'rgba(255,255,255,0.07)' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="w-3 h-3 rounded-full shrink-0 border border-white/20"
|
|
||||||
style={{
|
|
||||||
background: `conic-gradient(red,yellow,lime,cyan,blue,magenta,red)`,
|
|
||||||
boxShadow: accentIdx === -1 ? `0 0 7px ${activeAccent.accent}90` : 'none',
|
|
||||||
}}/>
|
|
||||||
Свой
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Color wheel — shown when custom is selected */}
|
|
||||||
{accentIdx === -1 && (
|
|
||||||
<div className="mb-6 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
|
||||||
<ColorWheel value={customHex} onChange={setCustom} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{accentIdx !== -1 && <div className="mb-2" />}
|
|
||||||
|
|
||||||
{/* Live background */}
|
|
||||||
<p className="text-[12px] text-muted mb-2.5">Живой фон</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
||||||
{BG_PRESETS.map((preset) => {
|
|
||||||
const active = bgMode === preset.id
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={preset.id}
|
|
||||||
onClick={() => setBg(preset.id)}
|
|
||||||
className="flex flex-col rounded-[10px] overflow-hidden border transition-all duration-150 cursor-pointer text-left"
|
|
||||||
style={active
|
|
||||||
? { borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }
|
|
||||||
: { borderColor: 'rgba(255,255,255,0.07)' }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="w-full aspect-video overflow-hidden">
|
|
||||||
{BG_PREVIEWS[preset.id]}
|
|
||||||
</div>
|
|
||||||
<div className="px-3 py-2" style={{ background: active ? 'rgba(var(--accent-rgb),0.07)' : 'rgba(255,255,255,0.02)' }}>
|
|
||||||
<p className="text-[12px] font-display font-bold" style={{ color: active ? 'var(--accent)' : '#bbb' }}>
|
|
||||||
{preset.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-muted mt-0.5">{preset.desc}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rays config — shown only when rays mode is active */}
|
|
||||||
{bgMode === 'rays' && (
|
|
||||||
<div className="mt-4 p-4 bg-surface2 rounded-[10px] border border-white/[0.07]">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<p className="text-[11px] font-display font-semibold tracking-[1.2px] uppercase text-muted">
|
|
||||||
Настройки лучей
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setRaysConfig(DEFAULT_RAYS)}
|
|
||||||
className="text-[10px] font-display font-semibold text-muted hover:text-app-text transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{([
|
|
||||||
{ key: 'count', label: 'Количество', min: 4, max: 16, step: 1, fmt: (v: number) => String(Math.round(v)) },
|
|
||||||
{ key: 'speed', label: 'Скорость', min: 0.2, max: 3.0, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
|
|
||||||
{ key: 'brightness', label: 'Яркость', min: 0.3, max: 2.5, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
|
|
||||||
{ key: 'spread', label: 'Ширина', min: 0.3, max: 2.0, step: 0.1, fmt: (v: number) => v.toFixed(1) + 'x' },
|
|
||||||
] as const).map(({ key, label, min, max, step, fmt }) => (
|
|
||||||
<div key={key}>
|
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
|
||||||
<span className="text-[12px] text-app-text/70">{label}</span>
|
|
||||||
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--accent)' }}>
|
|
||||||
{fmt(raysConfig[key])}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min} max={max} step={step}
|
|
||||||
value={raysConfig[key]}
|
|
||||||
onChange={(e) => setRaysConfig({ [key]: Number(e.target.value) })}
|
|
||||||
className="w-full h-1.5 rounded-full cursor-pointer appearance-none bg-white/[0.08]"
|
|
||||||
style={{ accentColor: 'var(--accent)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useFavoritesStore } from '@/store/favoritesStore'
|
import { useFavoritesStore } from '@/store/favoritesStore'
|
||||||
@@ -14,16 +14,16 @@ interface Props {
|
|||||||
|
|
||||||
export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) {
|
export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props) {
|
||||||
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
||||||
const { token } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
const [playlists, setPlaylists] = useState<Playlist[]>([])
|
||||||
const [added, setAdded] = useState<Record<string, boolean>>({})
|
const [added, setAdded] = useState<Record<string, boolean>>({})
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const favorited = isFavorite(trackTitle)
|
const favorited = isFavorite(trackTitle)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!user) return
|
||||||
getPlaylists(token).then(setPlaylists).catch(() => {})
|
getPlaylists().then(setPlaylists).catch(() => {})
|
||||||
}, [token])
|
}, [user])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -37,9 +37,9 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
|
|||||||
}, [onClose, anchorRef])
|
}, [onClose, anchorRef])
|
||||||
|
|
||||||
const handleAdd = async (playlist: Playlist) => {
|
const handleAdd = async (playlist: Playlist) => {
|
||||||
if (!token || added[playlist.id]) return
|
if (!user || added[playlist.id]) return
|
||||||
try {
|
try {
|
||||||
await addTrackToPlaylist(token, playlist.id, trackTitle)
|
await addTrackToPlaylist(playlist.id, trackTitle)
|
||||||
setAdded(prev => ({ ...prev, [playlist.id]: true }))
|
setAdded(prev => ({ ...prev, [playlist.id]: true }))
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export default function AddToPlaylist({ trackTitle, onClose, anchorRef }: Props)
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{token ? (
|
{user ? (
|
||||||
playlists.length > 0 ? (
|
playlists.length > 0 ? (
|
||||||
<div className="max-h-[180px] overflow-y-auto">
|
<div className="max-h-[180px] overflow-y-auto">
|
||||||
{playlists.map(pl => (
|
{playlists.map(pl => (
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ type Star = { x: number; y: number; r: number; ba: number; ph: number; sp: numbe
|
|||||||
type Drop = { x: number; y: number; speed: number; len: number; alpha: number }
|
type Drop = { x: number; y: number; speed: number; len: number; alpha: number }
|
||||||
|
|
||||||
export default function AudioBackground() {
|
export default function AudioBackground() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const { bgMode, raysConfig } = useBgStore()
|
const { bgMode, fxConfigs } = useBgStore()
|
||||||
const raysRef = useRef(raysConfig)
|
const fxRef = useRef(fxConfigs)
|
||||||
|
|
||||||
// Keep rays config in sync without restarting the animation loop
|
// Sync config changes without restarting the animation loop
|
||||||
useEffect(() => { raysRef.current = raysConfig }, [raysConfig])
|
useEffect(() => { fxRef.current = fxConfigs }, [fxConfigs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bgMode === 'none') return
|
if (bgMode === 'none') return
|
||||||
@@ -28,7 +28,7 @@ export default function AudioBackground() {
|
|||||||
let rafId: number
|
let rafId: number
|
||||||
let smoothBass = 0
|
let smoothBass = 0
|
||||||
let smoothMid = 0
|
let smoothMid = 0
|
||||||
let fastBass = 0 // fast tracker for onset/beat detection
|
let fastBass = 0
|
||||||
let dataBuf: Uint8Array<ArrayBuffer> | null = null
|
let dataBuf: Uint8Array<ArrayBuffer> | null = null
|
||||||
|
|
||||||
const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight }
|
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'
|
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 drawOrbs = () => {
|
||||||
const W = canvas.width, H = canvas.height, a = ac()
|
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 br = Math.sin(t) * 0.04 + Math.cos(t * 0.7) * 0.02
|
||||||
const diag = Math.hypot(W, H), base = diag * 0.62
|
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 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)
|
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)`)
|
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)
|
ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
// pink bottom-right
|
|
||||||
const r2 = base * (0.70 + smoothMid * 0.40 - br * 0.5)
|
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)
|
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)')
|
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)
|
ctx.fillStyle = g2; ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
// purple center — only shows with audio
|
|
||||||
const c = smoothBass * 0.5 + smoothMid * 0.5
|
const c = smoothBass * 0.5 + smoothMid * 0.5
|
||||||
if (c > 0.008) {
|
if (c > 0.008) {
|
||||||
const r3 = base * (0.40 + c * 0.35)
|
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)
|
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)
|
ctx.fillStyle = g3; ctx.fillRect(0, 0, W, H)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,14 +100,16 @@ export default function AudioBackground() {
|
|||||||
// ── WAVES ─────────────────────────────────────────────────────────────────
|
// ── WAVES ─────────────────────────────────────────────────────────────────
|
||||||
const drawWaves = () => {
|
const drawWaves = () => {
|
||||||
const W = canvas.width, H = canvas.height, a = ac()
|
const W = canvas.width, H = canvas.height, a = ac()
|
||||||
const t = Date.now() / 1000
|
const cfg = fxRef.current.waves
|
||||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
|
const t = (Date.now() / 1000) * cfg.speed
|
||||||
|
const amp = cfg.amplitude
|
||||||
|
clear(W, H, cfg.trail)
|
||||||
const layers = [
|
const layers = [
|
||||||
{ y: 0.70, amp: 20 + smoothBass * 60, freq: 0.007, ph: t * 0.35, al: 0.12, 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, freq: 0.011, ph: -t * 0.5, al: 0.09, c: 'rgba(255,60,172,' },
|
{ 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, freq: 0.005, ph: t * 0.28, al: 0.14, c: `rgba(${a},` },
|
{ 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, freq: 0.013, ph: -t * 0.62, al: 0.08, c: 'rgba(140,100,255,' },
|
{ 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, freq: 0.004, ph: t * 0.18, al: 0.18, c: `rgba(${a},` },
|
{ 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) {
|
for (const l of layers) {
|
||||||
const baseY = H * l.y
|
const baseY = H * l.y
|
||||||
@@ -119,8 +131,9 @@ export default function AudioBackground() {
|
|||||||
}))
|
}))
|
||||||
const drawParticles = () => {
|
const drawParticles = () => {
|
||||||
const W = canvas.width, H = canvas.height, a = ac()
|
const W = canvas.width, H = canvas.height, a = ac()
|
||||||
ctx.fillStyle = 'rgba(10,10,15,0.2)'; ctx.fillRect(0, 0, W, H)
|
const cfg = fxRef.current.particles
|
||||||
const spd = 1 + smoothBass * 3
|
clear(W, H, cfg.trail)
|
||||||
|
const spd = (1 + smoothBass * 3) * cfg.speed
|
||||||
for (const p of PTS) {
|
for (const p of PTS) {
|
||||||
p.x += p.vx * spd; p.y += p.vy * spd
|
p.x += p.vx * spd; p.y += p.vy * spd
|
||||||
if (p.x < 0) p.x += 1; if (p.x > 1) p.x -= 1
|
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.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()
|
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 i = 0; i < PTS.length; i++)
|
||||||
for (let j = i + 1; j < PTS.length; j++) {
|
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
|
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 drawAurora = () => {
|
||||||
const W = canvas.width, H = canvas.height, a = ac()
|
const W = canvas.width, H = canvas.height, a = ac()
|
||||||
const t = Date.now() / 3500
|
const cfg = fxRef.current.aurora
|
||||||
ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, W, H)
|
const t = (Date.now() / 3500) * cfg.speed
|
||||||
|
const bri = cfg.brightness
|
||||||
|
clear(W, H, cfg.trail)
|
||||||
|
|
||||||
const bands = [
|
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.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, c: '140,100,255' },
|
{ 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, c: '255,60,172' },
|
{ 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, c: a },
|
{ 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, c: '140,100,255' },
|
{ 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, c: '255,60,172' },
|
{ 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) {
|
for (const band of bands) {
|
||||||
const cx = band.cx * W, cy = (band.cy + Math.sin(band.ph) * 0.07) * H
|
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[] = []
|
const RINGS: Ring[] = []
|
||||||
let prevFast = 0
|
let lastRingMs = 0
|
||||||
|
let lastAmbMs = 0
|
||||||
|
|
||||||
const drawPulse = () => {
|
const drawPulse = () => {
|
||||||
const W = canvas.width, H = canvas.height, a = ac()
|
const W = canvas.width, H = canvas.height, a = ac()
|
||||||
|
const cfg = fxRef.current.pulse
|
||||||
const cx = W * 0.5, cy = H * 0.5
|
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 now = Date.now()
|
||||||
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
|
|
||||||
|
|
||||||
// Slow ambient rings so mode looks alive without audio
|
// Beat-triggered ring
|
||||||
if (Math.random() < 0.008 && RINGS.length < 3) RINGS.push({ r: 5, alpha: 0.22, speed: 1.5 })
|
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--) {
|
for (let i = RINGS.length - 1; i >= 0; i--) {
|
||||||
const ring = RINGS[i]
|
const ring = RINGS[i]
|
||||||
ring.r += ring.speed
|
ring.r += ring.speed
|
||||||
ring.alpha -= 0.008
|
ring.alpha *= 0.982 // exponential fade — stays bright longer, fades smoothly
|
||||||
if (ring.alpha <= 0 || ring.r > maxR) { RINGS.splice(i, 1); continue }
|
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
|
// Main ring
|
||||||
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
|
ctx.beginPath(); ctx.arc(cx, cy, ring.r, 0, Math.PI * 2)
|
||||||
ctx.strokeStyle = `rgba(${a},${ring.alpha})`
|
ctx.strokeStyle = `rgba(${a},${ring.alpha})`
|
||||||
ctx.lineWidth = 1.5 + ring.alpha * 3.5; ctx.stroke()
|
ctx.lineWidth = lw; ctx.stroke()
|
||||||
|
|
||||||
// Inner echo
|
// Pink echo
|
||||||
if (ring.r > 40 && ring.alpha > 0.15) {
|
if (ring.r > 30 && ring.alpha > 0.08) {
|
||||||
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.80, 0, Math.PI * 2)
|
ctx.beginPath(); ctx.arc(cx, cy, ring.r * 0.85, 0, Math.PI * 2)
|
||||||
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.4})`
|
ctx.strokeStyle = `rgba(255,60,172,${ring.alpha * 0.35})`
|
||||||
ctx.lineWidth = 0.8; ctx.stroke()
|
ctx.lineWidth = lw * 0.55; ctx.stroke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persistent center glow
|
// Center dot — pulses with bass, small footprint so it doesn't drown rings
|
||||||
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 80 + smoothBass * 120)
|
const dotR = 14 + smoothBass * 55
|
||||||
cg.addColorStop(0, `rgba(${a},${0.08 + smoothBass * 0.14})`); cg.addColorStop(1, `rgba(${a},0)`)
|
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, dotR)
|
||||||
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)
|
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 ─────────────────────────────────────────────────────────────────
|
// ── STARS ─────────────────────────────────────────────────────────────────
|
||||||
@@ -220,22 +255,24 @@ export default function AudioBackground() {
|
|||||||
}))
|
}))
|
||||||
const drawStars = () => {
|
const drawStars = () => {
|
||||||
const W = canvas.width, H = canvas.height, a = ac()
|
const W = canvas.width, H = canvas.height, a = ac()
|
||||||
|
const cfg = fxRef.current.stars
|
||||||
const t = Date.now() / 1000
|
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)
|
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)
|
ctx.fillStyle = n1; ctx.fillRect(0, 0, W, H)
|
||||||
for (const s of STARS) {
|
for (const s of STARS) {
|
||||||
const tw = (Math.sin(t * s.sp + s.ph) + 1) * 0.5
|
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)
|
const alpha = s.ba * (0.35 + tw * 0.65) * (1 + smoothMid * 0.4) * bri
|
||||||
const r = s.r * (1 + smoothBass * tw * 1.0)
|
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() }
|
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()
|
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 ───────────────────────────────────────────
|
// ── RAIN ──────────────────────────────────────────────────────────────────
|
||||||
const DROPS: Drop[] = Array.from({ length: 30 }, () => ({
|
const DROPS: Drop[] = Array.from({ length: 60 }, () => ({
|
||||||
x: Math.random(), y: Math.random(),
|
x: Math.random(), y: Math.random(),
|
||||||
speed: Math.random() * 0.0015 + 0.0008,
|
speed: Math.random() * 0.0015 + 0.0008,
|
||||||
len: Math.random() * 0.055 + 0.03,
|
len: Math.random() * 0.055 + 0.03,
|
||||||
@@ -243,9 +280,12 @@ export default function AudioBackground() {
|
|||||||
}))
|
}))
|
||||||
const drawRain = () => {
|
const drawRain = () => {
|
||||||
const W = canvas.width, H = canvas.height, a = ac()
|
const W = canvas.width, H = canvas.height, a = ac()
|
||||||
ctx.fillStyle = 'rgba(10,10,15,0.12)'; ctx.fillRect(0, 0, W, H)
|
const cfg = fxRef.current.rain
|
||||||
const spd = 1 + smoothBass * 2.5
|
const count = Math.round(cfg.drops)
|
||||||
for (const d of 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
|
d.y += d.speed * spd
|
||||||
if (d.y > 1.08) { d.y = -d.len - 0.02; d.x = Math.random() }
|
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)
|
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 drawRays = () => {
|
||||||
const W = canvas.width, H = canvas.height, a = ac()
|
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 t = (Date.now() / 1000) * cfg.speed * 0.08
|
||||||
const cx = W * 0.5, cy = H * 0.55
|
const cx = W * 0.5, cy = H * 0.55
|
||||||
const maxR = Math.hypot(W, H) * 1.1
|
const maxR = Math.hypot(W, H) * 1.1
|
||||||
const br = cfg.brightness
|
const br = cfg.brightness
|
||||||
const sp = cfg.spread
|
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
|
const count = cfg.count
|
||||||
|
|
||||||
// Primary rays — rotate forward
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const angle = (i / count) * Math.PI * 2 + t
|
const angle = (i / count) * Math.PI * 2 + t
|
||||||
const isMain = i % 2 === 0
|
const isMain = i % 2 === 0
|
||||||
const hw = Math.tan((0.055 + smoothBass * 0.035) * sp) * maxR
|
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 al = ((isMain ? 0.12 : 0.07) + smoothBass * 0.10 + smoothMid * 0.03) * br
|
||||||
|
|
||||||
const ex = cx + Math.cos(angle) * maxR
|
const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
|
||||||
const ey = cy + Math.sin(angle) * maxR
|
const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
|
||||||
const px = -Math.sin(angle) * hw
|
|
||||||
const 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()
|
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)
|
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
|
||||||
grad.addColorStop(0, `rgba(${a},${al * 2.5})`)
|
grad.addColorStop(0, `rgba(${a},${al * 2.5})`)
|
||||||
grad.addColorStop(0.12, `rgba(${a},${al})`)
|
grad.addColorStop(0.12, `rgba(${a},${al})`)
|
||||||
@@ -292,17 +327,14 @@ export default function AudioBackground() {
|
|||||||
ctx.fillStyle = grad; ctx.fill()
|
ctx.fillStyle = grad; ctx.fill()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary rays — counter-rotate, pink tint
|
|
||||||
const cnt2 = Math.max(2, Math.floor(count / 2))
|
const cnt2 = Math.max(2, Math.floor(count / 2))
|
||||||
for (let i = 0; i < cnt2; i++) {
|
for (let i = 0; i < cnt2; i++) {
|
||||||
const angle = (i / cnt2) * Math.PI * 2 - t * 0.65 + Math.PI / count
|
const angle = (i / cnt2) * Math.PI * 2 - t * 0.65 + Math.PI / count
|
||||||
const hw = Math.tan(0.035 * sp) * maxR
|
const hw = Math.tan(0.035 * sp) * maxR
|
||||||
const al = (0.05 + smoothMid * 0.06) * br
|
const al = (0.05 + smoothMid * 0.06) * br
|
||||||
|
|
||||||
const ex = cx + Math.cos(angle) * maxR
|
const ex = cx + Math.cos(angle) * maxR, ey = cy + Math.sin(angle) * maxR
|
||||||
const ey = cy + Math.sin(angle) * maxR
|
const px = -Math.sin(angle) * hw, py = Math.cos(angle) * hw
|
||||||
const px = -Math.sin(angle) * hw
|
|
||||||
const 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()
|
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)
|
const grad = ctx.createLinearGradient(cx, cy, ex, ey)
|
||||||
@@ -312,7 +344,6 @@ export default function AudioBackground() {
|
|||||||
ctx.fillStyle = grad; ctx.fill()
|
ctx.fillStyle = grad; ctx.fill()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Center glow
|
|
||||||
const cg = ctx.createRadialGradient(cx, cy, 0, cx, cy, 100 + smoothBass * 140)
|
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)`)
|
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)
|
ctx.fillStyle = cg; ctx.fillRect(0, 0, W, H)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
|
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
|
||||||
import { usePartyStore } from '@/store/partyStore'
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
@@ -9,6 +9,9 @@ import { useAudioEngine } from '@/hooks/usePlayer'
|
|||||||
import { useAudioViz } from '@/hooks/useAudioViz'
|
import { useAudioViz } from '@/hooks/useAudioViz'
|
||||||
import { audioState } from '@/lib/audioState'
|
import { audioState } from '@/lib/audioState'
|
||||||
import AddToPlaylist from '@/components/AddToPlaylist'
|
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'
|
import type { SearchResult } from '@/types'
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
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) {
|
export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
||||||
const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus, reorderQueue, setCurIdx, generateMix, removeFromQueue, addTrackToQueue } =
|
const {
|
||||||
usePartyStore()
|
queue, curIdx, loadKey, repeatMode,
|
||||||
|
updateQueueItemImg, setCurrentResults, setCurrentResult, setSearchStatus, searchStatus,
|
||||||
|
setCurIdx, setRepeatMode, removeFromQueue, addTrackToQueue,
|
||||||
|
} = usePartyStore()
|
||||||
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
const { isFavorite, toggleFavorite } = useFavoritesStore()
|
||||||
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
|
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
|
||||||
|
const showToast = useToastStore((s) => s.show)
|
||||||
|
|
||||||
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
|
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
@@ -43,7 +50,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
const [panel, setPanel] = useState<'queue' | 'versions' | null>(null)
|
const [panel, setPanel] = useState<'queue' | 'versions' | null>(null)
|
||||||
const [playlistOpen, setPlaylistOpen] = useState(false)
|
const [playlistOpen, setPlaylistOpen] = useState(false)
|
||||||
const playlistBtnRef = useRef<HTMLButtonElement>(null)
|
const playlistBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const dragSrcIdx = useRef<number | null>(null)
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
@@ -62,6 +68,9 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
const prefetchInflightRef = useRef<Map<string, Promise<SearchResult[]>>>(new Map())
|
const prefetchInflightRef = useRef<Map<string, Promise<SearchResult[]>>>(new Map())
|
||||||
const preloadAudioRef = useRef<HTMLAudioElement | null>(null)
|
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)
|
useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isPlaying)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,6 +86,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
if (!r) return
|
if (!r) return
|
||||||
setActiveResultIdx(resIdx)
|
setActiveResultIdx(resIdx)
|
||||||
activeResultIdxRef.current = resIdx
|
activeResultIdxRef.current = resIdx
|
||||||
|
setCurrentResult(r)
|
||||||
setAudioMeta({ title: r.title, artist: r.artist })
|
setAudioMeta({ title: r.title, artist: r.artist })
|
||||||
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
|
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
|
||||||
const audio = audioRef.current
|
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)
|
if (err?.name !== 'AbortError') console.warn('[player] play() failed:', err?.name)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[audioRef, resumeContext],
|
[audioRef, resumeContext, setCurrentResult],
|
||||||
)
|
)
|
||||||
|
|
||||||
const playResultRef = useRef(playResult)
|
const playResultRef = useRef(playResult)
|
||||||
useEffect(() => { playResultRef.current = playResult }, [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 prefetchOrGet = useCallback(async (title: string): Promise<SearchResult[]> => {
|
||||||
const cached = prefetchCacheRef.current.get(title)
|
const cached = prefetchCacheRef.current.get(title)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
@@ -142,6 +150,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
|
|
||||||
if (!found.length) {
|
if (!found.length) {
|
||||||
setSearchStatus('not-found')
|
setSearchStatus('not-found')
|
||||||
|
showToast(`Трек не найден: ${track.title}`, 'error')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (loadingKeyRef.current !== key) return
|
if (loadingKeyRef.current !== key) return
|
||||||
const s = usePartyStore.getState()
|
const s = usePartyStore.getState()
|
||||||
@@ -167,7 +176,6 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
|
|
||||||
playResult(found, startIdx)
|
playResult(found, startIdx)
|
||||||
|
|
||||||
// Prefetch next 3 tracks; for N+1 also preload audio into hidden element
|
|
||||||
for (let offset = 1; offset <= 3; offset++) {
|
for (let offset = 1; offset <= 3; offset++) {
|
||||||
const nextTrack = queue[idx + offset]
|
const nextTrack = queue[idx + offset]
|
||||||
if (!nextTrack) continue
|
if (!nextTrack) continue
|
||||||
@@ -176,29 +184,25 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
p.then(nextResults => {
|
p.then(nextResults => {
|
||||||
if (loadingKeyRef.current !== key) return
|
if (loadingKeyRef.current !== key) return
|
||||||
if (!nextResults.length) return
|
if (!nextResults.length) return
|
||||||
const saved = getSavedVersion(nextTrack.title)
|
const sv = getSavedVersion(nextTrack.title)
|
||||||
const si = saved ? nextResults.findIndex(r => r.title === saved.title && r.artist === saved.artist) : -1
|
const si = sv ? nextResults.findIndex(r => r.title === sv.title && r.artist === sv.artist) : -1
|
||||||
const resIdx = si >= 0 ? si : 0
|
const resIdx = si >= 0 ? si : 0
|
||||||
const preloadEl = preloadAudioRef.current
|
const preloadEl = preloadAudioRef.current
|
||||||
if (!preloadEl) return
|
if (!preloadEl) return
|
||||||
const url = proxyMp3Url(nextResults[resIdx].mp3)
|
const url = proxyMp3Url(nextResults[resIdx].mp3)
|
||||||
if (preloadEl.src !== url) {
|
if (preloadEl.src !== url) { preloadEl.src = url; preloadEl.load() }
|
||||||
preloadEl.src = url
|
|
||||||
preloadEl.load()
|
|
||||||
}
|
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
p.catch(() => {})
|
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))
|
const windowTitles = new Set(queue.slice(Math.max(0, idx - 1), idx + 6).map(t => t.title))
|
||||||
prefetchCacheRef.current.forEach((_, title) => {
|
prefetchCacheRef.current.forEach((_, title) => {
|
||||||
if (!windowTitles.has(title)) prefetchCacheRef.current.delete(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(() => {
|
useEffect(() => {
|
||||||
@@ -220,8 +224,8 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
audioState.analyser = analyserRef.current
|
audioState.analyser = analyserRef.current
|
||||||
}
|
}
|
||||||
const onPause = () => { setIsPlaying(false); isPlayingRef.current = false; audioState.isPlaying = false }
|
const onPause = () => { setIsPlaying(false); isPlayingRef.current = false; audioState.isPlaying = false }
|
||||||
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
|
const onTimeUpdate = () => { setCurrentTime(audio.currentTime); audioState.currentTime = audio.currentTime }
|
||||||
const onDuration = () => setDuration(audio.duration || 0)
|
const onDuration = () => { const d = audio.duration || 0; setDuration(d); audioState.duration = d }
|
||||||
const onEnded = () => {
|
const onEnded = () => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
isPlayingRef.current = false
|
isPlayingRef.current = false
|
||||||
@@ -229,15 +233,18 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
const r = activeResultsRef.current[activeResultIdxRef.current]
|
const r = activeResultsRef.current[activeResultIdxRef.current]
|
||||||
if (r) onTrackEnd(r)
|
if (r) onTrackEnd(r)
|
||||||
const s = usePartyStore.getState()
|
const s = usePartyStore.getState()
|
||||||
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
|
const repeat = s.repeatMode
|
||||||
|
if (repeat === 'one') {
|
||||||
|
s.setCurIdx(s.curIdx)
|
||||||
|
} else if (repeat === 'all') {
|
||||||
|
s.setCurIdx(s.curIdx < s.queue.length - 1 ? s.curIdx + 1 : 0)
|
||||||
|
} else {
|
||||||
|
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
if (!audio.src) return
|
if (!audio.src) return
|
||||||
setTimeout(() => {
|
setTimeout(() => { resumeContext(); audio.load(); audio.play().catch(() => {}) }, 1500)
|
||||||
resumeContext()
|
|
||||||
audio.load()
|
|
||||||
audio.play().catch(() => {})
|
|
||||||
}, 1500)
|
|
||||||
}
|
}
|
||||||
audio.addEventListener('play', onPlay)
|
audio.addEventListener('play', onPlay)
|
||||||
audio.addEventListener('pause', onPause)
|
audio.addEventListener('pause', onPause)
|
||||||
@@ -255,11 +262,10 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
}
|
}
|
||||||
}, [audioRef, analyserRef, initAudioViz, resumeContext, onTrackEnd])
|
}, [audioRef, analyserRef, initAudioViz, resumeContext, onTrackEnd])
|
||||||
|
|
||||||
// Keep meta refs in sync for remote
|
|
||||||
useEffect(() => { audioMetaRef.current = audioMeta }, [audioMeta])
|
useEffect(() => { audioMetaRef.current = audioMeta }, [audioMeta])
|
||||||
useEffect(() => { coverSrcRef.current = coverSrc }, [coverSrc])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!roomId) return
|
if (!roomId) return
|
||||||
|
|
||||||
@@ -331,7 +337,7 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
pushState()
|
pushState()
|
||||||
pollCommands()
|
pollCommands()
|
||||||
const ivState = setInterval(pushState, 2000)
|
const ivState = setInterval(pushState, 2000)
|
||||||
const ivCmd = setInterval(pollCommands, 500)
|
const ivCmd = setInterval(pollCommands, 1500)
|
||||||
return () => { clearInterval(ivState); clearInterval(ivCmd) }
|
return () => { clearInterval(ivState); clearInterval(ivCmd) }
|
||||||
}, [roomId, audioRef, resumeContext])
|
}, [roomId, audioRef, resumeContext])
|
||||||
|
|
||||||
@@ -360,6 +366,13 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio) return
|
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(() => {})
|
if (audio.paused) audio.play().catch(() => {})
|
||||||
else audio.pause()
|
else audio.pause()
|
||||||
}, [audioRef])
|
}, [audioRef])
|
||||||
@@ -368,331 +381,314 @@ export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
|
|||||||
const reloadTrack = () => { const s = usePartyStore.getState(); s.setCurIdx(s.curIdx) }
|
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 nextTrack = () => { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) }
|
||||||
|
|
||||||
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
dragSrcIdx.current = idx
|
touchStartX.current = e.touches[0].clientX
|
||||||
el.classList.add('dragging')
|
}
|
||||||
}, [])
|
const onTouchEnd = (e: React.TouchEvent) => {
|
||||||
const onDragEnd = useCallback((el: HTMLElement) => {
|
if (touchStartX.current === null) return
|
||||||
el.classList.remove('dragging')
|
const delta = e.changedTouches[0].clientX - touchStartX.current
|
||||||
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
|
touchStartX.current = null
|
||||||
}, [])
|
if (delta > 60) prevTrack()
|
||||||
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
|
else if (delta < -60) nextTrack()
|
||||||
e.preventDefault()
|
}
|
||||||
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
|
|
||||||
el.classList.add('drag-over')
|
|
||||||
}, [])
|
|
||||||
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
|
|
||||||
e.preventDefault()
|
|
||||||
el.classList.remove('drag-over')
|
|
||||||
if (dragSrcIdx.current === null || dragSrcIdx.current === tgtIdx) return
|
|
||||||
reorderQueue(dragSrcIdx.current, tgtIdx)
|
|
||||||
dragSrcIdx.current = null
|
|
||||||
}, [reorderQueue])
|
|
||||||
|
|
||||||
const track = queue[Math.max(0, curIdx)] ?? null
|
const track = queue[Math.max(0, curIdx)] ?? null
|
||||||
const trackTitle = track?.title ?? ''
|
const trackTitle = track?.title ?? ''
|
||||||
const favorited = isFavorite(trackTitle)
|
const favorited = isFavorite(trackTitle)
|
||||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
|
const repeatLabel = repeatMode === 'none' ? 'Повтор выкл' : repeatMode === 'all' ? 'Повтор всего' : 'Повтор трека'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Audio is always mounted at the same tree position so audioRef never changes and event listeners persist */}
|
{/* 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' }} />
|
<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) && (
|
||||||
{/* Slide-up panels */}
|
<div className="fixed bottom-0 left-0 right-0 z-50" ref={panelRef}>
|
||||||
{panel && (
|
{/* Slide-up panels */}
|
||||||
<div className="bg-surface border-t border-white/[0.07] max-h-[50vh] overflow-hidden flex flex-col">
|
{panel && (
|
||||||
{/* Versions panel */}
|
<div className="bg-surface border-t border-white/[0.07] max-h-[50vh] overflow-hidden flex flex-col">
|
||||||
{panel === 'versions' && results.length > 1 && (
|
{panel === 'versions' && results.length > 1 && (
|
||||||
<div className="overflow-y-auto">
|
<VersionsPanel
|
||||||
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between">
|
results={results}
|
||||||
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Версии трека</span>
|
activeResultIdx={activeResultIdx}
|
||||||
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text">✕</button>
|
|
||||||
</div>
|
|
||||||
{results.map((r, i) => {
|
|
||||||
const saved = isSaved(trackTitle, r)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
onClick={() => playResult(results, i)}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
|
|
||||||
>
|
|
||||||
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
|
|
||||||
{r.img && !r.img.includes('no-cover') && (
|
|
||||||
<img src={proxyImgUrl(r.img)} alt="" className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">{r.title}</div>
|
|
||||||
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
|
|
||||||
title={saved ? 'Забыть версию' : 'Запомнить'}
|
|
||||||
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
|
|
||||||
style={{ borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent' }}
|
|
||||||
>
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? 'var(--accent)' : 'none'} stroke={saved ? 'var(--accent)' : '#555'} strokeWidth="2">
|
|
||||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); playResult(results, i) }}
|
|
||||||
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
|
|
||||||
style={{ width: 26, height: 26 }}
|
|
||||||
>
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Queue panel */}
|
|
||||||
{panel === 'queue' && (
|
|
||||||
<div className="overflow-y-auto flex flex-col">
|
|
||||||
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Очередь · {queue.length}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => generateMix()}
|
|
||||||
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
|
|
||||||
>↺</button>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text">✕</button>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-y-auto">
|
|
||||||
{queue.map((item, i) => {
|
|
||||||
const active = i === curIdx
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
draggable
|
|
||||||
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
|
|
||||||
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
|
|
||||||
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
|
|
||||||
onDragStart={(e) => onDragStart(i, e.currentTarget)}
|
|
||||||
onDragEnd={(e) => onDragEnd(e.currentTarget)}
|
|
||||||
onDragOver={(e) => onDragOver(e, e.currentTarget)}
|
|
||||||
onDrop={(e) => onDrop(e, i, e.currentTarget)}
|
|
||||||
>
|
|
||||||
<div className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
|
|
||||||
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{active ? (
|
|
||||||
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0">
|
|
||||||
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
|
|
||||||
)}
|
|
||||||
{item.img ? (
|
|
||||||
<img src={item.img} alt="" className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
|
|
||||||
) : (
|
|
||||||
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color.bg, color: item.color.text }}>
|
|
||||||
{item.owner}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bottom bar */}
|
|
||||||
<div className="bg-surface border-t border-white/[0.07] px-4 py-2.5">
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group" onClick={(e) => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
|
||||||
const pct = (e.clientX - rect.left) / rect.width
|
|
||||||
const audio = audioRef.current
|
|
||||||
if (audio && duration) audio.currentTime = pct * duration
|
|
||||||
}}>
|
|
||||||
<div className="absolute inset-y-0 left-0 bg-accent rounded-full transition-all duration-100" style={{ width: `${progress}%` }} />
|
|
||||||
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-accent rounded-full opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-app mx-auto flex items-center gap-3">
|
|
||||||
{/* Cover */}
|
|
||||||
<div className="relative shrink-0 w-11 h-11 rounded-[8px] overflow-hidden bg-surface2">
|
|
||||||
{coverSrc ? (
|
|
||||||
<img src={coverSrc} alt="" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="w-full h-full bg-surface2" />
|
|
||||||
)}
|
|
||||||
<canvas ref={canvasRef} className={`absolute inset-0 w-full h-full pointer-events-none transition-opacity duration-300 ${isPlaying ? 'opacity-100' : 'opacity-0'}`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Track info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{searchStatus === 'searching' ? (
|
|
||||||
<div className="flex items-center gap-1.5 text-[12px] text-muted">
|
|
||||||
<div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" />
|
|
||||||
<span>Ищем...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-[13px] font-display font-bold whitespace-nowrap overflow-hidden text-ellipsis leading-tight">{track.title}</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
|
||||||
{audioMeta.artist && <span className="text-[11px] text-muted truncate">{audioMeta.artist}</span>}
|
|
||||||
<span className="text-[10px] px-1.5 py-px rounded-[5px] font-medium shrink-0" style={{ background: track.color.bg, color: track.color.text }}>{track.owner}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time */}
|
|
||||||
<div className="text-[10px] text-muted font-display shrink-0 hidden sm:block tabular-nums">
|
|
||||||
{formatTime(currentTime)}<span className="opacity-40 mx-0.5">/</span>{formatTime(duration)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<button onClick={prevTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
|
|
||||||
</button>
|
|
||||||
<button onClick={togglePlay} className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80">
|
|
||||||
{isPlaying ? (
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
|
||||||
) : (
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button onClick={nextTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
{/* Reload */}
|
|
||||||
<button onClick={reloadTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer hidden sm:flex">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
|
|
||||||
</button>
|
|
||||||
{/* Versions */}
|
|
||||||
{results.length > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setPanel(p => p === 'versions' ? null : 'versions')}
|
|
||||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
|
||||||
style={{ color: panel === 'versions' ? 'var(--accent)' : undefined, background: panel === 'versions' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
|
||||||
title="Версии трека"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Add to playlist */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
ref={playlistBtnRef}
|
|
||||||
onClick={() => setPlaylistOpen(v => !v)}
|
|
||||||
title="Добавить в плейлист"
|
|
||||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
|
||||||
style={{ color: playlistOpen ? 'var(--accent)' : undefined, background: playlistOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{playlistOpen && (
|
|
||||||
<AddToPlaylist
|
|
||||||
trackTitle={trackTitle}
|
trackTitle={trackTitle}
|
||||||
onClose={() => setPlaylistOpen(false)}
|
onPlay={playResult}
|
||||||
anchorRef={playlistBtnRef as React.RefObject<HTMLElement | null>}
|
isSaved={isSaved}
|
||||||
|
saveVersion={saveVersion}
|
||||||
|
removeVersion={removeVersion}
|
||||||
|
onClose={() => setPanel(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{panel === 'queue' && (
|
||||||
|
<QueuePanel
|
||||||
|
queue={queue}
|
||||||
|
curIdx={curIdx}
|
||||||
|
onClose={() => setPanel(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Favorite */}
|
)}
|
||||||
<button
|
|
||||||
onClick={() => toggleFavorite(trackTitle)}
|
{/* Bottom bar */}
|
||||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2"
|
<div
|
||||||
style={{ color: favorited ? 'var(--accent)' : undefined }}
|
className="bg-surface border-t border-white/[0.07] px-4 py-2.5"
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div
|
||||||
|
role="slider"
|
||||||
|
aria-label="Прогресс трека"
|
||||||
|
aria-valuenow={Math.round(progress)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group"
|
||||||
|
onClick={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const pct = (e.clientX - rect.left) / rect.width
|
||||||
|
const audio = audioRef.current
|
||||||
|
if (audio && duration) audio.currentTime = pct * duration
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? 'var(--accent)' : 'none'} stroke={favorited ? 'var(--accent)' : 'currentColor'} strokeWidth="2">
|
<div className="absolute inset-y-0 left-0 bg-accent rounded-full transition-all duration-100" style={{ width: `${progress}%` }} />
|
||||||
<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" />
|
<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}%` }} />
|
||||||
</svg>
|
</div>
|
||||||
</button>
|
|
||||||
{/* Queue */}
|
<div className="max-w-app mx-auto flex items-center gap-3">
|
||||||
<button
|
{/* Cover */}
|
||||||
onClick={() => setPanel(p => p === 'queue' ? null : 'queue')}
|
<div className="relative shrink-0 w-11 h-11 rounded-[8px] overflow-hidden bg-surface2">
|
||||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
{coverSrc ? (
|
||||||
style={{ color: panel === 'queue' ? 'var(--accent)' : undefined, background: panel === 'queue' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
<img src={coverSrc} alt="Обложка" className="w-full h-full object-cover" />
|
||||||
title="Очередь"
|
) : (
|
||||||
>
|
<div className="w-full h-full bg-surface2" />
|
||||||
<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" />
|
<canvas
|
||||||
<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" />
|
ref={canvasRef}
|
||||||
</svg>
|
aria-hidden="true"
|
||||||
</button>
|
className={`absolute inset-0 w-full h-full pointer-events-none transition-opacity duration-300 ${isPlaying ? 'opacity-100' : 'opacity-0'}`}
|
||||||
{/* Remote / Share */}
|
/>
|
||||||
<div className="relative share-popover-root">
|
</div>
|
||||||
<button
|
|
||||||
ref={shareBtnRef}
|
{/* Track info */}
|
||||||
onClick={async () => {
|
<div className="flex-1 min-w-0">
|
||||||
let id = roomId
|
{searchStatus === 'searching' ? (
|
||||||
if (!id) {
|
<div className="flex items-center gap-1.5 text-[12px] text-muted">
|
||||||
const res = await fetch(`${API_URL}/api/remote`, { method: 'POST' }).catch(() => null)
|
<div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" aria-hidden="true" />
|
||||||
if (res?.ok) {
|
<span>Ищем...</span>
|
||||||
const data = await res.json()
|
|
||||||
id = data.id as string
|
|
||||||
setRoomId(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (id) setShareOpen(v => !v)
|
|
||||||
}}
|
|
||||||
title="Пульт управления"
|
|
||||||
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
|
||||||
style={{ color: shareOpen || roomId ? 'var(--accent)' : undefined, background: shareOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
|
||||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
|
||||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
|
||||||
<circle cx="12" cy="20" r="1" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{shareOpen && roomId && (
|
|
||||||
<div className="absolute bottom-full right-0 mb-2 w-64 bg-surface border border-white/[0.07] rounded-[12px] p-3 shadow-2xl z-50">
|
|
||||||
<p className="text-[11px] text-muted font-display font-bold tracking-[1px] uppercase mb-2">Пульт управления</p>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={typeof window !== 'undefined' ? `${window.location.origin}/remote/${roomId}` : `/remote/${roomId}`}
|
|
||||||
className="flex-1 min-w-0 text-[11px] bg-surface2 border border-white/[0.07] rounded-[7px] px-2 py-1.5 text-muted outline-none font-mono truncate"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
navigator.clipboard.writeText(`${window.location.origin}/remote/${roomId}`)
|
|
||||||
setShareCopied(true)
|
|
||||||
setTimeout(() => setShareCopied(false), 2000)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="shrink-0 px-2 py-1.5 text-[11px] rounded-[7px] border border-white/[0.07] hover:bg-surface2 transition-all cursor-pointer font-display"
|
|
||||||
style={{ color: shareCopied ? 'var(--accent)' : undefined }}
|
|
||||||
>
|
|
||||||
{shareCopied ? '✓' : 'Копировать'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted mt-1.5 leading-relaxed">Откройте ссылку на другом устройстве для управления плеером</p>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-[13px] font-display font-bold whitespace-nowrap overflow-hidden text-ellipsis leading-tight">
|
||||||
|
{track.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
{audioMeta.artist && <span className="text-[11px] text-muted truncate">{audioMeta.artist}</span>}
|
||||||
|
<span className="text-[10px] px-1.5 py-px rounded-[5px] font-medium shrink-0" style={{ background: track.color.bg, color: track.color.text }}>
|
||||||
|
{track.owner}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
<div className="text-[10px] text-muted font-display shrink-0 hidden sm:block tabular-nums" aria-label={`${formatTime(currentTime)} из ${formatTime(duration)}`}>
|
||||||
|
{formatTime(currentTime)}<span className="opacity-40 mx-0.5">/</span>{formatTime(duration)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playback controls */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={prevTrack}
|
||||||
|
aria-label="Предыдущий трек"
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
aria-label={isPlaying ? 'Пауза' : 'Воспроизвести'}
|
||||||
|
className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
||||||
|
) : (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={nextTrack}
|
||||||
|
aria-label="Следующий трек"
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{/* Reload */}
|
||||||
|
<button
|
||||||
|
onClick={reloadTrack}
|
||||||
|
aria-label="Перезагрузить трек"
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer hidden sm:flex"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Repeat */}
|
||||||
|
<button
|
||||||
|
onClick={() => setRepeatMode(repeatMode === 'none' ? 'all' : repeatMode === 'all' ? 'one' : 'none')}
|
||||||
|
aria-label={repeatLabel}
|
||||||
|
title={repeatLabel}
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hidden sm:flex relative"
|
||||||
|
style={{
|
||||||
|
color: repeatMode !== 'none' ? 'var(--accent)' : undefined,
|
||||||
|
background: repeatMode !== 'none' ? 'rgba(var(--accent-rgb),0.08)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{repeatMode === 'one' ? (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||||
|
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
|
||||||
|
<text x="10" y="14" fontSize="6" fill="currentColor" stroke="none" fontWeight="bold">1</text>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
|
||||||
|
style={{ opacity: repeatMode === 'none' ? 0.4 : 1 }}>
|
||||||
|
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||||
|
<polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Versions */}
|
||||||
|
{results.length > 1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPanel(p => p === 'versions' ? null : 'versions')}
|
||||||
|
aria-label="Версии трека"
|
||||||
|
aria-expanded={panel === 'versions'}
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||||
|
style={{ color: panel === 'versions' ? 'var(--accent)' : undefined, background: panel === 'versions' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add to playlist */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
ref={playlistBtnRef}
|
||||||
|
onClick={() => setPlaylistOpen(v => !v)}
|
||||||
|
aria-label="Добавить в плейлист"
|
||||||
|
aria-expanded={playlistOpen}
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||||
|
style={{ color: playlistOpen ? 'var(--accent)' : undefined, background: playlistOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{playlistOpen && (
|
||||||
|
<AddToPlaylist
|
||||||
|
trackTitle={trackTitle}
|
||||||
|
onClose={() => setPlaylistOpen(false)}
|
||||||
|
anchorRef={playlistBtnRef as React.RefObject<HTMLElement | null>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Favorite */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleFavorite(trackTitle)}
|
||||||
|
aria-label={favorited ? 'Убрать из избранного' : 'В избранное'}
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2"
|
||||||
|
style={{ color: favorited ? 'var(--accent)' : undefined }}
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? 'var(--accent)' : 'none'} stroke={favorited ? 'var(--accent)' : 'currentColor'} strokeWidth="2">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Queue */}
|
||||||
|
<button
|
||||||
|
onClick={() => setPanel(p => p === 'queue' ? null : 'queue')}
|
||||||
|
aria-label="Очередь"
|
||||||
|
aria-expanded={panel === 'queue'}
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||||
|
style={{ color: panel === 'queue' ? 'var(--accent)' : undefined, background: panel === 'queue' ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
|
||||||
|
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Remote / Share */}
|
||||||
|
<div className="relative share-popover-root">
|
||||||
|
<button
|
||||||
|
ref={shareBtnRef}
|
||||||
|
onClick={async () => {
|
||||||
|
let id = roomId
|
||||||
|
if (!id) {
|
||||||
|
const res = await fetch(`${API_URL}/api/remote`, { method: 'POST' }).catch(() => null)
|
||||||
|
if (res?.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
id = data.id as string
|
||||||
|
setRoomId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id) setShareOpen(v => !v)
|
||||||
|
}}
|
||||||
|
aria-label="Пульт управления"
|
||||||
|
aria-expanded={shareOpen}
|
||||||
|
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
|
||||||
|
style={{ color: shareOpen || roomId ? 'var(--accent)' : undefined, background: shareOpen ? 'rgba(var(--accent-rgb),0.06)' : undefined }}
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||||
|
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||||
|
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
||||||
|
<circle cx="12" cy="20" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{shareOpen && roomId && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 w-64 bg-surface border border-white/[0.07] rounded-[12px] p-3 shadow-2xl z-50">
|
||||||
|
<p className="text-[11px] text-muted font-display font-bold tracking-[1px] uppercase mb-2">Пульт управления</p>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
aria-label="Ссылка на пульт"
|
||||||
|
value={typeof window !== 'undefined' ? `${window.location.origin}/remote/${roomId}` : `/remote/${roomId}`}
|
||||||
|
className="flex-1 min-w-0 text-[11px] bg-surface2 border border-white/[0.07] rounded-[7px] px-2 py-1.5 text-muted outline-none font-mono truncate"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
navigator.clipboard.writeText(`${window.location.origin}/remote/${roomId}`)
|
||||||
|
setShareCopied(true)
|
||||||
|
setTimeout(() => setShareCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="Копировать ссылку на пульт"
|
||||||
|
className="shrink-0 px-2 py-1.5 text-[11px] rounded-[7px] border border-white/[0.07] hover:bg-surface2 transition-all cursor-pointer font-display"
|
||||||
|
style={{ color: shareCopied ? 'var(--accent)' : undefined }}
|
||||||
|
>
|
||||||
|
{shareCopied ? '✓' : 'Копировать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted mt-1.5 leading-relaxed">Откройте ссылку на другом устройстве для управления плеером</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
</div>}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,101 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { usePartyStore } from '@/store/partyStore'
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
import { useFavoritesStore } from '@/store/favoritesStore'
|
import { useFavoritesStore } from '@/store/favoritesStore'
|
||||||
import { useVersionStore } from '@/store/versionStore'
|
import { useVersionStore } from '@/store/versionStore'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { useOverlayStore } from '@/store/overlayStore'
|
||||||
|
import { audioState } from '@/lib/audioState'
|
||||||
import BottomPlayer from '@/components/BottomPlayer'
|
import BottomPlayer from '@/components/BottomPlayer'
|
||||||
import type { SearchResult } from '@/types'
|
import type { SearchResult } from '@/types'
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||||
|
|
||||||
export default function GlobalPlayer() {
|
export default function GlobalPlayer() {
|
||||||
const { hydrate: hydrateFavorites } = useFavoritesStore()
|
const { hydrate: hydrateFavorites } = useFavoritesStore()
|
||||||
const { hydrate: hydrateVersions } = useVersionStore()
|
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(() => {
|
useEffect(() => {
|
||||||
hydrateFavorites()
|
return usePartyStore.subscribe((state, prev) => {
|
||||||
}, [hydrateFavorites])
|
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(() => {
|
useEffect(() => {
|
||||||
hydrateVersions()
|
return useOverlayStore.subscribe(() => {
|
||||||
}, [token, hydrateVersions])
|
pushOverlay(usePartyStore.getState().currentResult ?? null)
|
||||||
|
})
|
||||||
|
}, [pushOverlay])
|
||||||
|
|
||||||
const handleTrackEnd = useCallback((result: SearchResult) => {
|
const handleTrackEnd = useCallback((result: SearchResult) => {
|
||||||
const { queue, curIdx, addToHistory } = usePartyStore.getState()
|
const { queue, curIdx, addToHistory } = usePartyStore.getState()
|
||||||
const track = queue[curIdx]
|
const track = queue[curIdx]
|
||||||
if (!track) return
|
if (!track) return
|
||||||
addToHistory({
|
addToHistory({
|
||||||
title: result.title,
|
title: result.title,
|
||||||
artist: result.artist,
|
artist: result.artist,
|
||||||
img: result.img,
|
img: result.img,
|
||||||
owner: track.owner,
|
owner: track.owner,
|
||||||
color: track.color,
|
color: track.color,
|
||||||
playedAt: new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }),
|
playedAt: new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }),
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ export default function Header() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
@@ -22,8 +24,12 @@ export default function Header() {
|
|||||||
return () => document.removeEventListener('mousedown', handler)
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
// Close mobile menu on navigation
|
||||||
|
useEffect(() => { setMenuOpen(false) }, [pathname])
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
setMenuOpen(false)
|
||||||
clearAuth()
|
clearAuth()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
@@ -33,6 +39,8 @@ export default function Header() {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
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"
|
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
|
style={active
|
||||||
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.3)', color: 'var(--accent)' }
|
? { background: 'rgba(var(--accent-rgb),0.12)', borderColor: 'rgba(var(--accent-rgb),0.3)', color: 'var(--accent)' }
|
||||||
@@ -45,118 +53,213 @@ export default function Header() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const mobileNavLink = (href: string, label: string, icon: React.ReactNode) => {
|
||||||
<div className="flex items-center gap-2.5 mb-5 pb-5 border-b border-white/[0.07]">
|
const active = pathname === href
|
||||||
<Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline">
|
return (
|
||||||
<div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0">
|
<Link
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
href={href}
|
||||||
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
onClick={() => setMenuOpen(false)}
|
||||||
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
|
aria-current={active ? 'page' : undefined}
|
||||||
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
|
className="flex items-center gap-3 px-4 py-3 text-[14px] font-medium transition-colors duration-150"
|
||||||
</svg>
|
style={active
|
||||||
</div>
|
? { color: 'var(--accent)', background: 'rgba(var(--accent-rgb),0.06)' }
|
||||||
<h1 className="font-display text-xl font-extrabold tracking-tight text-app-text">Party Mix</h1>
|
: { color: 'var(--color-muted)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
const searchIcon = (
|
||||||
{navLink('/search', 'Поиск',
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
|
||||||
<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" />
|
||||||
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
</svg>
|
||||||
</svg>
|
)
|
||||||
)}
|
const communityIcon = (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const playlistsIcon = (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
const settingsIcon = (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
{navLink('/community', 'Сообщество',
|
return (
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
<div className="relative mb-5">
|
||||||
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" />
|
<div className="flex items-center gap-2.5 pb-5 border-b border-white/[0.07]">
|
||||||
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
<Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline" aria-label="Party Mix — главная">
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
<div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0" aria-hidden="true">
|
||||||
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
</svg>
|
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
)}
|
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
|
||||||
|
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-xl font-extrabold tracking-tight text-app-text">Party Mix</h1>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{user ? (
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<>
|
{navLink('/search', 'Поиск', searchIcon)}
|
||||||
{navLink('/playlists', 'Плейлисты',
|
{navLink('/community', 'Сообщество', communityIcon)}
|
||||||
<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" />
|
{user ? (
|
||||||
|
<>
|
||||||
|
{navLink('/playlists', 'Плейлисты', playlistsIcon)}
|
||||||
|
|
||||||
|
{/* User badge with dropdown */}
|
||||||
|
<div className="relative hidden sm:block" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
aria-label={`Меню пользователя ${user.username}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5 cursor-pointer transition-colors duration-150 hover:border-white/[0.14]"
|
||||||
|
>
|
||||||
|
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0" aria-hidden="true">
|
||||||
|
<span className="text-[10px] font-display font-extrabold text-accent">
|
||||||
|
{user.username[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
|
||||||
|
<svg
|
||||||
|
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="text-muted transition-transform duration-200 ml-0.5"
|
||||||
|
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1.5 w-52 bg-surface border border-white/[0.09] rounded-xl shadow-xl z-50 py-1 overflow-hidden"
|
||||||
|
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-app-text hover:bg-white/[0.04] transition-all duration-150"
|
||||||
|
>
|
||||||
|
{settingsIcon}
|
||||||
|
Настройки
|
||||||
|
</Link>
|
||||||
|
<div className="border-t border-white/[0.07] mx-1 my-0.5" />
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
aria-label="Выйти из аккаунта"
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted hover:text-[#ff6b6b] hover:bg-white/[0.04] transition-all duration-150 cursor-pointer"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5 hidden sm:block"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150 hidden sm:block"
|
||||||
|
>
|
||||||
|
Регистрация
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hamburger — mobile only */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(v => !v)}
|
||||||
|
aria-label={menuOpen ? 'Закрыть меню' : 'Открыть меню'}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded-[8px] sm:hidden text-muted hover:text-app-text hover:bg-surface2 transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
{menuOpen ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
{/* User badge with dropdown */}
|
</div>
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(v => !v)}
|
|
||||||
className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5 cursor-pointer transition-colors duration-150 hover:border-white/[0.14]"
|
|
||||||
>
|
|
||||||
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-[10px] font-display font-extrabold text-accent">
|
|
||||||
{user.username[0].toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
|
|
||||||
<svg
|
|
||||||
width="10" height="10" viewBox="0 0 24 24" fill="none"
|
|
||||||
className="text-muted transition-transform duration-200 ml-0.5"
|
|
||||||
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-full mt-1.5 w-52 bg-surface border border-white/[0.09] rounded-xl shadow-xl z-50 py-1 overflow-hidden"
|
|
||||||
style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{/* Settings link */}
|
|
||||||
<Link
|
|
||||||
href="/settings"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-app-text hover:bg-white/[0.04] transition-all duration-150"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
||||||
</svg>
|
|
||||||
Настройки
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="border-t border-white/[0.07] mx-1 my-0.5" />
|
|
||||||
|
|
||||||
{/* Logout */}
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted hover:text-[#ff6b6b] hover:bg-white/[0.04] transition-all duration-150 cursor-pointer"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5"
|
|
||||||
>
|
|
||||||
Войти
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/register"
|
|
||||||
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
|
|
||||||
>
|
|
||||||
Регистрация
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { usePartyStore } from '@/store/partyStore'
|
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'
|
import { proxyImgUrl } from '@/lib/api'
|
||||||
|
|
||||||
export default function HistoryTab() {
|
export default function HistoryTab() {
|
||||||
const { history, clearHistory } = usePartyStore()
|
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 (
|
return (
|
||||||
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
|
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden animate-fadeUp">
|
||||||
@@ -13,12 +35,23 @@ export default function HistoryTab() {
|
|||||||
Уже сыграло
|
Уже сыграло
|
||||||
</span>
|
</span>
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={clearHistory}
|
{user && (
|
||||||
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
|
||||||
>
|
onClick={handleSavePlaylist}
|
||||||
Очистить
|
disabled={saving}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
489
apps/web/src/components/OverlayWidget.tsx
Normal file
489
apps/web/src/components/OverlayWidget.tsx
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { getPalette, buildCustomPalette, type OverlayPalette } from '@/lib/overlayPalettes'
|
||||||
|
import { useOverlayStore, type OverlayStyle } from '@/store/overlayStore'
|
||||||
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
|
|
||||||
|
// Shared state type used by both the overlay page and the settings preview
|
||||||
|
export interface OverlayWidgetState {
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
cover: string
|
||||||
|
is_playing: boolean
|
||||||
|
progress: number
|
||||||
|
duration: number
|
||||||
|
enabled: boolean
|
||||||
|
design: string
|
||||||
|
style: string
|
||||||
|
accent_color: string
|
||||||
|
position: string
|
||||||
|
font: string
|
||||||
|
text_color: string
|
||||||
|
show_cover: boolean
|
||||||
|
show_eq: boolean
|
||||||
|
palette: string
|
||||||
|
custom_bg: string
|
||||||
|
custom_text: string
|
||||||
|
custom_text2: string
|
||||||
|
custom_chroma: string
|
||||||
|
custom_title_bg: string
|
||||||
|
custom_body_bg: string
|
||||||
|
margin: number
|
||||||
|
scale: number
|
||||||
|
opacity: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCover(src: string) {
|
||||||
|
const [err, setErr] = useState(false)
|
||||||
|
useEffect(() => setErr(false), [src])
|
||||||
|
return { src: err ? '' : src, onError: () => setErr(true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EqBars({ color, size = 14, playing = true }: { color: string; size?: number; playing?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2.5, height: size, flexShrink: 0 }}>
|
||||||
|
{[65, 100, 50, 85, 40].map((h, i) => (
|
||||||
|
<div key={i} className="eq-bar" style={{ height: `${h}%`, width: 2.5, background: color, borderRadius: 2, animationDelay: `${i * 0.13}s`, animationPlayState: playing ? 'running' : 'paused' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MusicIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="55%" height="55%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({ progress, duration, color, style: barStyle }: { progress: number; duration: number; color: string; style?: React.CSSProperties }) {
|
||||||
|
const pct = duration > 0 ? Math.min(100, (progress / duration) * 100) : 0
|
||||||
|
if (!duration) return null
|
||||||
|
return (
|
||||||
|
<div style={{ height: 2, background: 'rgba(255,255,255,0.1)', borderRadius: 1, overflow: 'hidden', ...barStyle }}>
|
||||||
|
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 1, transition: 'width 1s linear' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// position: fixed → in the overlay page, fixed to viewport.
|
||||||
|
// In the preview box (which has transform:translateZ(0)), fixed children are contained inside.
|
||||||
|
// --ov-scale is set by the overlay page per the user's scale setting.
|
||||||
|
// transform-origin follows --ov-origin (set based on position).
|
||||||
|
export const OVERLAY_POS: React.CSSProperties = {
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 'var(--ov-b)' as string,
|
||||||
|
top: 'var(--ov-t)' as string,
|
||||||
|
left: 'var(--ov-l)' as string,
|
||||||
|
right: 'var(--ov-r)' as string,
|
||||||
|
transform: 'scale(var(--ov-scale,1))',
|
||||||
|
transformOrigin: 'var(--ov-origin, bottom left)',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. Классика ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ClassicStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const cover = useCover(s.cover)
|
||||||
|
const bg = pal.bg ?? 'rgba(10,10,16,0.82)'
|
||||||
|
const border = pal.border ?? '1px solid rgba(255,255,255,0.09)'
|
||||||
|
const shadow = pal.shadow ?? '0 8px 32px rgba(0,0,0,0.55)'
|
||||||
|
const text = s.text_color || pal.text || '#fff'
|
||||||
|
const text2 = pal.text2 ?? 'rgba(255,255,255,0.45)'
|
||||||
|
const coverBg = pal.chroma ?? 'rgba(255,255,255,0.06)'
|
||||||
|
return (
|
||||||
|
<div style={{ ...OVERLAY_POS, display: 'flex', flexDirection: 'column', gap: 0, padding: '10px 16px 10px 10px', borderRadius: 16, background: bg, backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', border, boxShadow: shadow, maxWidth: 340 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{s.show_cover !== false && (
|
||||||
|
<div style={{ width: 44, height: 44, borderRadius: 10, overflow: 'hidden', background: coverBg, flexShrink: 0 }}>
|
||||||
|
{cover.src
|
||||||
|
? <img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
|
||||||
|
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.3 }}><MusicIcon /></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||||
|
</div>
|
||||||
|
{s.show_eq !== false && <EqBars color="var(--accent)" playing={s.is_playing} />}
|
||||||
|
</div>
|
||||||
|
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ marginTop: 8 }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Фрутигер Аеро ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AeroStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const cover = useCover(s.cover)
|
||||||
|
const bg = pal.bg ?? 'linear-gradient(160deg,rgba(190,230,255,0.55) 0%,rgba(120,185,255,0.42) 100%)'
|
||||||
|
const border = pal.border ?? '1.5px solid rgba(255,255,255,0.72)'
|
||||||
|
const shadow = pal.shadow ?? '0 4px 20px rgba(80,160,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)'
|
||||||
|
const text = s.text_color || pal.text || '#003a6e'
|
||||||
|
const text2 = pal.text2 ?? 'rgba(0,60,130,0.60)'
|
||||||
|
return (
|
||||||
|
<div style={{ ...OVERLAY_POS, display: 'flex', flexDirection: 'column', borderRadius: 50, background: bg, backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)', border, boxShadow: shadow, maxWidth: 340, overflow: 'hidden', position: 'fixed' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '55%', background: 'linear-gradient(180deg,rgba(255,255,255,0.38) 0%,rgba(255,255,255,0) 100%)', borderRadius: '50px 50px 0 0', pointerEvents: 'none' }} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 20px 8px 8px' }}>
|
||||||
|
{s.show_cover !== false && (
|
||||||
|
<div style={{ width: 42, height: 42, borderRadius: '50%', overflow: 'hidden', background: 'rgba(255,255,255,0.25)', flexShrink: 0, border: '2px solid rgba(255,255,255,0.6)', boxShadow: '0 2px 8px rgba(0,80,180,0.2)' }}>
|
||||||
|
{cover.src
|
||||||
|
? <img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" />
|
||||||
|
: <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(0,80,160,0.5)' }}><MusicIcon /></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, position: 'relative' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '-0.02em' }}>{s.title || '—'}</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||||
|
</div>
|
||||||
|
{s.show_eq !== false && <EqBars color="var(--accent)" size={12} playing={s.is_playing} />}
|
||||||
|
</div>
|
||||||
|
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ borderRadius: 0, margin: '0 0 0 0' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Ретро ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RetroStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const [blink, setBlink] = useState(true)
|
||||||
|
useEffect(() => { const iv = setInterval(() => setBlink(b => !b), 800); return () => clearInterval(iv) }, [])
|
||||||
|
const now = new Date()
|
||||||
|
const ts = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`
|
||||||
|
const bg = pal.bg ?? 'rgba(12,5,0,0.94)'
|
||||||
|
const border = pal.border ?? '2px solid #c07030'
|
||||||
|
const shadow = pal.shadow ?? '0 0 28px rgba(180,90,20,0.45),4px 4px 0 rgba(0,0,0,0.6)'
|
||||||
|
const chromaA = pal.chroma ?? 'rgba(180,100,20,0.30)'
|
||||||
|
const text = s.text_color || pal.text || '#f8d090'
|
||||||
|
const text2 = pal.text2 ?? '#9a6020'
|
||||||
|
const blinkColor = pal.chroma?.startsWith('#') ? pal.chroma : 'var(--accent)'
|
||||||
|
const pct = s.duration > 0 ? Math.min(100, (s.progress / s.duration) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div style={{ ...OVERLAY_POS, width: 310, fontFamily: s.font || 'monospace', background: bg, border, boxShadow: shadow, overflow: 'hidden' }}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px)', pointerEvents: 'none', zIndex: 2 }} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: chromaA, borderBottom: `1px solid ${blinkColor}44` }}>
|
||||||
|
<span style={{ fontSize: 10, color: blinkColor, letterSpacing: '0.1em' }}>▶ NOW PLAYING</span>
|
||||||
|
<span style={{ fontSize: 10, color: blink ? blinkColor : 'transparent', letterSpacing: '0.05em' }}>● REC</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '10px 12px 8px', position: 'relative', zIndex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: text, letterSpacing: '0.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 3, letterSpacing: '0.05em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||||
|
{s.duration > 0 && (
|
||||||
|
<div style={{ marginTop: 8, height: 3, background: 'rgba(255,255,255,0.1)', borderRadius: 0 }}>
|
||||||
|
<div style={{ height: '100%', width: `${pct}%`, background: blinkColor, transition: 'width 1s linear' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
|
<span style={{ fontSize: 9, color: text2, fontFamily: 'monospace' }}>{ts}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Неон ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function NeonStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const bg = pal.bg ?? 'rgba(0,0,10,0.9)'
|
||||||
|
const vars = pal.chroma && pal.chroma2
|
||||||
|
? { '--accent': pal.chroma, '--accent-rgb': pal.chroma2 } as React.CSSProperties
|
||||||
|
: {}
|
||||||
|
const text = s.text_color || 'var(--accent)'
|
||||||
|
return (
|
||||||
|
<div style={{ ...vars, ...OVERLAY_POS, padding: '12px 18px', background: bg, backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', border: '1px solid rgba(var(--accent-rgb),0.6)', boxShadow: '0 0 28px rgba(var(--accent-rgb),0.3),inset 0 0 20px rgba(var(--accent-rgb),0.04)', borderRadius: 8, maxWidth: 340 }}>
|
||||||
|
{[['0','0'],['0','auto'],['auto','0'],['auto','auto']].map(([t,b], i) => (
|
||||||
|
<div key={i} style={{ position: 'absolute', top: t === '0' ? -1 : 'auto', bottom: b === 'auto' ? 'auto' : -1, left: i < 2 ? -1 : 'auto', right: i < 2 ? 'auto' : -1, width: 8, height: 8, border: '2px solid var(--accent)', borderRadius: 1 }} />
|
||||||
|
))}
|
||||||
|
<div style={{ fontSize: 10, color: 'rgba(var(--accent-rgb),0.5)', letterSpacing: '0.2em', marginBottom: 6, fontFamily: 'monospace' }}>◈ STREAM</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 800, color: text, letterSpacing: '0.04em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', textShadow: '0 0 12px rgba(var(--accent-rgb),0.8)' }}>{s.title || '—'}</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 11, color: 'rgba(var(--accent-rgb),0.5)', marginTop: 4, letterSpacing: '0.06em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
|
||||||
|
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ flex: 1, background: 'rgba(var(--accent-rgb),0.15)' }} />
|
||||||
|
{(!s.duration) && <div style={{ flex: 1, height: 1, background: 'linear-gradient(90deg,rgba(var(--accent-rgb),0.6),transparent)' }} />}
|
||||||
|
{s.show_eq !== false && <EqBars color="var(--accent)" size={10} playing={s.is_playing} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Минимализм ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CleanStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const text = s.text_color || pal.text || '#ffffff'
|
||||||
|
const text2 = pal.text2 ?? 'rgba(255,255,255,0.55)'
|
||||||
|
return (
|
||||||
|
<div style={{ ...OVERLAY_POS }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{ width: 4, height: 32, background: 'var(--accent)', borderRadius: 2, flexShrink: 0 }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 800, color: text, lineHeight: 1.1, letterSpacing: '-0.03em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 400, textShadow: '0 2px 16px rgba(0,0,0,0.8)' }}>{s.title || '—'}</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 13, color: text2, marginTop: 3, letterSpacing: '-0.01em', textShadow: '0 2px 8px rgba(0,0,0,0.8)' }}>{s.artist}</div>}
|
||||||
|
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ marginTop: 6, width: 200 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Y2K ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Y2kStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const titleBg = pal.titleBg ?? 'linear-gradient(90deg,#1a4090 0%,#4a80d0 40%,#1a4090 100%)'
|
||||||
|
const bodyBg = pal.bodyBg ?? 'linear-gradient(160deg,#dce4f0,#c0c8e0)'
|
||||||
|
const text = s.text_color || pal.text || '#000060'
|
||||||
|
const text2 = pal.text2 ?? '#404880'
|
||||||
|
const pct = s.duration > 0 ? Math.min(100, (s.progress / s.duration) * 100) : 42
|
||||||
|
return (
|
||||||
|
<div style={{ ...OVERLAY_POS, width: 300, fontFamily: s.font || 'Tahoma, Arial, sans-serif', boxShadow: '3px 3px 0 rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.9)', border: '2px solid #7080b0', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '3px 4px 3px 6px', background: titleBg, userSelect: 'none' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span style={{ fontSize: 9 }}>🎵</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#fff', fontWeight: 700, letterSpacing: '0.01em' }}>Party Mix Player</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 2 }}>
|
||||||
|
{['_','□','×'].map(c => (
|
||||||
|
<div key={c} style={{ width: 16, height: 14, background: 'linear-gradient(180deg,#d0d8e8,#9aa8c0)', border: '1px solid #607090', borderRadius: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, color: '#000', fontWeight: 700, cursor: 'default' }}>{c}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: bodyBg, padding: '10px 12px 12px' }}>
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.5)', border: '1px solid #8090b0', borderRadius: 2, padding: '6px 10px', marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title || '—'}</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 10, color: text2, marginTop: 2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 14, background: 'rgba(255,255,255,0.4)', border: '1px solid #8090b0', borderRadius: 2, overflow: 'hidden', boxShadow: 'inset 1px 1px 2px rgba(0,0,0,0.15)' }}>
|
||||||
|
<div style={{ height: '100%', width: `${pct}%`, background: 'linear-gradient(180deg,var(--accent),rgba(var(--accent-rgb),0.65))', borderRadius: 1, transition: 'width 1s linear' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 3 }}>
|
||||||
|
<span style={{ fontSize: 9, color: text2 }}>♪ {s.is_playing ? 'Playing' : 'Paused'}</span>
|
||||||
|
<span style={{ fontSize: 9, color: text2 }}>WMP 9.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. Ло-фай ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function LofiStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const cover = useCover(s.cover)
|
||||||
|
const bg = pal.bg ?? 'rgba(42,26,14,0.92)'
|
||||||
|
const text = s.text_color || pal.text || '#fde8c0'
|
||||||
|
const text2 = pal.text2 ?? 'rgba(var(--accent-rgb),0.60)'
|
||||||
|
return (
|
||||||
|
<div style={{ ...OVERLAY_POS, display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px', borderRadius: 18, background: bg, backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', border: '1px solid rgba(var(--accent-rgb),0.2)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', transform: `rotate(-0.8deg) scale(var(--ov-scale,1))`, maxWidth: 320 }}>
|
||||||
|
{s.show_cover !== false && (
|
||||||
|
<div style={{ width: 52, height: 52, borderRadius: '50%', flexShrink: 0, background: 'radial-gradient(circle at center,#3a2010 0%,#1a0e06 40%,#2a1808 60%,#1a0e06 100%)', border: '2px solid rgba(var(--accent-rgb),0.3)', boxShadow: '0 2px 12px rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', position: 'relative' }}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'repeating-radial-gradient(circle at center,transparent 0%,transparent 14%,rgba(255,255,255,0.03) 14.5%,transparent 15%)', borderRadius: '50%' }} />
|
||||||
|
{cover.src
|
||||||
|
? <div style={{ width: 22, height: 22, borderRadius: '50%', overflow: 'hidden', zIndex: 1 }}><img src={cover.src} onError={cover.onError} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt="" /></div>
|
||||||
|
: <div style={{ width: 10, height: 10, borderRadius: '50%', background: 'rgba(var(--accent-rgb),0.5)', zIndex: 1 }} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.55)', letterSpacing: '0.15em', marginBottom: 4, fontStyle: 'italic' }}>now playing</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontStyle: 'italic', letterSpacing: '0.01em' }}>{s.title || '—'}</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 11, color: text2, marginTop: 3, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', fontStyle: 'italic' }}>{s.artist}</div>}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6 }}>
|
||||||
|
<ProgressBar progress={s.progress} duration={s.duration} color="var(--accent)" style={{ flex: 1, background: 'rgba(var(--accent-rgb),0.2)' }} />
|
||||||
|
{!s.duration && <div style={{ flex: 1, height: 1, background: 'rgba(var(--accent-rgb),0.25)' }} />}
|
||||||
|
{s.show_eq !== false && <EqBars color="var(--accent)" size={10} playing={s.is_playing} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. Гламур ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function GlamStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const bg = pal.bg ?? 'linear-gradient(145deg,rgba(28,18,4,0.95),rgba(16,10,2,0.95))'
|
||||||
|
const chroma = pal.chroma ?? '212,175,55'
|
||||||
|
const text = s.text_color || pal.text || `rgb(${chroma})`
|
||||||
|
const text2 = pal.text2 ?? `rgba(${chroma},0.65)`
|
||||||
|
const metalG = `linear-gradient(90deg,transparent,rgba(${chroma},0.8) 30%,rgb(${chroma}) 50%,rgba(${chroma},0.8) 70%,transparent)`
|
||||||
|
return (
|
||||||
|
<div style={{ ...OVERLAY_POS, padding: '16px 20px', background: bg, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', border: `1px solid rgba(${chroma},0.45)`, boxShadow: `0 8px 40px rgba(0,0,0,0.7),0 0 0 1px rgba(${chroma},0.2),inset 0 1px 0 rgba(${chroma},0.15)`, borderRadius: 14, maxWidth: 340, overflow: 'hidden' }}>
|
||||||
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: metalG }} />
|
||||||
|
<div style={{ position: 'absolute', top: 6, left: 6, fontSize: 10, color: `rgba(${chroma},0.55)` }}>✦</div>
|
||||||
|
<div style={{ position: 'absolute', top: 6, right: 6, fontSize: 10, color: `rgba(${chroma},0.55)` }}>✦</div>
|
||||||
|
<div style={{ textAlign: 'center', fontSize: 8, color: `rgba(${chroma},0.5)`, letterSpacing: '0.3em', marginBottom: 8, textTransform: 'uppercase' }}>Now Playing</div>
|
||||||
|
<div style={{ height: 1, background: `linear-gradient(90deg,transparent,rgba(${chroma},0.4),transparent)`, marginBottom: 10 }} />
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: text, textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', letterSpacing: '0.02em', textShadow: `0 0 20px rgba(${chroma},0.4)` }}>{s.title || '—'}</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 11, color: text2, textAlign: 'center', marginTop: 5, letterSpacing: '0.08em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.artist}</div>}
|
||||||
|
<div style={{ height: 1, background: `linear-gradient(90deg,transparent,rgba(${chroma},0.4),transparent)`, marginTop: 10 }} />
|
||||||
|
{s.show_eq !== false && <div style={{ textAlign: 'center', marginTop: 6 }}><EqBars color={`rgb(${chroma})`} size={10} playing={s.is_playing} /></div>}
|
||||||
|
<ProgressBar progress={s.progress} duration={s.duration} color={`rgb(${chroma})`} style={{ marginTop: 8, background: `rgba(${chroma},0.15)` }} />
|
||||||
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: metalG }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. Матрица ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function MatrixStyle({ s, pal }: { s: OverlayWidgetState; pal: OverlayPalette }) {
|
||||||
|
const [cursor, setCursor] = useState(true)
|
||||||
|
const [dots, setDots] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const iv = setInterval(() => { setCursor(c => !c); setDots(d => (d + 1) % 4) }, 530)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [])
|
||||||
|
const bg = pal.bg ?? 'rgba(0,8,0,0.93)'
|
||||||
|
const border = pal.border ?? '1px solid rgba(0,255,50,0.35)'
|
||||||
|
const vars = pal.chroma && pal.chroma2
|
||||||
|
? { '--accent': pal.chroma, '--accent-rgb': pal.chroma2 } as React.CSSProperties
|
||||||
|
: {}
|
||||||
|
const text = s.text_color || pal.chroma || '#00ff32'
|
||||||
|
return (
|
||||||
|
<div style={{ ...vars, ...OVERLAY_POS, padding: '12px 16px', background: bg, backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', border, boxShadow: '0 0 24px rgba(var(--accent-rgb),0.15),inset 0 0 12px rgba(var(--accent-rgb),0.03)', borderRadius: 4, maxWidth: 360, fontFamily: s.font || '"Courier New",Courier,monospace' }}>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.5)', letterSpacing: '0.08em', marginBottom: 6 }}>PARTY_MIX@STREAM:~$ play --now</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(var(--accent-rgb),0.6)', marginRight: 6 }}>></span>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, color: text, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 280, textShadow: '0 0 8px rgba(var(--accent-rgb),0.6)' }}>{s.title || 'NULL'}</span>
|
||||||
|
<span style={{ color: text, opacity: cursor ? 1 : 0, marginLeft: 2, textShadow: '0 0 6px rgba(var(--accent-rgb),0.8)' }}>▌</span>
|
||||||
|
</div>
|
||||||
|
{s.artist && <div style={{ fontSize: 11, color: 'rgba(var(--accent-rgb),0.55)', marginTop: 3, letterSpacing: '0.05em' }}>{`:: artist="${s.artist}"`}</div>}
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(var(--accent-rgb),0.4)', marginTop: 6, letterSpacing: '0.05em' }}>{`[LOADING${'.'.repeat(dots)}]`}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Style map ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const OVERLAY_STYLE_MAP: Record<string, (s: OverlayWidgetState, pal: OverlayPalette) => React.ReactNode> = {
|
||||||
|
classic: (s, pal) => <ClassicStyle s={s} pal={pal} />,
|
||||||
|
aero: (s, pal) => <AeroStyle s={s} pal={pal} />,
|
||||||
|
retro: (s, pal) => <RetroStyle s={s} pal={pal} />,
|
||||||
|
neon: (s, pal) => <NeonStyle s={s} pal={pal} />,
|
||||||
|
clean: (s, pal) => <CleanStyle s={s} pal={pal} />,
|
||||||
|
y2k: (s, pal) => <Y2kStyle s={s} pal={pal} />,
|
||||||
|
lofi: (s, pal) => <LofiStyle s={s} pal={pal} />,
|
||||||
|
glam: (s, pal) => <GlamStyle s={s} pal={pal} />,
|
||||||
|
matrix: (s, pal) => <MatrixStyle s={s} pal={pal} />,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings preview ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function OverlayPreview() {
|
||||||
|
const {
|
||||||
|
style: overlayStyle,
|
||||||
|
palette: overlayPalette,
|
||||||
|
customPalettes,
|
||||||
|
accentColor,
|
||||||
|
textColor,
|
||||||
|
showCover,
|
||||||
|
showEq,
|
||||||
|
font,
|
||||||
|
} = useOverlayStore()
|
||||||
|
|
||||||
|
const currentResult = usePartyStore(s => s.currentResult)
|
||||||
|
|
||||||
|
const cp = customPalettes[overlayStyle] ?? {}
|
||||||
|
const pal = overlayPalette === 'custom'
|
||||||
|
? buildCustomPalette(overlayStyle as OverlayStyle, {
|
||||||
|
bg: cp.bg ?? '',
|
||||||
|
text: cp.text ?? '',
|
||||||
|
text2: cp.text2 ?? '',
|
||||||
|
chroma: cp.chroma ?? '',
|
||||||
|
titleBg: cp.titleBg ?? '',
|
||||||
|
bodyBg: cp.bodyBg ?? '',
|
||||||
|
})
|
||||||
|
: getPalette(overlayStyle as OverlayStyle, overlayPalette)
|
||||||
|
|
||||||
|
const hex = /^#[0-9a-fA-F]{6}$/.test(accentColor) ? accentColor : '#de9cfe'
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
|
||||||
|
// Use real track if playing, otherwise mock
|
||||||
|
const mockState: OverlayWidgetState = {
|
||||||
|
title: currentResult?.title ?? 'Название трека',
|
||||||
|
artist: currentResult?.artist ?? 'Артист',
|
||||||
|
cover: currentResult?.img ?? '',
|
||||||
|
is_playing: true,
|
||||||
|
progress: 0,
|
||||||
|
duration: 0,
|
||||||
|
enabled: true,
|
||||||
|
design: 'minimal',
|
||||||
|
style: overlayStyle,
|
||||||
|
accent_color: hex,
|
||||||
|
position: 'bl',
|
||||||
|
font,
|
||||||
|
text_color: textColor,
|
||||||
|
show_cover: showCover,
|
||||||
|
show_eq: showEq,
|
||||||
|
palette: overlayPalette,
|
||||||
|
custom_bg: cp.bg ?? '',
|
||||||
|
custom_text: cp.text ?? '',
|
||||||
|
custom_text2: cp.text2 ?? '',
|
||||||
|
custom_chroma: cp.chroma ?? '',
|
||||||
|
custom_title_bg: cp.titleBg ?? '',
|
||||||
|
custom_body_bg: cp.bodyBg ?? '',
|
||||||
|
margin: 24,
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
updated_at: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = OVERLAY_STYLE_MAP[overlayStyle] ?? OVERLAY_STYLE_MAP.classic
|
||||||
|
|
||||||
|
// Re-key on style change to replay enter animation
|
||||||
|
const animKey = overlayStyle + overlayPalette
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
height: 160,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
// transform creates a new containing block for position:fixed children
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
'--accent': hex,
|
||||||
|
'--accent-rgb': `${r},${g},${b}`,
|
||||||
|
'--ov-b': '20px',
|
||||||
|
'--ov-t': 'auto',
|
||||||
|
'--ov-l': '20px',
|
||||||
|
'--ov-r': 'auto',
|
||||||
|
'--ov-scale': '1',
|
||||||
|
'--ov-origin': 'bottom left',
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{/* "stream" background */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'rgba(8,8,12,0.97)' }} />
|
||||||
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(255,255,255,0.022) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.022) 1px,transparent 1px)', backgroundSize: '22px 22px' }} />
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse at 15% 80%,rgba(${r},${g},${b},0.10) 0%,transparent 55%)` }} />
|
||||||
|
<div key={animKey}>{render(mockState, pal)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local progress hook (for overlay page) ────────────────────────────────────
|
||||||
|
|
||||||
|
export function useLocalProgress(serverProgress: number, serverDuration: number, isPlaying: boolean, updatedAt: number) {
|
||||||
|
const [progress, setProgress] = useState(serverProgress)
|
||||||
|
const startRef = useRef({ t: Date.now(), p: serverProgress })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startRef.current = { t: Date.now(), p: serverProgress }
|
||||||
|
setProgress(serverProgress)
|
||||||
|
}, [serverProgress, updatedAt])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !serverDuration) return
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
const elapsed = (Date.now() - startRef.current.t) / 1000
|
||||||
|
setProgress(Math.min(serverDuration, startRef.current.p + elapsed))
|
||||||
|
}, 500)
|
||||||
|
return () => clearInterval(iv)
|
||||||
|
}, [isPlaying, serverDuration])
|
||||||
|
|
||||||
|
return progress
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import type { Playlist } from '@/types'
|
|||||||
|
|
||||||
export default function AddPersonForm() {
|
export default function AddPersonForm() {
|
||||||
const { addPerson } = usePartyStore()
|
const { addPerson } = usePartyStore()
|
||||||
const { token } = useAuthStore()
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [tracks, setTracks] = useState('')
|
const [tracks, setTracks] = useState('')
|
||||||
@@ -18,17 +18,17 @@ export default function AddPersonForm() {
|
|||||||
const [loadingPlaylist, setLoadingPlaylist] = useState(false)
|
const [loadingPlaylist, setLoadingPlaylist] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
if (!user) return
|
||||||
getPlaylists(token)
|
getPlaylists()
|
||||||
.then(setPlaylists)
|
.then(setPlaylists)
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [token])
|
}, [user])
|
||||||
|
|
||||||
const handleSelectPlaylist = async (id: string) => {
|
const handleSelectPlaylist = async (id: string) => {
|
||||||
if (!token || !id) return
|
if (!user || !id) return
|
||||||
setLoadingPlaylist(true)
|
setLoadingPlaylist(true)
|
||||||
try {
|
try {
|
||||||
const pl = await getPlaylist(token, id)
|
const pl = await getPlaylist(id)
|
||||||
if (pl.tracks?.length) {
|
if (pl.tracks?.length) {
|
||||||
setTracks(pl.tracks.map((t) => t.title).join('\n'))
|
setTracks(pl.tracks.map((t) => t.title).join('\n'))
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ export default function AddPersonForm() {
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{token && playlists.length > 0 && (
|
{user && playlists.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<select
|
<select
|
||||||
onChange={(e) => handleSelectPlaylist(e.target.value)}
|
onChange={(e) => handleSelectPlaylist(e.target.value)}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { usePartyStore } from '@/store/partyStore'
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
import PersonCard from './PersonCard'
|
import PersonCard from './PersonCard'
|
||||||
import AddPersonForm from './AddPersonForm'
|
import AddPersonForm from './AddPersonForm'
|
||||||
|
|
||||||
export default function PartyTab() {
|
export default function PartyTab() {
|
||||||
const { people, shuffleMode, setShuffleMode, generateMix } = usePartyStore()
|
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 (
|
return (
|
||||||
<div className="animate-fadeUp">
|
<div className="animate-fadeUp">
|
||||||
@@ -31,14 +43,8 @@ export default function PartyTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleGenerate}
|
||||||
if (!people.length) {
|
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"
|
||||||
alert('Добавьте участников!')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
generateMix()
|
|
||||||
}}
|
|
||||||
className="w-full py-3.5 mb-4 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
|
|
||||||
>
|
>
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<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" />
|
<polyline points="16 3 21 3 21 8" />
|
||||||
@@ -49,7 +55,17 @@ export default function PartyTab() {
|
|||||||
Перемешать и включить
|
Перемешать и включить
|
||||||
</button>
|
</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) => (
|
{people.map((person, i) => (
|
||||||
<PersonCard key={`${person.name}-${i}`} person={person} index={i} />
|
<PersonCard key={`${person.name}-${i}`} person={person} index={i} />
|
||||||
))}
|
))}
|
||||||
@@ -59,3 +75,4 @@ export default function PartyTab() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
116
apps/web/src/components/Player/QueuePanel.tsx
Normal file
116
apps/web/src/components/Player/QueuePanel.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useCallback } from 'react'
|
||||||
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
|
import type { QueueItem } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
queue: QueueItem[]
|
||||||
|
curIdx: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QueuePanel({ queue, curIdx, onClose }: Props) {
|
||||||
|
const { setCurIdx, generateMix, reorderQueue } = usePartyStore()
|
||||||
|
const dragSrc = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
|
||||||
|
dragSrc.current = idx
|
||||||
|
el.classList.add('dragging')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDragEnd = useCallback((el: HTMLElement) => {
|
||||||
|
el.classList.remove('dragging')
|
||||||
|
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
|
||||||
|
e.preventDefault()
|
||||||
|
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
|
||||||
|
el.classList.add('drag-over')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
|
||||||
|
e.preventDefault()
|
||||||
|
el.classList.remove('drag-over')
|
||||||
|
if (dragSrc.current === null || dragSrc.current === tgtIdx) return
|
||||||
|
reorderQueue(dragSrc.current, tgtIdx)
|
||||||
|
dragSrc.current = null
|
||||||
|
}, [reorderQueue])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto flex flex-col">
|
||||||
|
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">
|
||||||
|
Очередь · {queue.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => generateMix()}
|
||||||
|
aria-label="Пересортировать очередь"
|
||||||
|
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
|
||||||
|
>↺</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть очередь"
|
||||||
|
className="text-muted text-[11px] cursor-pointer hover:text-app-text"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
{queue.map((item, i) => {
|
||||||
|
const active = i === curIdx
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
draggable
|
||||||
|
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
|
||||||
|
style={{ background: active ? 'rgba(var(--accent-rgb),0.04)' : undefined }}
|
||||||
|
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
|
||||||
|
onDragStart={(e) => onDragStart(i, e.currentTarget)}
|
||||||
|
onDragEnd={(e) => onDragEnd(e.currentTarget)}
|
||||||
|
onDragOver={(e) => onDragOver(e, e.currentTarget)}
|
||||||
|
onDrop={(e) => onDrop(e, i, e.currentTarget)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
|
||||||
|
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{active ? (
|
||||||
|
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0" aria-label="Играет">
|
||||||
|
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
|
||||||
|
)}
|
||||||
|
{item.img ? (
|
||||||
|
<img
|
||||||
|
src={item.img}
|
||||||
|
alt=""
|
||||||
|
className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2"
|
||||||
|
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium"
|
||||||
|
style={{ background: item.color.bg, color: item.color.text }}
|
||||||
|
>
|
||||||
|
{item.owner}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
apps/web/src/components/Player/VersionsPanel.tsx
Normal file
87
apps/web/src/components/Player/VersionsPanel.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { proxyImgUrl } from '@/lib/api'
|
||||||
|
import type { SearchResult } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
results: SearchResult[]
|
||||||
|
activeResultIdx: number
|
||||||
|
trackTitle: string
|
||||||
|
onPlay: (results: SearchResult[], i: number) => void
|
||||||
|
isSaved: (title: string, r: SearchResult) => boolean
|
||||||
|
saveVersion: (title: string, r: SearchResult) => void
|
||||||
|
removeVersion: (title: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VersionsPanel({
|
||||||
|
results, activeResultIdx, trackTitle, onPlay, isSaved, saveVersion, removeVersion, onClose,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Версии трека</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть версии"
|
||||||
|
className="text-muted text-[11px] cursor-pointer hover:text-app-text"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
{results.map((r, i) => {
|
||||||
|
const saved = isSaved(trackTitle, r)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => onPlay(results, i)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
|
||||||
|
{r.img && !r.img.includes('no-cover') && (
|
||||||
|
<img
|
||||||
|
src={proxyImgUrl(r.img)}
|
||||||
|
alt=""
|
||||||
|
className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2"
|
||||||
|
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">
|
||||||
|
{r.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
|
||||||
|
aria-label={saved ? 'Забыть версию' : 'Запомнить версию'}
|
||||||
|
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
|
||||||
|
style={{
|
||||||
|
borderColor: saved ? 'rgba(var(--accent-rgb),0.4)' : 'rgba(255,255,255,0.07)',
|
||||||
|
background: saved ? 'rgba(var(--accent-rgb),0.08)' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="10" height="10" viewBox="0 0 24 24"
|
||||||
|
fill={saved ? 'var(--accent)' : 'none'}
|
||||||
|
stroke={saved ? 'var(--accent)' : '#555'}
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onPlay(results, i) }}
|
||||||
|
aria-label={i === activeResultIdx ? 'Воспроизводится' : 'Воспроизвести эту версию'}
|
||||||
|
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
|
||||||
|
style={{ width: 26, height: 26 }}
|
||||||
|
>
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}>
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { usePartyStore } from '@/store/partyStore'
|
import { usePartyStore } from '@/store/partyStore'
|
||||||
import { fetchTopCharts } from '@/lib/api'
|
import { fetchTopCharts } from '@/lib/api'
|
||||||
import type { SearchResult } from '@/types'
|
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[] }) {
|
function TopChartsCard({ tracks }: { tracks: string[] }) {
|
||||||
const { loadPlaylist } = usePartyStore()
|
const { loadPlaylist } = usePartyStore()
|
||||||
const [launched, setLaunched] = useState(false)
|
const [launched, setLaunched] = useState(false)
|
||||||
@@ -48,24 +66,34 @@ function TopChartsCard({ tracks }: { tracks: string[] }) {
|
|||||||
export default function SoloTab() {
|
export default function SoloTab() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [topTracks, setTopTracks] = useState<string[] | null>(null)
|
const [topTracks, setTopTracks] = useState<string[] | null>(null)
|
||||||
|
const [searchHistory, setSearchHistory] = useState<string[]>([])
|
||||||
const { loadPlaylist } = usePartyStore()
|
const { loadPlaylist } = usePartyStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setSearchHistory(getSearchHistory())
|
||||||
fetchTopCharts().then((results: SearchResult[]) => {
|
fetchTopCharts().then((results: SearchResult[]) => {
|
||||||
if (!results.length) { setTopTracks([]); return }
|
if (!results.length) { setTopTracks([]); return }
|
||||||
setTopTracks(
|
setTopTracks(results.map((r) => (r.artist ? `${r.artist} — ${r.title}` : r.title)))
|
||||||
results.map((r) => (r.artist ? `${r.artist} — ${r.title}` : r.title))
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const doSearch = useCallback((q: string) => {
|
||||||
e.preventDefault()
|
if (!q.trim()) return
|
||||||
const q = search.trim()
|
addToSearchHistory(q.trim())
|
||||||
if (!q) return
|
setSearchHistory(getSearchHistory())
|
||||||
loadPlaylist([q])
|
loadPlaylist([q.trim()])
|
||||||
setSearch('')
|
setSearch('')
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}, [loadPlaylist])
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
doSearch(search)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearHistory = () => {
|
||||||
|
clearSearchHistory()
|
||||||
|
setSearchHistory([])
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,6 +128,31 @@ export default function SoloTab() {
|
|||||||
▶
|
▶
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
38
apps/web/src/components/Toaster.tsx
Normal file
38
apps/web/src/components/Toaster.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useToastStore } from '@/store/toastStore'
|
||||||
|
|
||||||
|
export default function Toaster() {
|
||||||
|
const { toasts, dismiss } = useToastStore()
|
||||||
|
if (!toasts.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-[84px] left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 z-[9999] pointer-events-none">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => dismiss(t.id)}
|
||||||
|
className="pointer-events-auto flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-[13px] font-sans shadow-xl cursor-pointer animate-fadeUp"
|
||||||
|
style={{
|
||||||
|
background: t.type === 'error' ? 'rgba(255,80,80,0.12)' : t.type === 'success' ? 'rgba(80,200,120,0.12)' : 'rgba(255,255,255,0.08)',
|
||||||
|
border: `1px solid ${t.type === 'error' ? 'rgba(255,80,80,0.25)' : t.type === 'success' ? 'rgba(80,200,120,0.25)' : 'rgba(255,255,255,0.1)'}`,
|
||||||
|
color: t.type === 'error' ? '#ff6b6b' : t.type === 'success' ? '#5cc87a' : '#ccc',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.type === 'error' && (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="shrink-0">
|
||||||
|
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{t.type === 'success' && (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" className="shrink-0">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,15 @@ export async function fetchYandexPlaylist(yandexUrl: string): Promise<{ name: st
|
|||||||
return data
|
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[]> {
|
export async function searchTracks(query: string): Promise<SearchResult[]> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/proxy/search?q=${encodeURIComponent(query)}`, {
|
const res = await fetch(`${API_URL}/api/proxy/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export const audioState = {
|
export const audioState = {
|
||||||
analyser: null as AnalyserNode | null,
|
analyser: null as AnalyserNode | null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,22 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
|||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(`${API_URL}${path}`, {
|
const res = await fetch(`${API_URL}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
...options,
|
...options,
|
||||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}))
|
const body = await res.json().catch(() => ({}))
|
||||||
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`)
|
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()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
function bearer(token: string): HeadersInit {
|
|
||||||
return { Authorization: `Bearer ${token}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function register(username: string, email: string, password: string): Promise<User> {
|
export async function register(username: string, email: string, password: string): Promise<User> {
|
||||||
return request<User>('/api/auth/register', {
|
return request<User>('/api/auth/register', {
|
||||||
method: 'POST',
|
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 }> {
|
export async function login(email: string, password: string): Promise<User> {
|
||||||
return request<{ token: string; user: User }>('/api/auth/login', {
|
const res = await request<{ user: User }>('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
})
|
})
|
||||||
|
return res.user
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMe(token: string): Promise<User> {
|
export async function logout(): Promise<void> {
|
||||||
return request<User>('/api/auth/me', { headers: bearer(token) })
|
await request<void>('/api/auth/logout', { method: 'POST' }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlaylists(token: string): Promise<Playlist[]> {
|
export async function fetchMe(): Promise<User> {
|
||||||
return request<Playlist[]>('/api/playlists', { headers: bearer(token) })
|
return request<User>('/api/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlaylists(): Promise<Playlist[]> {
|
||||||
|
return request<Playlist[]>('/api/playlists')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPublicPlaylists(): Promise<PublicPlaylist[]> {
|
export async function getPublicPlaylists(): Promise<PublicPlaylist[]> {
|
||||||
return request<PublicPlaylist[]>('/api/playlists/public')
|
return request<PublicPlaylist[]>('/api/playlists/public')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlaylist(token: string, id: string): Promise<Playlist> {
|
export async function getPlaylist(id: string): Promise<Playlist> {
|
||||||
return request<Playlist>(`/api/playlists/${id}`, { headers: bearer(token) })
|
return request<Playlist>(`/api/playlists/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPlaylist(
|
export async function createPlaylist(
|
||||||
token: string,
|
|
||||||
name: string,
|
name: string,
|
||||||
tracks: string[],
|
tracks: string[],
|
||||||
isPublic = false,
|
isPublic = false,
|
||||||
@@ -58,13 +71,11 @@ export async function createPlaylist(
|
|||||||
): Promise<Playlist> {
|
): Promise<Playlist> {
|
||||||
return request<Playlist>('/api/playlists', {
|
return request<Playlist>('/api/playlists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: bearer(token),
|
|
||||||
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
|
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePlaylist(
|
export async function updatePlaylist(
|
||||||
token: string,
|
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
tracks: string[],
|
tracks: string[],
|
||||||
@@ -73,22 +84,17 @@ export async function updatePlaylist(
|
|||||||
): Promise<Playlist> {
|
): Promise<Playlist> {
|
||||||
return request<Playlist>(`/api/playlists/${id}`, {
|
return request<Playlist>(`/api/playlists/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: bearer(token),
|
|
||||||
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
|
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`, {
|
return request<void>(`/api/playlists/${playlistId}/tracks`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: bearer(token),
|
|
||||||
body: JSON.stringify({ title }),
|
body: JSON.stringify({ title }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePlaylist(token: string, id: string): Promise<void> {
|
export async function deletePlaylist(id: string): Promise<void> {
|
||||||
return request<void>(`/api/playlists/${id}`, {
|
return request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
|
||||||
method: 'DELETE',
|
|
||||||
headers: bearer(token),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
368
apps/web/src/lib/overlayPalettes.ts
Normal file
368
apps/web/src/lib/overlayPalettes.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import type { OverlayStyle } from '@/store/overlayStore'
|
||||||
|
|
||||||
|
export interface OverlayPalette {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
swatches: string[] // preview dots (2-3 hex/rgba)
|
||||||
|
bg?: string // panel background (color or gradient)
|
||||||
|
border?: string // full CSS border declaration
|
||||||
|
shadow?: string // box-shadow
|
||||||
|
text?: string // primary text color
|
||||||
|
text2?: string // secondary text color
|
||||||
|
chroma?: string // characteristic color: retro border hex, neon/matrix phosphor hex, glam metal rgb-string
|
||||||
|
chroma2?: string // for RGB string "r,g,b" when chroma is hex (used for --accent-rgb override)
|
||||||
|
titleBg?: string // Y2K titlebar gradient
|
||||||
|
bodyBg?: string // Y2K body gradient
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALETTES: Record<OverlayStyle, OverlayPalette[]> = {
|
||||||
|
classic: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Тёмный', swatches: ['#0a0a10', '#ffffff', '#888888'],
|
||||||
|
bg: 'rgba(10,10,16,0.82)', border: '1px solid rgba(255,255,255,0.09)',
|
||||||
|
shadow: '0 8px 32px rgba(0,0,0,0.55)',
|
||||||
|
text: '#ffffff', text2: 'rgba(255,255,255,0.45)', chroma: 'rgba(255,255,255,0.06)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warm', name: 'Тёплый', swatches: ['#160c06', '#ffeedd', '#dca850'],
|
||||||
|
bg: 'rgba(22,12,6,0.86)', border: '1px solid rgba(220,168,80,0.20)',
|
||||||
|
shadow: '0 8px 32px rgba(20,8,0,0.6)',
|
||||||
|
text: '#ffeedd', text2: 'rgba(255,200,120,0.50)', chroma: 'rgba(220,168,80,0.10)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ocean', name: 'Морской', swatches: ['#040a1a', '#ddeeff', '#3c82ff'],
|
||||||
|
bg: 'rgba(4,10,26,0.88)', border: '1px solid rgba(60,130,255,0.22)',
|
||||||
|
shadow: '0 8px 32px rgba(0,10,40,0.6)',
|
||||||
|
text: '#ddeeff', text2: 'rgba(100,170,255,0.50)', chroma: 'rgba(60,130,255,0.08)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'frost', name: 'Белый', swatches: ['#ffffff', '#111111', '#bbbbbb'],
|
||||||
|
bg: 'rgba(255,255,255,0.90)', border: '1px solid rgba(0,0,0,0.09)',
|
||||||
|
shadow: '0 8px 32px rgba(0,0,0,0.15)',
|
||||||
|
text: '#111111', text2: 'rgba(0,0,0,0.42)', chroma: 'rgba(0,0,0,0.05)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
aero: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Небо', swatches: ['#bee6ff', '#78b9ff', '#003a6e'],
|
||||||
|
bg: 'linear-gradient(160deg,rgba(190,230,255,0.55) 0%,rgba(120,185,255,0.42) 100%)',
|
||||||
|
border: '1.5px solid rgba(255,255,255,0.72)',
|
||||||
|
shadow: '0 4px 20px rgba(80,160,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
|
||||||
|
text: '#003a6e', text2: 'rgba(0,60,130,0.60)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rose', name: 'Роза', swatches: ['#ffc8d7', '#ff87af', '#6e0030'],
|
||||||
|
bg: 'linear-gradient(160deg,rgba(255,200,215,0.55) 0%,rgba(255,135,175,0.42) 100%)',
|
||||||
|
border: '1.5px solid rgba(255,255,255,0.72)',
|
||||||
|
shadow: '0 4px 20px rgba(255,100,160,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
|
||||||
|
text: '#6e0030', text2: 'rgba(130,0,60,0.60)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mint', name: 'Мята', swatches: ['#b4ffd2', '#50dc9b', '#003a20'],
|
||||||
|
bg: 'linear-gradient(160deg,rgba(180,255,210,0.55) 0%,rgba(80,220,155,0.42) 100%)',
|
||||||
|
border: '1.5px solid rgba(255,255,255,0.72)',
|
||||||
|
shadow: '0 4px 20px rgba(60,200,130,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
|
||||||
|
text: '#003a20', text2: 'rgba(0,80,50,0.60)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lavender', name: 'Лаванда', swatches: ['#dcc8ff', '#9b7dff', '#3a006e'],
|
||||||
|
bg: 'linear-gradient(160deg,rgba(220,200,255,0.55) 0%,rgba(155,125,255,0.42) 100%)',
|
||||||
|
border: '1.5px solid rgba(255,255,255,0.72)',
|
||||||
|
shadow: '0 4px 20px rgba(130,80,255,0.30),inset 0 1.5px 0 rgba(255,255,255,0.65)',
|
||||||
|
text: '#3a006e', text2: 'rgba(70,0,140,0.55)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
retro: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Янтарь', swatches: ['#0c0500', '#c07030', '#f8d090'],
|
||||||
|
bg: 'rgba(12,5,0,0.94)', border: '2px solid #c07030',
|
||||||
|
shadow: '0 0 28px rgba(180,90,20,0.45),4px 4px 0 rgba(0,0,0,0.6)',
|
||||||
|
text: '#f8d090', text2: '#9a6020', chroma: 'rgba(180,100,20,0.30)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'phosphor', name: 'Фосфор', swatches: ['#000c04', '#30c060', '#90f8b0'],
|
||||||
|
bg: 'rgba(0,12,4,0.94)', border: '2px solid #30c060',
|
||||||
|
shadow: '0 0 28px rgba(20,160,60,0.45),4px 4px 0 rgba(0,0,0,0.6)',
|
||||||
|
text: '#90f8b0', text2: '#207840', chroma: 'rgba(20,160,60,0.28)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crt', name: 'CRT синий', swatches: ['#000412', '#3060c0', '#90b8f8'],
|
||||||
|
bg: 'rgba(0,4,18,0.94)', border: '2px solid #3060c0',
|
||||||
|
shadow: '0 0 28px rgba(30,80,200,0.45),4px 4px 0 rgba(0,0,0,0.6)',
|
||||||
|
text: '#90b8f8', text2: '#204880', chroma: 'rgba(30,80,200,0.30)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blood', name: 'Красный', swatches: ['#0e0202', '#c02828', '#f89090'],
|
||||||
|
bg: 'rgba(14,2,2,0.94)', border: '2px solid #c02828',
|
||||||
|
shadow: '0 0 28px rgba(180,28,20,0.45),4px 4px 0 rgba(0,0,0,0.6)',
|
||||||
|
text: '#f89090', text2: '#802020', chroma: 'rgba(180,28,20,0.28)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
neon: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Акцент', swatches: ['#00000a', 'var(--accent)', 'var(--accent)'],
|
||||||
|
bg: 'rgba(0,0,10,0.90)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cyan', name: 'Голубой', swatches: ['#00040c', '#00e5ff', '#00e5ff'],
|
||||||
|
bg: 'rgba(0,4,12,0.92)', chroma: '#00e5ff', chroma2: '0,229,255',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'magenta', name: 'Пурпур', swatches: ['#0a0008', '#ff00cc', '#ff00cc'],
|
||||||
|
bg: 'rgba(10,0,8,0.92)', chroma: '#ff00cc', chroma2: '255,0,204',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lime', name: 'Лайм', swatches: ['#000a04', '#00ff88', '#00ff88'],
|
||||||
|
bg: 'rgba(0,10,4,0.92)', chroma: '#00ff88', chroma2: '0,255,136',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
clean: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Стандарт', swatches: ['transparent', '#ffffff', 'var(--accent)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shadow', name: 'С тенью', swatches: ['transparent', '#ffffff', '#666666'],
|
||||||
|
text: '#ffffff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dark-text', name: 'Тёмный', swatches: ['transparent', '#111111', '#333333'],
|
||||||
|
text: '#111111', text2: 'rgba(0,0,0,0.50)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warm-text', name: 'Тёплый', swatches: ['transparent', '#ffeedd', '#ddaa66'],
|
||||||
|
text: '#ffeedd', text2: 'rgba(255,200,120,0.60)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
y2k: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Лунный', swatches: ['#c8d0e0', '#1a4090', '#000060'],
|
||||||
|
titleBg: 'linear-gradient(90deg,#1a4090 0%,#4a80d0 40%,#1a4090 100%)',
|
||||||
|
bodyBg: 'linear-gradient(160deg,#dce4f0,#c0c8e0)',
|
||||||
|
text: '#000060', text2: '#404880',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rose', name: 'Розовый', swatches: ['#f0d4dc', '#901440', '#600020'],
|
||||||
|
titleBg: 'linear-gradient(90deg,#901440 0%,#d04080 40%,#901440 100%)',
|
||||||
|
bodyBg: 'linear-gradient(160deg,#f0d4dc,#d0b0c0)',
|
||||||
|
text: '#600020', text2: '#804060',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dark', name: 'Тёмный', swatches: ['#c0c0c8', '#484848', '#000020'],
|
||||||
|
titleBg: 'linear-gradient(90deg,#282828 0%,#484848 40%,#282828 100%)',
|
||||||
|
bodyBg: 'linear-gradient(160deg,#c0c0c8,#a0a0b0)',
|
||||||
|
text: '#000020', text2: '#303040',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forest', name: 'Лесной', swatches: ['#d4ecd8', '#106020', '#004010'],
|
||||||
|
titleBg: 'linear-gradient(90deg,#106020 0%,#308040 40%,#106020 100%)',
|
||||||
|
bodyBg: 'linear-gradient(160deg,#d4ecd8,#b8d8c0)',
|
||||||
|
text: '#004010', text2: '#306040',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
lofi: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Коричневый', swatches: ['#2a1a0e', '#fde8c0', '#e8a060'],
|
||||||
|
bg: 'rgba(42,26,14,0.92)',
|
||||||
|
text: '#fde8c0', text2: 'rgba(var(--accent-rgb),0.60)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'night', name: 'Ночной', swatches: ['#0c1020', '#c0d4f8', '#7090d8'],
|
||||||
|
bg: 'rgba(12,16,32,0.92)',
|
||||||
|
text: '#c0d4f8', text2: 'rgba(var(--accent-rgb),0.60)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'forest', name: 'Лесной', swatches: ['#0c1c10', '#c8f0c8', '#60c870'],
|
||||||
|
bg: 'rgba(12,28,16,0.92)',
|
||||||
|
text: '#c8f0c8', text2: 'rgba(var(--accent-rgb),0.60)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plum', name: 'Сливовый', swatches: ['#1c0c20', '#e8c0f8', '#c060d8'],
|
||||||
|
bg: 'rgba(28,12,32,0.92)',
|
||||||
|
text: '#e8c0f8', text2: 'rgba(var(--accent-rgb),0.60)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
glam: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Золото', swatches: ['#1c1204', '#d4af37', '#f0c840'],
|
||||||
|
bg: 'linear-gradient(145deg,rgba(28,18,4,0.95),rgba(16,10,2,0.95))',
|
||||||
|
chroma: '212,175,55', text: '#f0c840', text2: 'rgba(200,150,30,0.65)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'silver', name: 'Серебро', swatches: ['#101214', '#c0c8d2', '#e0e8f0'],
|
||||||
|
bg: 'linear-gradient(145deg,rgba(16,18,20,0.96),rgba(10,12,14,0.96))',
|
||||||
|
chroma: '192,200,210', text: '#e0e8f0', text2: 'rgba(160,170,180,0.65)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rose-gold', name: 'Розовое золото', swatches: ['#160e0e', '#dc9e8e', '#f0c8b8'],
|
||||||
|
bg: 'linear-gradient(145deg,rgba(22,14,14,0.95),rgba(14,8,8,0.95))',
|
||||||
|
chroma: '220,158,142', text: '#f0c8b8', text2: 'rgba(200,140,120,0.65)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emerald', name: 'Изумруд', swatches: ['#08140e', '#32b478', '#80f8c0'],
|
||||||
|
bg: 'linear-gradient(145deg,rgba(8,20,14,0.95),rgba(4,12,8,0.95))',
|
||||||
|
chroma: '50,180,120', text: '#80f8c0', text2: 'rgba(40,160,100,0.65)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
matrix: [
|
||||||
|
{
|
||||||
|
id: 'default', name: 'Зелёный', swatches: ['#000800', '#00ff32', '#00ff32'],
|
||||||
|
bg: 'rgba(0,8,0,0.93)', chroma: '#00ff32', chroma2: '0,255,50',
|
||||||
|
border: '1px solid rgba(0,255,50,0.35)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cyan', name: 'Голубой', swatches: ['#00040c', '#00e5ff', '#00e5ff'],
|
||||||
|
bg: 'rgba(0,4,12,0.93)', chroma: '#00e5ff', chroma2: '0,229,255',
|
||||||
|
border: '1px solid rgba(0,229,255,0.35)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amber', name: 'Янтарь', swatches: ['#0c0800', '#ffb400', '#ffb400'],
|
||||||
|
bg: 'rgba(12,8,0,0.93)', chroma: '#ffb400', chroma2: '255,180,0',
|
||||||
|
border: '1px solid rgba(255,180,0,0.35)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'violet', name: 'Пурпур', swatches: ['#08000c', '#cc00ff', '#cc00ff'],
|
||||||
|
bg: 'rgba(8,0,12,0.93)', chroma: '#cc00ff', chroma2: '204,0,255',
|
||||||
|
border: '1px solid rgba(204,0,255,0.35)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPalettes(style: OverlayStyle): OverlayPalette[] {
|
||||||
|
return PALETTES[style] ?? PALETTES.classic
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPalette(style: OverlayStyle, paletteId: string): OverlayPalette {
|
||||||
|
const list = getPalettes(style)
|
||||||
|
return list.find(p => p.id === paletteId) ?? list[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom palette ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ColorFieldDef {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
default: string // hex default for the color picker
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STYLE_CUSTOM_FIELDS: Record<OverlayStyle, ColorFieldDef[]> = {
|
||||||
|
classic: [
|
||||||
|
{ key: 'bg', label: 'Фон', default: '#0a0a10' },
|
||||||
|
{ key: 'chroma', label: 'Рамка', default: '#ffffff' },
|
||||||
|
{ key: 'text', label: 'Текст', default: '#ffffff' },
|
||||||
|
{ key: 'text2', label: 'Текст 2', default: '#888888' },
|
||||||
|
],
|
||||||
|
aero: [
|
||||||
|
{ key: 'bg', label: 'Цвет', default: '#bee6ff' },
|
||||||
|
{ key: 'text', label: 'Текст', default: '#003a6e' },
|
||||||
|
{ key: 'text2', label: 'Текст 2', default: '#004488' },
|
||||||
|
],
|
||||||
|
retro: [
|
||||||
|
{ key: 'bg', label: 'Фон', default: '#0c0500' },
|
||||||
|
{ key: 'chroma', label: 'Хром', default: '#c07030' },
|
||||||
|
{ key: 'text', label: 'Текст', default: '#f8d090' },
|
||||||
|
{ key: 'text2', label: 'Текст 2', default: '#9a6020' },
|
||||||
|
],
|
||||||
|
neon: [
|
||||||
|
{ key: 'bg', label: 'Фон', default: '#00000a' },
|
||||||
|
{ key: 'chroma', label: 'Свечение', default: '#de9cfe' },
|
||||||
|
],
|
||||||
|
clean: [
|
||||||
|
{ key: 'text', label: 'Текст', default: '#ffffff' },
|
||||||
|
{ key: 'text2', label: 'Текст 2', default: '#888888' },
|
||||||
|
],
|
||||||
|
y2k: [
|
||||||
|
{ key: 'titleBg', label: 'Заголовок', default: '#1a4090' },
|
||||||
|
{ key: 'bodyBg', label: 'Тело', default: '#c0c8e0' },
|
||||||
|
{ key: 'text', label: 'Текст', default: '#000060' },
|
||||||
|
{ key: 'text2', label: 'Текст 2', default: '#404880' },
|
||||||
|
],
|
||||||
|
lofi: [
|
||||||
|
{ key: 'bg', label: 'Фон', default: '#2a1a0e' },
|
||||||
|
{ key: 'text', label: 'Текст', default: '#fde8c0' },
|
||||||
|
{ key: 'text2', label: 'Текст 2', default: '#e8a060' },
|
||||||
|
],
|
||||||
|
glam: [
|
||||||
|
{ key: 'bg', label: 'Фон', default: '#1c1204' },
|
||||||
|
{ key: 'chroma', label: 'Металл', default: '#d4af37' },
|
||||||
|
{ key: 'text', label: 'Текст', default: '#f0c840' },
|
||||||
|
],
|
||||||
|
matrix: [
|
||||||
|
{ key: 'bg', label: 'Фон', default: '#000800' },
|
||||||
|
{ key: 'chroma', label: 'Фосфор', default: '#00ff32' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, a: number): string {
|
||||||
|
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return hex
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
return `rgba(${r},${g},${b},${a})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): string {
|
||||||
|
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return '128,128,128'
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
return `${r},${g},${b}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomPalette(
|
||||||
|
style: OverlayStyle,
|
||||||
|
data: Record<string, string>,
|
||||||
|
): OverlayPalette {
|
||||||
|
const pal: OverlayPalette = { id: 'custom', name: 'Свой', swatches: [] }
|
||||||
|
|
||||||
|
if (data.text) pal.text = data.text
|
||||||
|
if (data.text2) pal.text2 = data.text2
|
||||||
|
|
||||||
|
if (data.bg) {
|
||||||
|
if (style === 'aero') {
|
||||||
|
pal.bg = `linear-gradient(160deg,${hexToRgba(data.bg, 0.55)} 0%,${hexToRgba(data.bg, 0.38)} 100%)`
|
||||||
|
} else {
|
||||||
|
const alpha = style === 'classic' ? 0.85 : 0.92
|
||||||
|
pal.bg = hexToRgba(data.bg, alpha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.chroma) {
|
||||||
|
switch (style) {
|
||||||
|
case 'glam':
|
||||||
|
pal.chroma = hexToRgb(data.chroma)
|
||||||
|
break
|
||||||
|
case 'neon':
|
||||||
|
case 'matrix':
|
||||||
|
pal.chroma = data.chroma
|
||||||
|
pal.chroma2 = hexToRgb(data.chroma)
|
||||||
|
if (style === 'matrix') {
|
||||||
|
pal.border = `1px solid ${hexToRgba(data.chroma, 0.38)}`
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'retro':
|
||||||
|
pal.chroma = hexToRgba(data.chroma, 0.30)
|
||||||
|
pal.border = `2px solid ${data.chroma}`
|
||||||
|
pal.shadow = `0 0 28px ${hexToRgba(data.chroma, 0.45)},4px 4px 0 rgba(0,0,0,0.6)`
|
||||||
|
break
|
||||||
|
case 'classic':
|
||||||
|
pal.border = `1px solid ${hexToRgba(data.chroma, 0.28)}`
|
||||||
|
pal.chroma = hexToRgba(data.chroma, 0.10)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
pal.chroma = data.chroma
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.titleBg) pal.titleBg = data.titleBg
|
||||||
|
if (data.bodyBg) pal.bodyBg = data.bodyBg
|
||||||
|
|
||||||
|
return pal
|
||||||
|
}
|
||||||
@@ -2,49 +2,53 @@
|
|||||||
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { User } from '@/types'
|
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'
|
const USER_KEY = 'pm_user'
|
||||||
|
|
||||||
interface AuthStore {
|
interface AuthStore {
|
||||||
token: string | null
|
|
||||||
user: User | null
|
user: User | null
|
||||||
setAuth: (token: string, user: User) => void
|
setAuth: (user: User) => void
|
||||||
clearAuth: () => void
|
clearAuth: () => void
|
||||||
hydrate: () => Promise<void>
|
hydrate: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthStore>((set, get) => ({
|
export const useAuthStore = create<AuthStore>((set) => ({
|
||||||
token: null,
|
|
||||||
user: null,
|
user: null,
|
||||||
|
|
||||||
setAuth: (token, user) => {
|
setAuth: (user) => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||||
}
|
}
|
||||||
set({ token, user })
|
set({ user })
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAuth: () => {
|
clearAuth: async () => {
|
||||||
|
await logout()
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
localStorage.removeItem(USER_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 () => {
|
hydrate: async () => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
// Optimistically restore user from cache for instant UI
|
||||||
if (!token) return
|
const cached = localStorage.getItem(USER_KEY)
|
||||||
|
if (cached) {
|
||||||
|
try { set({ user: JSON.parse(cached) }) } catch {}
|
||||||
|
}
|
||||||
|
// Verify with server via cookie
|
||||||
try {
|
try {
|
||||||
const user = await fetchMe(token)
|
const user = await fetchMe()
|
||||||
set({ token, user })
|
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||||
|
set({ user })
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
|
||||||
localStorage.removeItem(USER_KEY)
|
localStorage.removeItem(USER_KEY)
|
||||||
|
localStorage.removeItem('pm_token')
|
||||||
|
set({ user: null })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -18,23 +18,50 @@ export const BG_PRESETS: BgPreset[] = [
|
|||||||
{ id: 'none', name: 'Нет', desc: 'Чистый фон' },
|
{ id: 'none', name: 'Нет', desc: 'Чистый фон' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface RaysConfig {
|
export interface OrbsConfig { brightness: number; speed: number; trail: number }
|
||||||
count: number // 4–16
|
export interface WavesConfig { amplitude: number; speed: number; trail: number }
|
||||||
speed: number // 0.2–3.0 (rotation speed multiplier)
|
export interface ParticlesConfig { speed: number; linkDist: number; trail: number }
|
||||||
brightness: number // 0.3–2.5
|
export interface AuroraConfig { brightness: number; speed: number; trail: number }
|
||||||
spread: number // 0.3–2.0 (ray width multiplier)
|
export interface PulseConfig { sensitivity: number; ringSpeed: number; trail: number }
|
||||||
|
export interface StarsConfig { brightness: number; twinkle: number; trail: number }
|
||||||
|
export interface RainConfig { drops: number; speed: number; trail: number }
|
||||||
|
export interface RaysConfig { count: number; speed: number; brightness: number; spread: number; trail: number }
|
||||||
|
|
||||||
|
export interface FxConfigs {
|
||||||
|
orbs: OrbsConfig
|
||||||
|
waves: WavesConfig
|
||||||
|
particles: ParticlesConfig
|
||||||
|
aurora: AuroraConfig
|
||||||
|
pulse: PulseConfig
|
||||||
|
stars: StarsConfig
|
||||||
|
rain: RainConfig
|
||||||
|
rays: RaysConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_RAYS: RaysConfig = { count: 9, speed: 1, brightness: 1, spread: 1 }
|
export const DEFAULT_ORBS: OrbsConfig = { brightness: 1, speed: 1, trail: 0 }
|
||||||
|
export const DEFAULT_WAVES: WavesConfig = { amplitude: 1, speed: 1, trail: 0 }
|
||||||
|
export const DEFAULT_PARTICLES: ParticlesConfig = { speed: 1, linkDist: 1, trail: 0 }
|
||||||
|
export const DEFAULT_AURORA: AuroraConfig = { brightness: 1, speed: 1, trail: 0 }
|
||||||
|
export const DEFAULT_PULSE: PulseConfig = { sensitivity: 0.5, ringSpeed: 1, trail: 0 }
|
||||||
|
export const DEFAULT_STARS: StarsConfig = { brightness: 1, twinkle: 1, trail: 0 }
|
||||||
|
export const DEFAULT_RAIN: RainConfig = { drops: 30, speed: 1, trail: 0 }
|
||||||
|
export const DEFAULT_RAYS: RaysConfig = { count: 9, speed: 1, brightness: 1, spread: 1, trail: 0 }
|
||||||
|
|
||||||
const KEY_BG = 'pm_bg'
|
export const DEFAULT_FX: FxConfigs = {
|
||||||
const KEY_RAYS = 'pm_rays'
|
orbs: DEFAULT_ORBS, waves: DEFAULT_WAVES, particles: DEFAULT_PARTICLES,
|
||||||
|
aurora: DEFAULT_AURORA, pulse: DEFAULT_PULSE, stars: DEFAULT_STARS,
|
||||||
|
rain: DEFAULT_RAIN, rays: DEFAULT_RAYS,
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_BG = 'pm_bg'
|
||||||
|
const KEY_FX = 'pm_fx'
|
||||||
|
|
||||||
interface BgStore {
|
interface BgStore {
|
||||||
bgMode: BgMode
|
bgMode: BgMode
|
||||||
raysConfig: RaysConfig
|
fxConfigs: FxConfigs
|
||||||
setBg: (mode: BgMode) => void
|
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) => ({
|
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
|
const saved = localStorage.getItem(KEY_BG) as BgMode | null
|
||||||
return saved && BG_PRESETS.some(p => p.id === saved) ? saved : 'orbs'
|
return saved && BG_PRESETS.some(p => p.id === saved) ? saved : 'orbs'
|
||||||
})(),
|
})(),
|
||||||
raysConfig: (() => {
|
fxConfigs: (() => {
|
||||||
if (typeof window === 'undefined') return DEFAULT_RAYS
|
if (typeof window === 'undefined') return DEFAULT_FX
|
||||||
try { return { ...DEFAULT_RAYS, ...JSON.parse(localStorage.getItem(KEY_RAYS) || '{}') } }
|
try {
|
||||||
catch { return DEFAULT_RAYS }
|
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) => {
|
setBg: (mode) => {
|
||||||
if (typeof window !== 'undefined') localStorage.setItem(KEY_BG, mode)
|
if (typeof window !== 'undefined') localStorage.setItem(KEY_BG, mode)
|
||||||
set({ bgMode: mode })
|
set({ bgMode: mode })
|
||||||
},
|
},
|
||||||
setRaysConfig: (cfg) => {
|
setFxConfig: (mode, cfg) => {
|
||||||
const next = { ...get().raysConfig, ...cfg }
|
const next: FxConfigs = { ...get().fxConfigs, [mode]: { ...get().fxConfigs[mode], ...cfg } }
|
||||||
if (typeof window !== 'undefined') localStorage.setItem(KEY_RAYS, JSON.stringify(next))
|
if (typeof window !== 'undefined') localStorage.setItem(KEY_FX, JSON.stringify(next))
|
||||||
set({ raysConfig: next })
|
set({ fxConfigs: next })
|
||||||
|
},
|
||||||
|
resetFx: (mode) => {
|
||||||
|
const next: FxConfigs = { ...get().fxConfigs, [mode]: DEFAULT_FX[mode] }
|
||||||
|
if (typeof window !== 'undefined') localStorage.setItem(KEY_FX, JSON.stringify(next))
|
||||||
|
set({ fxConfigs: next })
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
215
apps/web/src/store/overlayStore.ts
Normal file
215
apps/web/src/store/overlayStore.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
const KEY_ENABLED = 'pm_overlay_enabled'
|
||||||
|
const KEY_DESIGN = 'pm_overlay_design'
|
||||||
|
const KEY_STYLE = 'pm_overlay_style'
|
||||||
|
const KEY_ACCENT = 'pm_overlay_accent'
|
||||||
|
const KEY_POSITION = 'pm_overlay_position'
|
||||||
|
const KEY_FONT = 'pm_overlay_font'
|
||||||
|
const KEY_TEXTCOLOR = 'pm_overlay_textcolor'
|
||||||
|
const KEY_SHOWCVR = 'pm_overlay_showcvr'
|
||||||
|
const KEY_SHOWEQ = 'pm_overlay_showeq'
|
||||||
|
const KEY_PALETTE = 'pm_overlay_palette'
|
||||||
|
const KEY_CUSTPALETTES = 'pm_overlay_custpalettes'
|
||||||
|
const KEY_MARGIN = 'pm_overlay_margin'
|
||||||
|
const KEY_SCALE = 'pm_overlay_scale'
|
||||||
|
const KEY_OPACITY = 'pm_overlay_opacity'
|
||||||
|
|
||||||
|
export type OverlayDesign = 'minimal' | 'card' | 'bar'
|
||||||
|
export type OverlayStyle =
|
||||||
|
| 'classic' | 'aero' | 'retro' | 'neon' | 'clean'
|
||||||
|
| 'y2k' | 'lofi' | 'glam' | 'matrix'
|
||||||
|
export type OverlayPosition = 'bl' | 'br' | 'tl' | 'tr'
|
||||||
|
|
||||||
|
export interface StyleConfig {
|
||||||
|
bg: string
|
||||||
|
blur: string
|
||||||
|
border: string
|
||||||
|
shadow: string
|
||||||
|
radius: string
|
||||||
|
text: string
|
||||||
|
text2: string
|
||||||
|
eqColor: string
|
||||||
|
fontStyle: 'normal' | 'italic'
|
||||||
|
fontWeight: number
|
||||||
|
letterSpacing: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OVERLAY_STYLES: Record<OverlayStyle, { name: string; desc: string; cfg: StyleConfig }> = {
|
||||||
|
classic: {
|
||||||
|
name: 'Классика', desc: 'Тёмное стекло, нейтральный',
|
||||||
|
cfg: {
|
||||||
|
bg: 'rgba(10,10,15,0.82)', blur: '20px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
shadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||||||
|
radius: '16px', text: '#ffffff', text2: 'rgba(255,255,255,0.45)',
|
||||||
|
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 600, letterSpacing: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aero: {
|
||||||
|
name: 'Фрутигер Аеро', desc: 'Глянцевый XP-стиль',
|
||||||
|
cfg: {
|
||||||
|
bg: 'linear-gradient(145deg,rgba(200,235,255,0.55) 0%,rgba(140,195,255,0.4) 100%)',
|
||||||
|
blur: '24px',
|
||||||
|
border: '1.5px solid rgba(255,255,255,0.75)',
|
||||||
|
shadow: '0 4px 20px rgba(80,160,255,0.35),inset 0 1px 0 rgba(255,255,255,0.6)',
|
||||||
|
radius: '22px', text: '#003a6e', text2: 'rgba(0,60,120,0.6)',
|
||||||
|
eqColor: '#0077cc', fontStyle: 'normal', fontWeight: 700, letterSpacing: '-0.02em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retro: {
|
||||||
|
name: 'Ретро', desc: 'VHS / кассетная эстетика',
|
||||||
|
cfg: {
|
||||||
|
bg: 'rgba(18,8,2,0.93)',
|
||||||
|
blur: '0px',
|
||||||
|
border: '2px solid #b06828',
|
||||||
|
shadow: '0 0 24px rgba(180,100,20,0.45),4px 4px 0 rgba(0,0,0,0.5)',
|
||||||
|
radius: '4px', text: '#f8d090', text2: '#9a6020',
|
||||||
|
eqColor: '#e08030', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.05em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
neon: {
|
||||||
|
name: 'Неон', desc: 'Киберпанк, светящиеся линии',
|
||||||
|
cfg: {
|
||||||
|
bg: 'rgba(0,0,10,0.9)',
|
||||||
|
blur: '8px',
|
||||||
|
border: '1px solid rgba(var(--accent-rgb),0.55)',
|
||||||
|
shadow: '0 0 24px rgba(var(--accent-rgb),0.35),inset 0 0 16px rgba(var(--accent-rgb),0.06)',
|
||||||
|
radius: '8px', text: 'var(--accent)', text2: 'rgba(var(--accent-rgb),0.55)',
|
||||||
|
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 800, letterSpacing: '0.04em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clean: {
|
||||||
|
name: 'Минимализм', desc: 'Только текст, без фона',
|
||||||
|
cfg: {
|
||||||
|
bg: 'transparent',
|
||||||
|
blur: '0px',
|
||||||
|
border: 'none',
|
||||||
|
shadow: 'none',
|
||||||
|
radius: '0', text: '#ffffff', text2: 'rgba(255,255,255,0.5)',
|
||||||
|
eqColor: 'var(--accent)', fontStyle: 'normal', fontWeight: 600, letterSpacing: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y2k: {
|
||||||
|
name: 'Y2K', desc: 'Хром, ранние 2000-е',
|
||||||
|
cfg: {
|
||||||
|
bg: 'linear-gradient(160deg,#d8d8e8 0%,#b0b8d0 50%,#c8cce0 100%)',
|
||||||
|
blur: '4px',
|
||||||
|
border: '2px solid rgba(255,255,255,0.9)',
|
||||||
|
shadow: '3px 3px 0 rgba(0,0,80,0.25),inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||||
|
radius: '6px', text: '#000060', text2: '#444488',
|
||||||
|
eqColor: '#0044cc', fontStyle: 'normal', fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lofi: {
|
||||||
|
name: 'Ло-фай', desc: 'Тёплые тона, уютная атмосфера',
|
||||||
|
cfg: {
|
||||||
|
bg: 'rgba(38,24,12,0.9)',
|
||||||
|
blur: '14px',
|
||||||
|
border: '1px solid rgba(240,180,100,0.18)',
|
||||||
|
shadow: '0 6px 28px rgba(0,0,0,0.45)',
|
||||||
|
radius: '14px', text: '#fde8c0', text2: 'rgba(240,180,100,0.55)',
|
||||||
|
eqColor: '#e8a060', fontStyle: 'italic', fontWeight: 500, letterSpacing: '0.01em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
glam: {
|
||||||
|
name: 'Гламур', desc: 'Золото, роскошь',
|
||||||
|
cfg: {
|
||||||
|
bg: 'linear-gradient(145deg,rgba(28,18,4,0.93) 0%,rgba(18,12,2,0.93) 100%)',
|
||||||
|
blur: '16px',
|
||||||
|
border: '1px solid rgba(212,175,55,0.45)',
|
||||||
|
shadow: '0 8px 32px rgba(0,0,0,0.65),0 0 0 1px rgba(212,175,55,0.2)',
|
||||||
|
radius: '14px', text: '#f0c840', text2: 'rgba(200,150,30,0.65)',
|
||||||
|
eqColor: '#d4af37', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.02em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
matrix: {
|
||||||
|
name: 'Матрица', desc: 'Зелёный терминал, хакер',
|
||||||
|
cfg: {
|
||||||
|
bg: 'rgba(0,8,0,0.92)',
|
||||||
|
blur: '4px',
|
||||||
|
border: '1px solid rgba(0,255,50,0.3)',
|
||||||
|
shadow: '0 0 20px rgba(0,255,50,0.18)',
|
||||||
|
radius: '4px', text: '#00ff32', text2: 'rgba(0,200,30,0.55)',
|
||||||
|
eqColor: '#00ff32', fontStyle: 'normal', fontWeight: 700, letterSpacing: '0.08em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverlayStore {
|
||||||
|
enabled: boolean
|
||||||
|
design: OverlayDesign
|
||||||
|
style: OverlayStyle
|
||||||
|
accentColor: string
|
||||||
|
position: OverlayPosition
|
||||||
|
font: string
|
||||||
|
textColor: string
|
||||||
|
showCover: boolean
|
||||||
|
showEq: boolean
|
||||||
|
palette: string
|
||||||
|
customPalettes: Record<string, Record<string, string>>
|
||||||
|
margin: number
|
||||||
|
scale: number
|
||||||
|
opacity: number
|
||||||
|
|
||||||
|
setEnabled: (v: boolean) => void
|
||||||
|
setDesign: (d: OverlayDesign) => void
|
||||||
|
setStyle: (s: OverlayStyle) => void
|
||||||
|
setAccentColor: (c: string) => void
|
||||||
|
setPosition: (p: OverlayPosition) => void
|
||||||
|
setFont: (f: string) => void
|
||||||
|
setTextColor: (c: string) => void
|
||||||
|
setShowCover: (v: boolean) => void
|
||||||
|
setShowEq: (v: boolean) => void
|
||||||
|
setPalette: (p: string) => void
|
||||||
|
setCustomPaletteField: (style: string, field: string, value: string) => void
|
||||||
|
setMargin: (v: number) => void
|
||||||
|
setScale: (v: number) => void
|
||||||
|
setOpacity: (v: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ls = (key: string) => (typeof window !== 'undefined' ? localStorage.getItem(key) : null)
|
||||||
|
const lsSet = (key: string, v: string) => { if (typeof window !== 'undefined') localStorage.setItem(key, v) }
|
||||||
|
|
||||||
|
export const useOverlayStore = create<OverlayStore>((set) => ({
|
||||||
|
enabled: ls(KEY_ENABLED) !== 'false',
|
||||||
|
design: (ls(KEY_DESIGN) ?? 'minimal') as OverlayDesign,
|
||||||
|
style: (ls(KEY_STYLE) ?? 'classic') as OverlayStyle,
|
||||||
|
accentColor: ls(KEY_ACCENT) ?? '#de9cfe',
|
||||||
|
position: (ls(KEY_POSITION) ?? 'bl') as OverlayPosition,
|
||||||
|
font: ls(KEY_FONT) ?? '',
|
||||||
|
textColor: ls(KEY_TEXTCOLOR) ?? '',
|
||||||
|
showCover: ls(KEY_SHOWCVR) !== 'false',
|
||||||
|
showEq: ls(KEY_SHOWEQ) !== 'false',
|
||||||
|
palette: ls(KEY_PALETTE) ?? 'default',
|
||||||
|
customPalettes: (() => { try { return JSON.parse(ls(KEY_CUSTPALETTES) ?? '{}') } catch { return {} } })(),
|
||||||
|
margin: Number(ls(KEY_MARGIN) ?? 24),
|
||||||
|
scale: Number(ls(KEY_SCALE) ?? 1),
|
||||||
|
opacity: Number(ls(KEY_OPACITY) ?? 1),
|
||||||
|
|
||||||
|
setEnabled: (v) => { lsSet(KEY_ENABLED, String(v)); set({ enabled: v }) },
|
||||||
|
setDesign: (d) => { lsSet(KEY_DESIGN, d); set({ design: d }) },
|
||||||
|
setStyle: (s) => { lsSet(KEY_STYLE, s); set({ style: s }) },
|
||||||
|
setAccentColor: (c) => { lsSet(KEY_ACCENT, c); set({ accentColor: c }) },
|
||||||
|
setPosition: (p) => { lsSet(KEY_POSITION, p); set({ position: p }) },
|
||||||
|
setFont: (f) => { lsSet(KEY_FONT, f); set({ font: f }) },
|
||||||
|
setTextColor: (c) => { lsSet(KEY_TEXTCOLOR, c); set({ textColor: c }) },
|
||||||
|
setShowCover: (v) => { lsSet(KEY_SHOWCVR, String(v)); set({ showCover: v }) },
|
||||||
|
setShowEq: (v) => { lsSet(KEY_SHOWEQ, String(v)); set({ showEq: v }) },
|
||||||
|
setPalette: (p) => { lsSet(KEY_PALETTE, p); set({ palette: p }) },
|
||||||
|
setCustomPaletteField: (style, field, value) => {
|
||||||
|
set((state) => {
|
||||||
|
const next = {
|
||||||
|
...state.customPalettes,
|
||||||
|
[style]: { ...(state.customPalettes[style] ?? {}), [field]: value },
|
||||||
|
}
|
||||||
|
lsSet(KEY_CUSTPALETTES, JSON.stringify(next))
|
||||||
|
return { customPalettes: next }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
setMargin: (v) => { lsSet(KEY_MARGIN, String(v)); set({ margin: v }) },
|
||||||
|
setScale: (v) => { lsSet(KEY_SCALE, String(v)); set({ scale: v }) },
|
||||||
|
setOpacity: (v) => { lsSet(KEY_OPACITY, String(v)); set({ opacity: v }) },
|
||||||
|
}))
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||||
import type { Person, QueueItem, SearchResult, HistoryEntry, ShuffleMode, Color } from '@/types'
|
import type { Person, QueueItem, SearchResult, HistoryEntry, ShuffleMode, Color } from '@/types'
|
||||||
import { COLORS } from '@/lib/colors'
|
import { COLORS } from '@/lib/colors'
|
||||||
import { fairShuffle, randomShuffle } from '@/lib/shuffle'
|
import { fairShuffle, randomShuffle } from '@/lib/shuffle'
|
||||||
|
|
||||||
|
const HISTORY_LIMIT = 100
|
||||||
|
|
||||||
|
export type RepeatMode = 'none' | 'one' | 'all'
|
||||||
|
|
||||||
interface PartyStore {
|
interface PartyStore {
|
||||||
people: Person[]
|
people: Person[]
|
||||||
queue: QueueItem[]
|
queue: QueueItem[]
|
||||||
curIdx: number
|
curIdx: number
|
||||||
loadKey: number
|
loadKey: number
|
||||||
shuffleMode: ShuffleMode
|
shuffleMode: ShuffleMode
|
||||||
|
repeatMode: RepeatMode
|
||||||
history: HistoryEntry[]
|
history: HistoryEntry[]
|
||||||
currentResults: SearchResult[]
|
currentResults: SearchResult[]
|
||||||
|
currentResult: SearchResult | null
|
||||||
searchStatus: 'idle' | 'searching' | 'not-found'
|
searchStatus: 'idle' | 'searching' | 'not-found'
|
||||||
|
|
||||||
addPerson: (name: string, tracks: string[]) => void
|
addPerson: (name: string, tracks: string[]) => void
|
||||||
@@ -27,174 +34,195 @@ interface PartyStore {
|
|||||||
updateQueueItemImg: (idx: number, img: string) => void
|
updateQueueItemImg: (idx: number, img: string) => void
|
||||||
reorderQueue: (fromIdx: number, toIdx: number) => void
|
reorderQueue: (fromIdx: number, toIdx: number) => void
|
||||||
setCurrentResults: (results: SearchResult[]) => void
|
setCurrentResults: (results: SearchResult[]) => void
|
||||||
|
setCurrentResult: (r: SearchResult | null) => void
|
||||||
setSearchStatus: (status: PartyStore['searchStatus']) => void
|
setSearchStatus: (status: PartyStore['searchStatus']) => void
|
||||||
setShuffleMode: (mode: ShuffleMode) => void
|
setShuffleMode: (mode: ShuffleMode) => void
|
||||||
|
setRepeatMode: (mode: RepeatMode) => void
|
||||||
addToHistory: (entry: HistoryEntry) => void
|
addToHistory: (entry: HistoryEntry) => void
|
||||||
clearHistory: () => void
|
clearHistory: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePartyStore = create<PartyStore>((set, get) => ({
|
export const usePartyStore = create<PartyStore>()(
|
||||||
people: [],
|
persist(
|
||||||
queue: [],
|
(set, get) => ({
|
||||||
curIdx: -1,
|
people: [],
|
||||||
loadKey: 0,
|
queue: [],
|
||||||
shuffleMode: 'fair',
|
curIdx: -1,
|
||||||
history: [],
|
loadKey: 0,
|
||||||
currentResults: [],
|
shuffleMode: 'fair',
|
||||||
searchStatus: 'idle',
|
repeatMode: 'none',
|
||||||
|
history: [],
|
||||||
|
currentResults: [],
|
||||||
|
currentResult: null,
|
||||||
|
searchStatus: 'idle',
|
||||||
|
|
||||||
addPerson: (name, tracks) => {
|
addPerson: (name, tracks) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const pi = state.people.length
|
const pi = state.people.length
|
||||||
return {
|
return {
|
||||||
people: [
|
people: [
|
||||||
...state.people,
|
...state.people,
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
color: COLORS[pi % COLORS.length],
|
color: COLORS[pi % COLORS.length],
|
||||||
tracks: tracks.map((t) => ({ title: t })),
|
tracks: tracks.map((t) => ({ title: t })),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
removePerson: (index) => {
|
removePerson: (index) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
people: state.people
|
people: state.people
|
||||||
.filter((_, i) => i !== index)
|
.filter((_, i) => i !== index)
|
||||||
.map((p, i) => ({ ...p, color: COLORS[i % COLORS.length] })),
|
.map((p, i) => ({ ...p, color: COLORS[i % COLORS.length] })),
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
generateMix: () => {
|
generateMix: () => {
|
||||||
const { people, shuffleMode } = get()
|
const { people, shuffleMode } = get()
|
||||||
if (!people.length) return
|
if (!people.length) return
|
||||||
const queue = shuffleMode === 'fair' ? fairShuffle(people) : randomShuffle(people)
|
const queue = shuffleMode === 'fair' ? fairShuffle(people) : randomShuffle(people)
|
||||||
set((state) => ({ queue, curIdx: 0, loadKey: state.loadKey + 1 }))
|
set((state) => ({ queue, curIdx: 0, loadKey: state.loadKey + 1 }))
|
||||||
},
|
},
|
||||||
|
|
||||||
addFairToQueue: (owner, color, tracks) => {
|
addFairToQueue: (owner, color, tracks) => {
|
||||||
const { queue, curIdx } = get()
|
const { queue, curIdx } = get()
|
||||||
const played = queue.slice(0, Math.max(curIdx + 1, 0))
|
const played = queue.slice(0, Math.max(curIdx + 1, 0))
|
||||||
const remaining = queue.slice(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 ownerOrder: string[] = []
|
const groups = new Map<string, QueueItem[]>()
|
||||||
const groups = new Map<string, QueueItem[]>()
|
for (const item of remaining) {
|
||||||
for (const item of remaining) {
|
if (!groups.has(item.owner)) {
|
||||||
if (!groups.has(item.owner)) {
|
groups.set(item.owner, [])
|
||||||
groups.set(item.owner, [])
|
ownerOrder.push(item.owner)
|
||||||
ownerOrder.push(item.owner)
|
}
|
||||||
}
|
groups.get(item.owner)!.push(item)
|
||||||
groups.get(item.owner)!.push(item)
|
}
|
||||||
|
|
||||||
|
const newItems: QueueItem[] = tracks.map((title, ti) => ({
|
||||||
|
title, owner, color, _pi: 0, _ti: ti, img: '',
|
||||||
|
}))
|
||||||
|
if (!groups.has(owner)) {
|
||||||
|
groups.set(owner, [])
|
||||||
|
ownerOrder.push(owner)
|
||||||
|
}
|
||||||
|
groups.get(owner)!.push(...newItems)
|
||||||
|
|
||||||
|
const groupArrays = ownerOrder.map(o => groups.get(o)!)
|
||||||
|
const maxLen = Math.max(...groupArrays.map(g => g.length))
|
||||||
|
const interleaved: QueueItem[] = []
|
||||||
|
for (let i = 0; i < maxLen; i++) {
|
||||||
|
for (const group of groupArrays) {
|
||||||
|
if (i < group.length) interleaved.push(group[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQueue = [...played, ...interleaved]
|
||||||
|
const newCurIdx = curIdx < 0 ? 0 : curIdx
|
||||||
|
set((state) => ({
|
||||||
|
queue: newQueue,
|
||||||
|
curIdx: newCurIdx,
|
||||||
|
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPlaylist: (tracks: string[]) => {
|
||||||
|
const color = COLORS[0]
|
||||||
|
const queue: QueueItem[] = tracks.map((title, ti) => ({
|
||||||
|
title,
|
||||||
|
owner: 'Соло',
|
||||||
|
color,
|
||||||
|
_pi: 0,
|
||||||
|
_ti: ti,
|
||||||
|
img: '',
|
||||||
|
}))
|
||||||
|
set((state) => ({
|
||||||
|
people: [{ name: 'Соло', color, tracks: tracks.map((t) => ({ title: t })) }],
|
||||||
|
queue,
|
||||||
|
curIdx: 0,
|
||||||
|
loadKey: state.loadKey + 1,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFromQueue: (idx) => {
|
||||||
|
set((state) => {
|
||||||
|
const queue = state.queue.filter((_, i) => i !== idx)
|
||||||
|
let curIdx = state.curIdx
|
||||||
|
if (idx < curIdx) curIdx--
|
||||||
|
else if (idx === curIdx) curIdx = Math.min(curIdx, queue.length - 1)
|
||||||
|
return { queue, curIdx }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
addTrackToQueue: (title) => {
|
||||||
|
const { curIdx } = get()
|
||||||
|
const color = COLORS[0]
|
||||||
|
const newItem: QueueItem = { title, owner: 'Remote', color, _pi: 0, _ti: 0, img: '' }
|
||||||
|
set((state) => ({
|
||||||
|
queue: [...state.queue, newItem],
|
||||||
|
curIdx: curIdx < 0 ? 0 : state.curIdx,
|
||||||
|
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurIdx: (idx) => {
|
||||||
|
set((state) => ({ curIdx: idx, loadKey: state.loadKey + 1 }))
|
||||||
|
},
|
||||||
|
|
||||||
|
setQueue: (queue) => set({ queue }),
|
||||||
|
|
||||||
|
updateQueueItemImg: (idx, img) => {
|
||||||
|
set((state) => {
|
||||||
|
const queue = [...state.queue]
|
||||||
|
if (queue[idx]) queue[idx] = { ...queue[idx], img }
|
||||||
|
return { queue }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderQueue: (fromIdx, toIdx) => {
|
||||||
|
set((state) => {
|
||||||
|
const queue = [...state.queue]
|
||||||
|
const [moved] = queue.splice(fromIdx, 1)
|
||||||
|
queue.splice(toIdx, 0, moved)
|
||||||
|
let { curIdx } = state
|
||||||
|
if (curIdx === fromIdx) curIdx = toIdx
|
||||||
|
else if (fromIdx < curIdx && toIdx >= curIdx) curIdx--
|
||||||
|
else if (fromIdx > curIdx && toIdx <= curIdx) curIdx++
|
||||||
|
return { queue, curIdx }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentResults: (results) => set({ currentResults: results }),
|
||||||
|
setCurrentResult: (r) => set({ currentResult: r }),
|
||||||
|
|
||||||
|
setSearchStatus: (searchStatus) => set({ searchStatus }),
|
||||||
|
|
||||||
|
setShuffleMode: (shuffleMode) => set({ shuffleMode }),
|
||||||
|
|
||||||
|
setRepeatMode: (repeatMode) => set({ repeatMode }),
|
||||||
|
|
||||||
|
addToHistory: (entry) => {
|
||||||
|
set((state) => ({
|
||||||
|
history: [entry, ...state.history].slice(0, HISTORY_LIMIT),
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHistory: () => set({ history: [] }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'pm_party',
|
||||||
|
storage: createJSONStorage(() => localStorage),
|
||||||
|
partialize: (state) => ({
|
||||||
|
people: state.people,
|
||||||
|
queue: state.queue,
|
||||||
|
curIdx: state.curIdx,
|
||||||
|
shuffleMode: state.shuffleMode,
|
||||||
|
repeatMode: state.repeatMode,
|
||||||
|
history: state.history,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
)
|
||||||
// Add new tracks to owner's group
|
)
|
||||||
const newItems: QueueItem[] = tracks.map((title, ti) => ({
|
|
||||||
title, owner, color, _pi: 0, _ti: ti, img: '',
|
|
||||||
}))
|
|
||||||
if (!groups.has(owner)) {
|
|
||||||
groups.set(owner, [])
|
|
||||||
ownerOrder.push(owner)
|
|
||||||
}
|
|
||||||
groups.get(owner)!.push(...newItems)
|
|
||||||
|
|
||||||
// Round-robin interleave
|
|
||||||
const groupArrays = ownerOrder.map(o => groups.get(o)!)
|
|
||||||
const maxLen = Math.max(...groupArrays.map(g => g.length))
|
|
||||||
const interleaved: QueueItem[] = []
|
|
||||||
for (let i = 0; i < maxLen; i++) {
|
|
||||||
for (const group of groupArrays) {
|
|
||||||
if (i < group.length) interleaved.push(group[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newQueue = [...played, ...interleaved]
|
|
||||||
// If nothing was playing yet, start from 0
|
|
||||||
const newCurIdx = curIdx < 0 ? 0 : curIdx
|
|
||||||
set((state) => ({
|
|
||||||
queue: newQueue,
|
|
||||||
curIdx: newCurIdx,
|
|
||||||
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
loadPlaylist: (tracks: string[]) => {
|
|
||||||
const color = COLORS[0]
|
|
||||||
const queue: QueueItem[] = tracks.map((title, ti) => ({
|
|
||||||
title,
|
|
||||||
owner: 'Соло',
|
|
||||||
color,
|
|
||||||
_pi: 0,
|
|
||||||
_ti: ti,
|
|
||||||
img: '',
|
|
||||||
}))
|
|
||||||
set((state) => ({
|
|
||||||
people: [{ name: 'Соло', color, tracks: tracks.map((t) => ({ title: t })) }],
|
|
||||||
queue,
|
|
||||||
curIdx: 0,
|
|
||||||
loadKey: state.loadKey + 1,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
removeFromQueue: (idx) => {
|
|
||||||
set((state) => {
|
|
||||||
const queue = state.queue.filter((_, i) => i !== idx)
|
|
||||||
let curIdx = state.curIdx
|
|
||||||
if (idx < curIdx) curIdx--
|
|
||||||
else if (idx === curIdx) curIdx = Math.min(curIdx, queue.length - 1)
|
|
||||||
return { queue, curIdx }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
addTrackToQueue: (title) => {
|
|
||||||
const { curIdx } = get()
|
|
||||||
const color = COLORS[0]
|
|
||||||
const newItem: QueueItem = { title, owner: 'Remote', color, _pi: 0, _ti: 0, img: '' }
|
|
||||||
set((state) => ({
|
|
||||||
queue: [...state.queue, newItem],
|
|
||||||
curIdx: curIdx < 0 ? 0 : state.curIdx,
|
|
||||||
loadKey: curIdx < 0 ? state.loadKey + 1 : state.loadKey,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
setCurIdx: (idx) => {
|
|
||||||
set((state) => ({ curIdx: idx, loadKey: state.loadKey + 1 }))
|
|
||||||
},
|
|
||||||
|
|
||||||
setQueue: (queue) => set({ queue }),
|
|
||||||
|
|
||||||
updateQueueItemImg: (idx, img) => {
|
|
||||||
set((state) => {
|
|
||||||
const queue = [...state.queue]
|
|
||||||
if (queue[idx]) queue[idx] = { ...queue[idx], img }
|
|
||||||
return { queue }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
reorderQueue: (fromIdx, toIdx) => {
|
|
||||||
set((state) => {
|
|
||||||
const queue = [...state.queue]
|
|
||||||
const [moved] = queue.splice(fromIdx, 1)
|
|
||||||
queue.splice(toIdx, 0, moved)
|
|
||||||
let { curIdx } = state
|
|
||||||
if (curIdx === fromIdx) curIdx = toIdx
|
|
||||||
else if (fromIdx < curIdx && toIdx >= curIdx) curIdx--
|
|
||||||
else if (fromIdx > curIdx && toIdx <= curIdx) curIdx++
|
|
||||||
return { queue, curIdx }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
setCurrentResults: (results) => set({ currentResults: results }),
|
|
||||||
|
|
||||||
setSearchStatus: (searchStatus) => set({ searchStatus }),
|
|
||||||
|
|
||||||
setShuffleMode: (shuffleMode) => set({ shuffleMode }),
|
|
||||||
|
|
||||||
addToHistory: (entry) => {
|
|
||||||
set((state) => ({ history: [entry, ...state.history] }))
|
|
||||||
},
|
|
||||||
|
|
||||||
clearHistory: () => set({ history: [] }),
|
|
||||||
}))
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface AccentPreset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ACCENT_PRESETS: AccentPreset[] = [
|
export const ACCENT_PRESETS: AccentPreset[] = [
|
||||||
|
{ name: 'Лаванда', accent: '#de9cfe', rgb: '222,156,254' },
|
||||||
{ name: 'Лайм', accent: '#c8ff00', rgb: '200,255,0' },
|
{ name: 'Лайм', accent: '#c8ff00', rgb: '200,255,0' },
|
||||||
{ name: 'Синий', accent: '#00D4FF', rgb: '0,212,255' },
|
{ name: 'Синий', accent: '#00D4FF', rgb: '0,212,255' },
|
||||||
{ name: 'Розовый', accent: '#FF2D78', rgb: '255,45,120' },
|
{ name: 'Розовый', accent: '#FF2D78', rgb: '255,45,120' },
|
||||||
|
|||||||
27
apps/web/src/store/toastStore.ts
Normal file
27
apps/web/src/store/toastStore.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number
|
||||||
|
message: string
|
||||||
|
type: 'error' | 'success' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastStore {
|
||||||
|
toasts: Toast[]
|
||||||
|
show: (message: string, type?: Toast['type']) => void
|
||||||
|
dismiss: (id: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
export const useToastStore = create<ToastStore>((set) => ({
|
||||||
|
toasts: [],
|
||||||
|
show: (message, type = 'info') => {
|
||||||
|
const id = ++nextId
|
||||||
|
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }))
|
||||||
|
setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 3500)
|
||||||
|
},
|
||||||
|
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||||
|
}))
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { SearchResult } from '@/types'
|
import type { SearchResult } from '@/types'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
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 API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
|
||||||
const STORAGE_KEY = 'pm_versions'
|
const STORAGE_KEY = 'pm_versions'
|
||||||
@@ -35,16 +36,12 @@ function saveLocal(v: Record<string, SavedVersion>) {
|
|||||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(v)) } catch {}
|
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> {
|
async function apiFetch(path: string, opts?: RequestInit): Promise<Response | null> {
|
||||||
const token = getToken()
|
if (!useAuthStore.getState().user) return null
|
||||||
if (!token) return null
|
|
||||||
return fetch(`${API_URL}${path}`, {
|
return fetch(`${API_URL}${path}`, {
|
||||||
|
credentials: 'include',
|
||||||
...opts,
|
...opts,
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...opts?.headers },
|
headers: { 'Content-Type': 'application/json', ...opts?.headers },
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -12,9 +16,25 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [
|
||||||
"paths": { "@/*": ["./src/*"] }
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": "ES2017"
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ services:
|
|||||||
GIN_MODE: release
|
GIN_MODE: release
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ports:
|
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost}
|
||||||
- "8081:8080"
|
SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID:-}
|
||||||
|
SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -41,14 +42,21 @@ services:
|
|||||||
context: ./apps/web
|
context: ./apps/web
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
|
NEXT_PUBLIC_API_URL: ""
|
||||||
environment:
|
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
|
|
||||||
ports:
|
|
||||||
- "3001:3000"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
Reference in New Issue
Block a user