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

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

15
apps/backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod ./
ENV GOFLAGS=-mod=mod
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./main.go
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

14
apps/backend/go.mod Normal file
View File

@@ -0,0 +1,14 @@
module github.com/toyffee/party-mix
go 1.23
require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.37.0
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
)

View File

@@ -0,0 +1,37 @@
package config
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
Port string
DBHost string
DBPort string
DBUser string
DBPass string
DBName string
JWTSecret string
}
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"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@@ -0,0 +1,39 @@
package database
import (
"fmt"
"log"
"github.com/toyffee/party-mix/internal/config"
"github.com/toyffee/party-mix/internal/models"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Connect(cfg *config.Config) *gorm.DB {
dsn := fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
cfg.DBHost, cfg.DBUser, cfg.DBPass, cfg.DBName, cfg.DBPort,
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
log.Fatalf("database connection failed: %v", err)
}
if err := db.AutoMigrate(
&models.Party{},
&models.Participant{},
&models.Track{},
&models.HistoryItem{},
&models.User{},
&models.Playlist{},
&models.PlaylistTrack{},
&models.UserVersion{},
); err != nil {
log.Fatalf("database migration failed: %v", err)
}
log.Println("Database connected and migrated")
return db
}

View File

@@ -0,0 +1,103 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/toyffee/party-mix/internal/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
func Register(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
user := models.User{
ID: uuid.NewString(),
Username: req.Username,
Email: strings.ToLower(req.Email),
PasswordHash: string(hash),
CreatedAt: time.Now(),
}
if err := db.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, gin.H{"error": "username or email already taken"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create user"})
return
}
c.JSON(http.StatusCreated, user)
}
}
type loginReq struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
func Login(db *gorm.DB, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := db.Where("email = ?", strings.ToLower(req.Email)).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
token, err := generateToken(user.ID, jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not generate token"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user,
})
}
}
func Me(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var user models.User
if err := db.First(&user, "id = ?", userID).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, user)
}
}

View File

