diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go index e09c439..d29478e 100644 --- a/apps/backend/internal/config/config.go +++ b/apps/backend/internal/config/config.go @@ -7,25 +7,29 @@ import ( ) type Config struct { - Port string - DBHost string - DBPort string - DBUser string - DBPass string - DBName string - JWTSecret string + Port string + DBHost string + DBPort string + DBUser string + DBPass string + DBName string + JWTSecret string + AllowedOrigins string + CookieSecure bool } func Load() *Config { _ = godotenv.Load() return &Config{ - Port: getEnv("PORT", "8080"), - DBHost: getEnv("DB_HOST", "localhost"), - DBPort: getEnv("DB_PORT", "5432"), - DBUser: getEnv("DB_USER", "partymix"), - DBPass: getEnv("DB_PASSWORD", "partymix"), - DBName: getEnv("DB_NAME", "partymix"), - JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), + Port: getEnv("PORT", "8080"), + DBHost: getEnv("DB_HOST", "localhost"), + DBPort: getEnv("DB_PORT", "5432"), + DBUser: getEnv("DB_USER", "partymix"), + DBPass: getEnv("DB_PASSWORD", "partymix"), + DBName: getEnv("DB_NAME", "partymix"), + JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), + AllowedOrigins: getEnv("ALLOWED_ORIGINS", "http://localhost:3001,http://localhost:3000"), + CookieSecure: getEnv("COOKIE_SECURE", "false") == "true", } } diff --git a/apps/backend/internal/handlers/auth.go b/apps/backend/internal/handlers/auth.go index 400b97d..a3dd1a5 100644 --- a/apps/backend/internal/handlers/auth.go +++ b/apps/backend/internal/handlers/auth.go @@ -12,6 +12,9 @@ import ( "gorm.io/gorm" ) +const cookieName = "pm_token" +const cookieMaxAge = 60 * 60 * 24 * 30 // 30 days + type registerReq struct { Username string `json:"username" binding:"required,min=3,max=50"` Email string `json:"email" binding:"required,email"` @@ -58,7 +61,7 @@ type loginReq struct { Password string `json:"password" binding:"required"` } -func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc { +func Login(db *gorm.DB, jwtSecret string, cookieSecure bool) gin.HandlerFunc { return func(c *gin.Context) { var req loginReq if err := c.ShouldBindJSON(&req); err != nil { @@ -83,10 +86,15 @@ func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc { return } - c.JSON(http.StatusOK, gin.H{ - "token": token, - "user": user, - }) + c.SetCookie(cookieName, token, cookieMaxAge, "/", "", cookieSecure, true) + c.JSON(http.StatusOK, gin.H{"user": user}) + } +} + +func Logout(cookieSecure bool) gin.HandlerFunc { + return func(c *gin.Context) { + c.SetCookie(cookieName, "", -1, "/", "", cookieSecure, true) + c.JSON(http.StatusOK, gin.H{"ok": true}) } } diff --git a/apps/backend/internal/handlers/middleware.go b/apps/backend/internal/handlers/middleware.go index f6637b6..b9a1338 100644 --- a/apps/backend/internal/handlers/middleware.go +++ b/apps/backend/internal/handlers/middleware.go @@ -43,8 +43,13 @@ func parseToken(tokenStr, secret string) (*jwtClaims, error) { func AuthRequired(jwtSecret string) gin.HandlerFunc { return func(c *gin.Context) { - header := c.GetHeader("Authorization") - tokenStr := strings.TrimPrefix(header, "Bearer ") + // Cookie-first, Bearer header as fallback for backwards compat + tokenStr, err := c.Cookie(cookieName) + if err != nil || tokenStr == "" { + header := c.GetHeader("Authorization") + tokenStr = strings.TrimPrefix(header, "Bearer ") + } + if tokenStr == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return diff --git a/apps/backend/internal/handlers/overlay.go b/apps/backend/internal/handlers/overlay.go new file mode 100644 index 0000000..573c435 --- /dev/null +++ b/apps/backend/internal/handlers/overlay.go @@ -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 + } + } +} diff --git a/apps/backend/internal/handlers/proxy.go b/apps/backend/internal/handlers/proxy.go index 912e9ed..4894fe4 100644 --- a/apps/backend/internal/handlers/proxy.go +++ b/apps/backend/internal/handlers/proxy.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "os" "regexp" "strings" "sync" @@ -378,6 +379,167 @@ func YandexPlaylistHandler(c *gin.Context) { }) } +// ── Spotify ─────────────────────────────────────────────────────────────────── + +var ( + spotifyTokenMu sync.Mutex + spotifyTokenVal string + spotifyTokenExp time.Time +) + +func getSpotifyToken() (string, error) { + spotifyTokenMu.Lock() + defer spotifyTokenMu.Unlock() + if spotifyTokenVal != "" && time.Now().Before(spotifyTokenExp) { + return spotifyTokenVal, nil + } + clientID := os.Getenv("SPOTIFY_CLIENT_ID") + clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET") + if clientID == "" || clientSecret == "" { + return "", fmt.Errorf("spotify credentials not configured") + } + body := strings.NewReader("grant_type=client_credentials") + req, err := http.NewRequest("POST", "https://accounts.spotify.com/api/token", body) + if err != nil { + return "", err + } + req.SetBasicAuth(clientID, clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var tok struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + return "", err + } + spotifyTokenVal = tok.AccessToken + spotifyTokenExp = time.Now().Add(time.Duration(tok.ExpiresIn-30) * time.Second) + return spotifyTokenVal, nil +} + +func SpotifyPlaylistHandler(c *gin.Context) { + rawURL := c.Query("url") + if rawURL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"}) + return + } + + var playlistID string + if strings.HasPrefix(rawURL, "spotify:playlist:") { + playlistID = strings.TrimPrefix(rawURL, "spotify:playlist:") + } else { + u, err := url.Parse(rawURL) + if err != nil || !strings.Contains(u.Host, "spotify.com") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid spotify url"}) + return + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + for i, p := range parts { + if p == "playlist" && i+1 < len(parts) { + playlistID = parts[i+1] + break + } + } + } + if playlistID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "could not extract playlist id"}) + return + } + // strip query params from id if present + if idx := strings.IndexAny(playlistID, "?#"); idx >= 0 { + playlistID = playlistID[:idx] + } + + token, err := getSpotifyToken() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + + doReq := func(apiURL string) ([]byte, error) { + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("spotify returned %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) + } + + // Fetch playlist name + nameBody, err := doReq("https://api.spotify.com/v1/playlists/" + url.PathEscape(playlistID) + "?fields=name") + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + var plMeta struct { + Name string `json:"name"` + } + _ = json.Unmarshal(nameBody, &plMeta) + + // Fetch tracks (paginated, up to 500) + type spArtist struct{ Name string `json:"name"` } + type spTrack struct { + Name string `json:"name"` + Artists []spArtist `json:"artists"` + } + type spItem struct { + Track *spTrack `json:"track"` + } + type spPage struct { + Items []spItem `json:"items"` + Next *string `json:"next"` + } + + tracks := make([]string, 0, 100) + nextURL := fmt.Sprintf( + "https://api.spotify.com/v1/playlists/%s/tracks?fields=items(track(name,artists(name))),next&limit=100", + url.PathEscape(playlistID), + ) + for nextURL != "" && len(tracks) < 500 { + pageBody, err := doReq(nextURL) + if err != nil { + break + } + var page spPage + if err := json.Unmarshal(pageBody, &page); err != nil { + break + } + for _, item := range page.Items { + if item.Track == nil || item.Track.Name == "" { + continue + } + t := item.Track.Name + if len(item.Track.Artists) > 0 && item.Track.Artists[0].Name != "" { + t = item.Track.Artists[0].Name + " — " + item.Track.Name + } + tracks = append(tracks, t) + } + if page.Next != nil { + nextURL = *page.Next + } else { + nextURL = "" + } + } + + c.JSON(http.StatusOK, gin.H{ + "name": plMeta.Name, + "tracks": tracks, + }) +} + func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) { if redirectCount > 5 { c.Status(http.StatusInternalServerError) diff --git a/apps/backend/internal/models/models.go b/apps/backend/internal/models/models.go index 3ecf6fa..1da8642 100644 --- a/apps/backend/internal/models/models.go +++ b/apps/backend/internal/models/models.go @@ -52,11 +52,11 @@ type User struct { type Playlist struct { ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` - UserID string `gorm:"not null;index" json:"user_id"` + UserID string `gorm:"not null;index;index:idx_playlist_user_created,composite:1" json:"user_id"` Name string `gorm:"not null" json:"name"` - IsPublic bool `gorm:"default:false" json:"is_public"` + IsPublic bool `gorm:"default:false;index:idx_playlist_public_created,composite:1" json:"is_public"` Tags string `gorm:"default:''" json:"-"` - CreatedAt time.Time `json:"created_at"` + CreatedAt time.Time `gorm:"index:idx_playlist_public_created,composite:2;index:idx_playlist_user_created,composite:2" json:"created_at"` Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"` } diff --git a/apps/backend/internal/router/router.go b/apps/backend/internal/router/router.go index c1e172a..645b86c 100644 --- a/apps/backend/internal/router/router.go +++ b/apps/backend/internal/router/router.go @@ -2,22 +2,29 @@ package router import ( "net/http" + "strings" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "github.com/toyffee/party-mix/internal/config" "github.com/toyffee/party-mix/internal/handlers" "gorm.io/gorm" ) -func New(db *gorm.DB, jwtSecret string) *gin.Engine { +func New(db *gorm.DB, cfg *config.Config) *gin.Engine { r := gin.Default() + origins := strings.Split(cfg.AllowedOrigins, ",") + for i, o := range origins { + origins[i] = strings.TrimSpace(o) + } + r.Use(cors.New(cors.Config{ - AllowAllOrigins: true, + AllowOrigins: origins, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"}, - AllowCredentials: false, + AllowCredentials: true, })) r.GET("/health", func(c *gin.Context) { @@ -31,25 +38,27 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine { proxy.GET("/img", handlers.ImgProxyHandler) proxy.GET("/mp3", handlers.MP3ProxyHandler) proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler) + proxy.GET("/spotify-playlist", handlers.SpotifyPlaylistHandler) } auth := r.Group("/api/auth") { auth.POST("/register", handlers.Register(db)) - auth.POST("/login", handlers.Login(db, jwtSecret)) - auth.GET("/me", handlers.AuthRequired(jwtSecret), handlers.Me(db)) + auth.POST("/login", handlers.Login(db, cfg.JWTSecret, cfg.CookieSecure)) + auth.POST("/logout", handlers.Logout(cfg.CookieSecure)) + auth.GET("/me", handlers.AuthRequired(cfg.JWTSecret), handlers.Me(db)) } r.GET("/api/playlists/public", handlers.GetPublicPlaylists(db)) - versions := r.Group("/api/versions", handlers.AuthRequired(jwtSecret)) + versions := r.Group("/api/versions", handlers.AuthRequired(cfg.JWTSecret)) { versions.GET("", handlers.GetVersions(db)) versions.POST("", handlers.SaveVersion(db)) versions.DELETE("", handlers.DeleteVersion(db)) } - playlists := r.Group("/api/playlists", handlers.AuthRequired(jwtSecret)) + playlists := r.Group("/api/playlists", handlers.AuthRequired(cfg.JWTSecret)) { playlists.GET("", handlers.GetPlaylists(db)) playlists.POST("", handlers.CreatePlaylist(db)) @@ -59,6 +68,13 @@ func New(db *gorm.DB, jwtSecret string) *gin.Engine { playlists.POST("/:id/tracks", handlers.AddTrackToPlaylist(db)) } + overlay := r.Group("/api/overlay") + { + overlay.PUT("/state", handlers.AuthRequired(cfg.JWTSecret), handlers.PushOverlayState) + overlay.GET("/:token/state", handlers.GetOverlayState) + overlay.GET("/:token/stream", handlers.StreamOverlayState) + } + remote := r.Group("/api/remote") { remote.POST("", handlers.CreateRemoteRoom) diff --git a/apps/backend/main.go b/apps/backend/main.go index 7c69d86..5866a78 100644 --- a/apps/backend/main.go +++ b/apps/backend/main.go @@ -11,7 +11,7 @@ import ( func main() { cfg := config.Load() db := database.Connect(cfg) - r := router.New(db, cfg.JWTSecret) + r := router.New(db, cfg) log.Printf("Party Mix backend starting on :%s", cfg.Port) if err := r.Run(":" + cfg.Port); err != nil { log.Fatalf("server failed: %v", err) diff --git a/apps/nginx/nginx.conf b/apps/nginx/nginx.conf new file mode 100644 index 0000000..4b94444 --- /dev/null +++ b/apps/nginx/nginx.conf @@ -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; + } + } +} diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..276606b --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +*.log +.env*.local diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 0d92e36..3fbeaff 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -9,6 +9,7 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . ARG NEXT_PUBLIC_API_URL=http://localhost:8080 ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build FROM node:22-alpine AS runner diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/app/page.tsx b/apps/web/src/app/(main)/app/page.tsx similarity index 100% rename from apps/web/src/app/app/page.tsx rename to apps/web/src/app/(main)/app/page.tsx diff --git a/apps/web/src/app/(main)/community/page.tsx b/apps/web/src/app/(main)/community/page.tsx new file mode 100644 index 0000000..dcccf4c --- /dev/null +++ b/apps/web/src/app/(main)/community/page.tsx @@ -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 ( +
+
+ {tracks.map((track, i) => ( +
+ {i + 1} + {track.title} +
+ ))} +
+
+ ) +} + +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 ( +
+
+
+ {pl.username[0].toUpperCase()} +
+ +
+
{pl.name}
+
+ {pl.username} + · + {trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'} + {tags.map(tag => ( + + {tag} + + ))} +
+
+ +
+ {trackCount > 0 && ( + + )} + + {canFork && trackCount > 0 && ( + + )} + + +
+
+ + {expanded && pl.tracks && pl.tracks.length > 0 && } +
+ ) +} + +function SortButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ) +} + +function TagToggleButton({ + count, open, onClick, +}: { + count: number + open: boolean + onClick: () => void +}) { + return ( + + ) +} + +const TAG_VISIBLE_DEFAULT = 12 + +function TagPanel({ + allTags, + activeTags, + tagCounts, + onToggle, + onClear, +}: { + allTags: string[] + activeTags: Set + tagCounts: Record + 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 ( +
+
+
+ + + + + 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 && ( + + )} +
+ {count > 0 && ( + + )} +
+ + {matched.length === 0 ? ( +

Не найдено

+ ) : ( + <> +
+ {visible.map(tag => { + const active = activeTags.has(tag) + const color = tagColor(tag) + return ( + + ) + })} +
+ + {!q && hiddenCount > 0 && ( + + )} + + )} +
+ ) +} + +export default function CommunityPage() { + const [playlists, setPlaylists] = useState([]) + const [loading, setLoading] = useState(true) + const [launched, setLaunched] = useState(null) + const [search, setSearch] = useState('') + const [activeTags, setActiveTags] = useState>(new Set()) + const [tagsOpen, setTagsOpen] = useState(false) + const [sort, setSort] = useState('newest') + const [forkStates, setForkStates] = useState>({}) + 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>((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 ( +
+
+ +
+
+

Сообщество

+ {!loading && ( + {playlists.length} плейлистов + )} +
+

Публичные плейлисты пользователей

+
+ + {!loading && playlists.length > 0 && ( + <> + {/* Stats bar */} +
+
+ + + + {filteredTracks} + треков +
+
+
+ + + + + + {new Set(filtered.map(pl => pl.username)).size} + {new Set(filtered.map(pl => pl.username)).size === 1 ? 'автор' : new Set(filtered.map(pl => pl.username)).size < 5 ? 'автора' : 'авторов'} +
+
+
+ + + + + {filtered.length} + {filtered.length === 1 ? 'плейлист' : filtered.length < 5 ? 'плейлиста' : 'плейлистов'} +
+
+ + {/* Search + sort + tags */} +
+
+
+ + + + + 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 && ( + + )} +
+ + {allTags.length > 0 && ( + setTagsOpen(v => !v)} + /> + )} +
+ + {tagsOpen && allTags.length > 0 && ( + setActiveTags(new Set())} + /> + )} + + {/* Sort */} +
+ setSort('newest')}>Новые + setSort('tracks')}>По трекам + setSort('alpha')}>А–Я +
+
+ + {/* Active tag chips */} + {activeTags.size > 0 && ( +
+ {Array.from(activeTags).map(tag => ( + + ))} + +
+ )} + + )} + + {loading ? ( +
+
+ Загрузка... +
+ ) : !filtered.length ? ( +
+
🎵
+

+ {playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'} +

+

+ {playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'} +

+
+ ) : ( + <> +
+ + {filtered.length !== playlists.length + ? `${filtered.length} из ${playlists.length} · ${filteredTracks} треков` + : `${filtered.length} плейлистов · ${filteredTracks} треков` + } + + +
+ +
+ {filtered.map(pl => ( + 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} + /> + ))} +
+ + )} +
+ ) +} diff --git a/apps/web/src/app/(main)/layout.tsx b/apps/web/src/app/(main)/layout.tsx new file mode 100644 index 0000000..c7e9a5f --- /dev/null +++ b/apps/web/src/app/(main)/layout.tsx @@ -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 ( +
+ + +
+ + {children} +
+ + +
+ ) +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/(main)/login/page.tsx similarity index 97% rename from apps/web/src/app/login/page.tsx rename to apps/web/src/app/(main)/login/page.tsx index ebf6f51..710a14e 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/(main)/login/page.tsx @@ -31,9 +31,9 @@ export default function LoginPage() { setError('') setLoading(true) try { - const { token, user } = await login(email, password) - setAuth(token, user) - router.push('/') + const user = await login(email, password) + setAuth(user) + router.push('/app') } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Ошибка входа') } finally { diff --git a/apps/web/src/app/(main)/page.tsx b/apps/web/src/app/(main)/page.tsx new file mode 100644 index 0000000..ffef904 --- /dev/null +++ b/apps/web/src/app/(main)/page.tsx @@ -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: ( + + + + + + + + ), + }, + { + title: 'Умный шаффл', + desc: 'Честный режим или случайный — музыка для всех, без диктатуры', + icon: ( + + + + + + ), + }, + { + title: 'Плейлисты', + desc: 'Сохраняй сеты для разных компаний и запускай одним кликом', + icon: ( + + + + ), + }, + { + title: 'Пульт с телефона', + desc: 'Управляй плеером с любого телефона по QR-ссылке — без установки', + icon: ( + + + + + ), + }, +] + +export default function LandingPage() { + return ( +
+ + {/* Nav */} + + + {/* Hero */} +
+ {/* Glow */} +
+ + {/* Steps */} +
+

Как начать

+
+ {STEPS.map(({ n, title, desc }) => ( +
+ + {n} + +
+
{title}
+
{desc}
+
+
+ ))} +
+
+ + {/* Divider */} +
+ + {/* Features */} +
+

Возможности

+
+ {FEATURES.map(({ icon, title, desc }) => ( +
+
+ {icon} +
+

{title}

+

{desc}

+
+ ))} +
+
+ + {/* Footer */} +
+ + PartyMix + +
+ Поиск + Сообщество + Плеер +
+
+ +
+ ) +} diff --git a/apps/web/src/app/playlists/page.tsx b/apps/web/src/app/(main)/playlists/page.tsx similarity index 74% rename from apps/web/src/app/playlists/page.tsx rename to apps/web/src/app/(main)/playlists/page.tsx index df52507..cce8cb2 100644 --- a/apps/web/src/app/playlists/page.tsx +++ b/apps/web/src/app/(main)/playlists/page.tsx @@ -7,7 +7,7 @@ import { useFavoritesStore } from '@/store/favoritesStore' import { usePartyStore } from '@/store/partyStore' import { useVersionStore } from '@/store/versionStore' import { getPlaylists, createPlaylist, updatePlaylist, deletePlaylist } from '@/lib/authApi' -import { searchTracks, proxyImgUrl, fetchYandexPlaylist } from '@/lib/api' +import { searchTracks, proxyImgUrl, fetchYandexPlaylist, fetchSpotifyPlaylist } from '@/lib/api' import type { Playlist, SearchResult } from '@/types' import Header from '@/components/Header' @@ -161,16 +161,21 @@ function PlaylistCard({ pl, onEdit, onDelete, + onAddTrack, }: { pl: Playlist onEdit: () => void onDelete: () => void + onAddTrack: (id: string, title: string) => Promise }) { const { loadPlaylist } = usePartyStore() const [expanded, setExpanded] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) const [versionsFor, setVersionsFor] = useState(null) const [launched, setLaunched] = useState(false) + const [addingTrack, setAddingTrack] = useState(false) + const [quickAdd, setQuickAdd] = useState('') + const [quickAdding, setQuickAdding] = useState(false) const tags = pl.tags ?? [] const trackCount = pl.tracks?.length ?? 0 @@ -201,6 +206,16 @@ function PlaylistCard({ setTimeout(() => setLaunched(false), 2500) } + const handleQuickAdd = async () => { + const t = quickAdd.trim() + if (!t) return + setQuickAdding(true) + await onAddTrack(pl.id, t) + setQuickAdd('') + setAddingTrack(false) + setQuickAdding(false) + } + return (
@@ -250,6 +265,14 @@ function PlaylistCard({ )} +
+ {addingTrack && ( +
+ 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" + /> + +
+ )} + {expanded && pl.tracks && pl.tracks.length > 0 && (
{pl.tracks.map((track, i) => ( @@ -491,7 +535,7 @@ function FavoritesCard() { ) } -function YandexImportForm({ onImport, onClose }: { +function YandexImportForm({ onImport }: { onImport: (name: string, tracks: string[]) => Promise onClose: () => void }) { @@ -533,18 +577,7 @@ function YandexImportForm({ onImport, onClose }: { } return ( -
-
-

- Импорт из Яндекс.Музыки -

- -
- +
Promise + 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 ( +
+
+ { 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" + /> + +
+ + {error && ( +
+ {error} +
+ )} + + {preview && ( + <> +
+
+ Найдено треков: + {preview.tracks.length} +
+
+ {preview.tracks.map((t, i) => ( +
+ {i + 1} + {t} +
+ ))} +
+
+ + 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="Название плейлиста" + /> + + + + )} +
+ ) +} + export default function PlaylistsPage() { const router = useRouter() - const { token, user } = useAuthStore() + const { user } = useAuthStore() const { hydrate: hydrateFavorites } = useFavoritesStore() const [playlists, setPlaylists] = useState([]) const [loading, setLoading] = useState(true) + const [plSearch, setPlSearch] = useState('') + const [plSort, setPlSort] = useState<'date' | 'name' | 'tracks'>('date') const [newName, setNewName] = useState('') const [newTracks, setNewTracks] = useState('') @@ -626,30 +768,31 @@ export default function PlaylistsPage() { const [editingId, setEditingId] = useState(null) const [showImport, setShowImport] = useState(false) + const [importSource, setImportSource] = useState<'yandex' | 'spotify'>('yandex') useEffect(() => { hydrateFavorites() }, [hydrateFavorites]) useEffect(() => { - if (!token) { router.push('/login'); return } - getPlaylists(token) + if (!user) { router.push('/login'); return } + getPlaylists() .then(setPlaylists) .catch(() => {}) .finally(() => setLoading(false)) - }, [token, router]) + }, [user, router]) const parseTracks = (raw: string) => raw.split('\n').map(l => l.trim()).filter(l => l.length > 1) const handleCreate = async (e: React.FormEvent) => { e.preventDefault() - if (!token || !newName.trim()) return + if (!user || !newName.trim()) return const tracks = parseTracks(newTracks) if (!tracks.length) { setCreateError('Добавьте хотя бы один трек'); return } setCreateError('') setSaving(true) try { - const pl = await createPlaylist(token, newName.trim(), tracks, newIsPublic, newTags) + const pl = await createPlaylist(newName.trim(), tracks, newIsPublic, newTags) setPlaylists(prev => [pl, ...prev]) setNewName(''); setNewTracks(''); setNewIsPublic(false); setNewTags([]) setShowForm(false) @@ -661,23 +804,39 @@ export default function PlaylistsPage() { } const handleUpdate = async (id: string, name: string, tracks: string[], isPublic: boolean, tags: string[]) => { - if (!token) return + if (!user) return try { - const updated = await updatePlaylist(token, id, name, tracks, isPublic, tags) + const updated = await updatePlaylist(id, name, tracks, isPublic, tags) setPlaylists(prev => prev.map(p => p.id === id ? updated : p)) setEditingId(null) } catch {} } const handleDelete = async (id: string) => { - if (!token) return + if (!user) return setPlaylists(prev => prev.filter(p => p.id !== id)) - await deletePlaylist(token, id).catch(() => {}) + await deletePlaylist(id).catch(() => {}) } + const handleAddTrack = async (id: string, title: string) => { + const pl = playlists.find(p => p.id === id) + if (!pl) return + const existing = pl.tracks?.map(t => t.title) ?? [] + const updated = await updatePlaylist(id, pl.name, [...existing, title], pl.is_public, pl.tags ?? []) + setPlaylists(prev => prev.map(p => p.id === id ? updated : p)) + } + + const filteredPlaylists = playlists + .filter(p => !plSearch.trim() || p.name.toLowerCase().includes(plSearch.toLowerCase())) + .sort((a, b) => { + if (plSort === 'name') return a.name.localeCompare(b.name, 'ru') + if (plSort === 'tracks') return (b.tracks?.length ?? 0) - (a.tracks?.length ?? 0) + return new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime() + }) + const handleImport = async (name: string, tracks: string[]) => { - if (!token) return - const pl = await createPlaylist(token, name, tracks, false, []) + if (!user) return + const pl = await createPlaylist(name, tracks, false, []) setPlaylists(prev => [pl, ...prev]) setShowImport(false) } @@ -740,10 +899,33 @@ export default function PlaylistsPage() { {showImport && ( - setShowImport(false)} - /> +
+
+
+ {(['yandex', 'spotify'] as const).map(src => ( + + ))} +
+ +
+ {importSource === 'yandex' + ? setShowImport(false)} /> + : setShowImport(false)} /> + } +
)} {showForm && ( @@ -801,24 +983,56 @@ export default function PlaylistsPage() {

Нажмите «Создать» чтобы добавить первый

) : ( -
- {playlists.map(pl => ( -
- setEditingId(editingId === pl.id ? null : pl.id)} - onDelete={() => handleDelete(pl.id)} + <> + {/* Search + sort */} +
+
+ + + + + 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 && ( - handleUpdate(pl.id, name, tracks, isPublic, tags)} - onCancel={() => setEditingId(null)} - /> - )}
- ))} -
+ +
+ +
+ {filteredPlaylists.map(pl => ( +
+ setEditingId(editingId === pl.id ? null : pl.id)} + onDelete={() => handleDelete(pl.id)} + onAddTrack={handleAddTrack} + /> + {editingId === pl.id && ( + handleUpdate(pl.id, name, tracks, isPublic, tags)} + onCancel={() => setEditingId(null)} + /> + )} +
+ ))} + {filteredPlaylists.length === 0 && plSearch && ( +
Ничего не найдено
+ )} +
+ )} ) diff --git a/apps/web/src/app/register/page.tsx b/apps/web/src/app/(main)/register/page.tsx similarity index 100% rename from apps/web/src/app/register/page.tsx rename to apps/web/src/app/(main)/register/page.tsx diff --git a/apps/web/src/app/(main)/remote/[id]/page.tsx b/apps/web/src/app/(main)/remote/[id]/page.tsx new file mode 100644 index 0000000..43a90c7 --- /dev/null +++ b/apps/web/src/app/(main)/remote/[id]/page.tsx @@ -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(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>({}) + 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(null) + const volumeDebounce = useRef | 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 ( +
+
+ + + +
+
+

Сессия не найдена

+

Ссылка устарела или сессия завершена

+
+
+ ) + + if (!state) return ( +
+
+
+ ) + + 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 ( +
+ + {/* Header */} +
+
+ + Party Mix · Пульт +
+
+ {(['player', 'queue'] as const).map(t => ( + + ))} +
+
+ + {tab === 'player' && ( +
+ + {/* Cover */} +
+ {state.cover ? ( + + ) : ( +
+ + + +
+ )} +
+ + {/* Track info */} +
+
+
+
+ {state.title || '—'} +
+ {state.artist && ( +
{state.artist}
+ )} +
+
+ {currentOwner && ( + + {currentOwner.owner} + + )} + {state.queue_len > 0 && ( + {state.cur_idx + 1} / {state.queue_len} + )} +
+
+
+ + {/* Progress bar */} +
+
{ e.preventDefault(); handleSeekStart(e.clientX) }} + onTouchStart={(e) => { if (e.touches[0]) handleSeekStart(e.touches[0].clientX) }} + > +
+
+
+
+
+
+
+
+ {formatTime(clampedProgress)} + {formatTime(state.duration)} +
+
+ + {/* Controls */} +
+ + + + + +
+ + {/* Volume */} +
+ + + + { + 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)' }} + /> + + + + +
+ + {/* Versions */} + {versions.length > 1 && ( +
+ + {versionsOpen && ( +
+ {versions.map((v, i) => { + const active = i === state.active_version + const saved = isSavedLocally(v) + return ( +
+ {i + 1} + {v.img ? ( + ((e.target as HTMLImageElement).style.display = 'none')} /> + ) : ( +
+ )} +
{ cmd(id, 'version', i); setVersionsOpen(false) }}> +
{v.title}
+
{v.artist}
+
+ {v.duration} + + +
+ ) + })} +
+ )} +
+ )} +
+ )} + + {tab === 'queue' && ( +
+ + {/* Add track */} +
+ 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" + /> + +
+ + {/* Queue list */} + {queue.length === 0 ? ( +
+ + + + + + + +

Очередь пуста

+
+ ) : ( +
+ {queue.map((item, i) => { + const active = i === state.cur_idx + return ( +
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 */} +
+ {active ? ( +
+
+
+ ) : ( + {i + 1} + )} +
+ + {/* Cover */} + {item.img ? ( + ((e.target as HTMLImageElement).style.display = 'none')} /> + ) : ( +
+ + + +
+ )} + + {/* Title + owner */} +
+ {item.title} + + {item.owner} + +
+ + {/* Remove */} + +
+ ) + })} +
+ )} +
+ )} + +
+ ) +} diff --git a/apps/web/src/app/search/page.tsx b/apps/web/src/app/(main)/search/page.tsx similarity index 78% rename from apps/web/src/app/search/page.tsx rename to apps/web/src/app/(main)/search/page.tsx index 9a1bcf2..322f198 100644 --- a/apps/web/src/app/search/page.tsx +++ b/apps/web/src/app/(main)/search/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useRef, useState } from 'react' +import { useRef, useState, useEffect, useCallback } from 'react' import { usePartyStore } from '@/store/partyStore' import { useFavoritesStore } from '@/store/favoritesStore' import { useVersionStore } from '@/store/versionStore' @@ -9,13 +9,31 @@ import AddToPlaylist from '@/components/AddToPlaylist' import Header from '@/components/Header' import type { SearchResult } from '@/types' +// Module-level cache: survives navigation, cleared on page refresh +let _cachedQuery = '' +let _cachedResults: SearchResult[] | null = null + +const SEARCH_HISTORY_KEY = 'pm_search_history' +const MAX_HISTORY = 8 + +function getHistory(): string[] { + if (typeof window === 'undefined') return [] + try { return JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) ?? '[]') } catch { return [] } +} +function pushHistory(q: string) { + const next = [q, ...getHistory().filter(s => s !== q)].slice(0, MAX_HISTORY) + try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next)) } catch {} + return next +} + function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: SearchResult) => void }) { const { isFavorite, toggleFavorite } = useFavoritesStore() const { isSaved, saveVersion, removeVersion } = useVersionStore() const [playlistOpen, setPlaylistOpen] = useState(false) const addBtnRef = useRef(null) - const favorited = isFavorite(result.title) + const favKey = result.artist ? `${result.artist} — ${result.title}` : result.title + const favorited = isFavorite(favKey) const saved = isSaved(result.title, result) const hasImg = result.img && !result.img.includes('no-cover') @@ -94,7 +112,7 @@ function ResultCard({ result, onPlay }: { result: SearchResult; onPlay: (r: Sear {/* Favorite */} + {/* Search history */} + {!loading && results === null && searchHistory.length > 0 && ( +
+
+ Недавние + +
+
+ {searchHistory.map((q) => ( + + ))} +
+
+ )} + {/* Loading */} {loading && (
diff --git a/apps/web/src/app/(main)/settings/page.tsx b/apps/web/src/app/(main)/settings/page.tsx new file mode 100644 index 0000000..66efde7 --- /dev/null +++ b/apps/web/src/app/(main)/settings/page.tsx @@ -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 ( + + + + + + + + + + + + ) +} + +function WavesPreview() { + return ( + + + + + + + + ) +} + +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 ( + + + {ln.map(([a,b],i) => )} + {d.map(([x,y],i) => )} + + ) +} + +function AuroraPreview() { + return ( + + + + + + + + + + + + + + ) +} + +function PulsePreview() { + return ( + + + + + + + + + + + + + ) +} + +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 ( + + + + + + + {stars.map(([x,y,r],i) => ( + + ))} + + ) +} + +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 ( + + + {drops.map(([x,y,len],i) => ( + + + + + ))} + + ) +} + +function RaysPreview() { + const rays = Array.from({length: 9}, (_,i) => (i/9)*360) + return ( + + + + + + {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 ( + + ) + })} + + + + ) +} + +function NonePreview() { + return ( + + + + + + ) +} + +const BG_PREVIEWS: Record = { + orbs: , + waves: , + particles: , + aurora: , + pulse: , + stars: , + rain: , + rays: , + none: , +} + +// ── 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> = { + 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 ( + + + + ) +} + +// ── Overlay style previews ──────────────────────────────────────────────────── + +function OverlayStylePreview({ style }: { style: OverlayStyle }) { + const W = 160, H = 90 + if (style === 'classic') return ( + + + + + + + + + + + ) + if (style === 'aero') return ( + + + + + + + + + ) + if (style === 'retro') return ( + + + + + ▶ NOW PLAYING + ● REC + + + 00:42 + + ) + if (style === 'neon') return ( + + + + + + ◈ STREAM + + + + ) + if (style === 'clean') return ( + + + + + + + ) + if (style === 'y2k') return ( + + + + + + 🎵 Party Mix Player + + × + + + + + ) + if (style === 'lofi') return ( + + + + + + + now playing + + + + ) + if (style === 'glam') return ( + + + + + + + + + NOW PLAYING + + + + + ) + // matrix + return ( + + + + PARTY_MIX@STREAM:~$ + > Земфира — Хочешь?▌ + [LOADING...] + + ) +} + +// ── 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('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(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 ( +
+
+ +
+

