From 0097fb5183e16423e8fd9f7eb9845a4f2351bb97 Mon Sep 17 00:00:00 2001 From: Kirko Date: Sat, 25 Apr 2026 12:40:22 +0300 Subject: [PATCH] Initial commit: party-mix-app with prefetch cache, audio preload optimizations Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 66 + .env.example | 3 + .gitattributes | 2 + .gitignore | 7 + apps/backend/Dockerfile | 15 + apps/backend/go.mod | 14 + apps/backend/internal/config/config.go | 37 + apps/backend/internal/database/database.go | 39 + apps/backend/internal/handlers/auth.go | 103 + apps/backend/internal/handlers/middleware.go | 66 + apps/backend/internal/handlers/party.go | 173 ++ apps/backend/internal/handlers/playlists.go | 283 +++ apps/backend/internal/handlers/proxy.go | 432 ++++ apps/backend/internal/handlers/remote.go | 162 ++ apps/backend/internal/handlers/versions.go | 84 + apps/backend/internal/models/models.go | 79 + apps/backend/internal/router/router.go | 84 + apps/backend/main.go | 19 + apps/bot/Dockerfile | 13 + apps/bot/package-lock.json | 983 ++++++++ apps/bot/package.json | 19 + apps/bot/src/api/client.ts | 54 + apps/bot/src/bot.ts | 46 + apps/bot/src/commands/help.ts | 17 + apps/bot/src/commands/party.ts | 152 ++ apps/bot/src/commands/start.ts | 14 + apps/bot/src/config.ts | 8 + apps/bot/src/index.ts | 12 + apps/bot/src/types/index.ts | 32 + apps/bot/tsconfig.json | 15 + apps/web/Dockerfile | 23 + apps/web/next.config.ts | 13 + apps/web/package-lock.json | 2186 +++++++++++++++++ apps/web/package.json | 26 + apps/web/postcss.config.mjs | 7 + apps/web/public/.gitkeep | 1 + apps/web/src/app/app/page.tsx | 29 + apps/web/src/app/community/page.tsx | 234 ++ apps/web/src/app/globals.css | 146 ++ apps/web/src/app/layout.tsx | 44 + apps/web/src/app/login/page.tsx | 108 + apps/web/src/app/page.tsx | 164 ++ apps/web/src/app/playlists/page.tsx | 783 ++++++ apps/web/src/app/register/page.tsx | 106 + apps/web/src/app/remote/[id]/page.tsx | 417 ++++ apps/web/src/app/search/page.tsx | 259 ++ apps/web/src/components/AddToPlaylist.tsx | 114 + apps/web/src/components/AudioBackground.tsx | 109 + apps/web/src/components/AuthHydrator.tsx | 11 + apps/web/src/components/BottomPlayer.tsx | 698 ++++++ apps/web/src/components/ExtraTab/ExtraTab.tsx | 183 ++ .../components/FavoritesTab/FavoritesTab.tsx | 74 + apps/web/src/components/GlobalPlayer.tsx | 40 + apps/web/src/components/Header.tsx | 111 + .../src/components/HistoryTab/HistoryTab.tsx | 70 + .../src/components/PartyTab/AddPersonForm.tsx | 117 + apps/web/src/components/PartyTab/PartyTab.tsx | 61 + .../src/components/PartyTab/PersonCard.tsx | 71 + apps/web/src/components/Player/PlayerCard.tsx | 352 +++ apps/web/src/components/Queue/QueueCard.tsx | 136 + apps/web/src/components/SoloTab/SoloTab.tsx | 106 + apps/web/src/components/Tabs.tsx | 60 + apps/web/src/hooks/useAudioViz.ts | 64 + apps/web/src/hooks/usePlayer.ts | 41 + apps/web/src/lib/api.ts | 44 + apps/web/src/lib/audioState.ts | 4 + apps/web/src/lib/authApi.ts | 94 + apps/web/src/lib/colors.ts | 33 + apps/web/src/lib/shuffle.ts | 44 + apps/web/src/store/authStore.ts | 50 + apps/web/src/store/favoritesStore.ts | 55 + apps/web/src/store/partyStore.ts | 200 ++ apps/web/src/store/versionStore.ts | 95 + apps/web/src/types/index.ts | 70 + apps/web/tailwind.config.ts | 49 + apps/web/tsconfig.json | 20 + apps/web/tsconfig.tsbuildinfo | 1 + docker-compose.dev.yml | 6 + docker-compose.yml | 54 + index.html | 771 ++++++ server.js | 184 ++ start.bat | 11 + start.sh | 6 + 83 files changed, 11788 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 apps/backend/Dockerfile create mode 100644 apps/backend/go.mod create mode 100644 apps/backend/internal/config/config.go create mode 100644 apps/backend/internal/database/database.go create mode 100644 apps/backend/internal/handlers/auth.go create mode 100644 apps/backend/internal/handlers/middleware.go create mode 100644 apps/backend/internal/handlers/party.go create mode 100644 apps/backend/internal/handlers/playlists.go create mode 100644 apps/backend/internal/handlers/proxy.go create mode 100644 apps/backend/internal/handlers/remote.go create mode 100644 apps/backend/internal/handlers/versions.go create mode 100644 apps/backend/internal/models/models.go create mode 100644 apps/backend/internal/router/router.go create mode 100644 apps/backend/main.go create mode 100644 apps/bot/Dockerfile create mode 100644 apps/bot/package-lock.json create mode 100644 apps/bot/package.json create mode 100644 apps/bot/src/api/client.ts create mode 100644 apps/bot/src/bot.ts create mode 100644 apps/bot/src/commands/help.ts create mode 100644 apps/bot/src/commands/party.ts create mode 100644 apps/bot/src/commands/start.ts create mode 100644 apps/bot/src/config.ts create mode 100644 apps/bot/src/index.ts create mode 100644 apps/bot/src/types/index.ts create mode 100644 apps/bot/tsconfig.json create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/public/.gitkeep create mode 100644 apps/web/src/app/app/page.tsx create mode 100644 apps/web/src/app/community/page.tsx create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/login/page.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/src/app/playlists/page.tsx create mode 100644 apps/web/src/app/register/page.tsx create mode 100644 apps/web/src/app/remote/[id]/page.tsx create mode 100644 apps/web/src/app/search/page.tsx create mode 100644 apps/web/src/components/AddToPlaylist.tsx create mode 100644 apps/web/src/components/AudioBackground.tsx create mode 100644 apps/web/src/components/AuthHydrator.tsx create mode 100644 apps/web/src/components/BottomPlayer.tsx create mode 100644 apps/web/src/components/ExtraTab/ExtraTab.tsx create mode 100644 apps/web/src/components/FavoritesTab/FavoritesTab.tsx create mode 100644 apps/web/src/components/GlobalPlayer.tsx create mode 100644 apps/web/src/components/Header.tsx create mode 100644 apps/web/src/components/HistoryTab/HistoryTab.tsx create mode 100644 apps/web/src/components/PartyTab/AddPersonForm.tsx create mode 100644 apps/web/src/components/PartyTab/PartyTab.tsx create mode 100644 apps/web/src/components/PartyTab/PersonCard.tsx create mode 100644 apps/web/src/components/Player/PlayerCard.tsx create mode 100644 apps/web/src/components/Queue/QueueCard.tsx create mode 100644 apps/web/src/components/SoloTab/SoloTab.tsx create mode 100644 apps/web/src/components/Tabs.tsx create mode 100644 apps/web/src/hooks/useAudioViz.ts create mode 100644 apps/web/src/hooks/usePlayer.ts create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/audioState.ts create mode 100644 apps/web/src/lib/authApi.ts create mode 100644 apps/web/src/lib/colors.ts create mode 100644 apps/web/src/lib/shuffle.ts create mode 100644 apps/web/src/store/authStore.ts create mode 100644 apps/web/src/store/favoritesStore.ts create mode 100644 apps/web/src/store/partyStore.ts create mode 100644 apps/web/src/store/versionStore.ts create mode 100644 apps/web/src/types/index.ts create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.tsbuildinfo create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 index.html create mode 100644 server.js create mode 100644 start.bat create mode 100644 start.sh diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e189ca6 --- /dev/null +++ b/.claude/settings.local.json @@ -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 *)" + ] + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..675fd1b --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DB_PASSWORD=supersecretpassword +BOT_TOKEN=your_telegram_bot_token_here +NEXT_PUBLIC_API_URL=http://localhost:8080 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cc23192 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.bat text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d10b54a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +node_modules/ +.next/ +dist/ +build/ +*.log +postgres_data/ diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 0000000..2e07389 --- /dev/null +++ b/apps/backend/Dockerfile @@ -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"] diff --git a/apps/backend/go.mod b/apps/backend/go.mod new file mode 100644 index 0000000..70e74fa --- /dev/null +++ b/apps/backend/go.mod @@ -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 +) diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go new file mode 100644 index 0000000..e09c439 --- /dev/null +++ b/apps/backend/internal/config/config.go @@ -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 +} diff --git a/apps/backend/internal/database/database.go b/apps/backend/internal/database/database.go new file mode 100644 index 0000000..56c5fa5 --- /dev/null +++ b/apps/backend/internal/database/database.go @@ -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 +} diff --git a/apps/backend/internal/handlers/auth.go b/apps/backend/internal/handlers/auth.go new file mode 100644 index 0000000..400b97d --- /dev/null +++ b/apps/backend/internal/handlers/auth.go @@ -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) + } +} diff --git a/apps/backend/internal/handlers/middleware.go b/apps/backend/internal/handlers/middleware.go new file mode 100644 index 0000000..f6637b6 --- /dev/null +++ b/apps/backend/internal/handlers/middleware.go @@ -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 +} diff --git a/apps/backend/internal/handlers/party.go b/apps/backend/internal/handlers/party.go new file mode 100644 index 0000000..3a5cce6 --- /dev/null +++ b/apps/backend/internal/handlers/party.go @@ -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) + } +} diff --git a/apps/backend/internal/handlers/playlists.go b/apps/backend/internal/handlers/playlists.go new file mode 100644 index 0000000..03c17ac --- /dev/null +++ b/apps/backend/internal/handlers/playlists.go @@ -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 +} diff --git a/apps/backend/internal/handlers/proxy.go b/apps/backend/internal/handlers/proxy.go new file mode 100644 index 0000000..912e9ed --- /dev/null +++ b/apps/backend/internal/handlers/proxy.go @@ -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) +} diff --git a/apps/backend/internal/handlers/remote.go b/apps/backend/internal/handlers/remote.go new file mode 100644 index 0000000..581e6c1 --- /dev/null +++ b/apps/backend/internal/handlers/remote.go @@ -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) +} diff --git a/apps/backend/internal/handlers/versions.go b/apps/backend/internal/handlers/versions.go new file mode 100644 index 0000000..f347de7 --- /dev/null +++ b/apps/backend/internal/handlers/versions.go @@ -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) + } +} diff --git a/apps/backend/internal/models/models.go b/apps/backend/internal/models/models.go new file mode 100644 index 0000000..3ecf6fa --- /dev/null +++ b/apps/backend/internal/models/models.go @@ -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"` +} diff --git a/apps/backend/internal/router/router.go b/apps/backend/internal/router/router.go new file mode 100644 index 0000000..c1e172a --- /dev/null +++ b/apps/backend/internal/router/router.go @@ -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 +} diff --git a/apps/backend/main.go b/apps/backend/main.go new file mode 100644 index 0000000..7c69d86 --- /dev/null +++ b/apps/backend/main.go @@ -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) + } +} diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile new file mode 100644 index 0000000..a65bbe9 --- /dev/null +++ b/apps/bot/Dockerfile @@ -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"] diff --git a/apps/bot/package-lock.json b/apps/bot/package-lock.json new file mode 100644 index 0000000..adeb1e0 --- /dev/null +++ b/apps/bot/package-lock.json @@ -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" + } + } + } +} diff --git a/apps/bot/package.json b/apps/bot/package.json new file mode 100644 index 0000000..0584378 --- /dev/null +++ b/apps/bot/package.json @@ -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" + } +} diff --git a/apps/bot/src/api/client.ts b/apps/bot/src/api/client.ts new file mode 100644 index 0000000..e0805ed --- /dev/null +++ b/apps/bot/src/api/client.ts @@ -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 { + const { data } = await api.post('/api/parties', { name, telegram_id: telegramId }) + return data +} + +export async function getPartyByCode(code: string): Promise { + try { + const { data } = await api.get(`/api/parties/code/${code}`) + return data + } catch { + return null + } +} + +export async function getParty(id: string): Promise { + try { + const { data } = await api.get(`/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 { + const color = COLORS[colorIndex % COLORS.length] + const { data } = await api.post(`/api/parties/${partyId}/participants`, { + name, + tracks, + color_bg: color.bg, + color_text: color.text, + }) + return data +} diff --git a/apps/bot/src/bot.ts b/apps/bot/src/bot.ts new file mode 100644 index 0000000..478ff0a --- /dev/null +++ b/apps/bot/src/bot.ts @@ -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[0] + +function createBot() { + const bot = new Bot(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 diff --git a/apps/bot/src/commands/help.ts b/apps/bot/src/commands/help.ts new file mode 100644 index 0000000..087beb5 --- /dev/null +++ b/apps/bot/src/commands/help.ts @@ -0,0 +1,17 @@ +import { CommandContext, Context } from 'grammy' + +export async function handleHelp(ctx: CommandContext) { + 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' }, + ) +} diff --git a/apps/bot/src/commands/party.ts b/apps/bot/src/commands/party.ts new file mode 100644 index 0000000..afab1f5 --- /dev/null +++ b/apps/bot/src/commands/party.ts @@ -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) { + 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) { + 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) { + 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) { + 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, '\\$&') +} diff --git a/apps/bot/src/commands/start.ts b/apps/bot/src/commands/start.ts new file mode 100644 index 0000000..1397630 --- /dev/null +++ b/apps/bot/src/commands/start.ts @@ -0,0 +1,14 @@ +import { CommandContext, Context } from 'grammy' + +export async function handleStart(ctx: CommandContext) { + await ctx.reply( + `🎉 *Party Mix Bot*\n\nСоздавай вечеринки с общими плейлистами!\n\n` + + `*Команды:*\n` + + `/newparty \\[название\\] — создать новую вечеринку\n` + + `/joinparty \\[код\\] — присоединиться к вечеринке\n` + + `/addme \\[имя\\] — добавить себя как участника\n` + + `/party — информация о текущей вечеринке\n` + + `/help — показать эту справку`, + { parse_mode: 'MarkdownV2' }, + ) +} diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts new file mode 100644 index 0000000..3a2a5a9 --- /dev/null +++ b/apps/bot/src/config.ts @@ -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') +} diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts new file mode 100644 index 0000000..8d12988 --- /dev/null +++ b/apps/bot/src/index.ts @@ -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}`) + }, +}) diff --git a/apps/bot/src/types/index.ts b/apps/bot/src/types/index.ts new file mode 100644 index 0000000..da08b15 --- /dev/null +++ b/apps/bot/src/types/index.ts @@ -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 +} diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json new file mode 100644 index 0000000..51e1711 --- /dev/null +++ b/apps/bot/tsconfig.json @@ -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"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..0d92e36 --- /dev/null +++ b/apps/web/Dockerfile @@ -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"] diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..4d8f341 --- /dev/null +++ b/apps/web/next.config.ts @@ -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 diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..57cc345 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,2186 @@ +{ + "name": "party-mix-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "party-mix-web", + "version": "1.0.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz", + "integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.15.tgz", + "integrity": "sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.15.tgz", + "integrity": "sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.15.tgz", + "integrity": "sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.15.tgz", + "integrity": "sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.15.tgz", + "integrity": "sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.15.tgz", + "integrity": "sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.15.tgz", + "integrity": "sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz", + "integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "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/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.15", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", + "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.15", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.15", + "@next/swc-darwin-x64": "15.5.15", + "@next/swc-linux-arm64-gnu": "15.5.15", + "@next/swc-linux-arm64-musl": "15.5.15", + "@next/swc-linux-x64-gnu": "15.5.15", + "@next/swc-linux-x64-musl": "15.5.15", + "@next/swc-win32-arm64-msvc": "15.5.15", + "@next/swc-win32-x64-msvc": "15.5.15", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..6df3b9a --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..943d389 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} +export default config diff --git a/apps/web/public/.gitkeep b/apps/web/public/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/web/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/web/src/app/app/page.tsx b/apps/web/src/app/app/page.tsx new file mode 100644 index 0000000..e2b8a5f --- /dev/null +++ b/apps/web/src/app/app/page.tsx @@ -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('party') + const { history } = usePartyStore() + + return ( +
+
+ + {activeTab === 'party' && } + {activeTab === 'extra' && } + {activeTab === 'history' && } +
+ ) +} diff --git a/apps/web/src/app/community/page.tsx b/apps/web/src/app/community/page.tsx new file mode 100644 index 0000000..2f53081 --- /dev/null +++ b/apps/web/src/app/community/page.tsx @@ -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 ( +
+
+ {tracks.map((track, i) => ( +
+ {i + 1} + {track.title} +
+ ))} +
+
+ ) +} + +function PlaylistCard({ + pl, + onPlay, + isLaunched, +}: { + pl: PublicPlaylist + onPlay: () => void + isLaunched: boolean +}) { + const [expanded, setExpanded] = useState(false) + const tags = pl.tags ?? [] + const trackCount = pl.tracks?.length ?? 0 + + return ( +
+
+
+ {pl.username[0].toUpperCase()} +
+ +
+
{pl.name}
+
+ {pl.username} + · + {trackCount} {trackCount === 1 ? 'трек' : trackCount < 5 ? 'трека' : 'треков'} + {tags.map(tag => ( + + {tag} + + ))} +
+
+ +
+ {trackCount > 0 && ( + + )} + +
+
+ + {expanded && pl.tracks && pl.tracks.length > 0 && ( + + )} +
+ ) +} + +export default function CommunityPage() { + const [playlists, setPlaylists] = useState([]) + const [loading, setLoading] = useState(true) + const [launched, setLaunched] = useState(null) + const [search, setSearch] = useState('') + const [activeTag, setActiveTag] = useState(null) + const { loadPlaylist } = usePartyStore() + + useEffect(() => { + getPublicPlaylists() + .then(setPlaylists) + .catch(() => setPlaylists([])) + .finally(() => setLoading(false)) + }, []) + + const handlePlay = (pl: PublicPlaylist) => { + const tracks = pl.tracks?.map(t => t.title) ?? [] + if (!tracks.length) return + loadPlaylist(tracks) + setLaunched(pl.id) + setTimeout(() => setLaunched(null), 2500) + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + const allTags = Array.from(new Set(playlists.flatMap(pl => pl.tags ?? []))) + + const filtered = playlists.filter(pl => { + const q = search.toLowerCase().trim() + const matchesSearch = !q + || pl.name.toLowerCase().includes(q) + || pl.username.toLowerCase().includes(q) + || (pl.tags ?? []).some(t => t.toLowerCase().includes(q)) + const matchesTag = !activeTag || (pl.tags ?? []).includes(activeTag) + return matchesSearch && matchesTag + }) + + return ( +
+
+ +
+
+

Сообщество

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

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

+
+ + {!loading && playlists.length > 0 && ( +
+
+ + + + + setSearch(e.target.value)} + placeholder="Поиск по названию, автору или тегу..." + className="w-full font-sans text-[13px] bg-surface border border-white/[0.07] rounded-[11px] pl-9 pr-3 py-2.5 text-app-text outline-none focus:border-accent/30 placeholder:text-muted transition-colors" + /> + {search && ( + + )} +
+ + {allTags.length > 0 && ( +
+ {allTags.map(tag => ( + + ))} +
+ )} +
+ )} + + {loading ? ( +
+
+ Загрузка... +
+ ) : !filtered.length ? ( +
+
🎵
+

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

+

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

+
+ ) : ( +
+ {filtered.map(pl => ( + handlePlay(pl)} + isLaunched={launched === pl.id} + /> + ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css new file mode 100644 index 0000000..a8a7f9b --- /dev/null +++ b/apps/web/src/app/globals.css @@ -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; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 0000000..ea6a524 --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -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 ( + + + +
+ + {children} +
+ + + + ) +} diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100644 index 0000000..ebf6f51 --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -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 ( +
+ Аккаунт создан — войдите +
+ ) +} + +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 ( +
+
+
+

Вход

+ + + + + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Нет аккаунта?{' '} + + Зарегистрироваться + +

+
+
+ ) +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx new file mode 100644 index 0000000..d469699 --- /dev/null +++ b/apps/web/src/app/page.tsx @@ -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 ( +
+ + {/* ── Nav ── */} + + + {/* ── Hero ── */} +
+ + {/* Ambient glow behind EQ */} +
+ + {/* ── Divider ── */} +
+ + {/* ── Features ── */} +
+

+ Возможности +

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

{title}

+

{desc}

+
+ ))} +
+
+ + {/* ── Footer ── */} +
+
+ +
+ ) +} diff --git a/apps/web/src/app/playlists/page.tsx b/apps/web/src/app/playlists/page.tsx new file mode 100644 index 0000000..f6049f9 --- /dev/null +++ b/apps/web/src/app/playlists/page.tsx @@ -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(null) + + useEffect(() => { + searchTracks(title).then(setResults).catch(() => setResults([])) + }, [title]) + + if (results === null) { + return ( +
+
+ Ищем версии... +
+ ) + } + + if (!results.length) { + return
Версии не найдены
+ } + + return ( +
+ {results.map((r, i) => { + const saved = isSaved(title, r) + const hasImg = r.img && !r.img.includes('no-cover') + return ( +
+ {i + 1} + {hasImg && ( + ((e.target as HTMLImageElement).style.display = 'none')} /> + )} +
+
{r.title}
+
{r.artist}
+
+ {r.duration} + + +
+ ) + })} +
+ ) +} + +function Toggle({ value, onChange, label }: { value: boolean; onChange: (v: boolean) => void; label: string }) { + return ( +
+ + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..12cd266 --- /dev/null +++ b/server.js @@ -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`); +}); diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..213745a --- /dev/null +++ b/start.bat @@ -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 diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..56f4eae --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ +#!/bin/bash +echo "Запуск Party Mix..." +node server.js & +sleep 1 +open http://localhost:3000 2>/dev/null || xdg-open http://localhost:3000 2>/dev/null +wait