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:
66
.claude/settings.local.json
Normal file
66
.claude/settings.local.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go mod *)",
|
||||
"Bash(npm --version)",
|
||||
"Bash(docker --version)",
|
||||
"Bash(docker-compose build *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(docker-compose up *)",
|
||||
"Bash(docker-compose -f \"D:\\\\!toyffee\\\\party-mix-app\\\\party-mix-app\\\\docker-compose.yml\" ps)",
|
||||
"Bash(curl -s http://localhost:8081/health)",
|
||||
"Bash(curl -s \"http://localhost:3001\" -o /dev/null -w \"Web HTTP status: %{http_code}\")",
|
||||
"Bash(docker-compose stop *)",
|
||||
"Bash(docker-compose rm *)",
|
||||
"Bash(docker stop *)",
|
||||
"Bash(docker rm *)",
|
||||
"Bash(docker compose *)",
|
||||
"Bash(curl *)",
|
||||
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
|
||||
"Bash(pg_isready)",
|
||||
"Bash(where psql *)",
|
||||
"Bash(powershell -command \"Get-Service 'com.docker.service' 2>/dev/null | Select-Object Status,Name\")",
|
||||
"Bash(net start *)",
|
||||
"Bash(powershell -command \"Start-Process 'C:\\\\Program Files\\\\Docker\\\\Docker\\\\Docker Desktop.exe' -WindowStyle Hidden\")",
|
||||
"Bash(break)",
|
||||
"Bash(xargs grep *)",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(f'Tracks: {len\\(d\\)}'\\); [print\\(f' {t[\\\\\"artist\\\\\"]} — {t[\\\\\"title\\\\\"]}'\\) for t in d[:5]]\")",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(repr\\(d[0]['artist'] + ' — ' + d[0]['title']\\)\\)\")",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); a=d[0]['artist']; print\\([hex\\(ord\\(c\\)\\) for c in a]\\)\")",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d[0], ensure_ascii=False, indent=2\\)\\)\")",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(go build *)",
|
||||
"Bash(where go *)",
|
||||
"Read(//c/Program Files/Go/**)",
|
||||
"Read(//c/Go/**)",
|
||||
"Bash(tasklist)",
|
||||
"Bash(docker-compose down *)",
|
||||
"Bash(docker-compose ps *)",
|
||||
"Bash(xargs -I {} basename {})",
|
||||
"Bash(docker-compose restart *)",
|
||||
"Bash(Get-Content \"C:\\\\Users\\\\Kirko\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\D---toyffee-party-mix-app-party-mix-app\\\\b38c92ce-ec1f-4d1c-bb67-25a43c2d0293\\\\tasks\\\\blijpxwoy.output\" -Wait)",
|
||||
"Bash(Select-String -Pattern \"Step|Successfully|ERROR|error|FAILED|=>|Building|writing\")",
|
||||
"PowerShell(dir \"D:\\\\!toyffee\\\\party-mix-app\\\\party-mix-app\\\\apps\\\\backend\" -Recurse | Where-Object {!$_.PSIsContainer} | Select-Object FullName)",
|
||||
"PowerShell(cmd /c \"dir /s /b D:\\\\!toyffee\\\\party-mix-app\\\\party-mix-app\\\\apps\\\\backend\")",
|
||||
"PowerShell(Get-Content \"C:\\\\Users\\\\Kirko\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\D---toyffee-party-mix-app-party-mix-app\\\\b38c92ce-ec1f-4d1c-bb67-25a43c2d0293\\\\tasks\\\\bcqbr16en.output\" | Where-Object { $_ -match \"#13\" })",
|
||||
"PowerShell(Get-Content *)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:yandex-music.readthedocs.io)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:ym.marshal.dev)",
|
||||
"WebFetch(domain:music.yandex.ru)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"PowerShell(Invoke-WebRequest -Uri \"http://localhost:8081/api/proxy/yandex-playlist?url=https://music.yandex.ru/playlists/lk.b1bcb266-22d1-4c65-836d-963babe68f3d\" -UseBasicParsing -TimeoutSec 20 | Select-Object StatusCode, @{n='Body';e={$_.Content | ConvertFrom-Json | ConvertTo-Json -Depth 3}} 2>&1)",
|
||||
"PowerShell($r = Invoke-WebRequest -Uri \"http://localhost:8081/api/proxy/yandex-playlist?url=https://music.yandex.ru/playlists/lk.b1bcb266-22d1-4c65-836d-963babe68f3d\" -UseBasicParsing -TimeoutSec 20 -ErrorAction SilentlyContinue; \"Status: $\\($r.StatusCode\\)\"; $r.Content | ConvertFrom-Json | ForEach-Object { \"Name: $\\($_.name\\)\"; \"Tracks count: $\\($_.tracks.Count\\)\"; $_.tracks | Select-Object -First 5 })",
|
||||
"PowerShell(docker build *)",
|
||||
"PowerShell(Invoke-WebRequest -Uri \"http://localhost:8081/api/proxy/yandex-playlist?url=https://music.yandex.ru/playlists/lk.b1bcb266-22d1-4c65-836d-963babe68f3d\" -UseBasicParsing -TimeoutSec 10 | Select-Object StatusCode 2>&1)",
|
||||
"PowerShell(try { $r = Invoke-WebRequest -Uri \"http://localhost:8081/api/proxy/yandex-playlist?url=https://music.yandex.ru/playlists/lk.b1bcb266-22d1-4c65-836d-963babe68f3d\" -UseBasicParsing -TimeoutSec 10; \"HTTP $\\($r.StatusCode\\)\" } catch { \"Error: $_\" }; docker inspect party-mix-app-backend-1 --format \"Created: {{.Created}}\" 2>&1)",
|
||||
"PowerShell(docker image inspect *)",
|
||||
"Bash(powershell -Command ' *)",
|
||||
"Bash(git -C \"/d/!toyffee/party-mix-app/party-mix-app\" log --oneline -3)",
|
||||
"Bash(docker run *)",
|
||||
"Bash(docker exec *)",
|
||||
"PowerShell(docker exec party-mix-app-web-1 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
DB_PASSWORD=supersecretpassword
|
||||
BOT_TOKEN=your_telegram_bot_token_here
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.bat text eol=crlf
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
postgres_data/
|
||||
15
apps/backend/Dockerfile
Normal file
15
apps/backend/Dockerfile
Normal 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
14
apps/backend/go.mod
Normal 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
|
||||
)
|
||||
37
apps/backend/internal/config/config.go
Normal file
37
apps/backend/internal/config/config.go
Normal 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
|
||||
}
|
||||
39
apps/backend/internal/database/database.go
Normal file
39
apps/backend/internal/database/database.go
Normal 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
|
||||
}
|
||||
103
apps/backend/internal/handlers/auth.go
Normal file
103
apps/backend/internal/handlers/auth.go
Normal 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)
|
||||
}
|
||||
}
|
||||
66
apps/backend/internal/handlers/middleware.go
Normal file
66
apps/backend/internal/handlers/middleware.go
Normal 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
|
||||
}
|
||||
173
apps/backend/internal/handlers/party.go
Normal file
173
apps/backend/internal/handlers/party.go
Normal 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)
|
||||
}
|
||||
}
|
||||
283
apps/backend/internal/handlers/playlists.go
Normal file
283
apps/backend/internal/handlers/playlists.go
Normal 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
|
||||
}
|
||||
432
apps/backend/internal/handlers/proxy.go
Normal file
432
apps/backend/internal/handlers/proxy.go
Normal 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)
|
||||
}
|
||||
162
apps/backend/internal/handlers/remote.go
Normal file
162
apps/backend/internal/handlers/remote.go
Normal 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)
|
||||
}
|
||||
84
apps/backend/internal/handlers/versions.go
Normal file
84
apps/backend/internal/handlers/versions.go
Normal 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)
|
||||
}
|
||||
}
|
||||
79
apps/backend/internal/models/models.go
Normal file
79
apps/backend/internal/models/models.go
Normal 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"`
|
||||
}
|
||||
84
apps/backend/internal/router/router.go
Normal file
84
apps/backend/internal/router/router.go
Normal 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
19
apps/backend/main.go
Normal 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
13
apps/bot/Dockerfile
Normal 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
983
apps/bot/package-lock.json
generated
Normal 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
19
apps/bot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
54
apps/bot/src/api/client.ts
Normal file
54
apps/bot/src/api/client.ts
Normal 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
46
apps/bot/src/bot.ts
Normal 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
|
||||
17
apps/bot/src/commands/help.ts
Normal file
17
apps/bot/src/commands/help.ts
Normal 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' },
|
||||
)
|
||||
}
|
||||
152
apps/bot/src/commands/party.ts
Normal file
152
apps/bot/src/commands/party.ts
Normal 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, '\\$&')
|
||||
}
|
||||
14
apps/bot/src/commands/start.ts
Normal file
14
apps/bot/src/commands/start.ts
Normal 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
8
apps/bot/src/config.ts
Normal 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
12
apps/bot/src/index.ts
Normal 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}`)
|
||||
},
|
||||
})
|
||||
32
apps/bot/src/types/index.ts
Normal file
32
apps/bot/src/types/index.ts
Normal 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
15
apps/bot/tsconfig.json
Normal 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
23
apps/web/Dockerfile
Normal 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
13
apps/web/next.config.ts
Normal 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
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
26
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
apps/web/postcss.config.mjs
Normal file
7
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
export default config
|
||||
1
apps/web/public/.gitkeep
Normal file
1
apps/web/public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
29
apps/web/src/app/app/page.tsx
Normal file
29
apps/web/src/app/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
234
apps/web/src/app/community/page.tsx
Normal file
234
apps/web/src/app/community/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
apps/web/src/app/globals.css
Normal file
146
apps/web/src/app/globals.css
Normal 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;
|
||||
}
|
||||
44
apps/web/src/app/layout.tsx
Normal file
44
apps/web/src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
apps/web/src/app/login/page.tsx
Normal file
108
apps/web/src/app/login/page.tsx
Normal 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
164
apps/web/src/app/page.tsx
Normal 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 />
|
||||
Каждый гость — часть музыки.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
783
apps/web/src/app/playlists/page.tsx
Normal file
783
apps/web/src/app/playlists/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
apps/web/src/app/register/page.tsx
Normal file
106
apps/web/src/app/register/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
417
apps/web/src/app/remote/[id]/page.tsx
Normal file
417
apps/web/src/app/remote/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
259
apps/web/src/app/search/page.tsx
Normal file
259
apps/web/src/app/search/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
apps/web/src/components/AddToPlaylist.tsx
Normal file
114
apps/web/src/components/AddToPlaylist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
apps/web/src/components/AudioBackground.tsx
Normal file
109
apps/web/src/components/AudioBackground.tsx
Normal 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
11
apps/web/src/components/AuthHydrator.tsx
Normal file
11
apps/web/src/components/AuthHydrator.tsx
Normal 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
|
||||
}
|
||||
698
apps/web/src/components/BottomPlayer.tsx
Normal file
698
apps/web/src/components/BottomPlayer.tsx
Normal 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>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
183
apps/web/src/components/ExtraTab/ExtraTab.tsx
Normal file
183
apps/web/src/components/ExtraTab/ExtraTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
apps/web/src/components/FavoritesTab/FavoritesTab.tsx
Normal file
74
apps/web/src/components/FavoritesTab/FavoritesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
apps/web/src/components/GlobalPlayer.tsx
Normal file
40
apps/web/src/components/GlobalPlayer.tsx
Normal 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} />
|
||||
}
|
||||
111
apps/web/src/components/Header.tsx
Normal file
111
apps/web/src/components/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
apps/web/src/components/HistoryTab/HistoryTab.tsx
Normal file
70
apps/web/src/components/HistoryTab/HistoryTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
apps/web/src/components/PartyTab/AddPersonForm.tsx
Normal file
117
apps/web/src/components/PartyTab/AddPersonForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
apps/web/src/components/PartyTab/PartyTab.tsx
Normal file
61
apps/web/src/components/PartyTab/PartyTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
apps/web/src/components/PartyTab/PersonCard.tsx
Normal file
71
apps/web/src/components/PartyTab/PersonCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
352
apps/web/src/components/Player/PlayerCard.tsx
Normal file
352
apps/web/src/components/Player/PlayerCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
apps/web/src/components/Queue/QueueCard.tsx
Normal file
136
apps/web/src/components/Queue/QueueCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
apps/web/src/components/SoloTab/SoloTab.tsx
Normal file
106
apps/web/src/components/SoloTab/SoloTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
apps/web/src/components/Tabs.tsx
Normal file
60
apps/web/src/components/Tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
apps/web/src/hooks/useAudioViz.ts
Normal file
64
apps/web/src/hooks/useAudioViz.ts
Normal 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])
|
||||
}
|
||||
41
apps/web/src/hooks/usePlayer.ts
Normal file
41
apps/web/src/hooks/usePlayer.ts
Normal 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
44
apps/web/src/lib/api.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
4
apps/web/src/lib/audioState.ts
Normal file
4
apps/web/src/lib/audioState.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const audioState = {
|
||||
analyser: null as AnalyserNode | null,
|
||||
isPlaying: false,
|
||||
}
|
||||
94
apps/web/src/lib/authApi.ts
Normal file
94
apps/web/src/lib/authApi.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
33
apps/web/src/lib/colors.ts
Normal file
33
apps/web/src/lib/colors.ts
Normal 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})`
|
||||
}
|
||||
44
apps/web/src/lib/shuffle.ts
Normal file
44
apps/web/src/lib/shuffle.ts
Normal 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)
|
||||
}
|
||||
50
apps/web/src/store/authStore.ts
Normal file
50
apps/web/src/store/authStore.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
55
apps/web/src/store/favoritesStore.ts
Normal file
55
apps/web/src/store/favoritesStore.ts
Normal 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: [] })
|
||||
},
|
||||
}))
|
||||
200
apps/web/src/store/partyStore.ts
Normal file
200
apps/web/src/store/partyStore.ts
Normal 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: [] }),
|
||||
}))
|
||||
95
apps/web/src/store/versionStore.ts
Normal file
95
apps/web/src/store/versionStore.ts
Normal 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
|
||||
},
|
||||
}))
|
||||
70
apps/web/src/types/index.ts
Normal file
70
apps/web/src/types/index.ts
Normal 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
|
||||
}
|
||||
49
apps/web/tailwind.config.ts
Normal file
49
apps/web/tailwind.config.ts
Normal 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
20
apps/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
6
docker-compose.dev.yml
Normal file
6
docker-compose.dev.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
54
docker-compose.yml
Normal file
54
docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: partymix
|
||||
POSTGRES_USER: partymix
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U partymix"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./apps/backend
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: partymix
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: partymix
|
||||
GIN_MODE: release
|
||||
PORT: 8080
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ports:
|
||||
- "8081:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./apps/web
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
|
||||
ports:
|
||||
- "3001:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
771
index.html
Normal file
771
index.html
Normal file
@@ -0,0 +1,771 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<title>Party Mix</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f;--surface:#12121a;--surface2:#1a1a26;
|
||||
--border:rgba(255,255,255,0.07);--accent:#c8ff00;
|
||||
--text:#f0f0f0;--muted:#555;--radius:16px;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
font-family:'DM Sans',sans-serif;background:var(--bg);color:var(--text);
|
||||
min-height:100vh;padding:1.25rem 1rem 5rem;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 20% -10%,rgba(200,255,0,0.05) 0%,transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 110%,rgba(255,60,172,0.05) 0%,transparent 60%);
|
||||
}
|
||||
.app{max-width:660px;margin:0 auto}
|
||||
@keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* ── HEADER ── */
|
||||
.header{display:flex;align-items:center;gap:12px;margin-bottom:1.25rem;padding-bottom:1.25rem;border-bottom:1px solid var(--border)}
|
||||
.logo{width:40px;height:40px;border-radius:11px;background:var(--accent);display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.logo svg{width:20px;height:20px}
|
||||
h1{font-family:'Syne',sans-serif;font-size:20px;font-weight:800;letter-spacing:-.5px}
|
||||
.header-sub{font-size:12px;color:var(--muted);margin-top:1px}
|
||||
|
||||
/* ── TABS ── */
|
||||
.tabs{display:flex;gap:0;margin-bottom:1rem;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:3px}
|
||||
.tab-btn{flex:1;padding:8px;font-size:13px;font-family:'Syne',sans-serif;font-weight:700;letter-spacing:.2px;border:none;border-radius:9px;cursor:pointer;background:transparent;color:var(--muted);transition:all .2s;display:flex;align-items:center;justify-content:center;gap:6px}
|
||||
.tab-btn.active{background:var(--surface2);color:var(--text)}
|
||||
.tab-btn .badge{background:var(--accent);color:#0a0a0f;font-size:10px;font-weight:800;padding:1px 5px;border-radius:4px;min-width:18px;text-align:center}
|
||||
.tab-content{display:none}
|
||||
.tab-content.active{display:block;animation:fadeUp .25s ease both}
|
||||
|
||||
/* ── PLAYER ── */
|
||||
.player-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:1rem;display:none;overflow:hidden;animation:fadeUp .3s ease both}
|
||||
.player-hero{display:flex;gap:0;position:relative;overflow:hidden;border-bottom:1px solid var(--border);min-height:110px}
|
||||
.cover-wrap{position:relative;flex-shrink:0;width:110px;height:110px;overflow:hidden;background:var(--surface2)}
|
||||
.cover-img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .5s ease}
|
||||
.cover-img.playing{animation:coverPulse 2s ease-in-out infinite alternate}
|
||||
@keyframes coverPulse{from{transform:scale(1)}to{transform:scale(1.06)}}
|
||||
.cover-canvas{position:absolute;inset:0;width:100%;height:100%;opacity:0;transition:opacity .4s;pointer-events:none}
|
||||
.cover-canvas.active{opacity:1}
|
||||
.cover-glow{position:absolute;inset:-20px;border-radius:50%;background:radial-gradient(circle,var(--glow-color,rgba(200,255,0,.25)) 0%,transparent 70%);opacity:0;transition:opacity .5s;filter:blur(12px);pointer-events:none;z-index:0}
|
||||
.cover-wrap.playing .cover-glow{opacity:1;animation:glowPulse 1.5s ease-in-out infinite alternate}
|
||||
@keyframes glowPulse{from{opacity:.4}to{opacity:.9}}
|
||||
.player-info{flex:1;padding:.9rem 1rem;display:flex;flex-direction:column;justify-content:center;gap:4px;background:linear-gradient(135deg,var(--surface2) 0%,var(--surface) 100%);min-width:0}
|
||||
.now-eyebrow{font-size:10px;font-weight:500;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent);display:flex;align-items:center;gap:5px}
|
||||
.eq-bars{display:flex;align-items:flex-end;gap:2px;height:10px}
|
||||
.eq-bar{width:2.5px;border-radius:2px;background:var(--accent);animation:eqAnim .7s ease-in-out infinite alternate;height:100%}
|
||||
.eq-bar:nth-child(2){animation-delay:.15s;height:60%}
|
||||
.eq-bar:nth-child(3){animation-delay:.3s;height:80%}
|
||||
.eq-bar:nth-child(4){animation-delay:.1s;height:50%}
|
||||
@keyframes eqAnim{from{transform:scaleY(.3)}to{transform:scaleY(1)}}
|
||||
.now-title{font-family:'Syne',sans-serif;font-size:15px;font-weight:800;letter-spacing:-.3px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.owner-badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;padding:3px 9px;border-radius:7px;align-self:flex-start}
|
||||
.search-status{padding:.75rem 1rem;display:none;align-items:center;gap:10px;font-size:13px;color:var(--muted);border-bottom:1px solid var(--border)}
|
||||
.spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--surface2);border-top-color:var(--accent);animation:spin .6s linear infinite;flex-shrink:0}
|
||||
.audio-wrap{padding:.75rem 1rem;border-bottom:1px solid var(--border);display:none}
|
||||
.audio-meta{font-size:12px;color:var(--muted);margin-bottom:7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
audio{width:100%;height:34px;border-radius:8px;outline:none;filter:invert(1) hue-rotate(180deg) saturate(0.5)}
|
||||
.results-wrap{display:none}
|
||||
.results-header{padding:7px 1rem;font-size:10px;font-weight:600;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none;transition:background .12s}
|
||||
.results-header:hover{background:var(--surface2)}
|
||||
.results-chevron{font-size:10px;transition:transform .2s}
|
||||
.results-chevron.open{transform:rotate(180deg)}
|
||||
.results-body{max-height:0;overflow:hidden;transition:max-height .3s ease}
|
||||
.results-body.open{max-height:600px}
|
||||
.result-item{display:flex;align-items:center;gap:9px;padding:9px 1rem;border-bottom:1px solid var(--border);cursor:pointer;transition:background .12s}
|
||||
.result-item:last-child{border-bottom:none}
|
||||
.result-item:hover{background:var(--surface2)}
|
||||
.result-item.active{background:rgba(200,255,0,0.04)}
|
||||
.res-cover{width:32px;height:32px;border-radius:6px;object-fit:cover;flex-shrink:0;background:var(--surface2)}
|
||||
.result-num{font-size:11px;color:var(--muted);width:14px;flex-shrink:0;text-align:right;font-family:'Syne',sans-serif}
|
||||
.result-info{flex:1;min-width:0}
|
||||
.result-name{font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
|
||||
.result-artist{font-size:11px;color:var(--muted);margin-top:1px}
|
||||
.result-dur{font-size:11px;color:var(--muted);flex-shrink:0;font-family:'Syne',sans-serif}
|
||||
.result-play-btn{width:26px;height:26px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s}
|
||||
.result-play-btn:hover,.result-play-btn.active{background:var(--accent);border-color:var(--accent)}
|
||||
.result-play-btn svg{fill:var(--muted);transition:fill .15s}
|
||||
.result-play-btn:hover svg,.result-play-btn.active svg{fill:#0a0a0f}
|
||||
.player-controls{display:flex;align-items:center;gap:8px;padding:.65rem 1rem}
|
||||
.ctrl{width:36px;height:36px;border-radius:9px;border:1px solid var(--border);background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--muted);flex-shrink:0;transition:all .15s}
|
||||
.ctrl:hover{background:var(--surface2);color:var(--text)}
|
||||
.ctrl-info{flex:1;text-align:center;font-size:12px;color:var(--muted);font-family:'Syne',sans-serif}
|
||||
.btn-next{padding:6px 12px;font-size:12px;font-weight:600;font-family:'Syne',sans-serif;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--muted);cursor:pointer;transition:all .15s;white-space:nowrap}
|
||||
.btn-next:hover{background:var(--surface2);color:var(--text)}
|
||||
|
||||
/* ── QUEUE ── */
|
||||
.queue-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:1rem}
|
||||
.queue-head{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;cursor:pointer;user-select:none;transition:background .12s}
|
||||
.queue-head:hover{background:var(--surface2)}
|
||||
.queue-head-left{display:flex;align-items:center;gap:8px}
|
||||
.queue-label{font-family:'Syne',sans-serif;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted)}
|
||||
.queue-chevron{color:var(--muted);font-size:11px;transition:transform .25s;flex-shrink:0}
|
||||
.queue-chevron.open{transform:rotate(180deg)}
|
||||
.queue-body{max-height:0;overflow:hidden;transition:max-height .35s ease}
|
||||
.queue-body.open{max-height:500px}
|
||||
.queue-list{display:flex;flex-direction:column;max-height:500px;overflow-y:auto}
|
||||
.queue-list::-webkit-scrollbar{width:3px}
|
||||
.queue-list::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
|
||||
|
||||
/* DRAG & DROP */
|
||||
.q-item{
|
||||
display:flex;align-items:center;gap:8px;
|
||||
padding:8px 1rem;border-bottom:1px solid var(--border);
|
||||
cursor:pointer;transition:background .1s;
|
||||
user-select:none;position:relative;
|
||||
}
|
||||
.q-item:last-child{border-bottom:none}
|
||||
.q-item:hover{background:var(--surface2)}
|
||||
.q-item.active{background:rgba(200,255,0,0.04)}
|
||||
.q-item.dragging{opacity:.4;background:var(--surface2)}
|
||||
.q-item.drag-over{border-top:2px solid var(--accent)}
|
||||
.drag-handle{
|
||||
color:var(--muted);cursor:grab;flex-shrink:0;
|
||||
padding:4px 2px;opacity:.4;transition:opacity .15s;
|
||||
display:flex;align-items:center;touch-action:none;
|
||||
}
|
||||
.q-item:hover .drag-handle{opacity:.8}
|
||||
.drag-handle:active{cursor:grabbing}
|
||||
.q-num{font-size:11px;color:var(--muted);width:18px;text-align:right;flex-shrink:0;font-family:'Syne',sans-serif}
|
||||
.q-cover{width:28px;height:28px;border-radius:5px;object-fit:cover;flex-shrink:0;background:var(--surface2)}
|
||||
.q-track{flex:1;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.q-owner{font-size:10px;padding:2px 6px;border-radius:5px;flex-shrink:0;font-weight:500}
|
||||
.bars{width:12px;height:12px;display:flex;align-items:flex-end;gap:1.5px;flex-shrink:0}
|
||||
.bar{width:2.5px;border-radius:1px;background:var(--accent);animation:bA .7s ease-in-out infinite alternate}
|
||||
.bar:nth-child(2){animation-delay:.15s}.bar:nth-child(3){animation-delay:.3s}
|
||||
@keyframes bA{from{height:2px}to{height:11px}}
|
||||
|
||||
/* ── HISTORY TAB ── */
|
||||
.history-empty{text-align:center;padding:3rem 1rem;color:var(--muted);font-size:13px}
|
||||
.history-empty svg{margin-bottom:.75rem;opacity:.3}
|
||||
.history-item{display:flex;align-items:center;gap:10px;padding:10px 1rem;border-bottom:1px solid var(--border);animation:fadeUp .2s ease both}
|
||||
.history-item:last-child{border-bottom:none}
|
||||
.h-cover{width:40px;height:40px;border-radius:8px;object-fit:cover;flex-shrink:0;background:var(--surface2)}
|
||||
.h-info{flex:1;min-width:0}
|
||||
.h-title{font-size:13px;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.h-sub{font-size:11px;color:var(--muted);margin-top:2px;display:flex;align-items:center;gap:6px}
|
||||
.h-owner{font-size:10px;padding:1px 6px;border-radius:4px}
|
||||
.h-time{font-size:10px;color:var(--muted)}
|
||||
.btn-h-play{width:30px;height:30px;border-radius:50%;border:1px solid var(--border);background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s}
|
||||
.btn-h-play:hover{background:var(--accent);border-color:var(--accent)}
|
||||
.btn-h-play:hover svg{fill:#0a0a0f}
|
||||
.btn-h-play svg{fill:var(--muted);transition:fill .15s}
|
||||
.history-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
|
||||
.history-head{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;border-bottom:1px solid var(--border)}
|
||||
.history-label{font-family:'Syne',sans-serif;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted)}
|
||||
.btn-clear{font-size:11px;padding:3px 9px;border:1px solid rgba(255,100,100,0.2);border-radius:7px;background:transparent;color:rgba(255,100,100,0.5);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s}
|
||||
.btn-clear:hover{background:rgba(255,100,100,0.08);color:#ff6b6b;border-color:rgba(255,100,100,0.35)}
|
||||
|
||||
/* ── PARTY TAB (участники + форма) ── */
|
||||
.btn-mix{
|
||||
width:100%;padding:13px;margin-bottom:1rem;
|
||||
font-family:'Syne',sans-serif;font-size:14px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;
|
||||
background:var(--accent);border:none;border-radius:var(--radius);
|
||||
color:#0a0a0f;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:9px;
|
||||
transition:opacity .15s;
|
||||
}
|
||||
.btn-mix:hover{opacity:.9}
|
||||
.btn-mix:active{transform:scale(.99)}
|
||||
.mode-toggle{display:flex;gap:5px;margin-bottom:1rem;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:4px}
|
||||
.mode-btn{flex:1;padding:7px;font-size:12px;font-family:'Syne',sans-serif;font-weight:600;letter-spacing:.2px;border:none;border-radius:7px;cursor:pointer;background:transparent;color:var(--muted);transition:all .2s}
|
||||
.mode-btn.active{background:var(--surface2);color:var(--text)}
|
||||
.people-list{display:flex;flex-direction:column;gap:.6rem;margin-bottom:1rem}
|
||||
.p-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;animation:fadeUp .3s ease both}
|
||||
.p-head{display:flex;align-items:center;gap:9px;padding:.75rem 1rem;cursor:pointer;user-select:none;transition:background .12s}
|
||||
.p-head:hover{background:var(--surface2)}
|
||||
.avatar{width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-family:'Syne',sans-serif;font-size:11px;font-weight:800;flex-shrink:0}
|
||||
.p-name{font-family:'Syne',sans-serif;font-size:13px;font-weight:700;flex:1;letter-spacing:-.1px}
|
||||
.p-count{font-size:11px;color:var(--muted);flex-shrink:0}
|
||||
.p-chevron{color:var(--muted);flex-shrink:0;transition:transform .2s;font-size:11px}
|
||||
.p-chevron.open{transform:rotate(180deg)}
|
||||
.btn-del{width:24px;height:24px;border-radius:7px;border:1px solid rgba(255,100,100,0.15);background:transparent;color:rgba(255,100,100,0.4);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0}
|
||||
.btn-del:hover{background:rgba(255,100,100,0.1);color:#ff6b6b;border-color:rgba(255,100,100,0.35)}
|
||||
.p-chips-wrap{border-top:1px solid var(--border);max-height:0;overflow:hidden;transition:max-height .3s ease}
|
||||
.p-chips-wrap.open{max-height:500px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:4px;padding:.7rem 1rem}
|
||||
.chip{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:3px 8px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--muted);max-width:220px}
|
||||
.chip span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:165px}
|
||||
.chip.playing{background:rgba(200,255,0,0.07);border-color:rgba(200,255,0,0.25);color:var(--accent);font-weight:500}
|
||||
.dot{width:4px;height:4px;border-radius:50%;background:#4ade80;flex-shrink:0}
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.1rem;margin-bottom:1rem}
|
||||
input[type=text],textarea{font-family:'DM Sans',sans-serif;font-size:13px;background:var(--surface2);border:1px solid var(--border);border-radius:9px;padding:9px 13px;color:var(--text);outline:none;width:100%;transition:border-color .2s}
|
||||
input[type=text]:focus,textarea:focus{border-color:rgba(200,255,0,0.35)}
|
||||
.hint{font-size:12px;color:var(--muted);margin-bottom:6px;line-height:1.5}
|
||||
textarea{min-height:80px;resize:vertical;line-height:1.6;margin-top:8px}
|
||||
.err-msg{font-size:12px;color:#ff6b6b;background:rgba(255,107,107,0.08);padding:7px 11px;border-radius:8px;margin-top:6px;display:none;border:1px solid rgba(255,107,107,0.15)}
|
||||
.btn-add{width:100%;padding:11px;margin-top:9px;font-family:'Syne',sans-serif;font-size:13px;font-weight:700;letter-spacing:.4px;background:var(--surface2);border:1px solid var(--border);border-radius:9px;color:var(--text);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:7px;transition:all .2s}
|
||||
.btn-add:hover{border-color:rgba(200,255,0,0.3);color:var(--accent)}
|
||||
.btn{padding:6px 12px;font-size:12px;font-family:'DM Sans',sans-serif;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--muted);cursor:pointer;transition:all .15s}
|
||||
.btn:hover{background:var(--surface2);color:var(--text)}
|
||||
|
||||
/* ── MOBILE ── */
|
||||
@media(max-width:500px){
|
||||
body{padding:1rem .75rem 5rem}
|
||||
h1{font-size:18px}
|
||||
.header-sub{display:none}
|
||||
.player-hero{min-height:96px}
|
||||
.cover-wrap{width:96px;height:96px}
|
||||
.now-title{font-size:13px}
|
||||
.owner-badge{font-size:10px;padding:2px 7px}
|
||||
.ctrl{width:32px;height:32px}
|
||||
.player-controls{gap:6px;padding:.6rem .75rem}
|
||||
.ctrl-info{font-size:11px}
|
||||
.btn-next{padding:5px 10px;font-size:11px}
|
||||
.audio-wrap{padding:.65rem .75rem}
|
||||
.result-item{padding:8px .75rem}
|
||||
.q-item{padding:8px .75rem}
|
||||
.history-item{padding:9px .75rem}
|
||||
.queue-head{padding:.7rem .75rem}
|
||||
.chips{padding:.6rem .75rem}
|
||||
.p-head{padding:.7rem .75rem}
|
||||
.card{padding:.9rem .75rem}
|
||||
.tab-btn{font-size:12px;padding:7px 4px}
|
||||
.btn-mix{font-size:13px;padding:12px}
|
||||
.mode-btn{font-size:11px;padding:6px 3px}
|
||||
.results-header{padding:6px .75rem}
|
||||
.player-info{padding:.75rem .85rem}
|
||||
.queue-label{font-size:10px}
|
||||
}
|
||||
@media(max-width:360px){
|
||||
.cover-wrap{width:80px;height:80px}
|
||||
.now-title{font-size:12px}
|
||||
.tab-btn span:not(.badge){display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M9 18V5l12-2v13" stroke="#0a0a0f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="18" r="3" fill="#0a0a0f"/><circle cx="18" cy="16" r="3" fill="#0a0a0f"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Party Mix</h1>
|
||||
<p class="header-sub">Музыка с hitmotop.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER (всегда виден поверх вкладок) -->
|
||||
<div class="player-card" id="playerCard">
|
||||
<div class="player-hero">
|
||||
<div class="cover-wrap" id="coverWrap">
|
||||
<div class="cover-glow" id="coverGlow"></div>
|
||||
<img class="cover-img" id="coverImg" src="" alt=""/>
|
||||
<canvas class="cover-canvas" id="coverCanvas"></canvas>
|
||||
</div>
|
||||
<div class="player-info">
|
||||
<div class="now-eyebrow">
|
||||
<div class="eq-bars"><div class="eq-bar"></div><div class="eq-bar"></div><div class="eq-bar"></div><div class="eq-bar"></div></div>
|
||||
Сейчас играет
|
||||
</div>
|
||||
<div class="now-title" id="nowTitle">—</div>
|
||||
<span class="owner-badge" id="nowOwner">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="searchStatus" class="search-status">
|
||||
<div class="spinner"></div><span id="searchMsg">Ищем...</span>
|
||||
</div>
|
||||
<div class="audio-wrap" id="audioWrap">
|
||||
<div class="audio-meta"><span id="audioName"></span><span style="margin:0 5px;opacity:.3">·</span><span id="audioArtist"></span></div>
|
||||
<audio id="audioEl" controls preload="auto" crossorigin="anonymous"></audio>
|
||||
</div>
|
||||
<div class="results-wrap" id="resultsWrap">
|
||||
<div class="results-header" onclick="toggleResults()">
|
||||
<span>Найдено — выберите версию</span>
|
||||
<span class="results-chevron" id="resultsChevron">▼</span>
|
||||
</div>
|
||||
<div class="results-body" id="resultsBody"><div id="resultsList"></div></div>
|
||||
</div>
|
||||
<div class="player-controls">
|
||||
<button class="ctrl" onclick="prevTrack()"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg></button>
|
||||
<button class="ctrl" onclick="reloadTrack()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4"/></svg></button>
|
||||
<button class="ctrl" onclick="nextTrack()"><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 class="ctrl-info" id="ctrlInfo">— / —</div>
|
||||
<button class="btn-next" onclick="nextTrack()">Далее →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUEUE (под плеером) -->
|
||||
<div class="queue-card" id="queueCard" style="display:none">
|
||||
<div class="queue-head" onclick="toggleQueue()">
|
||||
<div class="queue-head-left">
|
||||
<span class="queue-label" id="queueLabel">Очередь</span>
|
||||
<button class="btn" onclick="event.stopPropagation();generateMix()" style="padding:3px 9px;font-size:11px">↺</button>
|
||||
</div>
|
||||
<span class="queue-chevron" id="queueChevron">▼</span>
|
||||
</div>
|
||||
<div class="queue-body" id="queueBody">
|
||||
<div class="queue-list" id="queueList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABS -->
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" onclick="switchTab('party')" id="tab-party">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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 class="tab-btn" onclick="switchTab('history')" id="tab-history">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<span>История</span>
|
||||
<span class="badge" id="historyBadge" style="display:none">0</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB: PARTY -->
|
||||
<div class="tab-content active" id="tab-content-party">
|
||||
<div class="mode-toggle" id="modeToggle" style="display:none">
|
||||
<button class="mode-btn active" id="modeFair" onclick="setMode('fair')">⚖️ По очереди</button>
|
||||
<button class="mode-btn" id="modeRandom" onclick="setMode('random')">🎲 Случайно</button>
|
||||
</div>
|
||||
<button class="btn-mix" onclick="generateMix()">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="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 id="peopleList" class="people-list"></div>
|
||||
<div class="card">
|
||||
<input type="text" id="nameIn" placeholder="Имя участника"/>
|
||||
<div class="hint" style="margin-top:8px">Список треков — каждый с новой строки. Формат: <b style="color:var(--text);font-weight:500">Исполнитель — Название</b></div>
|
||||
<textarea id="tracksIn" placeholder="Тараканы - Пойдём на улицу PALC - Залип Дора - Втюрилась ..."></textarea>
|
||||
<div class="err-msg" id="errMsg"></div>
|
||||
<button class="btn-add" onclick="addPerson()">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12l7-7 7 7"/></svg>
|
||||
Добавить участника
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: HISTORY -->
|
||||
<div class="tab-content" id="tab-content-history">
|
||||
<div class="history-card">
|
||||
<div class="history-head">
|
||||
<span class="history-label">Уже сыграло</span>
|
||||
<button class="btn-clear" onclick="clearHistory()">Очистить</button>
|
||||
</div>
|
||||
<div id="historyList">
|
||||
<div class="history-empty">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<div>История пуста</div>
|
||||
<div style="font-size:11px;margin-top:4px;opacity:.6">Здесь появятся треки которые уже сыграли</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
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'},
|
||||
];
|
||||
let people=[], queue=[], curIdx=-1, currentResults=[];
|
||||
let shuffleMode='fair';
|
||||
const collapsed={};
|
||||
let queueOpen=false;
|
||||
let history=[];
|
||||
|
||||
// ── TABS ──
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));
|
||||
document.getElementById('tab-'+tab).classList.add('active');
|
||||
document.getElementById('tab-content-'+tab).classList.add('active');
|
||||
}
|
||||
|
||||
// ── WEB AUDIO VIZ ──
|
||||
let audioCtx=null,analyser=null,source=null,rafId=null;
|
||||
function initAudioViz(el){
|
||||
try{
|
||||
if(!audioCtx){audioCtx=new(window.AudioContext||window.webkitAudioContext)();analyser=audioCtx.createAnalyser();analyser.fftSize=64;analyser.smoothingTimeConstant=0.8;}
|
||||
if(source){try{source.disconnect();}catch(e){}}
|
||||
source=audioCtx.createMediaElementSource(el);
|
||||
source.connect(analyser);analyser.connect(audioCtx.destination);
|
||||
}catch(e){analyser=null;}
|
||||
}
|
||||
function startViz(){
|
||||
if(!analyser)return;
|
||||
const canvas=document.getElementById('coverCanvas'),wrap=document.getElementById('coverWrap'),img=document.getElementById('coverImg');
|
||||
canvas.classList.add('active');wrap.classList.add('playing');img.classList.add('playing');
|
||||
const ctx=canvas.getContext('2d');
|
||||
canvas.width=canvas.offsetWidth;canvas.height=canvas.offsetHeight;
|
||||
const W=canvas.width,H=canvas.height,data=new Uint8Array(analyser.frequencyBinCount);
|
||||
function draw(){
|
||||
rafId=requestAnimationFrame(draw);
|
||||
analyser.getByteFrequencyData(data);
|
||||
ctx.clearRect(0,0,W,H);
|
||||
const barCount=data.length,barW=W/barCount,maxH=H*0.55;
|
||||
for(let i=0;i<barCount;i++){
|
||||
const v=data[i]/255,bh=v*maxH,x=i*barW,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*.7,bh);
|
||||
}
|
||||
const ov=ctx.createLinearGradient(0,0,0,H);
|
||||
ov.addColorStop(0,'rgba(10,10,15,.3)');ov.addColorStop(.5,'rgba(10,10,15,0)');ov.addColorStop(1,'rgba(10,10,15,.6)');
|
||||
ctx.fillStyle=ov;ctx.fillRect(0,0,W,H);
|
||||
}
|
||||
draw();
|
||||
}
|
||||
function stopViz(){
|
||||
if(rafId){cancelAnimationFrame(rafId);rafId=null;}
|
||||
const canvas=document.getElementById('coverCanvas'),wrap=document.getElementById('coverWrap'),img=document.getElementById('coverImg');
|
||||
canvas.classList.remove('active');wrap.classList.remove('playing');img.classList.remove('playing');
|
||||
canvas.getContext('2d').clearRect(0,0,canvas.width,canvas.height);
|
||||
}
|
||||
function hexToRgba(c,a){if(c.startsWith('rgb'))return c.replace('rgb','rgba').replace(')',`,${a})`);if(c.startsWith('#')){const r=parseInt(c.slice(1,3),16),g=parseInt(c.slice(3,5),16),b=parseInt(c.slice(5,7),16);return`rgba(${r},${g},${b},${a})`;}return`rgba(200,255,0,${a})`;}
|
||||
|
||||
// ── SHUFFLE ──
|
||||
function fairShuffle(ppl){
|
||||
const pools=ppl.map((p,pi)=>{
|
||||
const arr=p.tracks.map((t,ti)=>({title:t.title,owner:p.name,color:p.color,_pi:pi,_ti:ti,img:''}));
|
||||
for(let i=arr.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[arr[i],arr[j]]=[arr[j],arr[i]];}
|
||||
return arr;
|
||||
});
|
||||
const order=[...Array(pools.length).keys()];
|
||||
for(let i=order.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[order[i],order[j]]=[order[j],order[i]];}
|
||||
const result=[];
|
||||
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;
|
||||
}
|
||||
function randomShuffle(ppl){
|
||||
const all=[];
|
||||
ppl.forEach((p,pi)=>p.tracks.forEach((t,ti)=>all.push({title:t.title,owner:p.name,color:p.color,_pi:pi,_ti:ti,img:''})));
|
||||
for(let i=all.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[all[i],all[j]]=[all[j],all[i]];}
|
||||
return all;
|
||||
}
|
||||
function setMode(m){shuffleMode=m;document.getElementById('modeFair').classList.toggle('active',m==='fair');document.getElementById('modeRandom').classList.toggle('active',m==='random');}
|
||||
|
||||
// ── QUEUE TOGGLE ──
|
||||
function toggleQueue(){queueOpen=!queueOpen;document.getElementById('queueBody').classList.toggle('open',queueOpen);document.getElementById('queueChevron').classList.toggle('open',queueOpen);}
|
||||
function toggleResults(){const b=document.getElementById('resultsBody'),c=document.getElementById('resultsChevron'),o=b.classList.toggle('open');c.classList.toggle('open',o);}
|
||||
|
||||
// ── DRAG & DROP ──
|
||||
let dragSrcIdx=null;
|
||||
function initDrag(){
|
||||
const items=document.querySelectorAll('.q-item[draggable]');
|
||||
items.forEach((item,i)=>{
|
||||
item.addEventListener('dragstart',e=>{dragSrcIdx=parseInt(item.dataset.idx);item.classList.add('dragging');e.dataTransfer.effectAllowed='move';});
|
||||
item.addEventListener('dragend',()=>{item.classList.remove('dragging');document.querySelectorAll('.q-item').forEach(it=>it.classList.remove('drag-over'));});
|
||||
item.addEventListener('dragover',e=>{e.preventDefault();e.dataTransfer.dropEffect='move';item.classList.add('drag-over');});
|
||||
item.addEventListener('dragleave',()=>item.classList.remove('drag-over'));
|
||||
item.addEventListener('drop',e=>{
|
||||
e.preventDefault();item.classList.remove('drag-over');
|
||||
const tgtIdx=parseInt(item.dataset.idx);
|
||||
if(dragSrcIdx===null||dragSrcIdx===tgtIdx)return;
|
||||
// Перемещаем в очереди
|
||||
const moved=queue.splice(dragSrcIdx,1)[0];
|
||||
queue.splice(tgtIdx,0,moved);
|
||||
// Корректируем curIdx
|
||||
if(curIdx===dragSrcIdx)curIdx=tgtIdx;
|
||||
else if(dragSrcIdx<curIdx&&tgtIdx>=curIdx)curIdx--;
|
||||
else if(dragSrcIdx>curIdx&&tgtIdx<=curIdx)curIdx++;
|
||||
dragSrcIdx=null;
|
||||
renderQueue();
|
||||
});
|
||||
});
|
||||
// Touch drag
|
||||
initTouchDrag();
|
||||
}
|
||||
|
||||
// Touch drag & drop
|
||||
function initTouchDrag(){
|
||||
let touchSrcIdx=null,clone=null,startY=0;
|
||||
const list=document.getElementById('queueList');
|
||||
list.querySelectorAll('.drag-handle').forEach(handle=>{
|
||||
handle.addEventListener('touchstart',e=>{
|
||||
const item=handle.closest('.q-item');
|
||||
touchSrcIdx=parseInt(item.dataset.idx);
|
||||
startY=e.touches[0].clientY;
|
||||
item.classList.add('dragging');
|
||||
e.preventDefault();
|
||||
},{passive:false});
|
||||
handle.addEventListener('touchmove',e=>{
|
||||
if(touchSrcIdx===null)return;
|
||||
e.preventDefault();
|
||||
const y=e.touches[0].clientY;
|
||||
const els=list.querySelectorAll('.q-item:not(.dragging)');
|
||||
let targetIdx=null;
|
||||
els.forEach(el=>{
|
||||
const r=el.getBoundingClientRect();
|
||||
if(y>r.top&&y<r.bottom)targetIdx=parseInt(el.dataset.idx);
|
||||
el.classList.remove('drag-over');
|
||||
});
|
||||
if(targetIdx!==null){
|
||||
const t=list.querySelector(`.q-item[data-idx="${targetIdx}"]`);
|
||||
if(t)t.classList.add('drag-over');
|
||||
}
|
||||
},{passive:false});
|
||||
handle.addEventListener('touchend',e=>{
|
||||
if(touchSrcIdx===null)return;
|
||||
const items=list.querySelectorAll('.q-item');
|
||||
let tgtIdx=null;
|
||||
items.forEach(el=>{if(el.classList.contains('drag-over'))tgtIdx=parseInt(el.dataset.idx);el.classList.remove('drag-over','dragging');});
|
||||
if(tgtIdx!==null&&tgtIdx!==touchSrcIdx){
|
||||
const moved=queue.splice(touchSrcIdx,1)[0];
|
||||
queue.splice(tgtIdx,0,moved);
|
||||
if(curIdx===touchSrcIdx)curIdx=tgtIdx;
|
||||
else if(touchSrcIdx<curIdx&&tgtIdx>=curIdx)curIdx--;
|
||||
else if(touchSrcIdx>curIdx&&tgtIdx<=curIdx)curIdx++;
|
||||
renderQueue();
|
||||
}
|
||||
touchSrcIdx=null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── SEARCH ──
|
||||
async function searchHitmotop(query){
|
||||
try{
|
||||
const r=await fetch(`/search?q=${encodeURIComponent(query)}`,{signal:AbortSignal.timeout(10000)});
|
||||
if(!r.ok)return[];
|
||||
const html=await r.text();
|
||||
if(!html.includes('track__download-btn'))return[];
|
||||
const results=[];
|
||||
const dlRegex=/href="(\/get\/music\/[^"]+\.mp3)"/g;
|
||||
const metaRegex=/data-musmeta='([^']+)'/g;
|
||||
const dlHrefs=[];let dlm;
|
||||
while((dlm=dlRegex.exec(html))!==null)dlHrefs.push(dlm[1]);
|
||||
const metas=[];let mm;
|
||||
while((mm=metaRegex.exec(html))!==null){try{metas.push(JSON.parse(mm[1]));}catch(e){}}
|
||||
for(let i=0;i<dlHrefs.length;i++){
|
||||
const href=dlHrefs[i];if(!href)continue;
|
||||
const meta=metas[i]||{};
|
||||
const originalMp3='https://rus.hitmotop.com'+href;
|
||||
const mp3=`/mp3?url=${encodeURIComponent(originalMp3)}`;
|
||||
const hrefPos=html.indexOf(href);
|
||||
const chunk=hrefPos>=0?html.substring(Math.max(0,hrefPos-800),hrefPos+200):'';
|
||||
const dur=chunk.match(/track__time[^>]*>([^<]+)</)|| ['',''];
|
||||
let title=meta.title||'',artist=meta.artist||'';
|
||||
if(!title){const tm=chunk.match(/track__title[^>]*>([^<]+)</);title=tm?tm[1].trim():'';}
|
||||
if(!artist){const am=chunk.match(/track__desc[^>]*>([^<]+)</);artist=am?am[1].trim():'';}
|
||||
results.push({mp3,originalMp3,title,artist,duration:dur[1].trim(),img:meta.img||''});
|
||||
}
|
||||
return results;
|
||||
}catch(e){return[];}
|
||||
}
|
||||
|
||||
// ── PEOPLE ──
|
||||
function initials(n){return n.split(' ').map(w=>w[0]).join('').toUpperCase().slice(0,2);}
|
||||
function addPerson(){
|
||||
const name=document.getElementById('nameIn').value.trim();
|
||||
const raw=document.getElementById('tracksIn').value.trim();
|
||||
const err=document.getElementById('errMsg');
|
||||
err.style.display='none';
|
||||
if(!name){document.getElementById('nameIn').focus();return;}
|
||||
if(!raw){err.textContent='Вставьте список треков';err.style.display='block';return;}
|
||||
const tracks=raw.split('\n').map(l=>l.trim()).filter(l=>l.length>1).slice(0,50);
|
||||
if(!tracks.length){err.textContent='Не нашли треков.';err.style.display='block';return;}
|
||||
const pi=people.length;
|
||||
people.push({name,color:COLORS[pi%COLORS.length],tracks:tracks.map(t=>({title:t}))});
|
||||
collapsed[pi]=false;
|
||||
document.getElementById('nameIn').value='';
|
||||
document.getElementById('tracksIn').value='';
|
||||
renderPeople();
|
||||
if(people.length>=2)document.getElementById('modeToggle').style.display='flex';
|
||||
}
|
||||
function removePerson(i){
|
||||
people.splice(i,1);people.forEach((p,j)=>p.color=COLORS[j%COLORS.length]);
|
||||
renderPeople();
|
||||
if(people.length<2)document.getElementById('modeToggle').style.display='none';
|
||||
}
|
||||
function toggleCollapse(pi){
|
||||
collapsed[pi]=!collapsed[pi];
|
||||
document.getElementById(`chips-${pi}`)?.classList.toggle('open',!collapsed[pi]);
|
||||
document.getElementById(`chev-${pi}`)?.classList.toggle('open',!collapsed[pi]);
|
||||
}
|
||||
function renderPeople(){
|
||||
const c=document.getElementById('peopleList');
|
||||
c.innerHTML=people.map((p,pi)=>{
|
||||
const isOpen=!collapsed[pi];
|
||||
const chips=p.tracks.map((t,ti)=>{
|
||||
const isPlay=curIdx>=0&&queue[curIdx]&&queue[curIdx]._pi===pi&&queue[curIdx]._ti===ti;
|
||||
return`<span class="chip${isPlay?' playing':''}"><div class="dot"></div><span>${t.title}</span></span>`;
|
||||
}).join('');
|
||||
return`<div class="p-card">
|
||||
<div class="p-head" onclick="toggleCollapse(${pi})">
|
||||
<div class="avatar" style="background:${p.color.bg};color:${p.color.text}">${initials(p.name)}</div>
|
||||
<span class="p-name">${p.name}</span>
|
||||
<span class="p-count">${p.tracks.length} тр.</span>
|
||||
<span class="p-chevron${isOpen?' open':''}" id="chev-${pi}">▼</span>
|
||||
<button class="btn-del" onclick="event.stopPropagation();removePerson(${pi})">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-chips-wrap${isOpen?' open':''}" id="chips-${pi}">
|
||||
<div class="chips">${chips}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── GENERATE ──
|
||||
function generateMix(){
|
||||
if(!people.length){alert('Добавьте участников!');return;}
|
||||
queue=shuffleMode==='fair'?fairShuffle(people):randomShuffle(people);
|
||||
curIdx=0;renderQueue();loadTrack(0);
|
||||
}
|
||||
|
||||
// ── LOAD TRACK ──
|
||||
async function loadTrack(i){
|
||||
if(i<0||i>=queue.length)return;
|
||||
curIdx=i;
|
||||
const t=queue[i];
|
||||
const audio=document.getElementById('audioEl');
|
||||
stopViz();audio.pause();audio.src='';
|
||||
document.getElementById('audioWrap').style.display='none';
|
||||
document.getElementById('resultsWrap').style.display='none';
|
||||
document.getElementById('playerCard').style.display='block';
|
||||
document.getElementById('nowTitle').textContent=t.title;
|
||||
const ob=document.getElementById('nowOwner');
|
||||
ob.textContent=t.owner;ob.style.background=t.color.bg;ob.style.color=t.color.text;
|
||||
document.getElementById('ctrlInfo').textContent=`${i+1} / ${queue.length}`;
|
||||
document.getElementById('searchStatus').style.display='flex';
|
||||
document.getElementById('searchMsg').textContent='Ищем...';
|
||||
document.getElementById('coverImg').src='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="110" height="110"><rect fill="%231a1a26" width="110" height="110"/></svg>';
|
||||
document.getElementById('playerCard').scrollIntoView({behavior:'smooth',block:'start'});
|
||||
renderQueue();renderPeople();
|
||||
|
||||
const results=await searchHitmotop(t.title);
|
||||
document.getElementById('searchStatus').style.display='none';
|
||||
|
||||
if(!results.length){
|
||||
document.getElementById('searchStatus').style.display='flex';
|
||||
document.getElementById('searchMsg').innerHTML=`Не найдено. <a href="https://rus.hitmotop.com/search?q=${encodeURIComponent(t.title)}" target="_blank" style="color:var(--accent);margin-left:4px">hitmotop ↗</a>`;
|
||||
setTimeout(()=>{if(curIdx===i)nextTrack();},4000);
|
||||
return;
|
||||
}
|
||||
|
||||
currentResults=results;
|
||||
if(results[0]?.img&&!results[0].img.includes('no-cover')){
|
||||
const proxyImg=`/img?url=${encodeURIComponent(results[0].img)}`;
|
||||
document.getElementById('coverImg').src=proxyImg;
|
||||
queue[i].img=proxyImg;renderQueue();
|
||||
}
|
||||
if(results.length>1)renderResultsList(results);
|
||||
playResult(0);
|
||||
}
|
||||
|
||||
function renderResultsList(results){
|
||||
document.getElementById('resultsWrap').style.display='block';
|
||||
document.getElementById('resultsList').innerHTML=results.map((r,i)=>`
|
||||
<div class="result-item" id="res-${i}" onclick="playResult(${i})">
|
||||
<span class="result-num">${i+1}</span>
|
||||
${r.img&&!r.img.includes('no-cover')?`<img class="res-cover" src="/img?url=${encodeURIComponent(r.img)}" onerror="this.style.display='none'"/>`:''}
|
||||
<div class="result-info"><div class="result-name">${r.title}</div><div class="result-artist">${r.artist}</div></div>
|
||||
<div class="result-dur">${r.duration}</div>
|
||||
<button class="result-play-btn" id="rpb-${i}" onclick="event.stopPropagation();playResult(${i})">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function playResult(idx){
|
||||
if(!currentResults[idx])return;
|
||||
const r=currentResults[idx];
|
||||
document.querySelectorAll('.result-item').forEach((el,i)=>el.classList.toggle('active',i===idx));
|
||||
document.querySelectorAll('.result-play-btn').forEach((el,i)=>el.classList.toggle('active',i===idx));
|
||||
document.getElementById('audioName').textContent=r.title;
|
||||
document.getElementById('audioArtist').textContent=r.artist;
|
||||
if(r.img&&!r.img.includes('no-cover'))document.getElementById('coverImg').src=`/img?url=${encodeURIComponent(r.img)}`;
|
||||
const audio=document.getElementById('audioEl');
|
||||
stopViz();audio.pause();audio.onerror=()=>{};
|
||||
audio.src=r.mp3;audio.load();
|
||||
document.getElementById('audioWrap').style.display='block';
|
||||
audio.play().then(()=>{if(audioCtx?.state==='suspended')audioCtx.resume();startViz();}).catch(()=>{});
|
||||
audio.onplay=()=>{if(audioCtx?.state==='suspended')audioCtx.resume();startViz();};
|
||||
audio.onpause=()=>stopViz();
|
||||
audio.onended=()=>{
|
||||
stopViz();
|
||||
// Добавляем в историю
|
||||
addToHistory({title:r.title,artist:r.artist,img:r.img,owner:queue[curIdx]?.owner||'',color:queue[curIdx]?.color||COLORS[0]});
|
||||
if(curIdx<queue.length-1)loadTrack(curIdx+1);
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('audioEl').addEventListener('play',function init(){initAudioViz(this);this.removeEventListener('play',init);},{once:true});
|
||||
|
||||
function prevTrack(){stopViz();if(curIdx>0)loadTrack(curIdx-1);}
|
||||
function nextTrack(){stopViz();if(curIdx<queue.length-1)loadTrack(curIdx+1);}
|
||||
function reloadTrack(){stopViz();loadTrack(curIdx);}
|
||||
|
||||
// ── HISTORY ──
|
||||
function addToHistory(item){
|
||||
item.playedAt=new Date().toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'});
|
||||
history.unshift(item); // новые сверху
|
||||
updateHistoryBadge();
|
||||
renderHistory();
|
||||
}
|
||||
function updateHistoryBadge(){
|
||||
const b=document.getElementById('historyBadge');
|
||||
if(history.length>0){b.textContent=history.length;b.style.display='inline-block';}
|
||||
else b.style.display='none';
|
||||
}
|
||||
function clearHistory(){history=[];updateHistoryBadge();renderHistory();}
|
||||
function renderHistory(){
|
||||
const el=document.getElementById('historyList');
|
||||
if(!history.length){
|
||||
el.innerHTML=`<div class="history-empty">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<div>История пуста</div>
|
||||
<div style="font-size:11px;margin-top:4px;opacity:.6">Здесь появятся треки которые уже сыграли</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML=history.map((item,i)=>`
|
||||
<div class="history-item" style="animation-delay:${i*0.03}s">
|
||||
${item.img&&!item.img.includes('no-cover')?`<img class="h-cover" src="/img?url=${encodeURIComponent(item.img)}" onerror="this.style.display='none'"/>`:'<div class="h-cover"></div>'}
|
||||
<div class="h-info">
|
||||
<div class="h-title">${item.title}</div>
|
||||
<div class="h-sub">
|
||||
<span class="h-owner" style="background:${item.color.bg};color:${item.color.text}">${item.owner}</span>
|
||||
<span class="h-time">${item.playedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ── QUEUE RENDER ──
|
||||
function renderQueue(){
|
||||
const qc=document.getElementById('queueCard'),ql=document.getElementById('queueList'),lbl=document.getElementById('queueLabel');
|
||||
if(!queue.length){qc.style.display='none';return;}
|
||||
qc.style.display='block';
|
||||
lbl.textContent=`Очередь · ${queue.length} треков`;
|
||||
ql.innerHTML=queue.map((t,i)=>{
|
||||
const active=i===curIdx;
|
||||
const ind=active
|
||||
?`<div class="bars"><div class="bar"></div><div class="bar"></div><div class="bar"></div></div>`
|
||||
:`<span class="q-num">${i+1}</span>`;
|
||||
const thumb=t.img?`<img class="q-cover" src="${t.img}" onerror="this.style.display='none'"/>`:'<div class="q-cover"></div>';
|
||||
return`<div class="q-item${active?' active':''}" data-idx="${i}" draggable="true" onclick="handleQueueClick(event,${i})">
|
||||
<div class="drag-handle" 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>
|
||||
${ind}${thumb}
|
||||
<span class="q-track">${t.title}</span>
|
||||
<span class="q-owner" style="background:${t.color.bg};color:${t.color.text}">${t.owner}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
setTimeout(()=>{
|
||||
document.querySelector('.q-item.active')?.scrollIntoView({block:'nearest'});
|
||||
initDrag();
|
||||
},50);
|
||||
}
|
||||
|
||||
function handleQueueClick(e,i){
|
||||
// Не реагируем на клик по drag handle
|
||||
if(e.target.closest('.drag-handle'))return;
|
||||
loadTrack(i);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
184
server.js
Normal file
184
server.js
Normal file
@@ -0,0 +1,184 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
|
||||
const PORT = 3000;
|
||||
|
||||
function proxyRequest(targetUrl, res, extraHeaders = {}) {
|
||||
const parsedTarget = new URL(targetUrl);
|
||||
const options = {
|
||||
hostname: parsedTarget.hostname,
|
||||
path: parsedTarget.pathname + parsedTarget.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'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',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.9',
|
||||
'Referer': 'https://rus.hitmotop.com/',
|
||||
'Origin': 'https://rus.hitmotop.com',
|
||||
...extraHeaders
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (proxyRes) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
// Пробрасываем важные заголовки
|
||||
const ct = proxyRes.headers['content-type'];
|
||||
if (ct) res.setHeader('Content-Type', ct);
|
||||
const cl = proxyRes.headers['content-length'];
|
||||
if (cl) res.setHeader('Content-Length', cl);
|
||||
const cr = proxyRes.headers['content-range'];
|
||||
if (cr) res.setHeader('Content-Range', cr);
|
||||
const ar = proxyRes.headers['accept-ranges'];
|
||||
if (ar) res.setHeader('Accept-Ranges', ar);
|
||||
res.statusCode = proxyRes.statusCode;
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
res.statusCode = 500;
|
||||
res.end('Proxy error: ' + e.message);
|
||||
});
|
||||
req.end();
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const parsed = url.parse(req.url, true);
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
if (req.method === 'OPTIONS') { res.end(); return; }
|
||||
|
||||
// /img?url=... — проксируем обложки альбомов
|
||||
if (parsed.pathname === '/img') {
|
||||
const imgurl = parsed.query.url || '';
|
||||
if (!imgurl.startsWith('http')) { res.statusCode = 400; res.end('Bad url'); return; }
|
||||
const pu = new URL(imgurl);
|
||||
const reqOpts = {
|
||||
hostname: pu.hostname,
|
||||
path: pu.pathname + pu.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Referer': 'https://rus.hitmotop.com/',
|
||||
}
|
||||
};
|
||||
const pr = https.request(reqOpts, (proxyRes) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
const ct = proxyRes.headers['content-type'] || 'image/jpeg';
|
||||
res.setHeader('Content-Type', ct);
|
||||
res.statusCode = proxyRes.statusCode;
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
pr.on('error', () => { res.statusCode = 500; res.end(); });
|
||||
pr.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// /search?q=... — поиск треков
|
||||
if (parsed.pathname === '/search') {
|
||||
const q = parsed.query.q || '';
|
||||
proxyRequest(`https://rus.hitmotop.com/search?q=${encodeURIComponent(q)}`, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// /mp3?url=... — проксируем MP3 файл с правильным Referer
|
||||
if (parsed.pathname === '/mp3') {
|
||||
const mp3url = parsed.query.url || '';
|
||||
console.log(`\n[MP3] Запрос: ${mp3url}`);
|
||||
if (!mp3url.startsWith('https://rus.hitmotop.com/') && !mp3url.startsWith('https://hitmotop.com/')) {
|
||||
console.log('[MP3] ОТКЛОНЁН — не hitmotop URL');
|
||||
res.statusCode = 403;
|
||||
res.end('Only hitmotop URLs allowed');
|
||||
return;
|
||||
}
|
||||
// Поддержка Range requests для перемотки
|
||||
const rangeHeader = req.headers['range'];
|
||||
if (rangeHeader) console.log(`[MP3] Range: ${rangeHeader}`);
|
||||
|
||||
function fetchMp3(targetUrl, redirectCount) {
|
||||
if (redirectCount > 5) {
|
||||
console.log('[MP3] Слишком много редиректов!');
|
||||
res.statusCode = 500;
|
||||
res.end('Too many redirects');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(targetUrl);
|
||||
const reqOptions = {
|
||||
hostname: parsedUrl.hostname,
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'audio/mpeg, audio/*, */*',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.9',
|
||||
'Referer': 'https://rus.hitmotop.com/',
|
||||
'Origin': 'https://rus.hitmotop.com',
|
||||
...(rangeHeader ? { 'Range': rangeHeader } : {})
|
||||
}
|
||||
};
|
||||
|
||||
const proxyReq = https.request(reqOptions, (proxyRes) => {
|
||||
console.log(`[MP3] HTTP ${proxyRes.statusCode} ← ${targetUrl.substring(0,80)}`);
|
||||
console.log(`[MP3] Content-Type: ${proxyRes.headers['content-type']}`);
|
||||
|
||||
// Следуем за редиректом
|
||||
if ((proxyRes.statusCode === 301 || proxyRes.statusCode === 302 || proxyRes.statusCode === 307 || proxyRes.statusCode === 308) && proxyRes.headers['location']) {
|
||||
const location = proxyRes.headers['location'];
|
||||
const nextUrl = location.startsWith('http') ? location : `https://${parsedUrl.hostname}${location}`;
|
||||
console.log(`[MP3] Редирект → ${nextUrl}`);
|
||||
proxyRes.resume(); // дочитываем тело чтобы освободить соединение
|
||||
fetchMp3(nextUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Content-Type', 'audio/mpeg');
|
||||
|
||||
['content-length','content-range','accept-ranges'].forEach(h => {
|
||||
if (proxyRes.headers[h]) res.setHeader(h, proxyRes.headers[h]);
|
||||
});
|
||||
|
||||
res.statusCode = proxyRes.statusCode;
|
||||
proxyRes.pipe(res);
|
||||
proxyRes.on('end', () => console.log('[MP3] ✓ Передача завершена'));
|
||||
});
|
||||
|
||||
proxyReq.on('error', (e) => {
|
||||
console.log(`[MP3] ОШИБКА: ${e.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end('Proxy error: ' + e.message);
|
||||
}
|
||||
});
|
||||
proxyReq.end();
|
||||
}
|
||||
|
||||
fetchMp3(mp3url, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// / или /index.html — отдаём приложение
|
||||
if (parsed.pathname === '/' || parsed.pathname === '/index.html') {
|
||||
const filePath = path.join(__dirname, 'index.html');
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) { res.statusCode = 404; res.end('Not found'); return; }
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(data);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log('\n\x1b[32m🎉 Party Mix запущен!\x1b[0m');
|
||||
console.log(`\nОткройте в браузере: \x1b[36mhttp://localhost:${PORT}\x1b[0m\n`);
|
||||
});
|
||||
11
start.bat
Normal file
11
start.bat
Normal file
@@ -0,0 +1,11 @@
|
||||
@echo off
|
||||
echo Запуск Party Mix...
|
||||
where node >nul 2>nul
|
||||
IF %ERRORLEVEL% NEQ 0 (
|
||||
echo Node.js не найден. Скачайте с https://nodejs.org
|
||||
pause
|
||||
exit
|
||||
)
|
||||
start "" http://localhost:3000
|
||||
node server.js
|
||||
pause
|
||||
Reference in New Issue
Block a user