@@ -0,0 +1,66 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type jwtClaims struct {
UserID string `json:"user_id"`
jwt.RegisteredClaims
}
func generateToken(userID, secret string) (string, error) {
claims := jwtClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
func parseToken(tokenStr, secret string) (*jwtClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &jwtClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*jwtClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
func AuthRequired(jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
tokenStr := strings.TrimPrefix(header, "Bearer ")
if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, err := parseToken(tokenStr, jwtSecret)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set("userID", claims.UserID)
c.Next()
}
}
func currentUserID(c *gin.Context) string {
id, _ := c.Get("userID")
s, _ := id.(string)
return s
}

View File

@@ -0,0 +1,173 @@
package handlers
import (
"math/rand"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/toyffee/party-mix/internal/models"
"gorm.io/gorm"
)
const codeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func generateCode() string {
b := make([]byte, 6)
for i := range b {
b[i] = codeChars[rand.Intn(len(codeChars))]
}
return string(b)
}
type createPartyReq struct {
Name string `json:"name" binding:"required"`
TelegramID int64 `json:"telegram_id"`
}
func CreateParty(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req createPartyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
party := models.Party{
ID: uuid.NewString(),
Name: req.Name,
Code: generateCode(),
TelegramID: req.TelegramID,
CreatedAt: time.Now(),
}
if err := db.Create(&party).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create party"})
return
}
c.JSON(http.StatusCreated, party)
}
}
func GetParty(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var party models.Party
err := db.Preload("Participants.Tracks").First(&party, "id = ?", c.Param("id")).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "party not found"})
return
}
c.JSON(http.StatusOK, party)
}
}
func GetPartyByCode(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var party models.Party
err := db.Preload("Participants.Tracks").First(&party, "code = ?", c.Param("code")).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "party not found"})
return
}
c.JSON(http.StatusOK, party)
}
}
type addParticipantReq struct {
Name string `json:"name" binding:"required"`
Tracks []string `json:"tracks" binding:"required"`
ColorBg string `json:"color_bg"`
ColorText string `json:"color_text"`
}
func AddParticipant(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req addParticipantReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
participant := models.Participant{
ID: uuid.NewString(),
PartyID: c.Param("id"),
Name: req.Name,
ColorBg: req.ColorBg,
ColorText: req.ColorText,
CreatedAt: time.Now(),
}
tracks := make([]models.Track, 0, len(req.Tracks))
for i, title := range req.Tracks {
tracks = append(tracks, models.Track{
ID: uuid.NewString(),
ParticipantID: participant.ID,
Title: title,
Position: i,
CreatedAt: time.Now(),
})
}
participant.Tracks = tracks
if err := db.Create(&participant).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add participant"})
return
}
c.JSON(http.StatusCreated, participant)
}
}
func RemoveParticipant(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
pid := c.Param("pid")
db.Where("participant_id = ?", pid).Delete(&models.Track{})
if err := db.Delete(&models.Participant{}, "id = ?", pid).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove participant"})
return
}
c.Status(http.StatusNoContent)
}
}
type addHistoryReq struct {
Title string `json:"title"`
Artist string `json:"artist"`
ImgURL string `json:"img_url"`
OwnerName string `json:"owner_name"`
OwnerColorBg string `json:"owner_color_bg"`
OwnerColorText string `json:"owner_color_text"`
}
func AddHistory(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req addHistoryReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item := models.HistoryItem{
ID: uuid.NewString(),
PartyID: c.Param("id"),
Title: req.Title,
Artist: req.Artist,
ImgURL: req.ImgURL,
OwnerName: req.OwnerName,
OwnerColorBg: req.OwnerColorBg,
OwnerColorText: req.OwnerColorText,
PlayedAt: time.Now(),
}
db.Create(&item)
c.JSON(http.StatusCreated, item)
}
}
func GetHistory(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var items []models.HistoryItem
db.Where("party_id = ?", c.Param("id")).Order("played_at desc").Limit(100).Find(&items)
c.JSON(http.StatusOK, items)
}
}
func ClearHistory(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
db.Where("party_id = ?", c.Param("id")).Delete(&models.HistoryItem{})
c.Status(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,283 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/toyffee/party-mix/internal/models"
"gorm.io/gorm"
)
func parseTags(s string) []string {
if s == "" {
return []string{}
}
result := []string{}
for _, t := range strings.Split(s, ",") {
if t = strings.TrimSpace(t); t != "" {
result = append(result, t)
}
}
return result
}
func joinTags(tags []string) string {
cleaned := make([]string, 0, len(tags))
for _, t := range tags {
if t = strings.TrimSpace(t); t != "" {
cleaned = append(cleaned, t)
}
}
return strings.Join(cleaned, ",")
}
type playlistResp struct {
models.Playlist
Tags []string `json:"tags"`
}
type pubPlaylistResp struct {
models.Playlist
Tags []string `json:"tags"`
Username string `json:"username"`
}
func toResp(p models.Playlist) playlistResp {
return playlistResp{Playlist: p, Tags: parseTags(p.Tags)}
}
func GetPublicPlaylists(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var playlists []models.Playlist
db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
return tx.Order("position asc")
}).Where("is_public = ?", true).Order("created_at desc").Find(&playlists)
type UserRow struct {
ID string
Username string
}
userIDs := make([]string, 0, len(playlists))
for _, p := range playlists {
userIDs = append(userIDs, p.UserID)
}
var users []UserRow
db.Model(&models.User{}).Select("id, username").Where("id IN ?", userIDs).Find(&users)
userMap := make(map[string]string, len(users))
for _, u := range users {
userMap[u.ID] = u.Username
}
resp := make([]pubPlaylistResp, 0, len(playlists))
for _, p := range playlists {
resp = append(resp, pubPlaylistResp{
Playlist: p,
Tags: parseTags(p.Tags),
Username: userMap[p.UserID],
})
}
c.JSON(http.StatusOK, resp)
}
}
func GetPlaylists(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlists []models.Playlist
db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
return tx.Order("position asc")
}).Where("user_id = ?", userID).Order("created_at desc").Find(&playlists)
resp := make([]playlistResp, 0, len(playlists))
for _, p := range playlists {
resp = append(resp, toResp(p))
}
c.JSON(http.StatusOK, resp)
}
}
type playlistTrackInput struct {
Title string `json:"title"`
Position int `json:"position"`
}
type playlistReq struct {
Name string `json:"name" binding:"required"`
Tracks []playlistTrackInput `json:"tracks"`
IsPublic bool `json:"is_public"`
Tags []string `json:"tags"`
}
func CreatePlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req playlistReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
playlist := models.Playlist{
ID: uuid.NewString(),
UserID: currentUserID(c),
Name: req.Name,
IsPublic: req.IsPublic,
Tags: joinTags(req.Tags),
CreatedAt: time.Now(),
}
tracks := buildTracks(playlist.ID, req.Tracks)
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&playlist).Error; err != nil {
return err
}
if len(tracks) > 0 {
return tx.Create(&tracks).Error
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create playlist"})
return
}
playlist.Tracks = tracks
c.JSON(http.StatusCreated, toResp(playlist))
}
}
func GetPlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlist models.Playlist
if err := db.Preload("Tracks", func(tx *gorm.DB) *gorm.DB {
return tx.Order("position asc")
}).First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
return
}
if playlist.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
c.JSON(http.StatusOK, toResp(playlist))
}
}
func UpdatePlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlist models.Playlist
if err := db.First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
return
}
if playlist.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var req playlistReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
playlist.Name = req.Name
playlist.IsPublic = req.IsPublic
playlist.Tags = joinTags(req.Tags)
newTracks := buildTracks(playlist.ID, req.Tracks)
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&playlist).Error; err != nil {
return err
}
if err := tx.Where("playlist_id = ?", playlist.ID).Delete(&models.PlaylistTrack{}).Error; err != nil {
return err
}
if len(newTracks) > 0 {
return tx.Create(&newTracks).Error
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not update playlist"})
return
}
playlist.Tracks = newTracks
c.JSON(http.StatusOK, toResp(playlist))
}
}
func AddTrackToPlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlist models.Playlist
if err := db.Preload("Tracks").First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
return
}
if playlist.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var req struct {
Title string `json:"title" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
track := models.PlaylistTrack{
ID: uuid.NewString(),
PlaylistID: playlist.ID,
Title: req.Title,
Position: len(playlist.Tracks),
CreatedAt: time.Now(),
}
if err := db.Create(&track).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not add track"})
return
}
c.JSON(http.StatusCreated, track)
}
}
func DeletePlaylist(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var playlist models.Playlist
if err := db.First(&playlist, "id = ?", c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
return
}
if playlist.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
db.Where("playlist_id = ?", playlist.ID).Delete(&models.PlaylistTrack{})
db.Delete(&playlist)
c.Status(http.StatusNoContent)
}
}
func buildTracks(playlistID string, inputs []playlistTrackInput) []models.PlaylistTrack {
tracks := make([]models.PlaylistTrack, 0, len(inputs))
for i, t := range inputs {
if t.Title == "" {
continue
}
pos := t.Position
if pos == 0 {
pos = i
}
tracks = append(tracks, models.PlaylistTrack{
ID: uuid.NewString(),
PlaylistID: playlistID,
Title: t.Title,
Position: pos,
CreatedAt: time.Now(),
})
}
return tracks
}

View File

@@ -0,0 +1,432 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
var (
httpClient = &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
// No body-read timeout: MP3 files are large and stream takes time.
// DisableCompression=true prevents Go from auto-decompressing gzip while
// forwarding the upstream's Content-Length (compressed size), which would
// cause ERR_CONTENT_LENGTH_MISMATCH in the browser.
mp3StreamClient = &http.Client{
Timeout: 5 * time.Minute,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
DisableCompression: true,
},
}
dlRegex = regexp.MustCompile(`href="(/get/music/[^"]+\.mp3)"`)
metaRegex = regexp.MustCompile(`data-musmeta='([^']+)'`)
durRegex = regexp.MustCompile(`track__time[^>]*>([^<\s][^<]*)`)
titleRegex = regexp.MustCompile(`track__title[^>]*>([^<]+)<`)
artistRegex = regexp.MustCompile(`track__desc[^>]*>([^<]+)<`)
)
// Search result cache
type searchCacheEntry struct {
results []SearchResult
expiry time.Time
}
var (
searchCacheMu sync.RWMutex
searchCacheMap = map[string]*searchCacheEntry{}
)
func cacheKey(q string) string {
return strings.ToLower(strings.TrimSpace(q))
}
func getSearchCache(q string) ([]SearchResult, bool) {
key := cacheKey(q)
searchCacheMu.RLock()
e, ok := searchCacheMap[key]
searchCacheMu.RUnlock()
if !ok || time.Now().After(e.expiry) {
return nil, false
}
return e.results, true
}
func setSearchCache(q string, results []SearchResult) {
key := cacheKey(q)
searchCacheMu.Lock()
searchCacheMap[key] = &searchCacheEntry{results: results, expiry: time.Now().Add(30 * time.Minute)}
if len(searchCacheMap) > 1000 {
now := time.Now()
for k, v := range searchCacheMap {
if now.After(v.expiry) {
delete(searchCacheMap, k)
}
}
}
searchCacheMu.Unlock()
}
type musMeta struct {
Title string `json:"title"`
Artist string `json:"artist"`
Img string `json:"img"`
}
type SearchResult struct {
MP3 string `json:"mp3"`
Title string `json:"title"`
Artist string `json:"artist"`
Duration string `json:"duration"`
Img string `json:"img"`
}
func hitmotopHeaders(req *http.Request) {
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "*/*")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9")
req.Header.Set("Referer", "https://rus.hitmotop.com/")
req.Header.Set("Origin", "https://rus.hitmotop.com")
}
func TopChartsHandler(c *gin.Context) {
targetURL := "https://rus.hitmotop.com/"
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
return
}
hitmotopHeaders(req)
resp, err := httpClient.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "fetch failed"})
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
return
}
results := parseSearchResults(string(body))
if len(results) > 20 {
results = results[:20]
}
c.Header("Cache-Control", "public, max-age=600")
c.JSON(http.StatusOK, results)
}
func SearchHandler(c *gin.Context) {
q := c.Query("q")
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing query"})
return
}
if cached, ok := getSearchCache(q); ok {
c.JSON(http.StatusOK, cached)
return
}
targetURL := fmt.Sprintf("https://rus.hitmotop.com/search?q=%s", url.QueryEscape(q))
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
return
}
hitmotopHeaders(req)
resp, err := httpClient.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "search request failed"})
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
return
}
results := parseSearchResults(string(body))
setSearchCache(q, results)
c.JSON(http.StatusOK, results)
}
func parseSearchResults(html string) []SearchResult {
dlMatches := dlRegex.FindAllStringSubmatch(html, -1)
metaMatches := metaRegex.FindAllStringSubmatch(html, -1)
results := make([]SearchResult, 0, len(dlMatches))
for i, dm := range dlMatches {
if len(dm) < 2 {
continue
}
href := dm[1]
originalMp3 := "https://rus.hitmotop.com" + href
mp3 := "/api/proxy/mp3?url=" + url.QueryEscape(originalMp3)
var meta musMeta
if i < len(metaMatches) && len(metaMatches[i]) >= 2 {
_ = json.Unmarshal([]byte(metaMatches[i][1]), &meta)
}
hrefPos := strings.Index(html, href)
chunk := ""
if hrefPos >= 0 {
start := hrefPos - 800
if start < 0 {
start = 0
}
end := hrefPos + 200
if end > len(html) {
end = len(html)
}
chunk = html[start:end]
}
dur := ""
if m := durRegex.FindStringSubmatch(chunk); len(m) >= 2 {
dur = strings.TrimSpace(m[1])
}
title := meta.Title
if title == "" {
if m := titleRegex.FindStringSubmatch(chunk); len(m) >= 2 {
title = strings.TrimSpace(m[1])
}
}
artist := meta.Artist
if artist == "" {
if m := artistRegex.FindStringSubmatch(chunk); len(m) >= 2 {
artist = strings.TrimSpace(m[1])
}
}
results = append(results, SearchResult{
MP3: mp3,
Title: title,
Artist: artist,
Duration: dur,
Img: meta.Img,
})
}
return results
}
func ImgProxyHandler(c *gin.Context) {
imgURL := c.Query("url")
if !strings.HasPrefix(imgURL, "http") {
c.Status(http.StatusBadRequest)
return
}
req, err := http.NewRequest("GET", imgURL, nil)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Referer", "https://rus.hitmotop.com/")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = "image/jpeg"
}
c.Header("Cache-Control", "public, max-age=86400")
c.Header("Content-Type", ct)
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}
func MP3ProxyHandler(c *gin.Context) {
mp3URL := c.Query("url")
if !strings.HasPrefix(mp3URL, "https://rus.hitmotop.com/") &&
!strings.HasPrefix(mp3URL, "https://hitmotop.com/") {
c.JSON(http.StatusForbidden, gin.H{"error": "only hitmotop URLs allowed"})
return
}
rangeHeader := c.GetHeader("Range")
fetchMP3(c, mp3URL, rangeHeader, 0)
}
type yandexArtist struct {
Name string `json:"name"`
}
type yandexTrackInfo struct {
Title string `json:"title"`
Artists []yandexArtist `json:"artists"`
}
func YandexPlaylistHandler(c *gin.Context) {
rawURL := c.Query("url")
if rawURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url"})
return
}
u, err := url.Parse(rawURL)
if err != nil || !strings.Contains(u.Host, "yandex") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid yandex music url"})
return
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
var playlistUUID string
if len(parts) >= 2 && parts[0] == "playlists" {
playlistUUID = parts[1]
}
if playlistUUID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported url, use share link from Яндекс.Музыка"})
return
}
apiURL := "https://api.music.yandex.net/playlist/" + url.PathEscape(playlistUUID)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request creation failed"})
return
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("X-Yandex-Music-Client", "WindowsPhone/3.20")
resp, err := httpClient.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "yandex unreachable"})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("yandex returned %d", resp.StatusCode)})
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "read failed"})
return
}
var apiResp struct {
Result struct {
Title string `json:"title"`
Tracks []struct {
Track *yandexTrackInfo `json:"track,omitempty"`
Title string `json:"title,omitempty"`
Artists []yandexArtist `json:"artists,omitempty"`
} `json:"tracks"`
} `json:"result"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "parse failed"})
return
}
tracks := make([]string, 0, len(apiResp.Result.Tracks))
for _, t := range apiResp.Result.Tracks {
var info *yandexTrackInfo
if t.Track != nil {
info = t.Track
} else if t.Title != "" {
info = &yandexTrackInfo{Title: t.Title, Artists: t.Artists}
}
if info == nil || info.Title == "" {
continue
}
trackStr := info.Title
if len(info.Artists) > 0 && info.Artists[0].Name != "" {
trackStr = info.Artists[0].Name + " — " + info.Title
}
tracks = append(tracks, trackStr)
}
c.JSON(http.StatusOK, gin.H{
"name": apiResp.Result.Title,
"tracks": tracks,
})
}
func fetchMP3(c *gin.Context, targetURL, rangeHeader string, redirectCount int) {
if redirectCount > 5 {
c.Status(http.StatusInternalServerError)
return
}
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
hitmotopHeaders(req)
req.Header.Set("Accept", "audio/mpeg, audio/*, */*")
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := mp3StreamClient.Do(req)
if err != nil {
if !c.Writer.Written() {
c.Status(http.StatusInternalServerError)
}
return
}
defer resp.Body.Close()
if resp.StatusCode == 301 || resp.StatusCode == 302 || resp.StatusCode == 307 || resp.StatusCode == 308 {
location := resp.Header.Get("Location")
if location == "" {
c.Status(http.StatusInternalServerError)
return
}
if !strings.HasPrefix(location, "http") {
parsed, _ := url.Parse(targetURL)
location = "https://" + parsed.Host + location
}
fetchMP3(c, location, rangeHeader, redirectCount+1)
return
}
c.Header("Content-Type", "audio/mpeg")
for _, h := range []string{"Content-Length", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
c.Header("Cache-Control", "public, max-age=300")
}
c.Status(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
}

View File

@@ -0,0 +1,162 @@
package handlers
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type RemoteQueueItem struct {
Title string `json:"title"`
Owner string `json:"owner"`
ColorBg string `json:"color_bg"`
ColorText string `json:"color_text"`
Img string `json:"img,omitempty"`
}
type RemoteVersion struct {
Title string `json:"title"`
Artist string `json:"artist"`
Duration string `json:"duration"`
Img string `json:"img,omitempty"`
}
type RemoteState struct {
Title string `json:"title"`
Artist string `json:"artist"`
Cover string `json:"cover"`
IsPlaying bool `json:"is_playing"`
Volume float64 `json:"volume"`
Progress float64 `json:"progress"`
Duration float64 `json:"duration"`
QueueLen int `json:"queue_len"`
CurIdx int `json:"cur_idx"`
Queue []RemoteQueueItem `json:"queue,omitempty"`
Versions []RemoteVersion `json:"versions,omitempty"`
ActiveVersion int `json:"active_version"`
}
type RemoteCommand struct {
ID string `json:"id"`
Cmd string `json:"cmd"`
Value float64 `json:"value,omitempty"`
Text string `json:"text,omitempty"`
}
type remoteRoom struct {
state RemoteState
commands []RemoteCommand
lastSeen time.Time
mu sync.Mutex
}
var (
remoteMu sync.RWMutex
remoteRooms = map[string]*remoteRoom{}
)
func init() {
go func() {
for {
time.Sleep(30 * time.Minute)
remoteMu.Lock()
for id, r := range remoteRooms {
if time.Since(r.lastSeen) > 4*time.Hour {
delete(remoteRooms, id)
}
}
remoteMu.Unlock()
}
}()
}
func CreateRemoteRoom(c *gin.Context) {
id := uuid.NewString()[:8]
remoteMu.Lock()
remoteRooms[id] = &remoteRoom{lastSeen: time.Now()}
remoteMu.Unlock()
c.JSON(http.StatusOK, gin.H{"id": id})
}
func PushRemoteState(c *gin.Context) {
id := c.Param("id")
remoteMu.RLock()
room, ok := remoteRooms[id]
remoteMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var s RemoteState
if err := c.ShouldBindJSON(&s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
room.mu.Lock()
room.state = s
room.lastSeen = time.Now()
room.mu.Unlock()
c.Status(http.StatusNoContent)
}
func GetRemoteState(c *gin.Context) {
id := c.Param("id")
remoteMu.RLock()
room, ok := remoteRooms[id]
remoteMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
room.mu.Lock()
s := room.state
room.mu.Unlock()
c.JSON(http.StatusOK, s)
}
func SendRemoteCommand(c *gin.Context) {
id := c.Param("id")
remoteMu.RLock()
room, ok := remoteRooms[id]
remoteMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var cmd RemoteCommand
if err := c.ShouldBindJSON(&cmd); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cmd.ID = uuid.NewString()
room.mu.Lock()
room.commands = append(room.commands, cmd)
if len(room.commands) > 50 {
room.commands = room.commands[len(room.commands)-50:]
}
room.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func PollRemoteCommands(c *gin.Context) {
id := c.Param("id")
remoteMu.RLock()
room, ok := remoteRooms[id]
remoteMu.RUnlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
room.mu.Lock()
cmds := room.commands
room.commands = nil
room.lastSeen = time.Now()
room.mu.Unlock()
if cmds == nil {
cmds = []RemoteCommand{}
}
c.JSON(http.StatusOK, cmds)
}

View File

@@ -0,0 +1,84 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/toyffee/party-mix/internal/models"
"gorm.io/gorm"
)
func GetVersions(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var rows []models.UserVersion
if err := db.Where("user_id = ?", userID).Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make(map[string]gin.H, len(rows))
for _, r := range rows {
result[r.TrackTitle] = gin.H{
"title": r.VersionTitle,
"artist": r.VersionArtist,
"duration": r.VersionDuration,
}
}
c.JSON(http.StatusOK, result)
}
}
type saveVersionReq struct {
TrackTitle string `json:"track_title" binding:"required"`
VersionTitle string `json:"title" binding:"required"`
VersionArtist string `json:"artist"`
VersionDuration string `json:"duration"`
}
func SaveVersion(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var req saveVersionReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var row models.UserVersion
err := db.Where("user_id = ? AND track_title = ?", userID, req.TrackTitle).First(&row).Error
if err != nil {
row = models.UserVersion{
ID: uuid.NewString(),
UserID: userID,
TrackTitle: req.TrackTitle,
CreatedAt: time.Now(),
}
}
row.VersionTitle = req.VersionTitle
row.VersionArtist = req.VersionArtist
row.VersionDuration = req.VersionDuration
if err := db.Save(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
type deleteVersionReq struct {
TrackTitle string `json:"track_title" binding:"required"`
}
func DeleteVersion(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userID := currentUserID(c)
var req deleteVersionReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db.Where("user_id = ? AND track_title = ?", userID, req.TrackTitle).Delete(&models.UserVersion{})
c.Status(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,79 @@
package models
import "time"
type Party struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
Name string `gorm:"not null" json:"name"`
Code string `gorm:"uniqueIndex;not null" json:"code"`
TelegramID int64 `json:"telegram_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Participants []Participant `gorm:"foreignKey:PartyID" json:"participants,omitempty"`
History []HistoryItem `gorm:"foreignKey:PartyID" json:"history,omitempty"`
}
type Participant struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
PartyID string `gorm:"not null;index" json:"party_id"`
Name string `gorm:"not null" json:"name"`
ColorBg string `json:"color_bg"`
ColorText string `json:"color_text"`
CreatedAt time.Time `json:"created_at"`
Tracks []Track `gorm:"foreignKey:ParticipantID" json:"tracks,omitempty"`
}
type Track struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
ParticipantID string `gorm:"not null;index" json:"participant_id"`
Title string `gorm:"not null" json:"title"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
}
type HistoryItem struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
PartyID string `gorm:"not null;index" json:"party_id"`
Title string `json:"title"`
Artist string `json:"artist"`
ImgURL string `json:"img_url"`
OwnerName string `json:"owner_name"`
OwnerColorBg string `json:"owner_color_bg"`
OwnerColorText string `json:"owner_color_text"`
PlayedAt time.Time `json:"played_at"`
}
type User struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
Username string `gorm:"uniqueIndex;not null" json:"username"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type Playlist struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
IsPublic bool `gorm:"default:false" json:"is_public"`
Tags string `gorm:"default:''" json:"-"`
CreatedAt time.Time `json:"created_at"`
Tracks []PlaylistTrack `gorm:"foreignKey:PlaylistID" json:"tracks,omitempty"`
}
type PlaylistTrack struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
PlaylistID string `gorm:"not null;index" json:"playlist_id"`
Title string `gorm:"not null" json:"title"`
Position int `json:"position"`
CreatedAt time.Time `json:"created_at"`
}
type UserVersion struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"not null;index:idx_user_version_track,unique" json:"user_id"`
TrackTitle string `gorm:"not null;index:idx_user_version_track,unique" json:"track_title"`
VersionTitle string `gorm:"not null" json:"version_title"`
VersionArtist string `gorm:"not null" json:"version_artist"`
VersionDuration string `gorm:"not null" json:"version_duration"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,84 @@
package router
import (
"net/http"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/toyffee/party-mix/internal/handlers"
"gorm.io/gorm"
)
func New(db *gorm.DB, jwtSecret string) *gin.Engine {
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length", "Content-Range", "Accept-Ranges"},
AllowCredentials: false,
}))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
proxy := r.Group("/api/proxy")
{
proxy.GET("/search", handlers.SearchHandler)
proxy.GET("/top", handlers.TopChartsHandler)
proxy.GET("/img", handlers.ImgProxyHandler)
proxy.GET("/mp3", handlers.MP3ProxyHandler)
proxy.GET("/yandex-playlist", handlers.YandexPlaylistHandler)
}
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))
}
r.GET("/api/playlists/public", handlers.GetPublicPlaylists(db))
versions := r.Group("/api/versions", handlers.AuthRequired(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.GET("", handlers.GetPlaylists(db))
playlists.POST("", handlers.CreatePlaylist(db))
playlists.GET("/:id", handlers.GetPlaylist(db))
playlists.PUT("/:id", handlers.UpdatePlaylist(db))
playlists.DELETE("/:id", handlers.DeletePlaylist(db))
playlists.POST("/:id/tracks", handlers.AddTrackToPlaylist(db))
}
remote := r.Group("/api/remote")
{
remote.POST("", handlers.CreateRemoteRoom)
remote.PUT("/:id/state", handlers.PushRemoteState)
remote.GET("/:id/state", handlers.GetRemoteState)
remote.POST("/:id/command", handlers.SendRemoteCommand)
remote.GET("/:id/commands", handlers.PollRemoteCommands)
}
parties := r.Group("/api/parties")
{
parties.POST("", handlers.CreateParty(db))
parties.GET("/:id", handlers.GetParty(db))
parties.GET("/code/:code", handlers.GetPartyByCode(db))
parties.POST("/:id/participants", handlers.AddParticipant(db))
parties.DELETE("/:id/participants/:pid", handlers.RemoveParticipant(db))
parties.POST("/:id/history", handlers.AddHistory(db))
parties.GET("/:id/history", handlers.GetHistory(db))
parties.DELETE("/:id/history", handlers.ClearHistory(db))
}
return r
}