Настройки

+ + {/* Tabs */} +
+ {([['appearance', 'Внешний вид'], ['overlay', 'Оверлей']] as [SettingsTab, string][]).map(([id, label]) => ( + + ))} +
+ + {/* ── Appearance tab ─────────────────────────────────────────────── */} + {tab === 'appearance' && ( +
+

+ Внешний вид +

+ + {/* ── Accent color (collapsible) ─────────────────────────────── */} + + + {showAccent && ( +
+
+ {ACCENT_PRESETS.map((preset, i) => ( + + ))} + +
+ + {accentIdx === -1 && ( +
+ +
+ )} +
+ )} + + {/* ── Live background (collapsible) ─────────────────────────── */} + + + {showBg && ( +
+
+ {BG_PRESETS.map((preset) => { + const active = bgMode === preset.id + return ( + + ) + })} +
+ + {/* Per-effect config panel */} + {activeFxMode && fxSliders && ( +
+
+

+ Настройки эффекта +

+ +
+
+ {fxSliders.map(({ key, label, min, max, step, fmt }) => { + const val = (fxConfigs[activeFxMode] as unknown as Record)[key] ?? (DEFAULT_FX[activeFxMode] as unknown as Record)[key] + return ( +
+
+ {label} + + {fmt(val)} + +
+ 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)' }} + /> +
+ ) + })} +
+
+ )} +
+ )} +
+ )} + + {/* ── Overlay tab ────────────────────────────────────────────────── */} + {tab === 'overlay' && ( +
+ + {/* ── Header: enable toggle + URL ──────────────────────────────── */} +
+ +
+

Оверлей для стрима

+

{overlayUrl}

+
+ +
+ + {/* ── Live preview ─────────────────────────────────────────────── */} +
+ + {showOvPreview && } +
+ + {/* ── Style picker ─────────────────────────────────────────────── */} +
+ + {showOvStyle &&
+ {(Object.entries(OVERLAY_STYLES) as [OverlayStyle, typeof OVERLAY_STYLES[OverlayStyle]][]).map(([id, { name, desc }]) => { + const active = overlayStyle === id + return ( + + ) + })} +
} +
+ + {/* ── Palette picker ───────────────────────────────────────────── */} +
+ + {showOvPalette && <> +
+ {getPalettes(overlayStyle).map((pal) => { + const active = overlayPalette === pal.id + return ( + + ) + })} + + {/* 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 ( + + ) + })()} +
+ + {/* 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 ( +
+
+ {fields.map((f) => { + const val = cp[f.key] ?? f.default + const isActive = currentField === f.key + return ( + + ) + })} +
+ {currentField && ( + setCustomPaletteField(overlayStyle, currentField, hex)} + /> + )} +
+ ) + })()} + } +
+ + {/* ── Colors: accent + text ─────────────────────────────────────── */} +
+ + {showOvColors && <> +
+ {/* Accent color */} + + {/* Text color */} + +
+ {activeColorPicker === 'accent' && ( +
+ +
+ )} + {activeColorPicker === 'text' && ( +
+ + {overlayTextColor && ( + + )} +
+ )} + } +
+ + {/* ── Font: horizontal chips ───────────────────────────────────── */} +
+ + {showOvFont &&
+ {OVERLAY_FONTS.map(({ id, name, css }) => { + const active = overlayFont === id + return ( + + ) + })} +
} +
+ + {/* ── Layout: position + cover/eq ──────────────────────────────── */} +
+ + {showOvLayout &&
+ {/* Position grid */} +
+

Позиция

+
+ {OVERLAY_POSITIONS.map(({ id, arrow, label }) => { + const active = overlayPosition === id + return ( + + ) + })} +
+
+ + {/* Toggles */} +
+ {([ + { label: 'Обложка', val: overlayShowCover, set: setOverlayShowCover }, + { label: 'EQ-анимация', val: overlayShowEq, set: setOverlayShowEq }, + ] as const).map(({ label, val, set }) => ( +
+ {label} + +
+ ))} +
+
} + + {/* Sliders: margin / scale / opacity */} + {showOvLayout && ( +
+ {([ + { 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 }) => ( +
+
+ {label} + {fmt(value)} +
+ 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)' }} + /> +
+ ))} +
+ )} +
+ + {/* ── Note ─────────────────────────────────────────────────────── */} +
+ + + + В OBS: источник «Браузер» → вставь URL. CSS: body {'{ background: transparent !important; }'} +
+ +
+ )} + +
+
+ ) +} diff --git a/apps/web/src/app/community/page.tsx b/apps/web/src/app/community/page.tsx deleted file mode 100644 index 89f55f9..0000000 --- a/apps/web/src/app/community/page.tsx +++ /dev/null @@ -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 ( -
-
- {tracks.map((track, i) => ( -
- {i + 1} - {track.title} -
- ))} -
-
- ) -} - -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 ( -
-
-
- {pl.username[0].toUpperCase()} -
- -
-
{pl.name}
-
- {pl.username} - · - {trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'} - {tags.map(tag => ( - - {tag} - - ))} -
-
- -
- {trackCount > 0 && ( - - )} - -
-
- - {expanded && pl.tracks && pl.tracks.length > 0 && ( - - )} -
- ) -} - -export default function CommunityPage() { - const [playlists, setPlaylists] = useState([]) - const [loading, setLoading] = useState(true) - const [launched, setLaunched] = useState(null) - const [search, setSearch] = useState('') - const [activeTag, setActiveTag] = useState(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 ( -
-
- -
-
-

Сообщество

- {!loading && ( - {playlists.length} плейлистов - )} -
-

Публичные плейлисты пользователей

-
- - {!loading && playlists.length > 0 && ( -
-
- - - - - 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 && ( - - )} -
- - {allTags.length > 0 && ( -
- {allTags.map(tag => ( - - ))} -
- )} -
- )} - - {loading ? ( -
-
- Загрузка... -
- ) : !filtered.length ? ( -
-
🎵
-

- {playlists.length ? 'Ничего не найдено' : 'Пока нет публичных плейлистов'} -

-

- {playlists.length ? 'Попробуйте другой запрос' : 'Создайте плейлист и сделайте его публичным'} -

-
- ) : ( -
- {filtered.map(pl => ( - handlePlay(pl)} - isLaunched={launched === pl.id} - /> - ))} -
- )} -
- ) -} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 6d48b10..a04ccfa 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -4,8 +4,8 @@ :root { --border: rgba(255, 255, 255, 0.07); - --accent: #c8ff00; - --accent-rgb: 200,255,0; + --accent: #de9cfe; + --accent-rgb: 222,156,254; } *, diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index cdf1cf5..9248c42 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,9 +1,5 @@ import type { Metadata, Viewport } from 'next' import { Syne, DM_Sans } from 'next/font/google' -import AuthHydrator from '@/components/AuthHydrator' -import AudioBackground from '@/components/AudioBackground' -import GlobalPlayer from '@/components/GlobalPlayer' -import ThemeApplier from '@/components/ThemeApplier' import './globals.css' const syne = Syne({ @@ -29,17 +25,16 @@ export const viewport: Viewport = { maximumScale: 1, } +const accentInitScript = `(function(){try{var P=[['#de9cfe','222,156,254'],['#c8ff00','200,255,0'],['#00D4FF','0,212,255'],['#FF2D78','255,45,120'],['#A855F7','168,85,247'],['#FF6B35','255,107,53'],['#00FFB2','0,255,178']];var idx=parseInt(localStorage.getItem('pm_accent')||'0',10);var a,r;if(idx===-1){a=localStorage.getItem('pm_accent_custom')||'#de9cfe';var h=a.replace('#','');r=parseInt(h.slice(0,2),16)+','+parseInt(h.slice(2,4),16)+','+parseInt(h.slice(4,6),16);}else{var p=P[idx]||P[0];a=p[0];r=p[1];}document.documentElement.style.setProperty('--accent',a);document.documentElement.style.setProperty('--accent-rgb',r);}catch(e){}})();` + export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - -
- - {children} -
- + +