19
apps/backend/main.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"log"
"github.com/toyffee/party-mix/internal/config"
"github.com/toyffee/party-mix/internal/database"
"github.com/toyffee/party-mix/internal/router"
)
func main() {
cfg := config.Load()
db := database.Connect(cfg)
r := router.New(db, cfg.JWTSecret)
log.Printf("Party Mix backend starting on :%s", cfg.Port)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server failed: %v", err)
}
}

13
apps/bot/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

983
apps/bot/package-lock.json generated Normal file
View File

@@ -0,0 +1,983 @@
{
"name": "party-mix-bot",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "party-mix-bot",
"version": "1.0.0",
"dependencies": {
"axios": "^1.7.2",
"grammy": "^1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.16.0",
"typescript": "^5.5.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@grammyjs/types": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.26.0.tgz",
"integrity": "sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.1.tgz",
"integrity": "sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/grammy": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/grammy/-/grammy-1.42.0.tgz",
"integrity": "sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==",
"license": "MIT",
"dependencies": {
"@grammyjs/types": "3.26.0",
"abort-controller": "^3.0.0",
"debug": "^4.4.3",
"node-fetch": "^2.7.0"
},
"engines": {
"node": "^12.20.0 || >=14.13.1"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
}

19
apps/bot/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "party-mix-bot",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"grammy": "^1",
"axios": "^1.7.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.16.0",
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,54 @@
import axios from 'axios'
import { config } from '../config'
import type { Party, Participant } from '../types'
const api = axios.create({ baseURL: config.backendUrl, timeout: 10_000 })
export async function createParty(name: string, telegramId: number): Promise<Party> {
const { data } = await api.post<Party>('/api/parties', { name, telegram_id: telegramId })
return data
}
export async function getPartyByCode(code: string): Promise<Party | null> {
try {
const { data } = await api.get<Party>(`/api/parties/code/${code}`)
return data
} catch {
return null
}
}
export async function getParty(id: string): Promise<Party | null> {
try {
const { data } = await api.get<Party>(`/api/parties/${id}`)
return data
} catch {
return null
}
}
const COLORS = [
{ bg: 'rgba(200,255,0,0.1)', text: '#c8ff00' },
{ bg: 'rgba(255,60,172,0.1)', text: '#ff3cac' },
{ bg: 'rgba(0,212,255,0.1)', text: '#00d4ff' },
{ bg: 'rgba(255,165,0,0.1)', text: '#ffa500' },
{ bg: 'rgba(140,100,255,0.1)', text: '#8c64ff' },
{ bg: 'rgba(0,255,140,0.1)', text: '#00ff8c' },
{ bg: 'rgba(255,80,80,0.1)', text: '#ff5050' },
]
export async function addParticipant(
partyId: string,
name: string,
tracks: string[],
colorIndex: number,
): Promise<Participant> {
const color = COLORS[colorIndex % COLORS.length]
const { data } = await api.post<Participant>(`/api/parties/${partyId}/participants`, {
name,
tracks,
color_bg: color.bg,
color_text: color.text,
})
return data
}

46
apps/bot/src/bot.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Bot, session } from 'grammy'
import { config } from './config'
import type { SessionData } from './types'
import { handleStart } from './commands/start'
import { handleHelp } from './commands/help'
import {
handleNewParty,
handleJoinParty,
handleAddMe,
handlePartyInfo,
handleTracksMessage,
} from './commands/party'
type SessionContext = Parameters<typeof handleTracksMessage>[0]
function createBot() {
const bot = new Bot<SessionContext>(config.botToken)
bot.use(
session({
initial: (): SessionData => ({}),
}),
)
bot.command('start', handleStart)
bot.command('help', handleHelp)
bot.command('newparty', handleNewParty)
bot.command('joinparty', handleJoinParty)
bot.command('addme', handleAddMe)
bot.command('party', handlePartyInfo)
bot.on('message:text', async (ctx) => {
const handled = await handleTracksMessage(ctx)
if (!handled) {
await ctx.reply('Неизвестная команда. /help для справки.')
}
})
bot.catch((err) => {
console.error('Bot error:', err)
})
return bot
}
export default createBot

View File

@@ -0,0 +1,17 @@
import { CommandContext, Context } from 'grammy'
export async function handleHelp(ctx: CommandContext<Context>) {
await ctx.reply(
`*Как использовать Party Mix:*\n\n` +
`1\\. Создай вечеринку: /newparty Моя вечеринка\n` +
`2\\. Поделись кодом с друзьями\n` +
`3\\. Каждый добавляет себя: /addme Имя\n` +
`4\\. После \\— список треков строчкой за строчкой\n` +
`5\\. Открой веб\\-ссылку и нажми *Перемешать*\\!\n\n` +
`*Формат треков:*\n` +
`\`Исполнитель — Название\`\n` +
`\`Тараканы — Пойдём на улицу\`\n` +
`\`PALC — Залип\``,
{ parse_mode: 'MarkdownV2' },
)
}

View File

@@ -0,0 +1,152 @@
import { CommandContext, Context } from 'grammy'
import { createParty, getPartyByCode, addParticipant, getParty } from '../api/client'
import type { SessionData } from '../types'
type SessionContext = Context & { session: SessionData }
export async function handleNewParty(ctx: CommandContext<SessionContext>) {
const name = ctx.match?.trim() || `Вечеринка ${new Date().toLocaleDateString('ru')}`
const telegramId = ctx.from?.id ?? 0
try {
const party = await createParty(name, telegramId)
ctx.session.partyId = party.id
ctx.session.partyCode = party.code
ctx.session.partyName = party.name
await ctx.reply(
`🎉 Вечеринка *${escapeMarkdown(party.name)}* создана\\!\n\n` +
`🔑 Код: \`${party.code}\`\n\n` +
`Поделись кодом с друзьями — они введут /joinparty ${party.code}\n\n` +
`Затем каждый добавляет себя командой /addme Имя`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при создании вечеринки. Попробуй ещё раз.')
}
}
export async function handleJoinParty(ctx: CommandContext<SessionContext>) {
const code = ctx.match?.trim().toUpperCase()
if (!code) {
await ctx.reply('Укажи код: /joinparty КОД')
return
}
try {
const party = await getPartyByCode(code)
if (!party) {
await ctx.reply(`Вечеринка с кодом \`${code}\` не найдена.`, { parse_mode: 'MarkdownV2' })
return
}
ctx.session.partyId = party.id
ctx.session.partyCode = party.code
ctx.session.partyName = party.name
const count = party.participants?.length ?? 0
await ctx.reply(
`✅ Подключился к вечеринке *${escapeMarkdown(party.name)}*\\!\n` +
`Участников: ${count}\n\n` +
`Добавь себя: /addme Имя`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при подключении. Проверь код и попробуй снова.')
}
}
export async function handleAddMe(ctx: CommandContext<SessionContext>) {
const name = ctx.match?.trim()
if (!name) {
await ctx.reply('Укажи своё имя: /addme Твоё Имя')
return
}
if (!ctx.session.partyId) {
await ctx.reply('Сначала создай или присоединись к вечеринке: /newparty или /joinparty КОД')
return
}
ctx.session.awaitingTracks = true
ctx.session.pendingParticipantName = name
await ctx.reply(
`Отлично, *${escapeMarkdown(name)}*\\! 🎵\n\n` +
`Отправь список своих треков — каждый с новой строки:\n\n` +
`\`Тараканы — Пойдём на улицу\n` +
`PALC — Залип\n` +
`Дора — Втюрилась\``,
{ parse_mode: 'MarkdownV2' },
)
}
export async function handlePartyInfo(ctx: CommandContext<SessionContext>) {
if (!ctx.session.partyId) {
await ctx.reply('У тебя нет активной вечеринки. Создай: /newparty или присоединись: /joinparty КОД')
return
}
try {
const party = await getParty(ctx.session.partyId)
if (!party) {
ctx.session.partyId = undefined
await ctx.reply('Вечеринка не найдена. Возможно она была удалена.')
return
}
const participants = party.participants ?? []
const list = participants.length
? participants.map((p) => `${p.name}${p.tracks?.length ?? 0} тр\\.`).join('\n')
: ' _Пока никого_'
await ctx.reply(
`🎉 *${escapeMarkdown(party.name)}*\n` +
`Код: \`${party.code}\`\n\n` +
`*Участники:*\n${list}`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при получении информации о вечеринке.')
}
}
export async function handleTracksMessage(ctx: SessionContext) {
if (!ctx.session.awaitingTracks || !ctx.session.pendingParticipantName || !ctx.session.partyId) {
return false
}
const text = ctx.message?.text ?? ''
const tracks = text
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 1)
.slice(0, 50)
if (!tracks.length) {
await ctx.reply('Не нашёл треков. Пришли список заново.')
return true
}
try {
const party = await getParty(ctx.session.partyId)
const colorIndex = party?.participants?.length ?? 0
await addParticipant(ctx.session.partyId, ctx.session.pendingParticipantName, tracks, colorIndex)
ctx.session.awaitingTracks = false
const name = ctx.session.pendingParticipantName
ctx.session.pendingParticipantName = undefined
await ctx.reply(
`✅ *${escapeMarkdown(name)}* добавлен с ${tracks.length} треками\\!\n\n` +
`Используй /party чтобы посмотреть всех участников\\.`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при добавлении участника. Попробуй ещё раз.')
}
return true
}
function escapeMarkdown(text: string): string {
return text.replace(/[_*[\]()~`>#+=|{}.!\\-]/g, '\\$&')
}

View File

@@ -0,0 +1,14 @@
import { CommandContext, Context } from 'grammy'
export async function handleStart(ctx: CommandContext<Context>) {
await ctx.reply(
`🎉 *Party Mix Bot*\n\nСоздавай вечеринки с общими плейлистами!\n\n` +
`*Команды:*\n` +
`/newparty \\[название\\] — создать новую вечеринку\n` +
`/joinparty \\[код\\] — присоединиться к вечеринке\n` +
`/addme \\[имя\\] — добавить себя как участника\n` +
`/party — информация о текущей вечеринке\n` +
`/help — показать эту справку`,
{ parse_mode: 'MarkdownV2' },
)
}

8
apps/bot/src/config.ts Normal file
View File

@@ -0,0 +1,8 @@
export const config = {
botToken: process.env.BOT_TOKEN ?? '',
backendUrl: process.env.BACKEND_URL ?? 'http://localhost:8080',
} as const
if (!config.botToken) {
throw new Error('BOT_TOKEN environment variable is required')
}

12
apps/bot/src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import createBot from './bot'
const bot = createBot()
process.once('SIGINT', () => bot.stop())
process.once('SIGTERM', () => bot.stop())
bot.start({
onStart: (info) => {
console.log(`Party Mix Bot started: @${info.username}`)
},
})

View File

@@ -0,0 +1,32 @@
export interface Party {
id: string
name: string
code: string
telegram_id?: number
created_at: string
participants?: Participant[]
}
export interface Participant {
id: string
party_id: string
name: string
color_bg: string
color_text: string
tracks?: Track[]
}
export interface Track {
id: string
participant_id: string
title: string
position: number
}
export interface SessionData {
partyId?: string
partyCode?: string
partyName?: string
awaitingTracks?: boolean
pendingParticipantName?: string
}

15
apps/bot/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

23
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

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

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

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

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
import type { Metadata, Viewport } from 'next'
import { Syne, DM_Sans } from 'next/font/google'
import AuthHydrator from '@/components/AuthHydrator'
import AudioBackground from '@/components/AudioBackground'
import GlobalPlayer from '@/components/GlobalPlayer'
import './globals.css'
const syne = Syne({
subsets: ['latin', 'latin-ext'],
weight: ['400', '700', '800'],
variable: '--font-syne',
})
const dmSans = DM_Sans({
subsets: ['latin'],
weight: ['300', '400', '500'],
variable: '--font-dm-sans',
})
export const metadata: Metadata = {
title: 'Party Mix',
description: 'Совместные плейлисты для вечеринок — музыка с hitmotop.com',
}
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" className={`${syne.variable} ${dmSans.variable}`}>
<body className="font-sans bg-bg text-app-text min-h-screen pb-[72px] px-4 pt-5 sm:px-4">
<AudioBackground />
<div className="relative" style={{ zIndex: 1 }}>
<AuthHydrator />
{children}
</div>
<GlobalPlayer />
</body>
</html>
)
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,698 @@
'use client'
import { useRef, useState, useCallback, useEffect, RefObject } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { proxyImgUrl, proxyMp3Url, searchTracks } from '@/lib/api'
import { useAudioEngine } from '@/hooks/usePlayer'
import { useAudioViz } from '@/hooks/useAudioViz'
import { audioState } from '@/lib/audioState'
import AddToPlaylist from '@/components/AddToPlaylist'
import type { SearchResult } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
interface BottomPlayerProps {
onTrackEnd: (result: SearchResult) => void
}
function formatTime(s: number) {
if (!s || isNaN(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
export default function BottomPlayer({ onTrackEnd }: BottomPlayerProps) {
const { queue, curIdx, loadKey, updateQueueItemImg, setCurrentResults, setSearchStatus, searchStatus, reorderQueue, setCurIdx, generateMix, removeFromQueue, addTrackToQueue } =
usePartyStore()
const { isFavorite, toggleFavorite } = useFavoritesStore()
const { isSaved, saveVersion, removeVersion, getSavedVersion } = useVersionStore()
const { audioRef, analyserRef, initAudioViz, resumeContext } = useAudioEngine()
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [coverSrc, setCoverSrc] = useState('')
const [audioMeta, setAudioMeta] = useState({ title: '', artist: '' })
const [results, setResults] = useState<SearchResult[]>([])
const [activeResultIdx, setActiveResultIdx] = useState(0)
const [panel, setPanel] = useState<'queue' | 'versions' | null>(null)
const [playlistOpen, setPlaylistOpen] = useState(false)
const playlistBtnRef = useRef<HTMLButtonElement>(null)
const dragSrcIdx = useRef<number | null>(null)
const panelRef = useRef<HTMLDivElement>(null)
// Remote control
const [roomId, setRoomId] = useState<string | null>(null)
const [shareOpen, setShareOpen] = useState(false)
const [shareCopied, setShareCopied] = useState(false)
const shareBtnRef = useRef<HTMLButtonElement>(null)
const isPlayingRef = useRef(false)
const audioMetaRef = useRef({ title: '', artist: '' })
const coverSrcRef = useRef('')
const loadingKeyRef = useRef(-1)
const activeResultsRef = useRef<SearchResult[]>([])
const activeResultIdxRef = useRef(0)
const prefetchCacheRef = useRef<Map<string, SearchResult[]>>(new Map())
const prefetchInflightRef = useRef<Map<string, Promise<SearchResult[]>>>(new Map())
const preloadAudioRef = useRef<HTMLAudioElement | null>(null)
useAudioViz(canvasRef as RefObject<HTMLCanvasElement | null>, analyserRef, isPlaying)
useEffect(() => {
const el = new Audio()
el.preload = 'auto'
preloadAudioRef.current = el
return () => { el.src = ''; preloadAudioRef.current = null }
}, [])
const playResult = useCallback(
(resultList: SearchResult[], resIdx: number) => {
const r = resultList[resIdx]
if (!r) return
setActiveResultIdx(resIdx)
activeResultIdxRef.current = resIdx
setAudioMeta({ title: r.title, artist: r.artist })
if (r.img && !r.img.includes('no-cover')) setCoverSrc(proxyImgUrl(r.img))
const audio = audioRef.current
if (!audio) return
audio.pause()
audio.src = proxyMp3Url(r.mp3)
audio.load()
resumeContext()
audio.play().catch((err) => {
if (err?.name !== 'AbortError') console.warn('[player] play() failed:', err?.name)
})
},
[audioRef, resumeContext],
)
const playResultRef = useRef(playResult)
useEffect(() => { playResultRef.current = playResult }, [playResult])
// Returns cached results immediately, awaits in-flight request, or starts new one.
// Deduplicates parallel requests for the same title.
const prefetchOrGet = useCallback(async (title: string): Promise<SearchResult[]> => {
const cached = prefetchCacheRef.current.get(title)
if (cached) return cached
const inflight = prefetchInflightRef.current.get(title)
if (inflight) return inflight
const promise = searchTracks(title)
prefetchInflightRef.current.set(title, promise)
promise.then(r => {
prefetchCacheRef.current.set(title, r)
prefetchInflightRef.current.delete(title)
}).catch(() => {
prefetchInflightRef.current.delete(title)
})
return promise
}, [])
const loadTrack = useCallback(
async (idx: number, key: number) => {
if (idx < 0 || idx >= queue.length) return
loadingKeyRef.current = key
setIsPlaying(false)
setCoverSrc('')
setResults([])
setAudioMeta({ title: '', artist: '' })
setCurrentTime(0)
setDuration(0)
activeResultsRef.current = []
const audio = audioRef.current
if (audio) { audio.pause(); audio.src = '' }
const track = queue[idx]
const hasCache = prefetchCacheRef.current.has(track.title) || prefetchInflightRef.current.has(track.title)
if (!hasCache) setSearchStatus('searching')
const found = await prefetchOrGet(track.title)
if (loadingKeyRef.current !== key) return
activeResultsRef.current = found
setCurrentResults(found)
setResults(found)
if (!found.length) {
setSearchStatus('not-found')
setTimeout(() => {
if (loadingKeyRef.current !== key) return
const s = usePartyStore.getState()
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
}, 2000)
return
}
setSearchStatus('idle')
const saved = getSavedVersion(track.title)
let startIdx = 0
if (saved) {
const si = found.findIndex(r => r.title === saved.title && r.artist === saved.artist)
if (si >= 0) startIdx = si
}
if (found[startIdx]?.img && !found[startIdx].img.includes('no-cover')) {
const proxied = proxyImgUrl(found[startIdx].img)
setCoverSrc(proxied)
updateQueueItemImg(idx, proxied)
}
playResult(found, startIdx)
// Prefetch next 3 tracks; for N+1 also preload audio into hidden element
for (let offset = 1; offset <= 3; offset++) {
const nextTrack = queue[idx + offset]
if (!nextTrack) continue
const p = prefetchOrGet(nextTrack.title)
if (offset === 1) {
p.then(nextResults => {
if (loadingKeyRef.current !== key) return
if (!nextResults.length) return
const saved = getSavedVersion(nextTrack.title)
const si = saved ? nextResults.findIndex(r => r.title === saved.title && r.artist === saved.artist) : -1
const resIdx = si >= 0 ? si : 0
const preloadEl = preloadAudioRef.current
if (!preloadEl) return
const url = proxyMp3Url(nextResults[resIdx].mp3)
if (preloadEl.src !== url) {
preloadEl.src = url
preloadEl.load()
}
}).catch(() => {})
} else {
p.catch(() => {})
}
}
// Evict stale cache entries outside the current window
const windowTitles = new Set(queue.slice(Math.max(0, idx - 1), idx + 6).map(t => t.title))
prefetchCacheRef.current.forEach((_, title) => {
if (!windowTitles.has(title)) prefetchCacheRef.current.delete(title)
})
},
[queue, audioRef, setCurrentResults, setSearchStatus, updateQueueItemImg, playResult, getSavedVersion, prefetchOrGet],
)
useEffect(() => {
if (queue.length > 0 && curIdx >= 0) {
loadTrack(curIdx, loadKey)
}
}, [loadKey])
// Audio event handlers
useEffect(() => {
const audio = audioRef.current
if (!audio) return
const onPlay = () => {
initAudioViz()
resumeContext()
setIsPlaying(true)
isPlayingRef.current = true
audioState.isPlaying = true
audioState.analyser = analyserRef.current
}
const onPause = () => { setIsPlaying(false); isPlayingRef.current = false; audioState.isPlaying = false }
const onTimeUpdate = () => setCurrentTime(audio.currentTime)
const onDuration = () => setDuration(audio.duration || 0)
const onEnded = () => {
setIsPlaying(false)
isPlayingRef.current = false
audioState.isPlaying = false
const r = activeResultsRef.current[activeResultIdxRef.current]
if (r) onTrackEnd(r)
const s = usePartyStore.getState()
if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1)
}
const onError = () => {
if (!audio.src) return
setTimeout(() => {
resumeContext()
audio.load()
audio.play().catch(() => {})
}, 1500)
}
audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onPause)
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('durationchange', onDuration)
audio.addEventListener('ended', onEnded)
audio.addEventListener('error', onError)
return () => {
audio.removeEventListener('play', onPlay)
audio.removeEventListener('pause', onPause)
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('durationchange', onDuration)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('error', onError)
}
}, [audioRef, analyserRef, initAudioViz, resumeContext, onTrackEnd])
// Keep meta refs in sync for remote
useEffect(() => { audioMetaRef.current = audioMeta }, [audioMeta])
useEffect(() => { coverSrcRef.current = coverSrc }, [coverSrc])
// Remote: push state every 2s, poll commands every 500ms
useEffect(() => {
if (!roomId) return
const pushState = () => {
const audio = audioRef.current
const { queue: q, curIdx: ci } = usePartyStore.getState()
fetch(`${API_URL}/api/remote/${roomId}/state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: audioMetaRef.current.title,
artist: audioMetaRef.current.artist,
cover: coverSrcRef.current,
is_playing: isPlayingRef.current,
volume: audio?.volume ?? 1,
progress: audio?.currentTime ?? 0,
duration: audio?.duration ?? 0,
queue_len: q.length,
cur_idx: ci,
versions: activeResultsRef.current.map(r => ({
title: r.title,
artist: r.artist,
duration: r.duration,
img: r.img && !r.img.includes('no-cover') ? proxyImgUrl(r.img) : '',
})),
active_version: activeResultIdxRef.current,
queue: q.map(item => ({
title: item.title,
owner: item.owner,
color_bg: item.color.bg,
color_text: item.color.text,
img: item.img || '',
})),
}),
}).catch(() => {})
}
const pollCommands = () => {
fetch(`${API_URL}/api/remote/${roomId}/commands`)
.then(r => r.ok ? r.json() : [])
.then((cmds: Array<{ cmd: string; value: number; text?: string }>) => {
const a = audioRef.current
for (const c of cmds) {
if (c.cmd === 'play') { if (a) { resumeContext(); a.play().catch(() => {}) } }
else if (c.cmd === 'pause') { if (a) a.pause() }
else if (c.cmd === 'next') { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) }
else if (c.cmd === 'prev') { const s = usePartyStore.getState(); if (s.curIdx > 0) s.setCurIdx(s.curIdx - 1) }
else if (c.cmd === 'volume') { if (a) a.volume = Math.max(0, Math.min(1, c.value)) }
else if (c.cmd === 'seek' && a?.duration) a.currentTime = c.value
else if (c.cmd === 'goto') { const s = usePartyStore.getState(); const i = Math.floor(c.value); if (i >= 0 && i < s.queue.length) s.setCurIdx(i) }
else if (c.cmd === 'remove') { const s = usePartyStore.getState(); const i = Math.floor(c.value); if (i >= 0 && i < s.queue.length) s.removeFromQueue(i) }
else if (c.cmd === 'add' && c.text) { usePartyStore.getState().addTrackToQueue(c.text) }
else if (c.cmd === 'version') { const i = Math.floor(c.value); if (i >= 0 && i < activeResultsRef.current.length) playResultRef.current(activeResultsRef.current, i) }
else if (c.cmd === 'save_version') {
const i = Math.floor(c.value)
const r = activeResultsRef.current[i]
const trackTitle = usePartyStore.getState().queue[usePartyStore.getState().curIdx]?.title
if (r && trackTitle) {
const vs = useVersionStore.getState()
if (vs.isSaved(trackTitle, r)) vs.removeVersion(trackTitle)
else vs.saveVersion(trackTitle, r)
}
}
}
})
.catch(() => {})
}
pushState()
pollCommands()
const ivState = setInterval(pushState, 2000)
const ivCmd = setInterval(pollCommands, 500)
return () => { clearInterval(ivState); clearInterval(ivCmd) }
}, [roomId, audioRef, resumeContext])
// Close panel on outside click
useEffect(() => {
if (!panel) return
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) setPanel(null)
}
setTimeout(() => document.addEventListener('mousedown', handler), 0)
return () => document.removeEventListener('mousedown', handler)
}, [panel])
// Close share popover on outside click
useEffect(() => {
if (!shareOpen) return
const handler = (e: MouseEvent) => {
if (shareBtnRef.current && !shareBtnRef.current.closest('.share-popover-root')?.contains(e.target as Node)) {
setShareOpen(false)
}
}
setTimeout(() => document.addEventListener('mousedown', handler), 0)
return () => document.removeEventListener('mousedown', handler)
}, [shareOpen])
const togglePlay = useCallback(() => {
const audio = audioRef.current
if (!audio) return
if (audio.paused) audio.play().catch(() => {})
else audio.pause()
}, [audioRef])
const prevTrack = () => { const s = usePartyStore.getState(); if (s.curIdx > 0) s.setCurIdx(s.curIdx - 1) }
const reloadTrack = () => { const s = usePartyStore.getState(); s.setCurIdx(s.curIdx) }
const nextTrack = () => { const s = usePartyStore.getState(); if (s.curIdx < s.queue.length - 1) s.setCurIdx(s.curIdx + 1) }
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
dragSrcIdx.current = idx
el.classList.add('dragging')
}, [])
const onDragEnd = useCallback((el: HTMLElement) => {
el.classList.remove('dragging')
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
}, [])
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
e.preventDefault()
document.querySelectorAll('.q-item').forEach(i => i.classList.remove('drag-over'))
el.classList.add('drag-over')
}, [])
const onDrop = useCallback((e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
e.preventDefault()
el.classList.remove('drag-over')
if (dragSrcIdx.current === null || dragSrcIdx.current === tgtIdx) return
reorderQueue(dragSrcIdx.current, tgtIdx)
dragSrcIdx.current = null
}, [reorderQueue])
const track = queue[Math.max(0, curIdx)] ?? null
const trackTitle = track?.title ?? ''
const favorited = isFavorite(trackTitle)
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<>
{/* Audio is always mounted at the same tree position so audioRef never changes and event listeners persist */}
<audio ref={audioRef} preload="auto" crossOrigin="anonymous" style={{ display: 'none' }} />
{(queue.length > 0 && track) && <div className="fixed bottom-0 left-0 right-0 z-50" ref={panelRef}>
{/* Slide-up panels */}
{panel && (
<div className="bg-surface border-t border-white/[0.07] max-h-[50vh] overflow-hidden flex flex-col">
{/* Versions panel */}
{panel === 'versions' && results.length > 1 && (
<div className="overflow-y-auto">
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Версии трека</span>
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text"></button>
</div>
{results.map((r, i) => {
const saved = isSaved(trackTitle, r)
return (
<div
key={i}
onClick={() => playResult(results, i)}
className={`flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 ${i === activeResultIdx ? 'bg-accent/[0.04]' : ''}`}
>
<span className="text-[11px] text-muted w-3.5 text-right shrink-0 font-display">{i + 1}</span>
{r.img && !r.img.includes('no-cover') && (
<img src={proxyImgUrl(r.img)} alt="" className="w-8 h-8 rounded-md object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
)}
<div className="flex-1 min-w-0">
<div className="text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis font-medium">{r.title}</div>
<div className="text-[11px] text-muted mt-px">{r.artist}</div>
</div>
<div className="text-[11px] text-muted shrink-0 font-display">{r.duration}</div>
<button
onClick={(e) => { e.stopPropagation(); saved ? removeVersion(trackTitle) : saveVersion(trackTitle, r) }}
title={saved ? 'Забыть версию' : 'Запомнить'}
className="w-[26px] h-[26px] rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:border-accent/40"
style={{ borderColor: saved ? 'rgba(200,255,0,0.4)' : 'rgba(255,255,255,0.07)', background: saved ? 'rgba(200,255,0,0.08)' : 'transparent' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill={saved ? '#c8ff00' : 'none'} stroke={saved ? '#c8ff00' : '#555'} strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); playResult(results, i) }}
className={`rounded-full border flex items-center justify-center shrink-0 transition-all cursor-pointer hover:bg-accent hover:border-accent ${i === activeResultIdx ? 'bg-accent border-accent' : 'border-white/[0.07]'}`}
style={{ width: 26, height: 26 }}
>
<svg width="9" height="9" viewBox="0 0 24 24" fill={i === activeResultIdx ? '#0a0a0f' : '#555'}><path d="M8 5v14l11-7z" /></svg>
</button>
</div>
)
})}
</div>
)}
{/* Queue panel */}
{panel === 'queue' && (
<div className="overflow-y-auto flex flex-col">
<div className="px-4 py-2.5 border-b border-white/[0.07] flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<span className="text-[11px] font-display font-bold tracking-[1.2px] uppercase text-muted">Очередь · {queue.length}</span>
<button
onClick={() => generateMix()}
className="px-2 py-0.5 text-[11px] border border-white/[0.07] rounded-lg text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer"
></button>
</div>
<button onClick={() => setPanel(null)} className="text-muted text-[11px] cursor-pointer hover:text-app-text"></button>
</div>
<div className="overflow-y-auto">
{queue.map((item, i) => {
const active = i === curIdx
return (
<div
key={i}
draggable
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors select-none"
style={{ background: active ? 'rgba(200,255,0,0.04)' : undefined }}
onClick={(e) => { if (!(e.target as HTMLElement).closest('.drag-handle')) setCurIdx(i) }}
onDragStart={(e) => onDragStart(i, e.currentTarget)}
onDragEnd={(e) => onDragEnd(e.currentTarget)}
onDragOver={(e) => onDragOver(e, e.currentTarget)}
onDrop={(e) => onDrop(e, i, e.currentTarget)}
>
<div className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 flex items-center touch-none">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="5" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="15" cy="19" r="1.5" />
</svg>
</div>
{active ? (
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0">
<div className="queue-bar" /><div className="queue-bar" /><div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img src={item.img} alt="" className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2" onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')} />
) : (
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">{item.title}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium" style={{ background: item.color.bg, color: item.color.text }}>
{item.owner}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Bottom bar */}
<div className="bg-surface border-t border-white/[0.07] px-4 py-2.5">
{/* Progress bar */}
<div className="relative h-[3px] bg-white/[0.07] rounded-full mb-2.5 cursor-pointer group" onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
const audio = audioRef.current
if (audio && duration) audio.currentTime = pct * duration
}}>
<div className="absolute inset-y-0 left-0 bg-accent rounded-full transition-all duration-100" style={{ width: `${progress}%` }} />
<div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2.5 h-2.5 bg-accent rounded-full opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
</div>
<div className="max-w-app mx-auto flex items-center gap-3">
{/* Cover */}
<div className="relative shrink-0 w-11 h-11 rounded-[8px] overflow-hidden bg-surface2">
{coverSrc ? (
<img src={coverSrc} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-surface2" />
)}
<canvas ref={canvasRef} className={`absolute inset-0 w-full h-full pointer-events-none transition-opacity duration-300 ${isPlaying ? 'opacity-100' : 'opacity-0'}`} />
</div>
{/* Track info */}
<div className="flex-1 min-w-0">
{searchStatus === 'searching' ? (
<div className="flex items-center gap-1.5 text-[12px] text-muted">
<div className="w-2.5 h-2.5 rounded-full border border-surface2 border-t-accent animate-spin shrink-0" />
<span>Ищем...</span>
</div>
) : (
<>
<div className="text-[13px] font-display font-bold whitespace-nowrap overflow-hidden text-ellipsis leading-tight">{track.title}</div>
<div className="flex items-center gap-1.5 mt-0.5">
{audioMeta.artist && <span className="text-[11px] text-muted truncate">{audioMeta.artist}</span>}
<span className="text-[10px] px-1.5 py-px rounded-[5px] font-medium shrink-0" style={{ background: track.color.bg, color: track.color.text }}>{track.owner}</span>
</div>
</>
)}
</div>
{/* Time */}
<div className="text-[10px] text-muted font-display shrink-0 hidden sm:block tabular-nums">
{formatTime(currentTime)}<span className="opacity-40 mx-0.5">/</span>{formatTime(duration)}
</div>
{/* Controls */}
<div className="flex items-center gap-1 shrink-0">
<button onClick={prevTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" /></svg>
</button>
<button onClick={togglePlay} className="w-9 h-9 rounded-[9px] flex items-center justify-center bg-accent text-bg transition-all cursor-pointer hover:bg-accent/80">
{isPlaying ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
)}
</button>
<button onClick={nextTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zm2.5-6 8.5 6V6z" /></svg>
</button>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
{/* Reload */}
<button onClick={reloadTrack} className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 hover:text-app-text transition-all cursor-pointer hidden sm:flex">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 1 0 .49-4" /></svg>
</button>
{/* Versions */}
{results.length > 1 && (
<button
onClick={() => setPanel(p => p === 'versions' ? null : 'versions')}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: panel === 'versions' ? '#c8ff00' : undefined, background: panel === 'versions' ? 'rgba(200,255,0,0.06)' : undefined }}
title="Версии трека"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
</svg>
</button>
)}
{/* Add to playlist */}
<div className="relative">
<button
ref={playlistBtnRef}
onClick={() => setPlaylistOpen(v => !v)}
title="Добавить в плейлист"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: playlistOpen ? '#c8ff00' : undefined, background: playlistOpen ? 'rgba(200,255,0,0.06)' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
</button>
{playlistOpen && (
<AddToPlaylist
trackTitle={trackTitle}
onClose={() => setPlaylistOpen(false)}
anchorRef={playlistBtnRef as React.RefObject<HTMLElement | null>}
/>
)}
</div>
{/* Favorite */}
<button
onClick={() => toggleFavorite(trackTitle)}
className="w-8 h-8 rounded-[8px] flex items-center justify-center transition-all cursor-pointer hover:bg-surface2"
style={{ color: favorited ? '#c8ff00' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill={favorited ? '#c8ff00' : 'none'} stroke={favorited ? '#c8ff00' : 'currentColor'} strokeWidth="2">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
</button>
{/* Queue */}
<button
onClick={() => setPanel(p => p === 'queue' ? null : 'queue')}
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: panel === 'queue' ? '#c8ff00' : undefined, background: panel === 'queue' ? 'rgba(200,255,0,0.06)' : undefined }}
title="Очередь"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</button>
{/* Remote / Share */}
<div className="relative share-popover-root">
<button
ref={shareBtnRef}
onClick={async () => {
let id = roomId
if (!id) {
const res = await fetch(`${API_URL}/api/remote`, { method: 'POST' }).catch(() => null)
if (res?.ok) {
const data = await res.json()
id = data.id as string
setRoomId(id)
}
}
if (id) setShareOpen(v => !v)
}}
title="Пульт управления"
className="w-8 h-8 rounded-[8px] flex items-center justify-center text-muted hover:bg-surface2 transition-all cursor-pointer"
style={{ color: shareOpen || roomId ? '#c8ff00' : undefined, background: shareOpen ? 'rgba(200,255,0,0.06)' : undefined }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<circle cx="12" cy="20" r="1" fill="currentColor" />
</svg>
</button>
{shareOpen && roomId && (
<div className="absolute bottom-full right-0 mb-2 w-64 bg-surface border border-white/[0.07] rounded-[12px] p-3 shadow-2xl z-50">
<p className="text-[11px] text-muted font-display font-bold tracking-[1px] uppercase mb-2">Пульт управления</p>
<div className="flex gap-1.5">
<input
readOnly
value={typeof window !== 'undefined' ? `${window.location.origin}/remote/${roomId}` : `/remote/${roomId}`}
className="flex-1 min-w-0 text-[11px] bg-surface2 border border-white/[0.07] rounded-[7px] px-2 py-1.5 text-muted outline-none font-mono truncate"
/>
<button
onClick={() => {
if (typeof window !== 'undefined') {
navigator.clipboard.writeText(`${window.location.origin}/remote/${roomId}`)
setShareCopied(true)
setTimeout(() => setShareCopied(false), 2000)
}
}}
className="shrink-0 px-2 py-1.5 text-[11px] rounded-[7px] border border-white/[0.07] hover:bg-surface2 transition-all cursor-pointer font-display"
style={{ color: shareCopied ? '#c8ff00' : undefined }}
>
{shareCopied ? '✓' : 'Копировать'}
</button>
</div>
<p className="text-[10px] text-muted mt-1.5 leading-relaxed">Откройте ссылку на другом устройстве для управления плеером</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>}
</>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
'use client'
import { useCallback, useEffect } from 'react'
import { usePartyStore } from '@/store/partyStore'
import { useFavoritesStore } from '@/store/favoritesStore'
import { useVersionStore } from '@/store/versionStore'
import { useAuthStore } from '@/store/authStore'
import BottomPlayer from '@/components/BottomPlayer'
import type { SearchResult } from '@/types'
export default function GlobalPlayer() {
const { hydrate: hydrateFavorites } = useFavoritesStore()
const { hydrate: hydrateVersions } = useVersionStore()
const { token } = useAuthStore()
useEffect(() => {
hydrateFavorites()
}, [hydrateFavorites])
// Re-hydrate versions whenever auth changes (null → token = fetch from API)
useEffect(() => {
hydrateVersions()
}, [token, hydrateVersions])
const handleTrackEnd = useCallback((result: SearchResult) => {
const { queue, curIdx, addToHistory } = usePartyStore.getState()
const track = queue[curIdx]
if (!track) return
addToHistory({
title: result.title,
artist: result.artist,
img: result.img,
owner: track.owner,
color: track.color,
playedAt: new Date().toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' }),
})
}, [])
return <BottomPlayer onTrackEnd={handleTrackEnd} />
}

View File

@@ -0,0 +1,111 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useAuthStore } from '@/store/authStore'
export default function Header() {
const { user, clearAuth } = useAuthStore()
const router = useRouter()
const pathname = usePathname()
const handleLogout = () => {
clearAuth()
router.push('/')
}
const navLink = (href: string, label: string, icon: React.ReactNode) => {
const active = pathname === href
return (
<Link
href={href}
className="flex items-center gap-1.5 text-[12px] font-display font-semibold px-3 py-1.5 rounded-xl border transition-all duration-150 hidden sm:flex"
style={active
? { background: 'rgba(200,255,0,0.12)', borderColor: 'rgba(200,255,0,0.3)', color: '#c8ff00' }
: { background: 'rgba(255,255,255,0.04)', borderColor: 'rgba(255,255,255,0.07)', color: 'var(--color-muted)' }
}
>
{icon}
{label}
</Link>
)
}
return (
<div className="flex items-center gap-2.5 mb-5 pb-5 border-b border-white/[0.07]">
<Link href="/app" className="flex items-center gap-3 flex-1 min-w-0 no-underline">
<div className="w-10 h-10 rounded-[11px] bg-accent flex items-center justify-center shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M9 18V5l12-2v13" stroke="#0a0a0f" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" fill="#0a0a0f" />
<circle cx="18" cy="16" r="3" fill="#0a0a0f" />
</svg>
</div>
<h1 className="font-display text-xl font-extrabold tracking-tight text-app-text">Party Mix</h1>
</Link>
<div className="flex items-center gap-2 shrink-0">
{navLink('/search', 'Поиск',
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="8" stroke="currentColor" strokeWidth="2" />
<path d="m21 21-4.35-4.35" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
{navLink('/community', 'Сообщество',
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<circle cx="9" cy="7" r="3" stroke="currentColor" strokeWidth="2" />
<path d="M3 21v-1a6 6 0 0 1 6-6h0a6 6 0 0 1 6 6v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M21 21v-1a4 4 0 0 0-3-3.85" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
{user ? (
<>
{navLink('/playlists', 'Плейлисты',
<svg width="13" height="13" viewBox="0 0 24 24" fill="none">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
<div className="flex items-center gap-2 bg-surface border border-white/[0.07] rounded-xl px-3 py-1.5">
<div className="w-5 h-5 rounded-md bg-accent/20 flex items-center justify-center shrink-0">
<span className="text-[10px] font-display font-extrabold text-accent">
{user.username[0].toUpperCase()}
</span>
</div>
<span className="text-[12px] font-medium text-app-text hidden sm:block">{user.username}</span>
<button
onClick={handleLogout}
className="flex items-center gap-1 text-[11px] font-display font-semibold text-muted hover:text-[#ff6b6b] transition-colors duration-150 cursor-pointer ml-1 border-l border-white/[0.07] pl-2"
title="Выйти из аккаунта"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="16,17 21,12 16,7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<span className="hidden sm:inline">Выйти</span>
</button>
</div>
</>
) : (
<>
<Link
href="/login"
className="text-[12px] font-display font-semibold text-muted hover:text-app-text transition-colors duration-150 px-2 py-1.5"
>
Войти
</Link>
<Link
href="/register"
className="text-[12px] font-display font-semibold px-3 py-1.5 bg-accent/90 rounded-xl text-bg hover:bg-accent transition-colors duration-150"
>
Регистрация
</Link>
</>
)}
</div>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
'use client'
import { usePartyStore } from '@/store/partyStore'
import PersonCard from './PersonCard'
import AddPersonForm from './AddPersonForm'
export default function PartyTab() {
const { people, shuffleMode, setShuffleMode, generateMix } = usePartyStore()
return (
<div className="animate-fadeUp">
{people.length >= 2 && (
<div className="flex gap-1.5 mb-4 bg-surface border border-white/[0.07] rounded-xl p-1">
<button
onClick={() => setShuffleMode('fair')}
className={`flex-1 py-1.5 text-xs font-display font-semibold tracking-[0.2px] border-none rounded-[7px] cursor-pointer transition-all duration-200 ${
shuffleMode === 'fair' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
По очереди
</button>
<button
onClick={() => setShuffleMode('random')}
className={`flex-1 py-1.5 text-xs font-display font-semibold tracking-[0.2px] border-none rounded-[7px] cursor-pointer transition-all duration-200 ${
shuffleMode === 'random' ? 'bg-surface2 text-app-text' : 'bg-transparent text-muted'
}`}
>
🎲 Случайно
</button>
</div>
)}
<button
onClick={() => {
if (!people.length) {
alert('Добавьте участников!')
return
}
generateMix()
}}
className="w-full py-3.5 mb-4 font-display text-sm font-extrabold tracking-[0.5px] uppercase bg-accent border-none rounded-app text-bg flex items-center justify-center gap-2.5 hover:opacity-90 active:scale-[0.99] transition-all duration-150 cursor-pointer sm:text-[13px] sm:py-3"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 3 21 3 21 8" />
<line x1="4" y1="20" x2="21" y2="3" />
<polyline points="21 16 21 21 16 21" />
<line x1="15" y1="15" x2="21" y2="21" />
</svg>
Перемешать и включить
</button>
<div className="flex flex-col gap-2.5 mb-4">
{people.map((person, i) => (
<PersonCard key={`${person.name}-${i}`} person={person} index={i} />
))}
</div>
<AddPersonForm />
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,136 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import { usePartyStore } from '@/store/partyStore'
export default function QueueCard() {
const { queue, curIdx, setCurIdx, generateMix, reorderQueue } = usePartyStore()
const [open, setOpen] = useState(false)
const dragSrcIdx = useRef<number | null>(null)
const handleQueueClick = (e: React.MouseEvent, idx: number) => {
if ((e.target as HTMLElement).closest('.drag-handle')) return
setCurIdx(idx)
}
const onDragStart = useCallback((idx: number, el: HTMLElement) => {
dragSrcIdx.current = idx
el.classList.add('dragging')
}, [])
const onDragEnd = useCallback((el: HTMLElement) => {
el.classList.remove('dragging')
document.querySelectorAll('.q-item').forEach((i) => i.classList.remove('drag-over'))
}, [])
const onDragOver = useCallback((e: React.DragEvent, el: HTMLElement) => {
e.preventDefault()
document.querySelectorAll('.q-item').forEach((i) => i.classList.remove('drag-over'))
el.classList.add('drag-over')
}, [])
const onDrop = useCallback(
(e: React.DragEvent, tgtIdx: number, el: HTMLElement) => {
e.preventDefault()
el.classList.remove('drag-over')
if (dragSrcIdx.current === null || dragSrcIdx.current === tgtIdx) return
reorderQueue(dragSrcIdx.current, tgtIdx)
dragSrcIdx.current = null
},
[reorderQueue],
)
if (!queue.length) return null
return (
<div className="bg-surface border border-white/[0.07] rounded-app overflow-hidden mb-4">
<div
onClick={() => setOpen((o) => !o)}
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface2 transition-colors duration-100 select-none sm:px-3"
>
<div className="flex items-center gap-2">
<span className="font-display text-[11px] font-bold tracking-[1.2px] uppercase text-muted">
Очередь · {queue.length} треков
</span>
<button
onClick={(e) => { e.stopPropagation(); generateMix() }}
className="px-2.5 py-0.5 text-[11px] border border-white/[0.07] rounded-lg bg-transparent text-muted hover:bg-surface2 hover:text-app-text transition-all duration-150 cursor-pointer"
>
</button>
</div>
<span className={`text-muted text-[11px] transition-transform duration-200 ${open ? 'rotate-180' : ''}`}></span>
</div>
<div
className="overflow-hidden transition-all duration-300"
style={{ maxHeight: open ? '500px' : '0' }}
>
<div className="flex flex-col max-h-[500px] overflow-y-auto">
{queue.map((item, i) => {
const active = i === curIdx
return (
<div
key={i}
data-idx={i}
draggable
className="q-item flex items-center gap-2 px-4 py-2 border-b border-white/[0.07] last:border-b-0 cursor-pointer hover:bg-surface2 transition-colors duration-100 select-none relative sm:px-3"
style={{ background: active ? 'rgba(200,255,0,0.04)' : undefined }}
onClick={(e) => handleQueueClick(e, i)}
onDragStart={(e) => onDragStart(i, e.currentTarget)}
onDragEnd={(e) => onDragEnd(e.currentTarget)}
onDragOver={(e) => onDragOver(e, e.currentTarget)}
onDrop={(e) => onDrop(e, i, e.currentTarget)}
>
<div
className="drag-handle text-muted cursor-grab shrink-0 p-1 opacity-40 hover:opacity-80 transition-opacity duration-150 flex items-center touch-none"
title="Перетащить"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="5" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="15" cy="19" r="1.5" />
</svg>
</div>
{active ? (
<div className="flex items-end gap-[1.5px] w-3 h-3 shrink-0">
<div className="queue-bar" />
<div className="queue-bar" />
<div className="queue-bar" />
</div>
) : (
<span className="text-[11px] text-muted w-[18px] text-right shrink-0 font-display">{i + 1}</span>
)}
{item.img ? (
<img
src={item.img}
alt=""
className="w-7 h-7 rounded-[5px] object-cover shrink-0 bg-surface2"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
) : (
<div className="w-7 h-7 rounded-[5px] bg-surface2 shrink-0" />
)}
<span className="flex-1 text-[13px] text-app-text whitespace-nowrap overflow-hidden text-ellipsis">
{item.title}
</span>
<span
className="text-[10px] px-1.5 py-0.5 rounded-[5px] shrink-0 font-medium"
style={{ background: item.color.bg, color: item.color.text }}
>
{item.owner}
</span>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,94 @@
import type { User, Playlist, PublicPlaylist } from '@/types'
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`)
}
if (res.status === 204) return undefined as T
return res.json()
}
function bearer(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` }
}
export async function register(username: string, email: string, password: string): Promise<User> {
return request<User>('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ username, email, password }),
})
}
export async function login(email: string, password: string): Promise<{ token: string; user: User }> {
return request<{ token: string; user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
}
export async function fetchMe(token: string): Promise<User> {
return request<User>('/api/auth/me', { headers: bearer(token) })
}
export async function getPlaylists(token: string): Promise<Playlist[]> {
return request<Playlist[]>('/api/playlists', { headers: bearer(token) })
}
export async function getPublicPlaylists(): Promise<PublicPlaylist[]> {
return request<PublicPlaylist[]>('/api/playlists/public')
}
export async function getPlaylist(token: string, id: string): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`, { headers: bearer(token) })
}
export async function createPlaylist(
token: string,
name: string,
tracks: string[],
isPublic = false,
tags: string[] = [],
): Promise<Playlist> {
return request<Playlist>('/api/playlists', {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
})
}
export async function updatePlaylist(
token: string,
id: string,
name: string,
tracks: string[],
isPublic = false,
tags: string[] = [],
): Promise<Playlist> {
return request<Playlist>(`/api/playlists/${id}`, {
method: 'PUT',
headers: bearer(token),
body: JSON.stringify({ name, is_public: isPublic, tags, tracks: tracks.map((title, i) => ({ title, position: i })) }),
})
}
export async function addTrackToPlaylist(token: string, playlistId: string, title: string): Promise<void> {
return request<void>(`/api/playlists/${playlistId}/tracks`, {
method: 'POST',
headers: bearer(token),
body: JSON.stringify({ title }),
})
}
export async function deletePlaylist(token: string, id: string): Promise<void> {
return request<void>(`/api/playlists/${id}`, {
method: 'DELETE',
headers: bearer(token),
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

File diff suppressed because one or more lines are too long