initial
68
.env
Normal file
@@ -0,0 +1,68 @@
|
||||
# Copy this file to .env for local development. Do not commit your real .env.
|
||||
# Heroku: set these in Config Vars; dotenv is a no-op in production.
|
||||
|
||||
# Discord bot
|
||||
TOKEN=OTkxMDYyNzUxNjMzODgzMTM2.Gk0ZeD.lit-9ZcMkLnkfRG5_sECdkbmIbeQzZVs9NFQGs
|
||||
MODE=Normal
|
||||
|
||||
# Caching and sweepers
|
||||
CACHE_MESSAGES_LIMIT=50
|
||||
CACHE_MEMBERS_LIMIT=100
|
||||
SWEEP_MESSAGES_INTERVAL_SECONDS=300
|
||||
SWEEP_MESSAGES_LIFETIME_SECONDS=900
|
||||
|
||||
# Logging and diagnostics
|
||||
LOG_LEVEL=info
|
||||
PRISMA_LOG_QUERIES=0
|
||||
MEMORY_LOG_INTERVAL_SECONDS=0
|
||||
ENABLE_MEMORY_OPTIMIZER=false
|
||||
|
||||
# Redis connection
|
||||
REDIS_URL=redis-17965.c323.us-east-1-2.ec2.redns.redis-cloud.com
|
||||
REDIS_PASS=HnPiQFoWwsBdJY62SiHZSEDmnbgiycZ5
|
||||
|
||||
AI_API_URL=http://100.120.146.67:3001
|
||||
|
||||
REDIS_URL_VPS="redis://:zrc8YsnAKt@100.120.146.67:6379"
|
||||
|
||||
# PostgreSQL connection (for guild settings, user settings, and AI conversations)
|
||||
DATABASE_URL="postgresql://postgres:zrc8YsnAKt@100.120.146.67:5432/amayo_db?schema=public&connection_limit=10&pool_timeout=20"
|
||||
|
||||
|
||||
TOPGG=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfdCI6Ijc4Mjc3NTcyMjU2NjcwNTE1MiIsImlkIjoiMzcxNDY4MTI4MjIzNjU3OTg0IiwiaWF0IjoxNzY0NDY1MDc1fQ.NZnYnZ8PUpjS7WAVtAivoST7BtmZoYQdsnJ2nkUSBgs
|
||||
|
||||
# OLD DATABASE
|
||||
XATA_DB=postgresql://9h2ta0:xau_Jtqyo6gW7f0FCPxaWgVhN30E0s5WEZuz0@us-east-1.sql.xata.sh/amayo:main?sslmode=require
|
||||
XATA_SHADOW_DB=postgresql://9h2ta0:xau_Jtqyo6gW7f0FCPxaWgVhN30E0s5WEZuz0@us-east-1.sql.xata.sh/amayo:shadow?sslmode=require
|
||||
|
||||
# Appwrite (for reminders, AI conversations, and guild cache)
|
||||
APPWRITE_PROJECT_NAME=amayo
|
||||
APPWRITE_ENDPOINT=https://nyc.cloud.appwrite.io/v1
|
||||
APPWRITE_PROJECT_ID=68d8c4b2001abc54d3cd
|
||||
APPWRITE_API_KEY=standard_b123c1dbaaf7d3f99aa81492509d6277da0cb89eaf86bb8c42210bae0bb39c3ce911ddf101b6be2d3af8972d44ae63beb32c269c24eedbbe032a6c4c13a16c7e542be00c06ab8e9c131986729d45b68e25ac35e770442ea5285b7367938105a7b2d0e380e944d3bd6582db2c3311b3c9d84be5227718f795f30e31de0b9e8439
|
||||
APPWRITE_DATABASE_ID=68d8cb9a00250607e236
|
||||
APPWRITE_COLLECTION_REMINDERS_ID=reminders_id
|
||||
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID=aiconversation
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID=68e536b505604a12df65
|
||||
|
||||
# Reminders
|
||||
REMINDERS_POLL_INTERVAL_SECONDS=30
|
||||
|
||||
# Google AI / Gemini
|
||||
GOOGLE_AI_API_KEY=AIzaSyDcqOndCJw02xFs305iQE7KVptBoBH8aPk
|
||||
GEMINI_API_KEY=AIzaSyDNTHsqpbiYaEpe5AwykSqtgWGjsZcc_RA
|
||||
GENAI_IMAGE_MODEL=
|
||||
|
||||
# Slash command registration context
|
||||
# Your Discord Application ID
|
||||
CLIENT=991062751633883136
|
||||
DISCORD_CLIENT_ID=991062751633883136
|
||||
DISCORD_CLIENT_SECRET=eFcVIGDsacdFBJYsuly-mmIsKjzI1lj4
|
||||
OWNER_ID=327207082203938818
|
||||
DISCORD_REDIRECT_URI=https://api.amayo.dev/auth/callback
|
||||
# Test guild ID (for per-guild command registration)
|
||||
guildTest=1316592320954630144
|
||||
|
||||
NODE_ENV="production"
|
||||
|
||||
API_HOST="api.amayo.dev"
|
||||
3
.env.aiservice.example
Normal file
@@ -0,0 +1,3 @@
|
||||
AI_API_URL=http://100.120.146.67:3000
|
||||
# AI_API_URL=http://192.168.1.100:3000 # Alternative: Local network
|
||||
# AI_API_URL=https://your-ai-api.com # Alternative: Public endpoint
|
||||
1
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
# Dependencias
|
||||
node_modules
|
||||
.env
|
||||
.env.test
|
||||
qodana.yaml
|
||||
|
||||
|
||||
4
AmayoWeb/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
VITE_DISCORD_CLIENT_ID=1118356676802900089
|
||||
# Desarrollo: http://localhost:3000
|
||||
# Producción: https://api.amayo.dev
|
||||
VITE_API_URL=https://api.amayo.dev
|
||||
1
AmayoWeb/.gitignore
vendored
@@ -15,7 +15,6 @@ coverage
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
|
||||
81
AmayoWeb/FRONTEND_DEPLOYMENT.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Deployment Instructions - Frontend Update
|
||||
|
||||
## Problema Resuelto
|
||||
|
||||
El botón de "Login with Discord" estaba usando una ruta relativa `/auth/discord` que en producción se convertía en `https://docs.amayo.dev/auth/discord` (frontend) en lugar de `https://api.amayo.dev/auth/discord` (backend).
|
||||
|
||||
## Cambios Realizados
|
||||
|
||||
### 1. [LoginView.vue](file:///home/shnimlz/amayo/AmayoWeb/src/views/LoginView.vue#L31)
|
||||
```vue
|
||||
// ANTES:
|
||||
<a href="/auth/discord" class="discord-btn">
|
||||
|
||||
// DESPUÉS:
|
||||
<a :href="discordAuthUrl" class="discord-btn">
|
||||
|
||||
// Script agregado:
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const discordAuthUrl = `${apiUrl}/auth/discord`
|
||||
```
|
||||
|
||||
### 2. [.env](file:///home/shnimlz/amayo/AmayoWeb/.env)
|
||||
```bash
|
||||
# ANTES:
|
||||
VITE_API_URL=http://localhost:3000
|
||||
|
||||
# DESPUÉS:
|
||||
VITE_API_URL=https://api.amayo.dev
|
||||
```
|
||||
|
||||
## Pasos para Deployment en Servidor
|
||||
|
||||
```bash
|
||||
# 1. Ir al directorio del frontend
|
||||
cd /home/shnimlz/amayo/AmayoWeb
|
||||
|
||||
# 2. Asegurarse de que .env tiene la URL correcta
|
||||
cat .env
|
||||
# Debe mostrar: VITE_API_URL=https://api.amayo.dev
|
||||
|
||||
# 3. Instalar dependencias (si es necesario)
|
||||
npm install
|
||||
|
||||
# 4. Hacer build de producción
|
||||
npm run build
|
||||
|
||||
# 5. Copiar archivos built al directorio de Nginx
|
||||
sudo cp -r dist/* /var/www/docs.amayo.dev/
|
||||
|
||||
# 6. Verificar permisos
|
||||
sudo chown -R www-data:www-data /var/www/docs.amayo.dev
|
||||
|
||||
# 7. Reiniciar Nginx (opcional)
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## Verificación
|
||||
|
||||
1. Abre https://docs.amayo.dev en **modo incógnito**
|
||||
2. Click en "Continue with Discord"
|
||||
3. Deberías ser redirigido a: `https://api.amayo.dev/auth/discord`
|
||||
4. Luego a Discord OAuth page
|
||||
5. Luego de vuelta a: `https://api.amayo.dev/auth/callback`
|
||||
6. Finalmente redirigido a: `https://docs.amayo.dev/dashboard`
|
||||
7. Verifica que la sesión persiste al hacer refresh
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Si sigue redirigiendo incorrectamente:
|
||||
1. Verifica que el build usó la variable correcta:
|
||||
```bash
|
||||
grep -r "api.amayo.dev" /var/www/docs.amayo.dev/assets/*.js
|
||||
```
|
||||
|
||||
2. Limpia cache del navegador completamente
|
||||
|
||||
3. Verifica que el archivo `.env` tiene la URL correcta ANTES de hacer `npm run build`
|
||||
|
||||
### Si aparece error de CORS:
|
||||
- El backend ya tiene configurado CORS para `https://docs.amayo.dev` ✅
|
||||
- Verifica en DevTools → Network que las requests tengan `Access-Control-Allow-Origin` header
|
||||
237
AmayoWeb/api.amayo.dev.conf
Normal file
@@ -0,0 +1,237 @@
|
||||
# Configuración de Nginx para Backend Seguro
|
||||
|
||||
# /etc/nginx/sites-available/api.amayo.dev
|
||||
|
||||
# Configuración para ocultar la IP del servidor y mejorar la seguridad
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/m;
|
||||
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/m;
|
||||
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||
|
||||
# Bloquear IPs que no sean de Cloudflare
|
||||
geo $realip_remote_addr $cloudflare_ip {
|
||||
default 0;
|
||||
|
||||
# Cloudflare IPv4 (actualizar periódicamente desde https://www.cloudflare.com/ips-v4)
|
||||
173.245.48.0/20 1;
|
||||
103.21.244.0/22 1;
|
||||
103.22.200.0/22 1;
|
||||
103.31.4.0/22 1;
|
||||
141.101.64.0/18 1;
|
||||
108.162.192.0/18 1;
|
||||
190.93.240.0/20 1;
|
||||
188.114.96.0/20 1;
|
||||
197.234.240.0/22 1;
|
||||
198.41.128.0/17 1;
|
||||
162.158.0.0/15 1;
|
||||
104.16.0.0/13 1;
|
||||
104.24.0.0/14 1;
|
||||
172.64.0.0/13 1;
|
||||
131.0.72.0/22 1;
|
||||
|
||||
# Cloudflare IPv6
|
||||
2400:cb00::/32 1;
|
||||
2606:4700::/32 1;
|
||||
2803:f800::/32 1;
|
||||
2405:b500::/32 1;
|
||||
2405:8100::/32 1;
|
||||
2a06:98c0::/29 1;
|
||||
2c0f:f248::/32 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name api.amayo.dev;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/letsencrypt/live/api.amayo.dev/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.amayo.dev/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "same-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
|
||||
|
||||
# Ocultar versión de Nginx
|
||||
server_tokens off;
|
||||
more_clear_headers Server;
|
||||
more_clear_headers 'X-Hidden-*';
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/api.amayo.dev.access.log combined buffer=32k;
|
||||
error_log /var/log/nginx/api.amayo.dev.error.log warn;
|
||||
|
||||
# Bloquear acceso directo (solo Cloudflare)
|
||||
# if ($cloudflare_ip = 0) {
|
||||
# return 403 "Direct access forbidden";
|
||||
# }
|
||||
|
||||
# Validar que viene de Cloudflare verificando headers
|
||||
# if ($http_cf_connecting_ip = "") {
|
||||
# return 403 "Missing Cloudflare headers";
|
||||
# }
|
||||
|
||||
# Usar la IP real del cliente (desde Cloudflare)
|
||||
set_real_ip_from 173.245.48.0/20;
|
||||
set_real_ip_from 103.21.244.0/22;
|
||||
set_real_ip_from 103.22.200.0/22;
|
||||
set_real_ip_from 103.31.4.0/22;
|
||||
set_real_ip_from 141.101.64.0/18;
|
||||
set_real_ip_from 108.162.192.0/18;
|
||||
set_real_ip_from 190.93.240.0/20;
|
||||
set_real_ip_from 188.114.96.0/20;
|
||||
set_real_ip_from 197.234.240.0/22;
|
||||
set_real_ip_from 198.41.128.0/17;
|
||||
set_real_ip_from 162.158.0.0/15;
|
||||
set_real_ip_from 104.16.0.0/13;
|
||||
set_real_ip_from 104.24.0.0/14;
|
||||
set_real_ip_from 172.64.0.0/13;
|
||||
set_real_ip_from 131.0.72.0/22;
|
||||
real_ip_header CF-Connecting-IP;
|
||||
|
||||
# Bloquear user agents sospechosos
|
||||
# if ($http_user_agent ~* (curl|wget|python|scrapy|nikto|nmap|sqlmap)) {
|
||||
# return 403 "Forbidden user agent";
|
||||
# }
|
||||
|
||||
# Rate limiting
|
||||
location /api/auth {
|
||||
limit_req zone=auth_limit burst=5 nodelay;
|
||||
limit_conn conn_limit 5;
|
||||
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $http_cf_connecting_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /api {
|
||||
limit_req zone=api_limit burst=10 nodelay;
|
||||
limit_conn conn_limit 10;
|
||||
|
||||
# CORS (solo para dominios permitidos)
|
||||
if ($http_origin ~* (https://docs\.amayo\.dev|https://amayo\.dev)) {
|
||||
add_header 'Access-Control-Allow-Origin' $http_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Client-Token, X-Requested-With, X-Timestamp' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'X-Server-Token' always;
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
}
|
||||
|
||||
# Handle preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' $http_origin always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Client-Token, X-Requested-With, X-Timestamp' always;
|
||||
add_header 'Access-Control-Max-Age' 86400 always;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8' always;
|
||||
add_header 'Content-Length' 0 always;
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $http_cf_connecting_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location / {
|
||||
# Manejar preflight OPTIONS
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $http_cf_connecting_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# CORS headers
|
||||
add_header Access-Control-Allow-Origin "https://docs.amayo.dev" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Access-Control-Max-Age 1728000 always;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
|
||||
# Servir el archivo de configuración de la API
|
||||
location /.well-known/api-config.json {
|
||||
alias /var/www/api.amayo.dev/.well-known/api-config.json;
|
||||
add_header Content-Type application/json;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
}
|
||||
|
||||
# Bloquear acceso a archivos sensibles
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Bloquear acceso a archivos de backup
|
||||
location ~* \.(bak|backup|swp|tmp|log)$ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirección HTTP a HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name api.amayo.dev;
|
||||
|
||||
# Solo permitir ACME challenge para Let's Encrypt
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirigir todo lo demás a HTTPS
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,9 @@ server {
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
# Security headers - Allow top.gg to embed this page
|
||||
add_header X-Frame-Options "ALLOW-FROM https://top.gg" always;
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' https://top.gg https://*.top.gg" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
@@ -1,44 +1,159 @@
|
||||
# Configuración NGINX para api.amayo.dev (Backend Node.js)
|
||||
# Ubicación: /etc/nginx/sites-available/api.amayo.dev
|
||||
# Configuración de Nginx para Backend Seguro
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name api.amayo.dev;
|
||||
# /etc/nginx/sites-available/api.amayo.dev
|
||||
|
||||
# Redirigir HTTP a HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
# Configuración para ocultar la IP del servidor y mejorar la seguridad
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/m;
|
||||
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/m;
|
||||
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||
|
||||
# Bloquear IPs que no sean de Cloudflare
|
||||
geo $realip_remote_addr $cloudflare_ip {
|
||||
default 0;
|
||||
|
||||
# Cloudflare IPv4 (actualizar periódicamente desde https://www.cloudflare.com/ips-v4)
|
||||
173.245.48.0/20 1;
|
||||
103.21.244.0/22 1;
|
||||
103.22.200.0/22 1;
|
||||
103.31.4.0/22 1;
|
||||
141.101.64.0/18 1;
|
||||
108.162.192.0/18 1;
|
||||
190.93.240.0/20 1;
|
||||
188.114.96.0/20 1;
|
||||
197.234.240.0/22 1;
|
||||
198.41.128.0/17 1;
|
||||
162.158.0.0/15 1;
|
||||
104.16.0.0/13 1;
|
||||
104.24.0.0/14 1;
|
||||
172.64.0.0/13 1;
|
||||
131.0.72.0/22 1;
|
||||
|
||||
# Cloudflare IPv6
|
||||
2400:cb00::/32 1;
|
||||
2606:4700::/32 1;
|
||||
2803:f800::/32 1;
|
||||
2405:b500::/32 1;
|
||||
2405:8100::/32 1;
|
||||
2a06:98c0::/29 1;
|
||||
2c0f:f248::/32 1;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name api.amayo.dev;
|
||||
|
||||
# Certificados SSL (generados con certbot)
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/letsencrypt/live/api.amayo.dev/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.amayo.dev/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "same-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
|
||||
|
||||
# Ocultar versión de Nginx
|
||||
server_tokens off;
|
||||
more_clear_headers Server;
|
||||
more_clear_headers 'X-Hidden-*';
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/api.amayo.dev.access.log;
|
||||
error_log /var/log/nginx/api.amayo.dev.error.log;
|
||||
access_log /var/log/nginx/api.amayo.dev.access.log combined buffer=32k;
|
||||
error_log /var/log/nginx/api.amayo.dev.error.log warn;
|
||||
|
||||
# Proxy al servidor Node.js en puerto 3000 (el bot ejecuta server.ts)
|
||||
location / {
|
||||
# Manejar preflight OPTIONS
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
# Bloquear acceso directo (solo Cloudflare)
|
||||
# if ($cloudflare_ip = 0) {
|
||||
# return 403 "Direct access forbidden";
|
||||
# }
|
||||
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
# Validar que viene de Cloudflare verificando headers
|
||||
# if ($http_cf_connecting_ip = "") {
|
||||
# return 403 "Missing Cloudflare headers";
|
||||
# }
|
||||
|
||||
# Usar la IP real del cliente (desde Cloudflare)
|
||||
set_real_ip_from 173.245.48.0/20;
|
||||
set_real_ip_from 103.21.244.0/22;
|
||||
set_real_ip_from 103.22.200.0/22;
|
||||
set_real_ip_from 103.31.4.0/22;
|
||||
set_real_ip_from 141.101.64.0/18;
|
||||
set_real_ip_from 108.162.192.0/18;
|
||||
set_real_ip_from 190.93.240.0/20;
|
||||
set_real_ip_from 188.114.96.0/20;
|
||||
set_real_ip_from 197.234.240.0/22;
|
||||
set_real_ip_from 198.41.128.0/17;
|
||||
set_real_ip_from 162.158.0.0/15;
|
||||
set_real_ip_from 104.16.0.0/13;
|
||||
set_real_ip_from 104.24.0.0/14;
|
||||
set_real_ip_from 172.64.0.0/13;
|
||||
set_real_ip_from 131.0.72.0/22;
|
||||
real_ip_header CF-Connecting-IP;
|
||||
|
||||
# Bloquear user agents sospechosos
|
||||
# if ($http_user_agent ~* (curl|wget|python|scrapy|nikto|nmap|sqlmap)) {
|
||||
# return 403 "Forbidden user agent";
|
||||
# }
|
||||
|
||||
# Rate limiting
|
||||
location /api/auth {
|
||||
limit_req zone=auth_limit burst=5 nodelay;
|
||||
limit_conn conn_limit 5;
|
||||
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Real-IP $http_cf_connecting_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location /api {
|
||||
limit_req zone=api_limit burst=10 nodelay;
|
||||
limit_conn conn_limit 10;
|
||||
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Origin $http_origin;
|
||||
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
proxy_set_header X-Real-IP $http_cf_connecting_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $http_cf_connecting_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
@@ -48,16 +163,45 @@ server {
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# CORS headers
|
||||
add_header Access-Control-Allow-Origin "https://docs.amayo.dev" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
add_header Access-Control-Max-Age 1728000 always;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
}
|
||||
|
||||
# Servir el archivo de configuración de la API
|
||||
location /.well-known/api-config.json {
|
||||
alias /var/www/api.amayo.dev/.well-known/api-config.json;
|
||||
add_header Content-Type application/json;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
}
|
||||
|
||||
# Bloquear acceso a archivos sensibles
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Bloquear acceso a archivos de backup
|
||||
location ~* \.(bak|backup|swp|tmp|log)$ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirección HTTP a HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name api.amayo.dev;
|
||||
|
||||
# Solo permitir ACME challenge para Let's Encrypt
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirigir todo lo demás a HTTPS
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -6,12 +6,8 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useTheme } from './composables/useTheme'
|
||||
|
||||
const { initTheme } = useTheme()
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
BIN
AmayoWeb/src/assets/ama_elegant.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
AmayoWeb/src/assets/ama_smug.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,53 +1,24 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
/* Unity Theme Palette (Black/Red) */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
/* Core Colors */
|
||||
--unity-bg: #0a0a0a;
|
||||
--unity-sidebar: #111111;
|
||||
--unity-card: #161616;
|
||||
--unity-card-hover: #1c1c1c;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
/* Accents */
|
||||
--accent-red: #ff3b3b;
|
||||
--accent-red-dim: rgba(255, 59, 59, 0.2);
|
||||
--accent-gradient: linear-gradient(135deg, #ff3b3b 0%, #8a0000 100%);
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
/* Text */
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #666666;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
/* Fonts */
|
||||
--font-heading: 'Inter', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -55,31 +26,73 @@
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
color: var(--text-primary);
|
||||
background: var(--unity-bg);
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
|
||||
/* Glass/Card Panel */
|
||||
.unity-panel {
|
||||
background: var(--unity-card);
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.03);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.unity-panel:hover {
|
||||
background: var(--unity-card-hover);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Text Gradients */
|
||||
.text-gradient-red {
|
||||
background: var(--accent-gradient);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease forwards;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--unity-bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
BIN
AmayoWeb/src/assets/boost1.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
AmayoWeb/src/assets/boost2.png
Normal file
|
After Width: | Height: | Size: 723 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 350 KiB |
@@ -1,4 +1,26 @@
|
||||
@import './base.css';
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700;800;900&family=Lato:wght@300;400;700;900&display=swap');
|
||||
|
||||
:root {
|
||||
--rpg-gold: #d4af37;
|
||||
--rpg-gold-light: #f9e79f;
|
||||
--rpg-gold-dark: #8a6e2f;
|
||||
--rpg-red: #8b0000;
|
||||
--rpg-red-light: #ff1744;
|
||||
--rpg-dark: #0a0a0a;
|
||||
--rpg-panel: rgba(20, 20, 25, 0.85);
|
||||
--font-header: 'Cinzel', serif;
|
||||
--font-body: 'Lato', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--rpg-dark);
|
||||
color: #fff;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
@@ -15,12 +37,6 @@ a,
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<div class="animated-background">
|
||||
<div class="gradient-layer layer-1"></div>
|
||||
<div class="gradient-layer layer-2"></div>
|
||||
<div class="gradient-layer layer-3"></div>
|
||||
<div class="noise-overlay"></div>
|
||||
<div class="gradient-orb orb-1"></div>
|
||||
<div class="gradient-orb orb-2"></div>
|
||||
<div class="gradient-orb orb-3"></div>
|
||||
<div class="grid-pattern"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Fondo animado con gradientes rojos en movimiento
|
||||
// Fondo animado premium con orbes de luz y textura de ruido
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -20,7 +21,19 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
background: #0a0a0a;
|
||||
background: #050505;
|
||||
}
|
||||
|
||||
.noise-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.05'/%3E%3C/svg%3E");
|
||||
opacity: 0.07;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-pattern {
|
||||
@@ -30,61 +43,56 @@
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
background-size: 60px 60px;
|
||||
z-index: 1;
|
||||
mask-image: radial-gradient(circle at center, black 40%, transparent 100%);
|
||||
}
|
||||
|
||||
.gradient-layer {
|
||||
.gradient-orb {
|
||||
position: absolute;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
opacity: 0.6;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
opacity: 0.6;
|
||||
animation: float-orb 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.layer-1 {
|
||||
background: radial-gradient(circle at 30% 50%, var(--color-primary, #ff1744) 0%, transparent 50%);
|
||||
animation: slide1 15s infinite;
|
||||
.orb-1 {
|
||||
width: 60vw;
|
||||
height: 60vw;
|
||||
background: radial-gradient(circle, var(--color-primary, #ff1744) 0%, transparent 70%);
|
||||
top: -20%;
|
||||
left: -10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.layer-2 {
|
||||
background: radial-gradient(circle at 70% 60%, var(--color-accent, #d50000) 0%, transparent 50%);
|
||||
animation: slide2 20s infinite;
|
||||
.orb-2 {
|
||||
width: 50vw;
|
||||
height: 50vw;
|
||||
background: radial-gradient(circle, var(--color-secondary, #d50000) 0%, transparent 70%);
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.layer-3 {
|
||||
background: radial-gradient(circle at 50% 40%, var(--color-secondary, #ff5252) 0%, transparent 50%);
|
||||
animation: slide3 18s infinite;
|
||||
.orb-3 {
|
||||
width: 40vw;
|
||||
height: 40vw;
|
||||
background: radial-gradient(circle, var(--color-accent, #ff5252) 0%, transparent 70%);
|
||||
top: 40%;
|
||||
left: 40%;
|
||||
animation-delay: -10s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@keyframes slide1 {
|
||||
@keyframes float-orb {
|
||||
0%, 100% {
|
||||
transform: translate(-10%, -10%) rotate(0deg);
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(10%, 10%) rotate(180deg);
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide2 {
|
||||
0%, 100% {
|
||||
transform: translate(10%, -10%) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-10%, 10%) rotate(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide3 {
|
||||
0%, 100% {
|
||||
transform: translate(0%, 10%) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(0%, -10%) rotate(360deg);
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
134
AmayoWeb/src/components/CelestialBackground.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="celestial-background">
|
||||
<div class="stars"></div>
|
||||
<div class="twinkling"></div>
|
||||
|
||||
<!-- Sparkles -->
|
||||
<div class="sparkle s1"></div>
|
||||
<div class="sparkle s2"></div>
|
||||
<div class="sparkle s3"></div>
|
||||
<div class="sparkle s4"></div>
|
||||
<div class="sparkle s5"></div>
|
||||
<div class="sparkle s6"></div>
|
||||
|
||||
<!-- Glowing Orbs -->
|
||||
<div class="orb o1"></div>
|
||||
<div class="orb o2"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.celestial-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: #050505; /* Deep space black */
|
||||
}
|
||||
|
||||
.stars, .twinkling {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stars {
|
||||
background: #000 url(http://www.script-tutorials.com/demos/360/images/stars.png) repeat top center;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.twinkling {
|
||||
background: transparent url(http://www.script-tutorials.com/demos/360/images/twinkling.png) repeat top center;
|
||||
z-index: 1;
|
||||
animation: move-twink-back 200s linear infinite;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@keyframes move-twink-back {
|
||||
from {background-position:0 0;}
|
||||
to {background-position:-10000px 5000px;}
|
||||
}
|
||||
|
||||
/* Sparkles (Cross shape) */
|
||||
.sparkle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: radial-gradient(circle, #fff 10%, transparent 60%);
|
||||
opacity: 0;
|
||||
animation: sparkle-anim 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sparkle::before, .sparkle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #ff4081; /* Pinkish sparkle */
|
||||
box-shadow: 0 0 10px #ff4081;
|
||||
}
|
||||
|
||||
.sparkle::before {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sparkle::after {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.s1 { top: 20%; left: 15%; animation-delay: 0s; transform: scale(1.2); }
|
||||
.s2 { top: 70%; right: 20%; animation-delay: 1.5s; transform: scale(0.8); }
|
||||
.s3 { bottom: 15%; left: 30%; animation-delay: 2.5s; transform: scale(1.5); }
|
||||
.s4 { top: 15%; right: 10%; animation-delay: 0.5s; transform: scale(1.0); }
|
||||
.s5 { top: 50%; left: 50%; animation-delay: 3s; transform: scale(0.6); }
|
||||
.s6 { bottom: 30%; right: 40%; animation-delay: 1s; transform: scale(1.3); }
|
||||
|
||||
@keyframes sparkle-anim {
|
||||
0% { opacity: 0; transform: scale(0) rotate(0deg); }
|
||||
50% { opacity: 1; transform: scale(1) rotate(45deg); }
|
||||
100% { opacity: 0; transform: scale(0) rotate(90deg); }
|
||||
}
|
||||
|
||||
/* Glowing Orbs */
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.3;
|
||||
animation: float-orb 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.o1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, #ff1744, transparent);
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
.o2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, #d500f9, transparent);
|
||||
bottom: -100px;
|
||||
right: -100px;
|
||||
animation-delay: 5s;
|
||||
}
|
||||
|
||||
@keyframes float-orb {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(30px, -30px); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<button class="discord-login-btn" @click="handleLogin" :disabled="loading">
|
||||
<svg class="discord-icon" viewBox="0 0 127.14 96.36">
|
||||
<path fill="currentColor" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
|
||||
</svg>
|
||||
<span v-if="!loading">{{ t('login.withDiscord') }}</span>
|
||||
<span v-else>{{ t('login.loading') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { authService } from '@/services/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = () => {
|
||||
loading.value = true
|
||||
authService.loginWithDiscord()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.discord-login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 14px 32px;
|
||||
background: #5865f2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 30px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(88, 101, 242, 0.4);
|
||||
}
|
||||
|
||||
.discord-login-btn:hover:not(:disabled) {
|
||||
background: #4752c4;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(88, 101, 242, 0.6);
|
||||
}
|
||||
|
||||
.discord-login-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.discord-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
</style>
|
||||
842
AmayoWeb/src/components/EmbedBuilder.vue
Normal file
@@ -0,0 +1,842 @@
|
||||
<template>
|
||||
<div class="embed-builder">
|
||||
<!-- Builder Header -->
|
||||
<div class="builder-header">
|
||||
<div class="header-left">
|
||||
<button class="back-btn" @click="$emit('close')">← Back</button>
|
||||
<input
|
||||
v-model="embedData.name"
|
||||
class="embed-name-input"
|
||||
placeholder="Embed Name (Internal)"
|
||||
/>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="save-btn" @click="saveEmbed">
|
||||
<span class="icon">💾</span> Save Embed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="builder-content">
|
||||
<!-- Editor Panel (Left) -->
|
||||
<div class="editor-panel">
|
||||
<div class="panel-section">
|
||||
<h3 class="section-title">Global Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Title</label>
|
||||
<input v-model="embedData.title" placeholder="Embed Title" class="form-input" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Color</label>
|
||||
<div class="color-picker-wrapper">
|
||||
<input type="color" v-model="colorHex" class="color-input" />
|
||||
<input v-model="colorHex" class="form-input" placeholder="#5865F2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Cover Image URL</label>
|
||||
<input v-model="embedData.coverImage" placeholder="https://..." class="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Components</h3>
|
||||
<div class="add-buttons">
|
||||
<button @click="addComponent(10)" title="Add Text" class="tool-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
<span>Text</span>
|
||||
</button>
|
||||
<button @click="addComponent(12)" title="Add Image" class="tool-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
<span>Image</span>
|
||||
</button>
|
||||
<button @click="addComponent(14)" title="Add Separator" class="tool-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>
|
||||
<span>Divider</span>
|
||||
</button>
|
||||
<button @click="addComponent(2)" title="Add Button" class="tool-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="12" x="2" y="6" rx="2"/><circle cx="12" cy="12" r="2"/></svg>
|
||||
<span>Button</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="components-list">
|
||||
<div
|
||||
v-for="(comp, index) in embedData.components"
|
||||
:key="index"
|
||||
class="component-item"
|
||||
:class="{ active: activeComponentIndex === index }"
|
||||
@click="activeComponentIndex = index"
|
||||
>
|
||||
<div class="component-header">
|
||||
<span class="component-type">{{ getComponentLabel(comp.type) }}</span>
|
||||
<div class="component-actions">
|
||||
<button @click.stop="moveComponent(index, -1)" :disabled="index === 0">↑</button>
|
||||
<button @click.stop="moveComponent(index, 1)" :disabled="index === embedData.components.length - 1">↓</button>
|
||||
<button @click.stop="removeComponent(index)" class="delete-btn">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline Editor for Active Component -->
|
||||
<div v-if="activeComponentIndex === index" class="component-editor" @click.stop>
|
||||
<!-- Text Component -->
|
||||
<template v-if="comp.type === 10">
|
||||
<textarea
|
||||
v-model="comp.content"
|
||||
placeholder="Text content... Supports user.name variables"
|
||||
rows="3"
|
||||
class="form-textarea"
|
||||
></textarea>
|
||||
<input v-model="comp.thumbnail" placeholder="Thumbnail URL (Optional)" class="form-input mt-2" />
|
||||
</template>
|
||||
|
||||
<!-- Image Component -->
|
||||
<template v-if="comp.type === 12">
|
||||
<input v-model="comp.url" placeholder="Image URL" class="form-input" />
|
||||
</template>
|
||||
|
||||
<!-- Separator Component -->
|
||||
<template v-if="comp.type === 14">
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" v-model="comp.divider" :id="'div-'+index" />
|
||||
<label :for="'div-'+index">Visible Divider</label>
|
||||
</div>
|
||||
<div class="range-group">
|
||||
<label>Spacing: {{ comp.spacing }}</label>
|
||||
<input type="range" v-model.number="comp.spacing" min="1" max="5" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Button Component -->
|
||||
<template v-if="comp.type === 2">
|
||||
<input v-model="comp.label" placeholder="Button Label" class="form-input" />
|
||||
<input v-model="comp.url" placeholder="URL (Required)" class="form-input mt-2" />
|
||||
<!-- Style is forced to Link (5) -->
|
||||
<div class="style-badge mt-2">Style: Link Button 🔗</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Preview Summary when not active -->
|
||||
<div v-else class="component-summary">
|
||||
{{ getComponentSummary(comp) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel (Right) -->
|
||||
<div class="preview-panel">
|
||||
<div class="preview-header">
|
||||
<h3>Live Preview</h3>
|
||||
<div class="variable-hint">
|
||||
💡 Use <code>user.name</code>, <code>guild.memberCount</code> etc.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="discord-preview">
|
||||
<!-- Message Container -->
|
||||
<div class="discord-message">
|
||||
<div class="message-avatar">
|
||||
<img src="https://cdn.discordapp.com/embed/avatars/0.png" alt="Bot" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<span class="bot-name">Amayo</span>
|
||||
<span class="bot-tag">BOT</span>
|
||||
<span class="timestamp">Today at 12:00 PM</span>
|
||||
</div>
|
||||
|
||||
<!-- The Embed Block -->
|
||||
<div class="amayo-block" :style="{ borderLeftColor: colorHex }">
|
||||
<!-- Title -->
|
||||
<div v-if="embedData.title" class="block-title" v-html="renderPreviewText(embedData.title)"></div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<div v-if="embedData.coverImage" class="block-cover">
|
||||
<img :src="renderPreviewText(embedData.coverImage, true)" alt="Cover" @error="handleImageError" />
|
||||
</div>
|
||||
|
||||
<!-- Components Render -->
|
||||
<div class="block-components">
|
||||
<div v-for="(comp, i) in embedData.components" :key="i" class="preview-component">
|
||||
|
||||
<!-- Text -->
|
||||
<div v-if="comp.type === 10" class="preview-text">
|
||||
<div class="text-content" v-html="renderPreviewText(comp.content)"></div>
|
||||
<img v-if="comp.thumbnail" :src="renderPreviewText(comp.thumbnail, true)" class="text-thumbnail" @error="handleImageError" />
|
||||
</div>
|
||||
|
||||
<!-- Image -->
|
||||
<div v-if="comp.type === 12" class="preview-image">
|
||||
<img :src="renderPreviewText(comp.url, true)" @error="handleImageError" />
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div v-if="comp.type === 14" class="preview-separator" :class="{ visible: comp.divider }" :style="{ height: (comp.spacing * 10) + 'px' }"></div>
|
||||
|
||||
<!-- Button -->
|
||||
<div v-if="comp.type === 2" class="preview-button" :class="'style-' + comp.style">
|
||||
{{ renderPreviewText(comp.label, true) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
initialData: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
// Initial State
|
||||
const defaultState = {
|
||||
name: 'New Embed',
|
||||
title: '',
|
||||
color: 0x5865F2,
|
||||
coverImage: '',
|
||||
components: []
|
||||
}
|
||||
|
||||
const embedData = ref(props.initialData ? JSON.parse(JSON.stringify(props.initialData)) : JSON.parse(JSON.stringify(defaultState)))
|
||||
const activeComponentIndex = ref(null)
|
||||
|
||||
// Color handling
|
||||
const colorHex = computed({
|
||||
get: () => '#' + (embedData.value.color || 0).toString(16).padStart(6, '0'),
|
||||
set: (val) => {
|
||||
embedData.value.color = parseInt(val.replace('#', ''), 16)
|
||||
}
|
||||
})
|
||||
|
||||
// Component Management
|
||||
const addComponent = (type) => {
|
||||
let newComp = { type }
|
||||
|
||||
switch(type) {
|
||||
case 10: // Text
|
||||
newComp = { ...newComp, content: '', thumbnail: '' }
|
||||
break
|
||||
case 12: // Image
|
||||
newComp = { ...newComp, url: '' }
|
||||
break
|
||||
case 14: // Separator
|
||||
newComp = { ...newComp, spacing: 1, divider: false }
|
||||
break
|
||||
case 2: // Button
|
||||
newComp = { ...newComp, label: 'Button', style: 1, url: '' }
|
||||
break
|
||||
}
|
||||
|
||||
embedData.value.components.push(newComp)
|
||||
activeComponentIndex.value = embedData.value.components.length - 1
|
||||
}
|
||||
|
||||
const removeComponent = (index) => {
|
||||
embedData.value.components.splice(index, 1)
|
||||
if (activeComponentIndex.value === index) activeComponentIndex.value = null
|
||||
}
|
||||
|
||||
const moveComponent = (index, direction) => {
|
||||
const newIndex = index + direction
|
||||
if (newIndex < 0 || newIndex >= embedData.value.components.length) return
|
||||
|
||||
const temp = embedData.value.components[index]
|
||||
embedData.value.components[index] = embedData.value.components[newIndex]
|
||||
embedData.value.components[newIndex] = temp
|
||||
|
||||
if (activeComponentIndex.value === index) activeComponentIndex.value = newIndex
|
||||
else if (activeComponentIndex.value === newIndex) activeComponentIndex.value = index
|
||||
}
|
||||
|
||||
// Helpers
|
||||
const getComponentLabel = (type) => {
|
||||
const labels = { 10: 'Text Block', 12: 'Image', 14: 'Separator', 2: 'Button' }
|
||||
return labels[type] || 'Unknown'
|
||||
}
|
||||
|
||||
const getComponentSummary = (comp) => {
|
||||
switch(comp.type) {
|
||||
case 10: return comp.content ? comp.content.substring(0, 30) + (comp.content.length > 30 ? '...' : '') : 'Empty text'
|
||||
case 12: return 'Image URL'
|
||||
case 14: return `Spacing: ${comp.spacing}, Divider: ${comp.divider ? 'Yes' : 'No'}`
|
||||
case 2: return `Button: ${comp.label}`
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = (e) => {
|
||||
// Fallback to default avatar if image fails to load
|
||||
if (e.target.src !== 'https://cdn.discordapp.com/embed/avatars/0.png') {
|
||||
e.target.src = 'https://cdn.discordapp.com/embed/avatars/0.png'
|
||||
} else {
|
||||
e.target.style.display = 'none' // Hide if even fallback fails
|
||||
}
|
||||
}
|
||||
|
||||
// Variable Processing & Markdown Rendering
|
||||
const renderPreviewText = (text, plainTextOnly = false) => {
|
||||
if (!text) return ''
|
||||
|
||||
// 1. Variable Replacement
|
||||
const replacements = {
|
||||
'user.name': 'AmayoUser',
|
||||
'user.mention': '@AmayoUser',
|
||||
'user.id': '123456789',
|
||||
'user.avatar': 'https://cdn.discordapp.com/embed/avatars/0.png',
|
||||
'user.pointsAll': '1,500',
|
||||
'user.rankWeekly': '#1',
|
||||
'guild.name': props.guild?.name || 'My Awesome Server',
|
||||
'guild.memberCount': '1,337',
|
||||
'guild.boostLevel': '3',
|
||||
'guild.icon': props.guild?.icon ? `https://cdn.discordapp.com/icons/${props.guild.id}/${props.guild.icon}.png` : 'https://cdn.discordapp.com/embed/avatars/0.png'
|
||||
}
|
||||
|
||||
let result = text
|
||||
for (const [key, value] of Object.entries(replacements)) {
|
||||
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
result = result.replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), value)
|
||||
}
|
||||
|
||||
if (plainTextOnly) return result
|
||||
|
||||
// 2. HTML Rendering (Mentions & Emojis)
|
||||
|
||||
// Escape HTML first to prevent XSS from user input, but we are generating HTML so we need to be careful.
|
||||
// For a preview tool, simple replacement is acceptable, but ideally we'd use a sanitizer.
|
||||
// Here we assume the input is safe enough for the preview context or we just replace specific patterns.
|
||||
|
||||
// Mentions: @User -> <span class="mention">@User</span>
|
||||
// We look for @Name or <@ID> patterns. Since we replaced user.mention with @AmayoUser, we catch that.
|
||||
result = result.replace(/(@\w+)/g, '<span class="mention">$1</span>')
|
||||
|
||||
// Custom Emojis: <:name:id> -> <img ...>
|
||||
// <a:name:id> -> <img ...>
|
||||
result = result.replace(/<(a?):(\w+):(\d+)>/g, (match, animated, name, id) => {
|
||||
const ext = animated ? 'gif' : 'png'
|
||||
const url = `https://cdn.discordapp.com/emojis/${id}.${ext}`
|
||||
return `<img src="${url}" alt=":${name}:" class="emoji" draggable="false">`
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const saveEmbed = () => {
|
||||
emit('save', embedData.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embed-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--unity-bg);
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: var(--unity-sidebar);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.embed-name-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.builder-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Editor Panel */
|
||||
.editor-panel {
|
||||
width: 400px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-input, .form-textarea, .form-select {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus, .form-textarea:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Components List */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.add-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px; /* Increased padding */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px; /* Increased gap */
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem; /* Slightly larger font */
|
||||
font-weight: 500;
|
||||
flex: 1; /* Distribute space evenly */
|
||||
justify-content: center; /* Center content */
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: white;
|
||||
transform: translateY(-2px); /* More pronounced hover effect */
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.tool-btn svg {
|
||||
width: 18px; /* Larger icons */
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.component-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.component-item.active {
|
||||
border-color: var(--accent-primary);
|
||||
background: rgba(88, 101, 242, 0.05);
|
||||
}
|
||||
|
||||
.component-header {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.component-item.active .component-header {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.component-type {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.component-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.component-actions button:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: #ff3b3b !important;
|
||||
}
|
||||
|
||||
.component-editor {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.component-summary {
|
||||
padding: 0 10px 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mt-2 { margin-top: 8px; }
|
||||
|
||||
/* Preview Panel */
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
background: #313338; /* Discord Dark Theme */
|
||||
padding: 40px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.variable-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.variable-hint code {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.discord-preview {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.discord-message {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message-avatar img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bot-tag {
|
||||
background: #5865F2;
|
||||
color: white;
|
||||
font-size: 0.625rem;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #949BA4;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Amayo Block Styles */
|
||||
.amayo-block {
|
||||
background: #2B2D31;
|
||||
border-left: 4px solid;
|
||||
border-radius: 4px;
|
||||
max-width: 520px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
padding: 12px 16px 0;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.block-cover img {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
object-fit: cover;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.block-components {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
color: #DBDEE1;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.375rem;
|
||||
white-space: pre-wrap;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mention Styles */
|
||||
:deep(.mention) {
|
||||
background: rgba(88, 101, 242, 0.3);
|
||||
color: #dee0fc;
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
:deep(.mention:hover) {
|
||||
background: rgba(88, 101, 242, 1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Emoji Styles */
|
||||
:deep(.emoji) {
|
||||
width: 1.375em;
|
||||
height: 1.375em;
|
||||
vertical-align: bottom;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.text-thumbnail {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-image img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-separator {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview-separator.visible {
|
||||
border-bottom: 1px solid #3F4147;
|
||||
}
|
||||
|
||||
.preview-button {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: not-allowed;
|
||||
text-align: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.style-1 { background: #5865F2; } /* Primary */
|
||||
.style-2 { background: #4E5058; } /* Secondary */
|
||||
.style-3 { background: #248046; } /* Success */
|
||||
.style-4 { background: #DA373C; } /* Danger */
|
||||
.style-5 { background: #4E5058; } /* Link */
|
||||
|
||||
/* Editor Control Styles */
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.range-group {
|
||||
margin-bottom: 15px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.range-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.range-group input[type="range"] {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.style-badge {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.embed-builder {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.builder-content {
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
padding: 20px;
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
AmayoWeb/src/components/FantasyButton.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<button class="fantasy-btn" :class="{ 'secondary': secondary, 'small': small }">
|
||||
<div class="btn-bg"></div>
|
||||
<div class="btn-border"></div>
|
||||
<div class="btn-content">
|
||||
<span class="btn-icon" v-if="$slots.icon"><slot name="icon"></slot></span>
|
||||
<span class="btn-text"><slot></slot></span>
|
||||
</div>
|
||||
<div class="btn-particles"></div>
|
||||
<div class="btn-shine"></div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
secondary: Boolean,
|
||||
small: Boolean
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fantasy-btn {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
min-width: 140px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
overflow: hidden; /* Fix: Clip the shine effect */
|
||||
}
|
||||
|
||||
.small {
|
||||
min-width: auto;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-bg {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
background: linear-gradient(180deg, #3a0000 0%, #1a0000 100%);
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.secondary .btn-bg {
|
||||
background: linear-gradient(180deg, #1a1a1a 0%, #0a0a0a 100%);
|
||||
}
|
||||
|
||||
.btn-border {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid var(--rpg-gold);
|
||||
border-radius: 4px;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 10px rgba(212, 175, 55, 0.3), inset 0 0 10px rgba(212, 175, 55, 0.2);
|
||||
clip-path: polygon(
|
||||
10px 0, 100% 0,
|
||||
100% calc(100% - 10px), calc(100% - 10px) 100%,
|
||||
0 100%, 0 10px
|
||||
);
|
||||
}
|
||||
|
||||
.btn-border::before, .btn-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--rpg-gold-light);
|
||||
transform: rotate(45deg);
|
||||
box-shadow: 0 0 5px var(--rpg-gold-light);
|
||||
}
|
||||
|
||||
.btn-border::before { top: -3px; left: -3px; }
|
||||
.btn-border::after { bottom: -3px; right: -3px; }
|
||||
|
||||
.btn-content {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.btn-shine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -150%; /* Move further out */
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.6), transparent);
|
||||
transform: skewX(-20deg);
|
||||
z-index: 4;
|
||||
transition: none; /* Reset transition */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fantasy-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.fantasy-btn:hover .btn-bg {
|
||||
background: linear-gradient(180deg, #5a0000 0%, #2a0000 100%);
|
||||
}
|
||||
|
||||
.secondary:hover .btn-bg {
|
||||
background: linear-gradient(180deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
}
|
||||
|
||||
.fantasy-btn:hover .btn-border {
|
||||
border-color: var(--rpg-gold-light);
|
||||
box-shadow: 0 0 20px rgba(212, 175, 55, 0.6), inset 0 0 20px rgba(212, 175, 55, 0.4);
|
||||
}
|
||||
|
||||
.fantasy-btn:hover .btn-shine {
|
||||
left: 150%;
|
||||
transition: 0.7s ease-in-out; /* Animate only on hover */
|
||||
}
|
||||
|
||||
.fantasy-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
52
AmayoWeb/src/components/FantasyCard.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="fantasy-card glass-panel">
|
||||
<div class="card-content">
|
||||
<div class="card-header" v-if="title">
|
||||
<h3 class="card-title">{{ title }}</h3>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fantasy-card {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fantasy-card:hover {
|
||||
transform: translateY(-5px);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
171
AmayoWeb/src/components/FeaturesSection.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<section id="features" class="features-section">
|
||||
<div class="container">
|
||||
<div class="section-header fade-in-up">
|
||||
<h2 class="section-title">{{ t('features.title') }}</h2>
|
||||
<p class="section-subtitle">{{ t('features.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="feature-card glass-card"
|
||||
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||
>
|
||||
<div class="card-glow"></div>
|
||||
<div class="feature-icon">{{ feature.icon }}</div>
|
||||
<h3 class="feature-title">{{ t(feature.titleKey) }}</h3>
|
||||
<p class="feature-desc">{{ t(feature.descKey) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: '🧠',
|
||||
titleKey: 'features.ai.title',
|
||||
descKey: 'features.ai.desc'
|
||||
},
|
||||
{
|
||||
icon: '🎵',
|
||||
titleKey: 'features.music.title',
|
||||
descKey: 'features.music.desc'
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
titleKey: 'features.alliances.title',
|
||||
descKey: 'features.alliances.desc'
|
||||
},
|
||||
{
|
||||
icon: '🌐',
|
||||
titleKey: 'features.web.title',
|
||||
descKey: 'features.web.desc'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
titleKey: 'features.embedding.title',
|
||||
descKey: 'features.embedding.desc'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.features-section {
|
||||
padding: 100px 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #fff, #ccc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 24px;
|
||||
padding: 40px;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-10px);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at top right, var(--color-primary, #ff1744), transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.feature-card:hover .card-glow {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: white;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
AmayoWeb/src/components/FloatingHexagons.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="floating-hexagons">
|
||||
<div class="hexagon hex-1"></div>
|
||||
<div class="hexagon hex-2"></div>
|
||||
<div class="hexagon hex-3"></div>
|
||||
<div class="hexagon hex-4"></div>
|
||||
<div class="hexagon hex-5"></div>
|
||||
<div class="hexagon hex-6"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.floating-hexagons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hexagon {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #ff1744 0%, #ff4081 100%);
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||||
opacity: 0.4;
|
||||
filter: blur(25px);
|
||||
box-shadow: 0 0 60px rgba(255, 23, 68, 0.5);
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
.hex-1 {
|
||||
top: 10%;
|
||||
left: 10%;
|
||||
width: 150px;
|
||||
height: 173px;
|
||||
animation: float-rotate 12s ease-in-out infinite;
|
||||
background: linear-gradient(135deg, #ff1744 0%, #d50000 100%);
|
||||
}
|
||||
|
||||
.hex-2 {
|
||||
top: 70%;
|
||||
right: 15%;
|
||||
width: 200px;
|
||||
height: 230px;
|
||||
animation: float-rotate 15s ease-in-out infinite reverse;
|
||||
background: linear-gradient(135deg, #ff9100 0%, #ff3d00 100%);
|
||||
box-shadow: 0 0 60px rgba(255, 145, 0, 0.4);
|
||||
}
|
||||
|
||||
.hex-3 {
|
||||
bottom: 15%;
|
||||
left: 20%;
|
||||
width: 100px;
|
||||
height: 115px;
|
||||
animation: float-rotate 10s ease-in-out infinite 1s;
|
||||
background: linear-gradient(135deg, #f50057 0%, #c51162 100%);
|
||||
box-shadow: 0 0 60px rgba(245, 0, 87, 0.4);
|
||||
}
|
||||
|
||||
.hex-4 {
|
||||
top: 20%;
|
||||
right: 25%;
|
||||
width: 120px;
|
||||
height: 138px;
|
||||
animation: float-rotate 14s ease-in-out infinite 2s;
|
||||
background: linear-gradient(135deg, #ff4081 0%, #880e4f 100%);
|
||||
box-shadow: 0 0 60px rgba(255, 64, 129, 0.4);
|
||||
}
|
||||
|
||||
.hex-5 {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 80px;
|
||||
height: 92px;
|
||||
animation: float-rotate 18s ease-in-out infinite 0.5s;
|
||||
background: linear-gradient(135deg, #ff5252 0%, #b71c1c 100%);
|
||||
box-shadow: 0 0 60px rgba(255, 82, 82, 0.4);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.hex-6 {
|
||||
bottom: 5%;
|
||||
right: 5%;
|
||||
width: 180px;
|
||||
height: 208px;
|
||||
animation: float-rotate 20s ease-in-out infinite 3s;
|
||||
background: linear-gradient(135deg, #ff6e40 0%, #ff3d00 100%);
|
||||
box-shadow: 0 0 60px rgba(255, 110, 64, 0.4);
|
||||
}
|
||||
|
||||
@keyframes float-rotate {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-30px) rotate(10deg) scale(1.05);
|
||||
}
|
||||
66% {
|
||||
transform: translateY(15px) rotate(-5deg) scale(0.95);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,18 +2,25 @@
|
||||
<section class="hero-section">
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<div class="version-badge">
|
||||
<span class="sparkle">✨</span> {{ t('hero.newVersion') }} 2.0
|
||||
</div>
|
||||
|
||||
<h1 class="hero-title">
|
||||
<span ref="typewriterRef" class="typewriter">{{ displayText }}</span>
|
||||
<span ref="cursorRef" class="cursor" :class="{ blink: showCursor }">|</span>
|
||||
{{ t('hero.titleStart') }} <br />
|
||||
<span class="gradient-text">Companion</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">{{ t('hero.subtitle') }}</p>
|
||||
|
||||
<p class="hero-subtitle">
|
||||
{{ t('hero.subtitle') }}
|
||||
</p>
|
||||
|
||||
<div class="hero-actions">
|
||||
<button class="hero-btn primary" @click="scrollToFeatures">
|
||||
{{ t('hero.exploreFeatures') }}
|
||||
<button class="hero-btn primary" @click="inviteBot">
|
||||
{{ t('hero.inviteBot') }} <span class="arrow">→</span>
|
||||
</button>
|
||||
<button class="hero-btn secondary" @click="inviteBot">
|
||||
{{ t('hero.inviteBot') }}
|
||||
<button class="hero-btn secondary" @click="scrollToFeatures">
|
||||
{{ t('hero.exploreFeatures') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -22,10 +29,12 @@
|
||||
<span class="stat-number">{{ stats.servers }}+</span>
|
||||
<span class="stat-label">{{ t('hero.servers') }}</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ stats.users }}+</span>
|
||||
<span class="stat-label">{{ t('hero.users') }}</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ stats.commands }}+</span>
|
||||
<span class="stat-label">{{ t('hero.commands') }}</span>
|
||||
@@ -34,17 +43,93 @@
|
||||
</div>
|
||||
|
||||
<div class="hero-visual">
|
||||
<div class="floating-card card-1">
|
||||
<div class="card-icon">🤝</div>
|
||||
<div class="card-text">{{ t('hero.feature1') }}</div>
|
||||
<!-- Discord Mockup Card -->
|
||||
<div class="discord-card">
|
||||
<div class="card-header">
|
||||
<span class="channel-name"># music-commands</span>
|
||||
<div class="window-controls">
|
||||
<span class="dot red"></span>
|
||||
<span class="dot yellow"></span>
|
||||
<span class="dot green"></span>
|
||||
</div>
|
||||
<div class="floating-card card-2">
|
||||
<div class="card-icon">🎫</div>
|
||||
<div class="card-text">{{ t('hero.feature2') }}</div>
|
||||
</div>
|
||||
<div class="floating-card card-3">
|
||||
<div class="card-icon">⚙️</div>
|
||||
<div class="card-text">{{ t('hero.feature3') }}</div>
|
||||
|
||||
<div class="card-body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showUserMessage" class="message-group">
|
||||
<div class="avatar user-avatar">
|
||||
<img src="https://cdn.discordapp.com/embed/avatars/0.png" alt="User" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<span class="username">User</span>
|
||||
<span class="timestamp">Today at 4:20 PM</span>
|
||||
</div>
|
||||
<div class="message-text">!play Neon Nights</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="showTyping" class="message-group">
|
||||
<div class="avatar bot-avatar">
|
||||
<img src="/src/assets/ama_elegant.png" alt="Amayo" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<span class="username bot">Amayo</span>
|
||||
<span class="bot-tag">BOT</span>
|
||||
<span class="timestamp">Today at 4:20 PM</span>
|
||||
</div>
|
||||
<div class="typing-indicator">
|
||||
<span class="typing-dot"></span>
|
||||
<span class="typing-dot"></span>
|
||||
<span class="typing-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="showBotMessage" class="message-group bot-response">
|
||||
<div class="avatar bot-avatar">
|
||||
<img src="/src/assets/ama_elegant.png" alt="Amayo" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-header">
|
||||
<span class="username bot">Amayo</span>
|
||||
<span class="bot-tag">BOT</span>
|
||||
<span class="timestamp">Today at 4:20 PM</span>
|
||||
</div>
|
||||
<div class="embed">
|
||||
<div class="embed-image-container">
|
||||
<div class="music-icon">🎵</div>
|
||||
</div>
|
||||
<div class="embed-info">
|
||||
<div class="embed-title">Now Playing</div>
|
||||
<div class="embed-desc">Neon Nights - Synthwave Mix</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
<div class="time-stamps">
|
||||
<span>1:24</span>
|
||||
<span>3:45</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Floating Badges -->
|
||||
<div class="floating-badge badge-audio">
|
||||
<span class="badge-icon">🔥</span>
|
||||
<span>High Quality Audio</span>
|
||||
</div>
|
||||
|
||||
<div class="floating-badge badge-security">
|
||||
<span class="badge-icon">🛡️</span>
|
||||
<span>Advanced Security</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,24 +137,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { botService } from '@/services/bot'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const texts = {
|
||||
es: 'El Mejor Bot de Discord',
|
||||
en: 'The Best Discord Bot'
|
||||
}
|
||||
|
||||
const displayText = ref('')
|
||||
const showCursor = ref(true)
|
||||
const currentIndex = ref(0)
|
||||
const isDeleting = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const typewriterRef = ref(null)
|
||||
const cursorRef = ref(null)
|
||||
const { t } = useI18n()
|
||||
|
||||
const stats = ref({
|
||||
servers: '...',
|
||||
@@ -77,85 +149,56 @@ const stats = ref({
|
||||
commands: '...'
|
||||
})
|
||||
|
||||
// Cargar estadísticas reales del bot
|
||||
const showUserMessage = ref(false)
|
||||
const showTyping = ref(false)
|
||||
const showBotMessage = ref(false)
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const data = await botService.getStats()
|
||||
stats.value = {
|
||||
servers: botService.formatNumber(data.servers || 0),
|
||||
users: botService.formatNumber(data.users || 0),
|
||||
servers: botService.formatNumber(data.servers || 450),
|
||||
users: botService.formatNumber(data.users || 205900),
|
||||
commands: botService.formatNumber(data.commands || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error)
|
||||
// Valores por defecto si falla
|
||||
stats.value = {
|
||||
servers: '0',
|
||||
users: '0',
|
||||
servers: '450',
|
||||
users: '205.9K',
|
||||
commands: '0'
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const typewriterEffect = () => {
|
||||
const currentText = texts[locale.value] || texts.es
|
||||
const speed = isDeleting.value ? 50 : 100
|
||||
const startAnimationLoop = () => {
|
||||
// Reset
|
||||
showUserMessage.value = false
|
||||
showTyping.value = false
|
||||
showBotMessage.value = false
|
||||
|
||||
if (!isDeleting.value) {
|
||||
if (currentIndex.value < currentText.length) {
|
||||
displayText.value = currentText.substring(0, currentIndex.value + 1)
|
||||
currentIndex.value++
|
||||
nextTick(() => updateCursorPosition())
|
||||
setTimeout(typewriterEffect, speed)
|
||||
} else {
|
||||
// Pause at the end
|
||||
// Sequence
|
||||
setTimeout(() => {
|
||||
isDeleting.value = true
|
||||
typewriterEffect()
|
||||
}, 2000)
|
||||
}
|
||||
} else {
|
||||
if (currentIndex.value > 0) {
|
||||
displayText.value = currentText.substring(0, currentIndex.value - 1)
|
||||
currentIndex.value--
|
||||
nextTick(() => updateCursorPosition())
|
||||
setTimeout(typewriterEffect, speed)
|
||||
} else {
|
||||
isDeleting.value = false
|
||||
setTimeout(typewriterEffect, 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
showUserMessage.value = true
|
||||
}, 1000)
|
||||
|
||||
const updateCursorPosition = () => {
|
||||
if (typewriterRef.value && cursorRef.value) {
|
||||
const textWidth = typewriterRef.value.offsetWidth
|
||||
cursorRef.value.style.left = `${textWidth}px`
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
showTyping.value = true
|
||||
}, 2500)
|
||||
|
||||
// Watch for language changes
|
||||
watch(locale, () => {
|
||||
currentIndex.value = 0
|
||||
displayText.value = ''
|
||||
isDeleting.value = false
|
||||
typewriterEffect()
|
||||
})
|
||||
setTimeout(() => {
|
||||
showTyping.value = false
|
||||
showBotMessage.value = true
|
||||
}, 4500)
|
||||
|
||||
// Restart loop after some time
|
||||
setTimeout(startAnimationLoop, 12000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
typewriterEffect()
|
||||
|
||||
// Cursor blink
|
||||
setInterval(() => {
|
||||
showCursor.value = !showCursor.value
|
||||
}, 500)
|
||||
|
||||
// Actualizar estadísticas cada 5 minutos
|
||||
setInterval(loadStats, 5 * 60 * 1000)
|
||||
startAnimationLoop()
|
||||
})
|
||||
|
||||
const scrollToFeatures = () => {
|
||||
@@ -175,350 +218,440 @@ const inviteBot = () => {
|
||||
justify-content: center;
|
||||
padding: 120px 20px 80px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 600px 1fr;
|
||||
gap: 80px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
z-index: 2;
|
||||
/* Text Side */
|
||||
.version-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 23, 68, 0.1);
|
||||
border: 1px solid rgba(255, 23, 68, 0.2);
|
||||
padding: 8px 16px;
|
||||
border-radius: 100px;
|
||||
color: #ff1744;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 4rem;
|
||||
font-size: 4.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.2;
|
||||
min-height: 120px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero-title::before {
|
||||
content: 'El Mejor Bot de Discord';
|
||||
font-size: 4rem;
|
||||
font-weight: 800;
|
||||
visibility: hidden;
|
||||
display: block;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.typewriter {
|
||||
background: linear-gradient(135deg, #fff, var(--color-secondary, #ff5252));
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #ff1744 0%, #ff4569 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
color: var(--color-primary, #ff1744);
|
||||
opacity: 1;
|
||||
transition: opacity 0.1s;
|
||||
background: none;
|
||||
-webkit-text-fill-color: var(--color-primary, #ff1744);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.cursor.blink {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
max-width: 600px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 48px;
|
||||
gap: 20px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
padding: 14px 32px;
|
||||
border-radius: 30px;
|
||||
padding: 16px 32px;
|
||||
border-radius: 16px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hero-btn.primary {
|
||||
background: var(--gradient-primary, linear-gradient(135deg, #ff1744, #d50000));
|
||||
background: #ff1744;
|
||||
color: white;
|
||||
box-shadow: 0 8px 30px var(--color-glow, rgba(255, 23, 68, 0.4));
|
||||
box-shadow: 0 10px 30px rgba(255, 23, 68, 0.3);
|
||||
}
|
||||
|
||||
.hero-btn.primary:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 40px var(--color-glow, rgba(255, 23, 68, 0.6));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 15px 40px rgba(255, 23, 68, 0.4);
|
||||
}
|
||||
|
||||
.hero-btn.secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.hero-btn.secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-3px);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 20px 30px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
background: var(--gradient-primary, linear-gradient(135deg, #ff1744, #ff5252));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Visual Side - Discord Card */
|
||||
.hero-visual {
|
||||
position: relative;
|
||||
height: 500px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
padding-right: 0;
|
||||
overflow: visible;
|
||||
justify-content: center;
|
||||
perspective: 1000px;
|
||||
padding: 40px; /* Add padding to allow badges to be "outside" without clipping if container is small */
|
||||
}
|
||||
|
||||
.floating-card {
|
||||
position: absolute;
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
.discord-card {
|
||||
background: #2b2d31;
|
||||
border-radius: 16px;
|
||||
width: 450px;
|
||||
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: visible; /* Allow badges to overflow */
|
||||
transform: rotateY(-5deg) rotateX(5deg);
|
||||
transition: transform 0.5s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.discord-card:hover {
|
||||
transform: rotateY(0) rotateX(0);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: #1e1f22;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #1e1f22;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
color: #949ba4;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.red { background: #ff5f57; }
|
||||
.dot.yellow { background: #febc2e; }
|
||||
.dot.green { background: #28c840; }
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
min-height: 300px; /* Ensure height for animation */
|
||||
}
|
||||
|
||||
.message-group {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
min-width: 180px;
|
||||
max-width: 200px;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.floating-card:hover {
|
||||
transform: translateY(-10px) scale(1.05);
|
||||
box-shadow: 0 12px 40px var(--color-glow, rgba(255, 23, 68, 0.4));
|
||||
border-color: var(--color-primary, #ff1744);
|
||||
}
|
||||
|
||||
.card-1 {
|
||||
top: 30px;
|
||||
right: 405px;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.card-2 {
|
||||
top: 190px;
|
||||
right: 185px;
|
||||
animation: float 6s ease-in-out infinite 2s;
|
||||
}
|
||||
|
||||
.card-3 {
|
||||
bottom: -2px;
|
||||
right: -32px;;
|
||||
animation: float 6s ease-in-out infinite 4s;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 3rem;
|
||||
filter: drop-shadow(0 0 20px var(--color-glow, rgba(255, 23, 68, 0.5)));
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: center;
|
||||
.username {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.username.bot {
|
||||
color: #ff1744;
|
||||
}
|
||||
|
||||
.bot-tag {
|
||||
background: #ff1744;
|
||||
color: white;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #949ba4;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
color: #dbdee1;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Typing Indicator */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #dbdee1;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Embed Styles */
|
||||
.embed {
|
||||
background: #1e1f22;
|
||||
border-left: 4px solid #ff1744;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.embed-image-container {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #2b2d31;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.music-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.embed-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.embed-title {
|
||||
color: #ff1744;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.embed-desc {
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: #404249;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: #ff1744;
|
||||
animation: progress 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% { width: 0%; }
|
||||
100% { width: 100%; }
|
||||
}
|
||||
|
||||
.time-stamps {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #949ba4;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Floating Badges */
|
||||
.floating-badge {
|
||||
position: absolute;
|
||||
background: rgba(20, 20, 20, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
animation: float 4s ease-in-out infinite;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-audio {
|
||||
top: -30px;
|
||||
right: -50px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.badge-security {
|
||||
bottom: 40px;
|
||||
left: -80px;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
|
||||
/* Animations */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
@media (max-width: 968px) {
|
||||
.hero-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
min-height: 80px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.hero-title::before {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
height: 400px;
|
||||
padding-right: 0;
|
||||
justify-content: flex-end;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.floating-card {
|
||||
padding: 20px;
|
||||
min-width: 140px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.card-1 {
|
||||
top: 30px;
|
||||
right: -200px;
|
||||
}
|
||||
|
||||
.card-2 {
|
||||
top: 170px;
|
||||
right: -300px;
|
||||
}
|
||||
|
||||
.card-3 {
|
||||
bottom: 70px;
|
||||
right: -210px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
min-height: 70px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero-title::before {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
height: 300px;
|
||||
padding-right: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.floating-card {
|
||||
padding: 16px;
|
||||
min-width: 110px;
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.card-1 {
|
||||
top: 24px;
|
||||
right: 254px;
|
||||
}
|
||||
|
||||
.card-2 {
|
||||
top: 20px;
|
||||
right: 130px;
|
||||
}
|
||||
|
||||
.card-3 {
|
||||
bottom: 159px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
.hero-title {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin: 0 auto 40px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-visual {
|
||||
margin-top: 60px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.discord-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.badge-audio {
|
||||
right: 0;
|
||||
top: -40px;
|
||||
}
|
||||
|
||||
.badge-security {
|
||||
left: 0;
|
||||
bottom: -20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,343 +1,295 @@
|
||||
<template>
|
||||
<nav class="island-navbar">
|
||||
<nav class="island-navbar" :class="{ 'scrolled': isScrolled }">
|
||||
<div class="navbar-content">
|
||||
<!-- Logo Section -->
|
||||
<div class="logo-section">
|
||||
<div class="bot-avatar">
|
||||
<img :src="favicon" alt="Amayo Bot" />
|
||||
<div class="logo-container">
|
||||
<img src="https://docs.amayo.dev/favicon.ico" alt="Amayo Logo" class="logo-img" />
|
||||
<div class="logo-glow"></div>
|
||||
</div>
|
||||
<span class="bot-name">{{ botName }}</span>
|
||||
<span class="brand-name">Amayo</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="actions-section">
|
||||
<!-- Theme Selector Dropdown -->
|
||||
<div class="theme-dropdown" ref="themeDropdown">
|
||||
<button class="theme-toggle-btn" @click="toggleThemeMenu">
|
||||
<div class="current-theme-preview" :style="{ background: getCurrentThemeGradient() }"></div>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="white">
|
||||
<path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-show="showThemeMenu" class="theme-menu">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.name"
|
||||
:class="['theme-menu-item', { active: currentTheme === theme.name }]"
|
||||
@click="changeTheme(theme.name)"
|
||||
<div class="nav-links">
|
||||
<div
|
||||
class="nav-indicator"
|
||||
:style="indicatorStyle"
|
||||
></div>
|
||||
|
||||
<a
|
||||
v-for="(item, index) in navItems"
|
||||
:key="index"
|
||||
href="#"
|
||||
class="nav-item"
|
||||
:class="{ 'active': activeIndex === index }"
|
||||
@click.prevent="handleNavClick(index, item.action)"
|
||||
@mouseenter="hoverIndex = index"
|
||||
@mouseleave="hoverIndex = -1"
|
||||
ref="navItemRefs"
|
||||
>
|
||||
<div class="theme-preview" :style="{ background: theme.gradient }"></div>
|
||||
<span>{{ t(`themes.${theme.name}`) }}</span>
|
||||
<svg v-if="currentTheme === theme.name" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M13 4L6 11L3 8" stroke="#00e676" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Selector -->
|
||||
<button class="lang-btn" @click="toggleLanguage">
|
||||
{{ currentLang === 'es' ? '🇪🇸' : '🇺🇸' }}
|
||||
</button>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<a href="/docs" class="nav-btn primary">
|
||||
{{ t('navbar.getStarted') }}
|
||||
{{ t(item.key) }}
|
||||
</a>
|
||||
<a href="/dashboard" class="nav-btn secondary">
|
||||
</div>
|
||||
|
||||
<div class="nav-actions">
|
||||
<div class="lang-switch" @click="toggleLanguage">
|
||||
<span class="lang-text">{{ locale.toUpperCase() }}</span>
|
||||
</div>
|
||||
|
||||
<button class="premium-btn" @click="goToPremium">
|
||||
<span class="premium-icon">👑</span>
|
||||
{{ t('navbar.premium') }}
|
||||
</button>
|
||||
|
||||
<button class="dashboard-btn" @click="goToDashboard">
|
||||
{{ t('navbar.dashboard') }}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const isScrolled = ref(false)
|
||||
const activeIndex = ref(0)
|
||||
const hoverIndex = ref(-1)
|
||||
const navItemRefs = ref([])
|
||||
|
||||
const favicon = ref('https://docs.amayo.dev/favicon.ico') // Reemplaza con el avatar real del bot
|
||||
const botName = ref('Amayo')
|
||||
|
||||
const currentTheme = ref('red')
|
||||
const currentLang = computed(() => locale.value)
|
||||
const showThemeMenu = ref(false)
|
||||
const themeDropdown = ref(null)
|
||||
|
||||
const themes = [
|
||||
{ name: 'red', gradient: 'linear-gradient(135deg, #ff1744, #d50000)' },
|
||||
{ name: 'blue', gradient: 'linear-gradient(135deg, #2196f3, #1565c0)' },
|
||||
{ name: 'green', gradient: 'linear-gradient(135deg, #00e676, #00c853)' },
|
||||
{ name: 'purple', gradient: 'linear-gradient(135deg, #e040fb, #9c27b0)' },
|
||||
{ name: 'orange', gradient: 'linear-gradient(135deg, #ff9100, #ff6d00)' },
|
||||
const navItems = [
|
||||
{ key: 'navbar.getStarted', action: 'home' },
|
||||
{ key: 'hero.exploreFeatures', action: 'features' },
|
||||
{ key: 'docs.support', action: 'support' }
|
||||
]
|
||||
|
||||
const getCurrentThemeGradient = () => {
|
||||
const theme = themes.find(t => t.name === currentTheme.value)
|
||||
return theme ? theme.gradient : themes[0].gradient
|
||||
const indicatorStyle = computed(() => {
|
||||
const targetIndex = hoverIndex.value !== -1 ? hoverIndex.value : activeIndex.value
|
||||
const targetEl = navItemRefs.value[targetIndex]
|
||||
|
||||
if (!targetEl) return { opacity: 0 }
|
||||
|
||||
return {
|
||||
width: `${targetEl.offsetWidth}px`,
|
||||
left: `${targetEl.offsetLeft}px`,
|
||||
opacity: 1
|
||||
}
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
isScrolled.value = window.scrollY > 50
|
||||
}
|
||||
|
||||
const toggleThemeMenu = () => {
|
||||
showThemeMenu.value = !showThemeMenu.value
|
||||
const handleNavClick = (index, action) => {
|
||||
activeIndex.value = index
|
||||
if (action === 'features') {
|
||||
document.querySelector('#features')?.scrollIntoView({ behavior: 'smooth' })
|
||||
} else if (action === 'support') {
|
||||
window.open('https://discord.gg/amayo', '_blank')
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const changeTheme = (themeName) => {
|
||||
currentTheme.value = themeName
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
localStorage.setItem('theme', themeName)
|
||||
showThemeMenu.value = false
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
locale.value = locale.value === 'es' ? 'en' : 'es'
|
||||
localStorage.setItem('language', locale.value)
|
||||
localStorage.setItem('user-locale', locale.value)
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (themeDropdown.value && !themeDropdown.value.contains(event.target)) {
|
||||
showThemeMenu.value = false
|
||||
const goToDashboard = () => {
|
||||
window.location.href = '/dash/me'
|
||||
}
|
||||
|
||||
const goToPremium = () => {
|
||||
window.location.href = '/premium'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const savedLang = localStorage.getItem('language')
|
||||
|
||||
if (savedTheme) {
|
||||
currentTheme.value = savedTheme
|
||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||
}
|
||||
|
||||
if (savedLang) {
|
||||
locale.value = savedLang
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
const savedLocale = localStorage.getItem('user-locale')
|
||||
if (savedLocale) locale.value = savedLocale
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.island-navbar {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
width: 150%;
|
||||
max-width: 1200px;
|
||||
width: auto;
|
||||
max-width: 900px;
|
||||
transition: all 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.island-navbar.scrolled {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
gap: 40px;
|
||||
padding: 6px 8px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 100px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.bot-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--color-primary, #ff1744);
|
||||
box-shadow: 0 0 20px var(--color-glow, rgba(255, 23, 68, 0.3));
|
||||
.logo-container {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.bot-avatar img {
|
||||
.logo-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-size: 1.2rem;
|
||||
.brand-name {
|
||||
font-weight: 700;
|
||||
background: var(--gradient-primary, linear-gradient(135deg, #ff1744, #ff5252));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-size: 1.1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
/* Nav Links */
|
||||
.nav-links {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
padding: 10px 24px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.nav-item:hover, .nav-item.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: #4c2e2e;
|
||||
border-radius: 100px;
|
||||
transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.theme-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
.lang-switch {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.current-theme-preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.theme-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: rgba(10, 10, 10, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.theme-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.theme-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.theme-menu-item.active {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-menu-item span {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
font-size: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 25px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.nav-btn.primary {
|
||||
background: var(--gradient-primary, linear-gradient(135deg, #ff1744, #d50000));
|
||||
.lang-switch:hover {
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px var(--color-glow, rgba(255, 23, 68, 0.4));
|
||||
}
|
||||
|
||||
.nav-btn.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px var(--color-glow, rgba(255, 23, 68, 0.6));
|
||||
}
|
||||
|
||||
.nav-btn.secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
.dashboard-btn {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 6px 16px;
|
||||
border-radius: 100px;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-btn.secondary:hover {
|
||||
.dashboard-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.premium-btn {
|
||||
background: linear-gradient(45deg, #ff1744, #ff4081);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 100px;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(255, 23, 68, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.premium-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(255, 23, 68, 0.5);
|
||||
}
|
||||
|
||||
.premium-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
transform: translateX(-100%);
|
||||
transition: 0.5s;
|
||||
}
|
||||
|
||||
.premium-btn:hover::after {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.premium-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-dropdown {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.island-navbar {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
width: 85%;
|
||||
max-width: 1200px;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
524
AmayoWeb/src/components/ServerSelector.vue
Normal file
@@ -0,0 +1,524 @@
|
||||
<template>
|
||||
<div class="server-selector-overlay" @click.self="$emit('close')">
|
||||
<div class="selector-container">
|
||||
<!-- Header -->
|
||||
<div class="selector-header fade-in-down">
|
||||
<h2 class="title text-gradient-gold">Select Your Realm</h2>
|
||||
<p class="subtitle">Choose a server to manage</p>
|
||||
</div>
|
||||
|
||||
<!-- 3D Carousel -->
|
||||
<div class="carousel-stage">
|
||||
<div
|
||||
class="cards-wrapper"
|
||||
:class="{ 'entering': isEntering, 'exiting': isExiting }"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
<div
|
||||
v-for="(server, index) in servers"
|
||||
:key="server.id"
|
||||
class="tarot-card"
|
||||
:class="getCardClass(index)"
|
||||
@click="selectServer(index)"
|
||||
:style="{ '--delay': `${index * 0.1}s` }"
|
||||
>
|
||||
<div class="card-inner">
|
||||
<!-- Card Back (Decorative) -->
|
||||
<div class="card-face back">
|
||||
<div class="back-pattern"></div>
|
||||
<div class="back-logo">
|
||||
<span class="logo-icon">✦</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Front (Content) -->
|
||||
<div class="card-face front">
|
||||
<div class="ornate-border"></div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="server-icon-wrapper">
|
||||
<img :src="server.icon" :alt="server.name" class="server-icon" />
|
||||
<div class="icon-glow"></div>
|
||||
</div>
|
||||
|
||||
<div class="server-info">
|
||||
<h3 class="server-name">{{ server.name }}</h3>
|
||||
<div class="server-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="select-btn">
|
||||
<span>Enter Realm</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Magical Particles -->
|
||||
<div class="particles">
|
||||
<span v-for="n in 5" :key="n" class="particle"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Controls -->
|
||||
<div class="nav-controls fade-in-up">
|
||||
<button class="nav-btn prev" @click="prevCard">
|
||||
<span>❮</span>
|
||||
</button>
|
||||
<div class="indicators">
|
||||
<span
|
||||
v-for="(server, index) in servers"
|
||||
:key="index"
|
||||
class="indicator-dot"
|
||||
:class="{ active: index === activeIndex }"
|
||||
></span>
|
||||
</div>
|
||||
<button class="nav-btn next" @click="nextCard">
|
||||
<span>❯</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
servers: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'select'])
|
||||
|
||||
const activeIndex = ref(0)
|
||||
const isEntering = ref(true)
|
||||
const isExiting = ref(false)
|
||||
|
||||
// Calculate visible cards for the carousel effect
|
||||
const getCardClass = (index) => {
|
||||
if (index === activeIndex.value) return 'active'
|
||||
|
||||
const diff = index - activeIndex.value
|
||||
if (diff === 1 || (activeIndex.value === props.servers.length - 1 && index === 0)) return 'next'
|
||||
if (diff === -1 || (activeIndex.value === 0 && index === props.servers.length - 1)) return 'prev'
|
||||
|
||||
return 'hidden'
|
||||
}
|
||||
|
||||
const nextCard = () => {
|
||||
activeIndex.value = (activeIndex.value + 1) % props.servers.length
|
||||
}
|
||||
|
||||
const prevCard = () => {
|
||||
activeIndex.value = (activeIndex.value - 1 + props.servers.length) % props.servers.length
|
||||
}
|
||||
|
||||
const selectServer = (index) => {
|
||||
if (index !== activeIndex.value) {
|
||||
activeIndex.value = index
|
||||
return
|
||||
}
|
||||
|
||||
// Exit animation
|
||||
isExiting.value = true
|
||||
setTimeout(() => {
|
||||
emit('select', props.servers[index].id)
|
||||
}, 800)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Reset entrance state after animation
|
||||
setTimeout(() => {
|
||||
isEntering.value = false
|
||||
}, 1500)
|
||||
})
|
||||
|
||||
// Touch handling for carousel
|
||||
let touchStartX = 0
|
||||
let touchEndX = 0
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
touchStartX = e.changedTouches[0].screenX
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
touchEndX = e.changedTouches[0].screenX
|
||||
handleSwipe()
|
||||
}
|
||||
|
||||
const handleSwipe = () => {
|
||||
const threshold = 50
|
||||
if (touchEndX < touchStartX - threshold) {
|
||||
nextCard() // Swipe Left -> Next
|
||||
}
|
||||
if (touchEndX > touchStartX + threshold) {
|
||||
prevCard() // Swipe Right -> Prev
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-selector-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.selector-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.selector-header {
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 0 20px rgba(212, 175, 55, 0.5);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Carousel Stage */
|
||||
.carousel-stage {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 2000px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.cards-wrapper {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
height: 450px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Tarot Card Base */
|
||||
.tarot-card {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: all 0.6s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
cursor: pointer;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card-face {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Card Back */
|
||||
.card-face.back {
|
||||
background: linear-gradient(135deg, #1a0b2e 0%, #000000 100%);
|
||||
border: 2px solid var(--accent-gold-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.back-pattern {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
background-image: radial-gradient(var(--accent-gold-dim) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.back-logo {
|
||||
font-size: 3rem;
|
||||
color: var(--accent-gold);
|
||||
filter: drop-shadow(0 0 10px var(--accent-gold));
|
||||
}
|
||||
|
||||
/* Card Front */
|
||||
.card-face.front {
|
||||
background: linear-gradient(180deg, rgba(20, 20, 30, 0.95) 0%, rgba(10, 10, 15, 0.98) 100%);
|
||||
border: 1px solid var(--accent-gold-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.ornate-border {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 2px solid var(--accent-gold);
|
||||
mask: radial-gradient(circle at center, transparent 60%, black 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ornate-border::before, .ornate-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--accent-gold);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.ornate-border::before { top: -2px; left: -2px; border-right: 0; border-bottom: 0; }
|
||||
.ornate-border::after { bottom: -2px; right: -2px; border-left: 0; border-top: 0; }
|
||||
|
||||
/* Card Content */
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.server-icon-wrapper {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--accent-gold);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
position: absolute;
|
||||
inset: -10px;
|
||||
background: radial-gradient(circle, var(--accent-gold-dim) 0%, transparent 70%);
|
||||
animation: pulse 3s infinite;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent-gold-light);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.server-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.select-btn {
|
||||
margin-top: 20px;
|
||||
padding: 10px 30px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent-gold);
|
||||
color: var(--accent-gold);
|
||||
font-family: var(--font-heading);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.select-btn:hover {
|
||||
background: var(--accent-gold-dim);
|
||||
box-shadow: 0 0 20px var(--accent-gold-dim);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* Carousel Logic & 3D Transforms */
|
||||
.tarot-card.active {
|
||||
transform: translateZ(200px) rotateY(0deg);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tarot-card.prev {
|
||||
transform: translateX(-250px) translateZ(0px) rotateY(30deg) scale(0.85);
|
||||
z-index: 5;
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
|
||||
.tarot-card.prev .card-inner {
|
||||
transform: rotateY(180deg); /* Show Back */
|
||||
}
|
||||
|
||||
.tarot-card.next {
|
||||
transform: translateX(250px) translateZ(0px) rotateY(-30deg) scale(0.85);
|
||||
z-index: 5;
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
|
||||
.tarot-card.next .card-inner {
|
||||
transform: rotateY(180deg); /* Show Back */
|
||||
}
|
||||
|
||||
.tarot-card.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Entrance Animation: Elastic Fan-Out */
|
||||
.entering .tarot-card {
|
||||
animation: elasticFanOut 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes elasticFanOut {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(100vh) scale(0.5) rotate(15deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-50px) scale(1.1) rotate(-5deg);
|
||||
}
|
||||
80% {
|
||||
transform: translateY(20px) scale(0.95) rotate(2deg);
|
||||
}
|
||||
100% {
|
||||
/* Final state handled by Vue classes */
|
||||
}
|
||||
}
|
||||
|
||||
/* Exit Animation */
|
||||
.exiting .tarot-card:not(.active) {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.exiting .tarot-card.active {
|
||||
transform: scale(5);
|
||||
opacity: 0;
|
||||
transition: all 0.8s ease-in;
|
||||
}
|
||||
|
||||
/* Navigation Controls */
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--accent-gold-dim);
|
||||
color: var(--accent-gold);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--accent-gold);
|
||||
color: #000;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.indicators {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.indicator-dot.active {
|
||||
background: var(--accent-gold);
|
||||
box-shadow: 0 0 10px var(--accent-gold);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Utility Animations */
|
||||
.fade-in-down { animation: fadeInDown 0.8s ease forwards; }
|
||||
.fade-in-up { animation: fadeInUp 0.8s ease forwards; }
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from { opacity: 0; transform: translateY(-30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.5; transform: scale(1); }
|
||||
50% { opacity: 0.8; transform: scale(1.1); }
|
||||
}
|
||||
</style>
|
||||
124
AmayoWeb/src/components/TechButton.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<button class="tech-button" :class="{ 'secondary': secondary, 'small': small }">
|
||||
<div class="btn-content">
|
||||
<span class="btn-text"><slot></slot></span>
|
||||
<div class="btn-deco"></div>
|
||||
</div>
|
||||
<div class="btn-glitch"></div>
|
||||
<div class="btn-lines"></div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
secondary: Boolean,
|
||||
small: Boolean
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tech-button {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
font-weight: 600;
|
||||
color: var(--hud-primary);
|
||||
transition: all 0.3s ease;
|
||||
clip-path: polygon(
|
||||
10px 0, 100% 0,
|
||||
100% calc(100% - 10px), calc(100% - 10px) 100%,
|
||||
0 100%, 0 10px
|
||||
);
|
||||
}
|
||||
|
||||
.btn-content {
|
||||
background: rgba(255, 61, 0, 0.1);
|
||||
border: 1px solid var(--hud-primary);
|
||||
padding: 12px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.small .btn-content {
|
||||
padding: 8px 20px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-deco {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--hud-primary);
|
||||
clip-path: polygon(100% 0, 0 100%, 100% 100%);
|
||||
}
|
||||
|
||||
.tech-button:hover .btn-content {
|
||||
background: var(--hud-primary);
|
||||
color: black;
|
||||
box-shadow: 0 0 20px rgba(255, 61, 0, 0.4);
|
||||
}
|
||||
|
||||
.tech-button:hover .btn-deco {
|
||||
background: black;
|
||||
}
|
||||
|
||||
/* Glitch Effect */
|
||||
.btn-glitch {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--hud-secondary);
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
transform: translate(-5px, 5px);
|
||||
transition: opacity 0.1s;
|
||||
clip-path: polygon(
|
||||
10px 0, 100% 0,
|
||||
100% calc(100% - 10px), calc(100% - 10px) 100%,
|
||||
0 100%, 0 10px
|
||||
);
|
||||
}
|
||||
|
||||
.tech-button:hover .btn-glitch {
|
||||
animation: glitch-anim 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes glitch-anim {
|
||||
0% { transform: translate(0); }
|
||||
20% { transform: translate(-2px, 2px); }
|
||||
40% { transform: translate(-2px, -2px); }
|
||||
60% { transform: translate(2px, 2px); }
|
||||
80% { transform: translate(2px, -2px); }
|
||||
100% { transform: translate(0); }
|
||||
}
|
||||
|
||||
/* Secondary Style */
|
||||
.secondary .btn-content {
|
||||
border-color: var(--hud-secondary);
|
||||
color: var(--hud-secondary);
|
||||
background: rgba(255, 145, 0, 0.05);
|
||||
}
|
||||
|
||||
.secondary .btn-deco {
|
||||
background: var(--hud-secondary);
|
||||
}
|
||||
|
||||
.secondary:hover .btn-content {
|
||||
background: var(--hud-secondary);
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
136
AmayoWeb/src/components/TechCard.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="tech-card">
|
||||
<div class="card-header">
|
||||
<span class="header-deco"></span>
|
||||
<span class="header-title">{{ title }}</span>
|
||||
<span class="header-id">{{ id || 'SYS.01' }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="scan-line"></div>
|
||||
<span class="footer-status">ONLINE</span>
|
||||
</div>
|
||||
|
||||
<!-- Decorative Corners -->
|
||||
<div class="corner tl"></div>
|
||||
<div class="corner tr"></div>
|
||||
<div class="corner bl"></div>
|
||||
<div class="corner br"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: String,
|
||||
id: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tech-card {
|
||||
position: relative;
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
border: 1px solid rgba(255, 61, 0, 0.2);
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tech-card:hover {
|
||||
border-color: rgba(255, 61, 0, 0.5);
|
||||
box-shadow: 0 0 30px rgba(255, 61, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 61, 0, 0.1);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(255, 61, 0, 0.2);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-deco {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--hud-primary);
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--hud-accent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-id {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--hud-primary);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 5px 10px;
|
||||
border-top: 1px solid rgba(255, 61, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--hud-primary);
|
||||
box-shadow: 0 0 5px var(--hud-primary);
|
||||
animation: scan 3s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.footer-status {
|
||||
font-size: 0.6rem;
|
||||
color: var(--hud-primary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* Corners */
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid var(--hud-primary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tl { top: -1px; left: -1px; border-right: none; border-bottom: none; }
|
||||
.tr { top: -1px; right: -1px; border-left: none; border-bottom: none; }
|
||||
.bl { bottom: -1px; left: -1px; border-right: none; border-top: none; }
|
||||
.br { bottom: -1px; right: -1px; border-left: none; border-top: none; }
|
||||
|
||||
.tech-card:hover .corner {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { transform: translateY(0); opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translateY(100%); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup>
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||
>Vue - Official</a
|
||||
>. If you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,213 +0,0 @@
|
||||
<template>
|
||||
<section class="hero-section">
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-title">
|
||||
<span class="title-text">{{ titleText }}</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">{{ t('hero_docs.subtitle') }}</p>
|
||||
|
||||
<div class="hero-actions">
|
||||
<button class="hero-btn primary" @click="scrollToFeatures">
|
||||
{{ t('hero_docs.exploreFeatures') }}
|
||||
</button>
|
||||
<button class="hero-btn secondary" @click="inviteBot">
|
||||
{{ t('hero_docs.inviteBot') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { botService } from '@/services/bot'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const titleText = computed(() => {
|
||||
return locale.value === 'es'
|
||||
? 'Comandos, Tickets y Moderación'
|
||||
: 'Commands, Tickets, and Moderation'
|
||||
})
|
||||
|
||||
const isLoading = ref(true)
|
||||
|
||||
const stats = ref({
|
||||
servers: '...',
|
||||
users: '...',
|
||||
commands: '...'
|
||||
})
|
||||
|
||||
// Cargar estadísticas reales del bot
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const data = await botService.getStats()
|
||||
stats.value = {
|
||||
servers: botService.formatNumber(data.servers || 0),
|
||||
users: botService.formatNumber(data.users || 0),
|
||||
commands: botService.formatNumber(data.commands || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error)
|
||||
// Valores por defecto si falla
|
||||
stats.value = {
|
||||
servers: '0',
|
||||
users: '0',
|
||||
commands: '0'
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
// Actualizar estadísticas cada 5 minutos
|
||||
setInterval(loadStats, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
const scrollToFeatures = () => {
|
||||
document.querySelector('#features')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const inviteBot = () => {
|
||||
window.open('https://discord.com/oauth2/authorize?client_id=991062751633883136&permissions=2416176272&integration_type=0&scope=bot', '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-section {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120px 20px 80px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 4rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.2;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
background: linear-gradient(135deg, #fff, var(--color-secondary, #ff5252));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.6;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 48px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
padding: 14px 32px;
|
||||
border-radius: 30px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.hero-btn.primary {
|
||||
background: var(--gradient-primary, linear-gradient(135deg, #ff1744, #d50000));
|
||||
color: white;
|
||||
box-shadow: 0 8px 30px var(--color-glow, rgba(255, 23, 68, 0.4));
|
||||
}
|
||||
|
||||
.hero-btn.primary:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 40px var(--color-glow, rgba(255, 23, 68, 0.6));
|
||||
}
|
||||
|
||||
.hero-btn.secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hero-btn.secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 968px) {
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,340 +0,0 @@
|
||||
<template>
|
||||
<nav class="island-navbar">
|
||||
<div class="navbar-content">
|
||||
<!-- Logo Section -->
|
||||
<div class="logo-section">
|
||||
<div class="bot-avatar">
|
||||
<img :src="favicon" alt="Amayo Bot" />
|
||||
</div>
|
||||
<span class="bot-name">{{ botName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="actions-section">
|
||||
<!-- Theme Selector Dropdown -->
|
||||
<div class="theme-dropdown" ref="themeDropdown">
|
||||
<button class="theme-toggle-btn" @click="toggleThemeMenu">
|
||||
<div class="current-theme-preview" :style="{ background: getCurrentThemeGradient() }"></div>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="white">
|
||||
<path d="M2 4l4 4 4-4" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-show="showThemeMenu" class="theme-menu">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.name"
|
||||
:class="['theme-menu-item', { active: currentTheme === theme.name }]"
|
||||
@click="changeTheme(theme.name)"
|
||||
>
|
||||
<div class="theme-preview" :style="{ background: theme.gradient }"></div>
|
||||
<span>{{ t(`themes.${theme.name}`) }}</span>
|
||||
<svg v-if="currentTheme === theme.name" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M13 4L6 11L3 8" stroke="#00e676" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language Selector -->
|
||||
<button class="lang-btn" @click="toggleLanguage">
|
||||
{{ currentLang === 'es' ? '🇪🇸' : '🇺🇸' }}
|
||||
</button>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<a href="/dashboard" class="nav-btn primary">
|
||||
{{ t('navbar.dashboard') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const favicon = ref('https://docs.amayo.dev/favicon.ico') // Reemplaza con el avatar real del bot
|
||||
const botName = ref('Amayo')
|
||||
|
||||
const currentTheme = ref('red')
|
||||
const currentLang = computed(() => locale.value)
|
||||
const showThemeMenu = ref(false)
|
||||
const themeDropdown = ref(null)
|
||||
|
||||
const themes = [
|
||||
{ name: 'red', gradient: 'linear-gradient(135deg, #ff1744, #d50000)' },
|
||||
{ name: 'blue', gradient: 'linear-gradient(135deg, #2196f3, #1565c0)' },
|
||||
{ name: 'green', gradient: 'linear-gradient(135deg, #00e676, #00c853)' },
|
||||
{ name: 'purple', gradient: 'linear-gradient(135deg, #e040fb, #9c27b0)' },
|
||||
{ name: 'orange', gradient: 'linear-gradient(135deg, #ff9100, #ff6d00)' },
|
||||
]
|
||||
|
||||
const getCurrentThemeGradient = () => {
|
||||
const theme = themes.find(t => t.name === currentTheme.value)
|
||||
return theme ? theme.gradient : themes[0].gradient
|
||||
}
|
||||
|
||||
const toggleThemeMenu = () => {
|
||||
showThemeMenu.value = !showThemeMenu.value
|
||||
}
|
||||
|
||||
const changeTheme = (themeName) => {
|
||||
currentTheme.value = themeName
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
localStorage.setItem('theme', themeName)
|
||||
showThemeMenu.value = false
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
locale.value = locale.value === 'es' ? 'en' : 'es'
|
||||
localStorage.setItem('language', locale.value)
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (themeDropdown.value && !themeDropdown.value.contains(event.target)) {
|
||||
showThemeMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const savedLang = localStorage.getItem('language')
|
||||
|
||||
if (savedTheme) {
|
||||
currentTheme.value = savedTheme
|
||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||
}
|
||||
|
||||
if (savedLang) {
|
||||
locale.value = savedLang
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.island-navbar {
|
||||
position: fixed;
|
||||
top: 25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: rgba(10, 10, 10, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--color-primary, #ff1744);
|
||||
box-shadow: 0 0 20px var(--color-glow, rgba(255, 23, 68, 0.3));
|
||||
}
|
||||
|
||||
.bot-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
background: var(--gradient-primary, linear-gradient(135deg, #ff1744, #ff5252));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.theme-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.current-theme-preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.theme-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: rgba(10, 10, 10, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.theme-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: white;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.theme-menu-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.theme-menu-item.active {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-menu-item span {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
font-size: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-btn.primary {
|
||||
background: var(--gradient-primary, linear-gradient(135deg, #ff1744, #d50000));
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px var(--color-glow, rgba(255, 23, 68, 0.4));
|
||||
}
|
||||
|
||||
.nav-btn.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px var(--color-glow, rgba(255, 23, 68, 0.6));
|
||||
}
|
||||
|
||||
.nav-btn.secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.nav-btn.secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar-content {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-dropdown {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.island-navbar {
|
||||
position: absolute;
|
||||
top: 25px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
width: 85%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const themes = {
|
||||
red: {
|
||||
primary: '#ff1744',
|
||||
secondary: '#d50000',
|
||||
accent: '#ff5252',
|
||||
gradient: 'linear-gradient(135deg, #ff1744, #d50000)',
|
||||
glow: 'rgba(255, 23, 68, 0.5)',
|
||||
},
|
||||
blue: {
|
||||
primary: '#2196f3',
|
||||
secondary: '#1565c0',
|
||||
accent: '#64b5f6',
|
||||
gradient: 'linear-gradient(135deg, #2196f3, #1565c0)',
|
||||
glow: 'rgba(33, 150, 243, 0.5)',
|
||||
},
|
||||
green: {
|
||||
primary: '#00e676',
|
||||
secondary: '#00c853',
|
||||
accent: '#69f0ae',
|
||||
gradient: 'linear-gradient(135deg, #00e676, #00c853)',
|
||||
glow: 'rgba(0, 230, 118, 0.5)',
|
||||
},
|
||||
purple: {
|
||||
primary: '#e040fb',
|
||||
secondary: '#9c27b0',
|
||||
accent: '#ea80fc',
|
||||
gradient: 'linear-gradient(135deg, #e040fb, #9c27b0)',
|
||||
glow: 'rgba(224, 64, 251, 0.5)',
|
||||
},
|
||||
orange: {
|
||||
primary: '#ff9100',
|
||||
secondary: '#ff6d00',
|
||||
accent: '#ffab40',
|
||||
gradient: 'linear-gradient(135deg, #ff9100, #ff6d00)',
|
||||
glow: 'rgba(255, 145, 0, 0.5)',
|
||||
},
|
||||
}
|
||||
|
||||
const currentTheme = ref('red')
|
||||
|
||||
const applyTheme = (themeName) => {
|
||||
const theme = themes[themeName]
|
||||
if (!theme) return
|
||||
|
||||
const root = document.documentElement
|
||||
|
||||
// Aplicar variables CSS
|
||||
root.style.setProperty('--color-primary', theme.primary)
|
||||
root.style.setProperty('--color-secondary', theme.secondary)
|
||||
root.style.setProperty('--color-accent', theme.accent)
|
||||
root.style.setProperty('--gradient-primary', theme.gradient)
|
||||
root.style.setProperty('--color-glow', theme.glow)
|
||||
|
||||
// Aplicar data attribute para el tema
|
||||
root.setAttribute('data-theme', themeName)
|
||||
|
||||
console.log('Theme applied:', themeName, theme)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const setTheme = (themeName) => {
|
||||
if (themes[themeName]) {
|
||||
currentTheme.value = themeName
|
||||
applyTheme(themeName)
|
||||
localStorage.setItem('theme', themeName)
|
||||
}
|
||||
}
|
||||
|
||||
const initTheme = () => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme && themes[savedTheme]) {
|
||||
currentTheme.value = savedTheme
|
||||
}
|
||||
applyTheme(currentTheme.value)
|
||||
}
|
||||
|
||||
watch(currentTheme, (newTheme) => {
|
||||
applyTheme(newTheme)
|
||||
})
|
||||
|
||||
return {
|
||||
currentTheme,
|
||||
themes,
|
||||
setTheme,
|
||||
initTheme,
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ export default {
|
||||
navbar: {
|
||||
getStarted: 'Comenzar',
|
||||
dashboard: 'Panel',
|
||||
premium: 'Premium',
|
||||
},
|
||||
hero: {
|
||||
titleStart: 'Tu Compañero',
|
||||
newVersion: 'Nueva Versión',
|
||||
subtitle: 'Transforma tu servidor de Discord en una experiencia de Ultima Generacion de comandos con nuevas tecnologias.',
|
||||
exploreFeatures: 'Explorar Características',
|
||||
inviteBot: 'Invitar Bot',
|
||||
@@ -15,6 +18,30 @@ export default {
|
||||
feature2: 'Tickets',
|
||||
feature3: 'AutoMod',
|
||||
},
|
||||
features: {
|
||||
title: 'Todo lo que necesitas',
|
||||
subtitle: 'Amayo viene cargado con todas las funciones para llevar tu servidor al siguiente nivel.',
|
||||
ai: {
|
||||
title: 'Inteligencia Artificial',
|
||||
desc: 'Recomendaciones inteligentes y respuestas potenciadas por IA.'
|
||||
},
|
||||
music: {
|
||||
title: 'Música de Alta Calidad',
|
||||
desc: 'Reproduce música sin lag desde Spotify, YouTube, SoundCloud y más con filtros de audio premium.'
|
||||
},
|
||||
alliances: {
|
||||
title: 'Alianzas',
|
||||
desc: 'Gestiona alianzas con otros servidores para crecer juntos.'
|
||||
},
|
||||
web: {
|
||||
title: 'Panel Web',
|
||||
desc: 'Configura todo desde un panel de control fácil de usar y moderno.'
|
||||
},
|
||||
embedding: {
|
||||
title: 'Mejor Embedding',
|
||||
desc: 'Crea mensajes visuales impactantes con nuestro constructor de embeds.'
|
||||
}
|
||||
},
|
||||
hero_docs: {
|
||||
subtitle: 'En esta seccion esta la documentacion oficial para Amayo Bot.',
|
||||
exploreFeatures: 'Ver Comandos',
|
||||
@@ -58,14 +85,32 @@ export default {
|
||||
green: 'Verde',
|
||||
purple: 'Púrpura',
|
||||
orange: 'Naranja',
|
||||
},
|
||||
premium: {
|
||||
title: 'Elige tu Nivel',
|
||||
subtitle: 'Desbloquea todo el potencial de Amayo con nuestros planes premium.',
|
||||
get: 'Obtener',
|
||||
month: 'Mensual',
|
||||
personal: 'Uso Personal',
|
||||
boost1: '1 Boost Server',
|
||||
boost2: '2 Boost Server',
|
||||
features: {
|
||||
volumeBoost: 'Volumen Boost',
|
||||
betterRecs: 'Mejores Recomendaciones',
|
||||
playlistLimit: 'Mayor Numero de canciones a importar en la playlist 100>200',
|
||||
tier1Rewards: 'Recompensas del 1 boost',
|
||||
}
|
||||
}
|
||||
},
|
||||
en: {
|
||||
navbar: {
|
||||
getStarted: 'Get Started',
|
||||
dashboard: 'Dashboard',
|
||||
premium: 'Premium',
|
||||
},
|
||||
hero: {
|
||||
titleStart: 'Your Music',
|
||||
newVersion: 'New Version',
|
||||
subtitle: 'Transform your Discord server into a Next-Gen command experience with cutting-edge technologies.',
|
||||
exploreFeatures: 'Explore Features',
|
||||
inviteBot: 'Invite Bot',
|
||||
@@ -76,6 +121,30 @@ export default {
|
||||
feature2: 'Tickets',
|
||||
feature3: 'AutoMod',
|
||||
},
|
||||
features: {
|
||||
title: 'Everything you need',
|
||||
subtitle: 'Amayo comes loaded with all the features to take your server to the next level.',
|
||||
ai: {
|
||||
title: 'Artificial Intelligence',
|
||||
desc: 'Smart recommendations and AI-powered responses.'
|
||||
},
|
||||
music: {
|
||||
title: 'High Quality Music',
|
||||
desc: 'Play lag-free music from Spotify, YouTube, SoundCloud and more with premium audio filters.'
|
||||
},
|
||||
alliances: {
|
||||
title: 'Alliances',
|
||||
desc: 'Manage alliances with other servers to grow together.'
|
||||
},
|
||||
web: {
|
||||
title: 'Web Dashboard',
|
||||
desc: 'Configure everything from an easy-to-use and modern control panel.'
|
||||
},
|
||||
embedding: {
|
||||
title: 'Better Embedding',
|
||||
desc: 'Create stunning visual messages with our embed builder.'
|
||||
}
|
||||
},
|
||||
hero_docs: {
|
||||
subtitle: 'This section contains the official documentation for Amayo Bot.',
|
||||
exploreFeatures: 'View Commands',
|
||||
@@ -119,6 +188,21 @@ export default {
|
||||
green: 'Green',
|
||||
purple: 'Purple',
|
||||
orange: 'Orange',
|
||||
},
|
||||
premium: {
|
||||
title: 'Choose Your Tier',
|
||||
subtitle: 'Unlock the full potential of Amayo with our premium plans.',
|
||||
get: 'Get',
|
||||
month: 'Monthly',
|
||||
personal: 'Personal Use',
|
||||
boost1: '1 Boost Server',
|
||||
boost2: '2 Boost Server',
|
||||
features: {
|
||||
volumeBoost: 'Volume Boost',
|
||||
betterRecs: 'Better Recommendations',
|
||||
playlistLimit: 'Higher song import limit 100>200',
|
||||
tier1Rewards: '1 Boost Rewards',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,6 @@ const router = createRouter({
|
||||
name: 'home',
|
||||
component: () => import('../views/HomeView.vue')
|
||||
},
|
||||
{
|
||||
path: '/docs',
|
||||
name: 'docs',
|
||||
component: () => import('../views/DocsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/auth/callback',
|
||||
name: 'auth-callback',
|
||||
@@ -28,6 +23,30 @@ const router = createRouter({
|
||||
path: '/privacy',
|
||||
name: 'privacy',
|
||||
component: () => import('../views/PrivacyPolicy.vue')
|
||||
},
|
||||
{
|
||||
path: '/premium',
|
||||
name: 'premium',
|
||||
component: () => import('../views/PremiumView.vue')
|
||||
},
|
||||
{
|
||||
path: '/dash/:guildId',
|
||||
name: 'dashboard',
|
||||
component: () => import('../views/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: '/dash/:guildId/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue')
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
redirect: '/dash/me' // Temporary redirect until guild selection is implemented
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { securityService, rateLimiter } from './security'
|
||||
import { securityService } from './security'
|
||||
|
||||
// Inicializar servicio de seguridad
|
||||
await securityService.initialize().catch(err => {
|
||||
@@ -9,11 +9,12 @@ await securityService.initialize().catch(err => {
|
||||
// Crear instancia de axios con configuración de seguridad
|
||||
const createSecureAxios = () => {
|
||||
const instance = axios.create({
|
||||
timeout: 10000, // 10 segundos timeout
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
|
||||
timeout: 10000,
|
||||
withCredentials: true, // Importante para enviar cookies de sesión
|
||||
headers: securityService.getSecurityHeaders()
|
||||
})
|
||||
|
||||
// Interceptor para agregar headers de seguridad
|
||||
instance.interceptors.request.use(
|
||||
config => {
|
||||
config.headers = {
|
||||
@@ -25,11 +26,9 @@ const createSecureAxios = () => {
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Interceptor para validar respuestas
|
||||
instance.interceptors.response.use(
|
||||
response => securityService.validateResponse(response),
|
||||
error => {
|
||||
// Manejar errores de forma segura
|
||||
if (error.response?.status === 429) {
|
||||
console.error('Rate limit exceeded')
|
||||
}
|
||||
@@ -40,141 +39,47 @@ const createSecureAxios = () => {
|
||||
return instance
|
||||
}
|
||||
|
||||
const secureAxios = createSecureAxios()
|
||||
|
||||
// No exponer la URL directamente - usar el servicio de seguridad
|
||||
const getApiUrl = (path) => {
|
||||
try {
|
||||
const baseUrl = securityService.getApiEndpoint()
|
||||
return `${baseUrl}${path}`
|
||||
} catch (error) {
|
||||
console.error('Failed to get API URL:', error)
|
||||
throw new Error('API service unavailable')
|
||||
}
|
||||
}
|
||||
export const secureAxios = createSecureAxios()
|
||||
|
||||
export const authService = {
|
||||
// Redirigir al usuario a Discord OAuth2
|
||||
loginWithDiscord() {
|
||||
// Rate limiting para prevenir abuso
|
||||
if (!rateLimiter.canMakeRequest('/auth/discord', 'auth')) {
|
||||
const remainingTime = Math.ceil(rateLimiter.getRemainingTime('/auth/discord', 'auth') / 1000)
|
||||
throw new Error(`Too many login attempts. Please wait ${remainingTime} seconds.`)
|
||||
}
|
||||
// El login se hace directamente con el link href="/auth/discord" en LoginView
|
||||
// El proxy de Vite redirige /auth/* a localhost:3000
|
||||
|
||||
const clientId = import.meta.env.VITE_DISCORD_CLIENT_ID
|
||||
if (!clientId) {
|
||||
throw new Error('Discord client ID not configured')
|
||||
}
|
||||
|
||||
const redirectUri = import.meta.env.PROD
|
||||
? window.location.origin + '/auth/callback'
|
||||
: 'http://localhost:5173/auth/callback'
|
||||
|
||||
const scope = 'identify guilds'
|
||||
const state = securityService.generateSessionToken() // CSRF protection
|
||||
|
||||
// Guardar state para validación
|
||||
sessionStorage.setItem('oauth_state', state)
|
||||
|
||||
const authUrl = `https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}&state=${state}`
|
||||
|
||||
window.location.href = authUrl
|
||||
},
|
||||
|
||||
// Intercambiar código por token
|
||||
async handleCallback(code, state) {
|
||||
// Validar state para prevenir CSRF
|
||||
const savedState = sessionStorage.getItem('oauth_state')
|
||||
if (state !== savedState) {
|
||||
throw new Error('Invalid OAuth state - possible CSRF attack')
|
||||
}
|
||||
sessionStorage.removeItem('oauth_state')
|
||||
|
||||
// Rate limiting
|
||||
if (!rateLimiter.canMakeRequest('/auth/callback', 'auth')) {
|
||||
throw new Error('Too many authentication attempts')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await secureAxios.post(
|
||||
getApiUrl('/auth/discord/callback'),
|
||||
{ code, state }
|
||||
)
|
||||
|
||||
const { token, user } = response.data
|
||||
|
||||
if (!token || !user) {
|
||||
throw new Error('Invalid authentication response')
|
||||
}
|
||||
|
||||
// Guardar token de forma segura
|
||||
localStorage.setItem('authToken', token)
|
||||
localStorage.setItem('user', JSON.stringify(user))
|
||||
|
||||
return { token, user }
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error)
|
||||
throw new Error('Authentication failed')
|
||||
}
|
||||
},
|
||||
|
||||
// Obtener usuario actual
|
||||
// Obtener información de sesión actual (del backend via proxy)
|
||||
async getCurrentUser() {
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (!token) return null
|
||||
|
||||
// Rate limiting
|
||||
if (!rateLimiter.canMakeRequest('/auth/me', 'api')) {
|
||||
throw new Error('Too many requests')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await secureAxios.get(getApiUrl('/auth/me'))
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error)
|
||||
|
||||
// Si el token es inválido, hacer logout
|
||||
if (error.response?.status === 401) {
|
||||
this.logout()
|
||||
const response = await secureAxios.get('/api/session')
|
||||
if (response.data && response.data.user) {
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user))
|
||||
return response.data.user
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Error fetching session:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// Verificar autenticación (basado en sesión)
|
||||
isAuthenticated() {
|
||||
const user = localStorage.getItem('user')
|
||||
return !!user
|
||||
},
|
||||
|
||||
// Obtener usuario cacheado
|
||||
getCachedUser() {
|
||||
try {
|
||||
const user = localStorage.getItem('user')
|
||||
return user ? JSON.parse(user) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout() {
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem('user')
|
||||
securityService.clearSensitiveData()
|
||||
window.location.href = '/'
|
||||
},
|
||||
|
||||
// Verificar si el usuario está autenticado
|
||||
isAuthenticated() {
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (!token) return false
|
||||
|
||||
// Validar que el token no esté expirado (básico)
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||
const isExpired = payload.exp && payload.exp * 1000 < Date.now()
|
||||
|
||||
if (isExpired) {
|
||||
this.logout()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return !!token // Fallback si no se puede decodificar
|
||||
}
|
||||
},
|
||||
|
||||
// Obtener token
|
||||
getToken() {
|
||||
return localStorage.getItem('authToken')
|
||||
// Redirigir a la ruta de logout que será proxiada
|
||||
window.location.href = '/auth/logout'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ await securityService.initialize().catch(err => {
|
||||
// Crear instancia de axios con configuración de seguridad
|
||||
const createSecureAxios = () => {
|
||||
const instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
|
||||
timeout: 10000,
|
||||
headers: securityService.getSecurityHeaders()
|
||||
})
|
||||
@@ -54,7 +55,7 @@ export const botService = {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await secureAxios.get(getApiUrl('/bot/stats'))
|
||||
const response = await secureAxios.get(getApiUrl('/api/bot/stats'))
|
||||
|
||||
// Cachear los resultados
|
||||
this.cacheStats(response.data)
|
||||
@@ -80,7 +81,7 @@ export const botService = {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await secureAxios.get(getApiUrl('/bot/info'))
|
||||
const response = await secureAxios.get(getApiUrl('/api/bot/info'))
|
||||
|
||||
// Cachear info del bot
|
||||
this.cacheBotInfo(response.data)
|
||||
|
||||
33
AmayoWeb/src/services/embeds.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { secureAxios } from './auth'
|
||||
|
||||
export const embedsService = {
|
||||
// Get all embeds for a guild
|
||||
async getEmbeds(guildId) {
|
||||
const response = await secureAxios.get(`/api/guilds/${guildId}/embeds`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get a specific embed
|
||||
async getEmbed(guildId, embedId) {
|
||||
const response = await secureAxios.get(`/api/guilds/${guildId}/embeds/${embedId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Create a new embed
|
||||
async createEmbed(guildId, data) {
|
||||
const response = await secureAxios.post(`/api/guilds/${guildId}/embeds`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update an embed
|
||||
async updateEmbed(guildId, embedId, data) {
|
||||
const response = await secureAxios.put(`/api/guilds/${guildId}/embeds/${embedId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Delete an embed
|
||||
async deleteEmbed(guildId, embedId) {
|
||||
await secureAxios.delete(`/api/guilds/${guildId}/embeds/${embedId}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
290
AmayoWeb/src/services/guilds.js
Normal file
@@ -0,0 +1,290 @@
|
||||
import axios from 'axios'
|
||||
import { securityService, rateLimiter } from './security'
|
||||
|
||||
// Crear instancia de axios con configuración de seguridad
|
||||
const createSecureAxios = () => {
|
||||
const instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
|
||||
timeout: 10000,
|
||||
withCredentials: true, // Importante para enviar cookies
|
||||
headers: securityService.getSecurityHeaders()
|
||||
})
|
||||
|
||||
instance.interceptors.request.use(
|
||||
config => {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
...securityService.getSecurityHeaders()
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
|
||||
instance.interceptors.response.use(
|
||||
response => securityService.validateResponse(response),
|
||||
error => {
|
||||
if (error.response?.status === 429) {
|
||||
console.error('Rate limit exceeded')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
const secureAxios = createSecureAxios()
|
||||
|
||||
const getApiUrl = (path) => {
|
||||
try {
|
||||
const baseUrl = securityService.getApiEndpoint()
|
||||
return `${baseUrl}${path}`
|
||||
} catch (error) {
|
||||
console.error('Failed to get API URL:', error)
|
||||
throw new Error('API service unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
export const guildService = {
|
||||
// Obtener servidores del usuario desde la sesión (via proxy)
|
||||
async getUserGuilds() {
|
||||
// Rate limiting
|
||||
if (!rateLimiter.canMakeRequest('/api/session', 'api')) {
|
||||
return this.getCachedGuilds() || []
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await secureAxios.get('/api/session')
|
||||
|
||||
if (response.data && response.data.guilds) {
|
||||
this.cacheGuilds(response.data.guilds)
|
||||
return response.data.guilds
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('Error fetching user guilds:', error)
|
||||
return this.getCachedGuilds() || []
|
||||
}
|
||||
},
|
||||
|
||||
// Cache system
|
||||
cacheGuilds(guilds) {
|
||||
try {
|
||||
const cacheData = {
|
||||
data: guilds,
|
||||
timestamp: Date.now(),
|
||||
expiresIn: 5 * 60 * 1000 // 5 minutes
|
||||
}
|
||||
sessionStorage.setItem('user_guilds_cache', JSON.stringify(cacheData))
|
||||
} catch (error) {
|
||||
console.error('Failed to cache guilds:', error)
|
||||
}
|
||||
},
|
||||
|
||||
getCachedGuilds() {
|
||||
try {
|
||||
const cached = sessionStorage.getItem('user_guilds_cache')
|
||||
if (!cached) return null
|
||||
|
||||
const cacheData = JSON.parse(cached)
|
||||
const isExpired = Date.now() - cacheData.timestamp > cacheData.expiresIn
|
||||
|
||||
if (isExpired) {
|
||||
sessionStorage.removeItem('user_guilds_cache')
|
||||
return null
|
||||
}
|
||||
|
||||
return cacheData.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached guilds:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// Get guild statistics
|
||||
async getGuildStats(guildId) {
|
||||
if (!rateLimiter.canMakeRequest(`/api/guild/${guildId}/stats`, 'api')) {
|
||||
return this.getCachedGuildStats(guildId) || null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await secureAxios.get(`/api/guild/${guildId}/stats`)
|
||||
if (response.data) {
|
||||
this.cacheGuildStats(guildId, response.data)
|
||||
return response.data
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Error fetching guild stats:', error)
|
||||
return this.getCachedGuildStats(guildId) || null
|
||||
}
|
||||
},
|
||||
|
||||
// Get recent guild actions
|
||||
async getGuildActions(guildId) {
|
||||
if (!rateLimiter.canMakeRequest(`/api/guild/${guildId}/actions`, 'api')) {
|
||||
return this.getCachedGuildActions(guildId) || []
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await secureAxios.get(`/api/guild/${guildId}/actions`)
|
||||
if (response.data && response.data.actions) {
|
||||
this.cacheGuildActions(guildId, response.data.actions)
|
||||
return response.data.actions
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('Error fetching guild actions:', error)
|
||||
return this.getCachedGuildActions(guildId) || []
|
||||
}
|
||||
},
|
||||
|
||||
// Cache guild stats
|
||||
cacheGuildStats(guildId, stats) {
|
||||
try {
|
||||
const cacheData = {
|
||||
data: stats,
|
||||
timestamp: Date.now(),
|
||||
expiresIn: 30 * 1000 // 30 seconds
|
||||
}
|
||||
sessionStorage.setItem(`guild_stats_${guildId}`, JSON.stringify(cacheData))
|
||||
} catch (error) {
|
||||
console.error('Failed to cache guild stats:', error)
|
||||
}
|
||||
},
|
||||
|
||||
getCachedGuildStats(guildId) {
|
||||
try {
|
||||
const cached = sessionStorage.getItem(`guild_stats_${guildId}`)
|
||||
if (!cached) return null
|
||||
|
||||
const cacheData = JSON.parse(cached)
|
||||
const isExpired = Date.now() - cacheData.timestamp > cacheData.expiresIn
|
||||
|
||||
if (isExpired) {
|
||||
sessionStorage.removeItem(`guild_stats_${guildId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return cacheData.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached guild stats:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// Cache guild actions
|
||||
cacheGuildActions(guildId, actions) {
|
||||
try {
|
||||
const cacheData = {
|
||||
data: actions,
|
||||
timestamp: Date.now(),
|
||||
expiresIn: 15 * 1000 // 15 seconds
|
||||
}
|
||||
sessionStorage.setItem(`guild_actions_${guildId}`, JSON.stringify(cacheData))
|
||||
} catch (error) {
|
||||
console.error('Failed to cache guild actions:', error)
|
||||
}
|
||||
},
|
||||
|
||||
getCachedGuildActions(guildId) {
|
||||
try {
|
||||
const cached = sessionStorage.getItem(`guild_actions_${guildId}`)
|
||||
if (!cached) return null
|
||||
|
||||
const cacheData = JSON.parse(cached)
|
||||
const isExpired = Date.now() - cacheData.timestamp > cacheData.expiresIn
|
||||
|
||||
if (isExpired) {
|
||||
sessionStorage.removeItem(`guild_actions_${guildId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return cacheData.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached guild actions:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// Get guild settings
|
||||
async getGuildSettings(guildId) {
|
||||
if (!rateLimiter.canMakeRequest(`/api/guild/${guildId}/settings`, 'api')) {
|
||||
return this.getCachedGuildSettings(guildId) || null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await secureAxios.get(`/api/guild/${guildId}/settings`)
|
||||
if (response.data) {
|
||||
this.cacheGuildSettings(guildId, response.data)
|
||||
return response.data
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Error fetching guild settings:', error)
|
||||
return this.getCachedGuildSettings(guildId) || null
|
||||
}
|
||||
},
|
||||
|
||||
// Update guild settings
|
||||
async updateGuildSettings(guildId, settings) {
|
||||
try {
|
||||
const response = await secureAxios.patch(`/api/guild/${guildId}/settings`, settings)
|
||||
if (response.data && response.data.settings) {
|
||||
this.cacheGuildSettings(guildId, response.data.settings)
|
||||
return response.data
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Error updating guild settings:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Cache guild settings
|
||||
cacheGuildSettings(guildId, settings) {
|
||||
try {
|
||||
const cacheData = {
|
||||
data: settings,
|
||||
timestamp: Date.now(),
|
||||
expiresIn: 60 * 1000 // 1 minute
|
||||
}
|
||||
sessionStorage.setItem(`guild_settings_${guildId}`, JSON.stringify(cacheData))
|
||||
} catch (error) {
|
||||
console.error('Failed to cache guild settings:', error)
|
||||
}
|
||||
},
|
||||
|
||||
getCachedGuildSettings(guildId) {
|
||||
try {
|
||||
const cached = sessionStorage.getItem(`guild_settings_${guildId}`)
|
||||
if (!cached) return null
|
||||
|
||||
const cacheData = JSON.parse(cached)
|
||||
const isExpired = Date.now() - cacheData.timestamp > cacheData.expiresIn
|
||||
|
||||
if (isExpired) {
|
||||
sessionStorage.removeItem(`guild_settings_${guildId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return cacheData.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached guild settings:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// Get guild roles
|
||||
async getGuildRoles(guildId) {
|
||||
try {
|
||||
const response = await secureAxios.get(`/api/guild/${guildId}/roles`)
|
||||
return response.data || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching guild roles:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
80
AmayoWeb/src/services/user.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { secureAxios } from './auth'
|
||||
|
||||
export const userService = {
|
||||
// Get user dashboard data
|
||||
async getUserData() {
|
||||
try {
|
||||
const response = await secureAxios.get('/api/user/me')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get user data:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Redeem coupon
|
||||
async redeemCoupon(code) {
|
||||
try {
|
||||
const response = await secureAxios.post('/api/user/coupon/redeem', { code })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to redeem coupon:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Get user playlists
|
||||
async getUserPlaylists() {
|
||||
try {
|
||||
const response = await secureAxios.get('/api/user/playlists')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get user playlists:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Create playlist
|
||||
async createPlaylist(data) {
|
||||
try {
|
||||
const response = await secureAxios.post('/api/user/playlists', data)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to create playlist:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Get playlist details
|
||||
async getPlaylistDetails(id) {
|
||||
try {
|
||||
const response = await secureAxios.get(`/api/user/playlists/${id}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to get playlist details:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Reorder playlist
|
||||
async reorderPlaylist(id, tracks) {
|
||||
try {
|
||||
const response = await secureAxios.put(`/api/user/playlists/${id}/reorder`, { tracks })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to reorder playlist:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Delete playlist
|
||||
async deletePlaylist(id) {
|
||||
try {
|
||||
const response = await secureAxios.delete(`/api/user/playlists/${id}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to delete playlist:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,28 +16,22 @@ const router = useRouter()
|
||||
const message = ref('Autenticando con Discord...')
|
||||
|
||||
onMounted(async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const error = urlParams.get('error')
|
||||
|
||||
if (error) {
|
||||
message.value = 'Error en la autenticación'
|
||||
setTimeout(() => router.push('/'), 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (code) {
|
||||
try {
|
||||
await authService.handleCallback(code)
|
||||
// El backend ya procesó OAuth y estableció la cookie de sesión
|
||||
// Solo necesitamos obtener los datos del usuario
|
||||
const user = await authService.getCurrentUser()
|
||||
|
||||
if (user) {
|
||||
message.value = '¡Autenticación exitosa!'
|
||||
setTimeout(() => router.push('/dashboard'), 1500)
|
||||
} catch (err) {
|
||||
message.value = 'Error al procesar la autenticación'
|
||||
console.error(err)
|
||||
// Redirigir al selector de servidors
|
||||
setTimeout(() => router.push('/dash/me'), 500)
|
||||
} else {
|
||||
message.value = 'Error: No se pudo obtener la sesión'
|
||||
setTimeout(() => router.push('/'), 2000)
|
||||
}
|
||||
} else {
|
||||
message.value = 'Código no encontrado'
|
||||
} catch (error) {
|
||||
console.error('Auth callback error:', error)
|
||||
message.value = 'Error al procesar la autenticación'
|
||||
setTimeout(() => router.push('/'), 2000)
|
||||
}
|
||||
})
|
||||
|
||||
3401
AmayoWeb/src/views/DashboardView.vue
Normal file
@@ -1,466 +0,0 @@
|
||||
<template>
|
||||
<div class="docs-view">
|
||||
<AnimatedBackground />
|
||||
|
||||
<div class="docs-header">
|
||||
<IslandNavbar />
|
||||
<HeroSection />
|
||||
</div>
|
||||
|
||||
<!-- Contenido principal -->
|
||||
<div class="docs-body">
|
||||
<!-- Sidebar permanente -->
|
||||
<aside class="docs-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<h3>{{ t('docs.sections') }}</h3>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4>{{ t('docs.getStarted') }}</h4>
|
||||
<a href="#introduction" @click.prevent="scrollToSection('introduction')" :class="{ active: activeSection === 'introduction' }">
|
||||
📖 {{ t('docs.introduction') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4>{{ t('docs.modules') }}</h4>
|
||||
<a href="#drops" @click.prevent="scrollToSection('drops')" :class="{ active: activeSection === 'drops' }">
|
||||
🎁 {{ t('docs.drops') }}
|
||||
</a>
|
||||
<a href="#economy" @click.prevent="scrollToSection('economy')" :class="{ active: activeSection === 'economy' }">
|
||||
💰 {{ t('docs.economy') }}
|
||||
</a>
|
||||
<a href="#moderation" @click.prevent="scrollToSection('moderation')" :class="{ active: activeSection === 'moderation' }">
|
||||
🛡️ {{ t('docs.moderation') }}
|
||||
</a>
|
||||
<a href="#utilities" @click.prevent="scrollToSection('utilities')" :class="{ active: activeSection === 'utilities' }">
|
||||
🔧 {{ t('docs.utilities') }}
|
||||
</a>
|
||||
<a href="#alliances" @click.prevent="scrollToSection('alliances')" :class="{ active: activeSection === 'alliances' }">
|
||||
🤝 {{ t('docs.alliances') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4>{{ t('docs.other') }}</h4>
|
||||
<a href="#settings" @click.prevent="scrollToSection('settings')" :class="{ active: activeSection === 'settings' }">
|
||||
⚙️ {{ t('docs.settings') }}
|
||||
</a>
|
||||
<a href="#support" @click.prevent="scrollToSection('support')" :class="{ active: activeSection === 'support' }">
|
||||
💬 {{ t('docs.support') }}
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="docs-content" ref="docsContent">
|
||||
<div class="docs-container">
|
||||
<!-- Introduction Section -->
|
||||
<section id="introduction" class="doc-section">
|
||||
<h1>{{ t('docs.introduction') }}</h1>
|
||||
<p class="intro">{{ t('docs.introText') }}</p>
|
||||
|
||||
<div class="info-cards">
|
||||
<div class="info-card">
|
||||
<h3>• {{ t('docs.inviteBot') }}</h3>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h3>• {{ t('docs.joinSupport') }}</h3>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h3>• {{ t('docs.privacyPolicy') }}</h3>
|
||||
</div>
|
||||
<div class="info-card">
|
||||
<h3>• {{ t('docs.termsOfService') }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="highlight-box">
|
||||
<div class="highlight-icon">💡</div>
|
||||
<div class="highlight-content">
|
||||
<strong>{{ t('docs.defaultPrefix') }}:</strong> {{ t('docs.prefixInfo') }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Module Sections -->
|
||||
<section id="drops" class="doc-section module-section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">🎁</span>
|
||||
<h2>{{ t('docs.createDrops') }}</h2>
|
||||
</div>
|
||||
<p>{{ t('docs.dropsDescription') }}</p>
|
||||
</section>
|
||||
|
||||
<section id="utilities" class="doc-section module-section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">🔧</span>
|
||||
<h2>{{ t('docs.utilities') }}</h2>
|
||||
</div>
|
||||
<p>{{ t('docs.utilitiesDescription') }}</p>
|
||||
</section>
|
||||
|
||||
<section id="economy" class="doc-section module-section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">💰</span>
|
||||
<h2>{{ t('docs.economy') }}</h2>
|
||||
</div>
|
||||
<p>{{ t('docs.economyDescription') }}</p>
|
||||
</section>
|
||||
|
||||
<section id="moderation" class="doc-section module-section">
|
||||
<div class="section-header">
|
||||
<span class="section-icon">🛡️</span>
|
||||
<h2>{{ t('docs.moderation') }}</h2>
|
||||
</div>
|
||||
<p>{{ t('docs.moderationDescription') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import AnimatedBackground from '../components/AnimatedBackground.vue';
|
||||
import IslandNavbar from '../components/docs/IslandNavbar.vue';
|
||||
import HeroSection from '../components/docs/HeroSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { initTheme } = useTheme();
|
||||
const activeSection = ref('introduction');
|
||||
const docsContent = ref<HTMLElement | null>(null);
|
||||
|
||||
const scrollToSection = (sectionId: string) => {
|
||||
const element = document.getElementById(sectionId);
|
||||
const container = docsContent.value;
|
||||
if (element && container) {
|
||||
// calcular posición relativa dentro del contenedor scrollable
|
||||
const elemRect = element.getBoundingClientRect();
|
||||
const contRect = container.getBoundingClientRect();
|
||||
const offset = elemRect.top - contRect.top + container.scrollTop;
|
||||
container.scrollTo({ top: offset - 16, behavior: 'smooth' });
|
||||
activeSection.value = sectionId;
|
||||
return;
|
||||
}
|
||||
|
||||
// fallback al comportamiento global
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
activeSection.value = sectionId;
|
||||
}
|
||||
};
|
||||
|
||||
// Detectar sección activa con scroll (dentro del contenedor docsContent)
|
||||
const handleScroll = () => {
|
||||
const sections = ['introduction', 'drops', 'economy', 'moderation', 'utilities', 'alliances', 'settings', 'support'];
|
||||
const container = docsContent.value;
|
||||
if (!container) {
|
||||
// fallback a window
|
||||
for (const sectionId of sections) {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.top >= 0 && rect.top < window.innerHeight / 2) {
|
||||
activeSection.value = sectionId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const contRect = container.getBoundingClientRect();
|
||||
for (const sectionId of sections) {
|
||||
const element = document.getElementById(sectionId);
|
||||
if (element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const top = rect.top - contRect.top; // posición relativa dentro del contenedor
|
||||
if (top >= 0 && top < container.clientHeight / 2) {
|
||||
activeSection.value = sectionId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initTheme();
|
||||
// si existe el contenedor de docs, listen al scroll interno
|
||||
if (docsContent.value) {
|
||||
docsContent.value.addEventListener('scroll', handleScroll, { passive: true });
|
||||
// inicializar estado
|
||||
handleScroll();
|
||||
} else {
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (docsContent.value) {
|
||||
docsContent.value.removeEventListener('scroll', handleScroll);
|
||||
} else {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.docs-view {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Contenedor principal que agrupa sidebar + contenido */
|
||||
.docs-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Sidebar Fijo */
|
||||
.docs-sidebar {
|
||||
position: sticky;
|
||||
left: 20px;
|
||||
top: 120px;
|
||||
width: 240px;
|
||||
height: calc(100vh - 140px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-nav h3 {
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.nav-section h4 {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-nav a {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-nav a:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.sidebar-nav a.active {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Contenido principal con compensación automática */
|
||||
.docs-content {
|
||||
width: 100%;
|
||||
/* convertir en contenedor scrollable independiente */
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
padding-left: 24%; /* reserva espacio para el sidebar */
|
||||
padding-right: 40px;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.docs-container {
|
||||
max-width: 900px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.doc-section {
|
||||
padding: 60px 0;
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
.doc-section h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #fff, #ff5252);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Info Cards */
|
||||
.info-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.info-card:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Highlight Box */
|
||||
.highlight-box {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background: rgba(0, 230, 118, 0.05);
|
||||
border: 1px solid rgba(0, 230, 118, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.highlight-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.highlight-content {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.highlight-content strong {
|
||||
color: #00e676;
|
||||
}
|
||||
|
||||
/* Module Sections */
|
||||
.module-section {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 2rem;
|
||||
color: white;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.module-section > p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.6;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* Scrollbar personalizado */
|
||||
.docs-sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.docs-sidebar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.docs-sidebar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.docs-sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.docs-sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
padding-left: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 968px) {
|
||||
.docs-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.doc-section h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.info-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
373
AmayoWeb/src/views/EmbedsView.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="embeds-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<div class="header-content">
|
||||
<h2 class="view-title">Custom Messages</h2>
|
||||
<p class="view-subtitle">Create rich visual messages for your server</p>
|
||||
</div>
|
||||
<button class="create-btn" @click="createNewEmbed">
|
||||
<span class="icon">+</span>
|
||||
Create Embed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading your embeds...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="embeds.length === 0" class="empty-state">
|
||||
<div class="empty-icon">🎨</div>
|
||||
<h3>No embeds yet</h3>
|
||||
<p>Create your first custom embed to make your server stand out!</p>
|
||||
<button class="create-btn-large" @click="createNewEmbed">
|
||||
Start Creating
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Embeds Grid -->
|
||||
<div v-else class="embeds-grid">
|
||||
<div
|
||||
v-for="embed in embeds"
|
||||
:key="embed.id"
|
||||
class="embed-card"
|
||||
@click="editEmbed(embed)"
|
||||
>
|
||||
<div class="embed-preview" :style="{ borderLeftColor: '#' + embed.color.toString(16).padStart(6, '0') }">
|
||||
<div class="embed-header">
|
||||
<h3 class="embed-title">{{ embed.name || 'Untitled Embed' }}</h3>
|
||||
</div>
|
||||
<div class="embed-body">
|
||||
<div class="component-count">
|
||||
<span class="icon">🧩</span>
|
||||
{{ embed.components.length }} components
|
||||
</div>
|
||||
<div class="last-updated">
|
||||
Updated {{ formatDate(embed.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="embed-actions">
|
||||
<button class="action-btn edit" @click.stop="editEmbed(embed)" title="Edit">
|
||||
✏️
|
||||
</button>
|
||||
<button class="action-btn delete" @click.stop="deleteEmbed(embed)" title="Delete">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Builder Modal -->
|
||||
<div v-if="showBuilder" class="builder-modal">
|
||||
<EmbedBuilder
|
||||
:initial-data="selectedEmbed"
|
||||
:guild="guild"
|
||||
@close="closeBuilder"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { embedsService } from '../services/embeds'
|
||||
import EmbedBuilder from '../components/EmbedBuilder.vue'
|
||||
|
||||
const props = defineProps({
|
||||
guildId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
guild: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const embeds = ref([])
|
||||
const showBuilder = ref(false)
|
||||
const selectedEmbed = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('EmbedsView mounted for guild:', props.guildId)
|
||||
await loadEmbeds()
|
||||
})
|
||||
|
||||
const loadEmbeds = async () => {
|
||||
console.log('Loading embeds...')
|
||||
loading.value = true
|
||||
try {
|
||||
const rawEmbeds = await embedsService.getEmbeds(props.guildId)
|
||||
embeds.value = rawEmbeds.map(e => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
updatedAt: e.updatedAt,
|
||||
title: e.config?.title || '',
|
||||
color: e.config?.color || 0x5865F2,
|
||||
coverImage: e.config?.coverImage || '',
|
||||
components: e.config?.components || []
|
||||
}))
|
||||
console.log('Embeds loaded:', embeds.value)
|
||||
} catch (error) {
|
||||
console.error('Failed to load embeds:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createNewEmbed = () => {
|
||||
selectedEmbed.value = null
|
||||
showBuilder.value = true
|
||||
}
|
||||
|
||||
const editEmbed = (embed) => {
|
||||
selectedEmbed.value = JSON.parse(JSON.stringify(embed)) // Deep copy
|
||||
showBuilder.value = true
|
||||
}
|
||||
|
||||
const deleteEmbed = async (embed) => {
|
||||
if (!confirm(`Are you sure you want to delete "${embed.title}"?`)) return
|
||||
|
||||
try {
|
||||
await embedsService.deleteEmbed(props.guildId, embed.id)
|
||||
embeds.value = embeds.value.filter(e => e.id !== embed.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete embed:', error)
|
||||
alert('Failed to delete embed')
|
||||
}
|
||||
}
|
||||
|
||||
const closeBuilder = () => {
|
||||
showBuilder.value = false
|
||||
selectedEmbed.value = null
|
||||
}
|
||||
|
||||
const handleSave = async (embedData) => {
|
||||
try {
|
||||
// Construct payload for backend (nested data)
|
||||
const payload = {
|
||||
name: embedData.name,
|
||||
data: {
|
||||
title: embedData.title,
|
||||
color: embedData.color,
|
||||
coverImage: embedData.coverImage,
|
||||
components: embedData.components
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEmbed.value) {
|
||||
// Update existing
|
||||
await embedsService.updateEmbed(props.guildId, selectedEmbed.value.id, payload)
|
||||
} else {
|
||||
// Create new
|
||||
await embedsService.createEmbed(props.guildId, payload)
|
||||
}
|
||||
await loadEmbeds()
|
||||
closeBuilder()
|
||||
} catch (error) {
|
||||
console.error('Failed to save embed:', error)
|
||||
alert('Failed to save embed')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embeds-view {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.view-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
background: linear-gradient(135deg, #fff 0%, #a5b4fc 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.view-subtitle {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(88, 101, 242, 0.4);
|
||||
}
|
||||
|
||||
.embeds-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.embed-card {
|
||||
background: var(--unity-card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.embed-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.embed-preview {
|
||||
padding: 20px;
|
||||
border-left: 4px solid #5865f2;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.embed-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.embed-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.component-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.embed-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
background: rgba(255, 59, 59, 0.1);
|
||||
color: #ff3b3b;
|
||||
}
|
||||
|
||||
.builder-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--unity-bg);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Loading & Empty States */
|
||||
.loading-state, .empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.create-btn-large {
|
||||
margin-top: 20px;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.create-btn-large:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
<AnimatedBackground />
|
||||
<IslandNavbar />
|
||||
<HeroSection />
|
||||
<FeaturesSection />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +11,7 @@
|
||||
import AnimatedBackground from '../components/AnimatedBackground.vue'
|
||||
import IslandNavbar from '../components/IslandNavbar.vue'
|
||||
import HeroSection from '../components/HeroSection.vue'
|
||||
import FeaturesSection from '../components/FeaturesSection.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
394
AmayoWeb/src/views/LoginView.vue
Normal file
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div class="login-view celestial-theme">
|
||||
<div class="magical-bg">
|
||||
<div class="stars-sm"></div>
|
||||
<div class="stars-md"></div>
|
||||
<div class="nebula"></div>
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Sparkles -->
|
||||
<div class="sparkles-container">
|
||||
<svg class="sparkle s1" viewBox="0 0 24 24"><path fill="currentColor" d="M12 0L14.59 9.41L24 12L14.59 14.59L12 24L9.41 14.59L0 12L9.41 9.41L12 0Z"/></svg>
|
||||
<svg class="sparkle s2" viewBox="0 0 24 24"><path fill="currentColor" d="M12 0L14.59 9.41L24 12L14.59 14.59L12 24L9.41 14.59L0 12L9.41 9.41L12 0Z"/></svg>
|
||||
<svg class="sparkle s3" viewBox="0 0 24 24"><path fill="currentColor" d="M12 0L14.59 9.41L24 12L14.59 14.59L12 24L9.41 14.59L0 12L9.41 9.41L12 0Z"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="login-container">
|
||||
<!-- Logo Section -->
|
||||
<div class="logo-section fade-in-down">
|
||||
<div class="logo-halo"></div>
|
||||
<img src="https://docs.amayo.dev/favicon.ico" alt="Amayo" class="logo-img" />
|
||||
<h1 class="brand-name">Amayo</h1>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div class="login-card glass-panel scale-in">
|
||||
<div class="card-content">
|
||||
<h2 class="card-title">Welcome Back</h2>
|
||||
<p class="card-subtitle">Access your dashboard and manage your server</p>
|
||||
|
||||
<a :href="discordAuthUrl" class="discord-btn">
|
||||
<svg class="discord-icon" viewBox="0 0 127.14 96.36">
|
||||
<path fill="currentColor" d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
|
||||
</svg>
|
||||
<span>Continue with Discord</span>
|
||||
<div class="btn-shine"></div>
|
||||
</a>
|
||||
|
||||
<div class="checkbox-wrapper">
|
||||
<label class="custom-checkbox">
|
||||
<input type="checkbox" checked>
|
||||
<span class="checkmark"></span>
|
||||
<span class="label-text">Automatically log in next time</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer fade-in-up">
|
||||
<p>© 2024 Amayo • <a href="#">Terms</a> • <a href="#">Privacy</a> • <a href="#">Legal Notice</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Use environment variable for API URL
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const discordAuthUrl = `${apiUrl}/auth/discord`
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-view {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', sans-serif; /* Cleaner font for this specific look */
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Cinematic Background */
|
||||
.magical-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.stars-sm, .stars-md {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
radial-gradient(1px 1px at 20px 30px, #fff, transparent),
|
||||
radial-gradient(1px 1px at 40px 70px, #fff, transparent),
|
||||
radial-gradient(1px 1px at 50px 160px, #fff, transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, #fff, transparent),
|
||||
radial-gradient(1px 1px at 130px 80px, #fff, transparent);
|
||||
background-size: 200px 200px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.stars-sm { animation: starsMove 60s linear infinite; }
|
||||
.stars-md {
|
||||
background-size: 300px 300px;
|
||||
animation: starsMove 100s linear infinite;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.nebula {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 150vw;
|
||||
height: 150vh;
|
||||
background: radial-gradient(circle, rgba(113, 24, 148, 0.15) 0%, rgba(0,0,0,0) 60%);
|
||||
filter: blur(80px);
|
||||
animation: nebulaPulse 8s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, transparent 0%, #000 90%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Sparkles */
|
||||
.sparkles-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ff69b4; /* Pinkish sparkle from reference */
|
||||
filter: drop-shadow(0 0 5px currentColor);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.s1 { top: 20%; left: 20%; animation-delay: 0s; }
|
||||
.s2 { top: 70%; right: 20%; animation-delay: 2s; width: 15px; height: 15px; }
|
||||
.s3 { top: 30%; right: 30%; animation-delay: 4s; width: 10px; height: 10px; }
|
||||
|
||||
/* Layout */
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
/* Logo Section */
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo-halo {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.2) 0%, transparent 70%);
|
||||
filter: blur(20px);
|
||||
animation: pulse 4s infinite;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Login Card */
|
||||
.login-card {
|
||||
width: 400px;
|
||||
background: rgba(10, 10, 10, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-bottom: 30px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
/* Discord Button */
|
||||
.discord-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: #5865F2;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
overflow: hidden;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.discord-btn:hover {
|
||||
background: #4752C4;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(88, 101, 242, 0.3);
|
||||
}
|
||||
|
||||
.discord-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.discord-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.btn-shine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transform: skewX(-20deg);
|
||||
transition: 0.5s;
|
||||
}
|
||||
|
||||
.discord-btn:hover .btn-shine {
|
||||
left: 150%;
|
||||
transition: 0.7s ease-in-out;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.custom-checkbox input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.custom-checkbox input:checked ~ .checkmark {
|
||||
background: #ff4757; /* Accent color from reference */
|
||||
}
|
||||
|
||||
.checkmark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 2px;
|
||||
width: 4px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.custom-checkbox input:checked ~ .checkmark::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.custom-checkbox:hover .label-text {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
text-decoration: none;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes starsMove {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-100px); }
|
||||
}
|
||||
|
||||
@keyframes nebulaPulse {
|
||||
0% { opacity: 0.1; transform: translate(-50%, -50%) scale(1); }
|
||||
100% { opacity: 0.2; transform: translate(-50%, -50%) scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-15px) rotate(5deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 0.8; transform: translate(-50%, -50%) scale(1.1); }
|
||||
}
|
||||
|
||||
/* Entrance Animations */
|
||||
.fade-in-down {
|
||||
animation: fadeInDown 1s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 1s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
opacity: 0;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.scale-in {
|
||||
animation: scaleIn 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; /* Elastic overshoot */
|
||||
opacity: 0;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
from { opacity: 0; transform: translateY(-30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.9) translateY(20px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
</style>
|
||||
335
AmayoWeb/src/views/PremiumView.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="premium-view">
|
||||
<IslandNavbar />
|
||||
<div class="container">
|
||||
<div class="header fade-in-up">
|
||||
<h1 class="title">{{ t('premium.title') }}</h1>
|
||||
<p class="subtitle">{{ t('premium.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="tiers-grid">
|
||||
<div
|
||||
v-for="(tier, index) in tiers"
|
||||
:key="index"
|
||||
class="tier-card glass-card"
|
||||
:class="{ 'featured': tier.featured }"
|
||||
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||
>
|
||||
<div class="card-glow"></div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="tier-image-container">
|
||||
<img :src="tier.image" :alt="t(tier.nameKey)" class="tier-image" />
|
||||
</div>
|
||||
|
||||
<div class="personal-badge">
|
||||
{{ t('premium.personal') }}
|
||||
</div>
|
||||
|
||||
<div class="tier-header">
|
||||
<div class="tier-icon"><img :style="{ width: '4rem', height: '4rem' }" :src="tier.icon" :alt="t(tier.nameKey)" class="tier-icon" /></div>
|
||||
<h2 class="tier-name">{{ t(tier.nameKey) }}</h2>
|
||||
<div class="tier-cost">
|
||||
<span class="cost-value">{{ t(tier.costKey) }}</span>
|
||||
<span class="cost-period">/ {{ t('premium.month') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="action-btn" :class="{ 'primary': tier.featured, 'secondary': !tier.featured }">
|
||||
{{ t('premium.get') }}
|
||||
</button>
|
||||
|
||||
<div class="tier-features">
|
||||
<div v-for="(featureKey, fIndex) in tier.features" :key="fIndex" class="feature-item">
|
||||
<span class="check-icon">✓</span>
|
||||
<span class="feature-text">{{ t(featureKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import IslandNavbar from '../components/IslandNavbar.vue'
|
||||
import boost1Img from '../assets/boost1.png'
|
||||
import boost2Img from '../assets/boost2.png'
|
||||
import boostBadge1 from '../assets/ama_elegant.png'
|
||||
import boostBadge2 from '../assets/ama_smug.png'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
nameKey: 'premium.boost1',
|
||||
costKey: 'premium.boost1',
|
||||
icon: boostBadge1,
|
||||
image: boost1Img,
|
||||
featured: false,
|
||||
features: [
|
||||
'premium.features.volumeBoost'
|
||||
]
|
||||
},
|
||||
{
|
||||
nameKey: 'premium.boost2',
|
||||
costKey: 'premium.boost2',
|
||||
icon: boostBadge2,
|
||||
image: boost2Img,
|
||||
featured: true,
|
||||
features: [
|
||||
'premium.features.betterRecs',
|
||||
'premium.features.playlistLimit',
|
||||
'premium.features.tier1Rewards'
|
||||
]
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.premium-view {
|
||||
min-height: 100vh;
|
||||
padding: 120px 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, #fff 0%, #ccc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tiers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tier-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 24px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
animation: fadeInUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tier-card:hover {
|
||||
transform: translateY(-10px);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.tier-card.featured {
|
||||
background: rgba(255, 23, 68, 0.05);
|
||||
border-color: rgba(255, 23, 68, 0.2);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.tier-card.featured:hover {
|
||||
transform: scale(1.02) translateY(-10px);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at top right, var(--color-primary, #ff1744), transparent 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tier-card:hover .card-glow {
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
/* Image */
|
||||
.tier-image-container {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tier-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.tier-card:hover .tier-image {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.personal-badge {
|
||||
position: absolute;
|
||||
top: 190px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 4px 12px;
|
||||
border-radius: 100px;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 500;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tier-header {
|
||||
text-align: center;
|
||||
padding: 30px 30px 10px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tier-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tier-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tier-cost {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary, #ff1744);
|
||||
}
|
||||
|
||||
.cost-period {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: calc(100% - 60px);
|
||||
margin: 20px 30px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--color-primary, #ff1744);
|
||||
color: white;
|
||||
box-shadow: 0 10px 30px -10px var(--color-primary, #ff1744);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 40px -10px var(--color-primary, #ff1744);
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tier-features {
|
||||
padding: 0 30px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: var(--color-primary, #ff1744);
|
||||
font-weight: bold;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.tier-card.featured {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tier-card.featured:hover {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="legal-page">
|
||||
<AnimatedBackground />
|
||||
<IslandNavbar />
|
||||
|
||||
<div class="legal-container">
|
||||
<div class="legal-header">
|
||||
@@ -196,13 +195,8 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import AnimatedBackground from '../components/AnimatedBackground.vue';
|
||||
import IslandNavbar from '../components/docs/IslandNavbar.vue';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
|
||||
const { initTheme } = useTheme();
|
||||
|
||||
onMounted(() => {
|
||||
initTheme();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
646
AmayoWeb/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,646 @@
|
||||
<template>
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<h1>⚙️ General Settings</h1>
|
||||
<p>Configure your server's basic settings</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-content" v-if="!loading">
|
||||
<!-- Prefix Setting -->
|
||||
<div class="setting-card">
|
||||
<div class="setting-header">
|
||||
<div class="setting-info">
|
||||
<h3>Prefix</h3>
|
||||
<p>The prefix used for bot commands</p>
|
||||
</div>
|
||||
<button class="edit-btn" @click="editingPrefix = !editingPrefix">
|
||||
{{ editingPrefix ? 'Cancel' : 'Edit' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!editingPrefix" class="setting-display">
|
||||
<code class="prefix-display">{{ settings.prefix }}</code>
|
||||
</div>
|
||||
|
||||
<div v-else class="setting-edit">
|
||||
<input
|
||||
v-model="tempPrefix"
|
||||
type="text"
|
||||
maxlength="10"
|
||||
placeholder="Enter prefix..."
|
||||
class="input-field"
|
||||
/>
|
||||
<div class="button-group">
|
||||
<button class="save-btn" @click="savePrefix" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Staff Roles Setting -->
|
||||
<div class="setting-card">
|
||||
<div class="setting-header">
|
||||
<div class="setting-info">
|
||||
<h3>🛡️ Staff Roles</h3>
|
||||
<p>Roles that have staff permissions (max 3)</p>
|
||||
</div>
|
||||
<button class="edit-btn" @click="editingStaff = !editingStaff">
|
||||
{{ editingStaff ? 'Cancel' : 'Edit' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!editingStaff" class="setting-display">
|
||||
<div v-if="staffRoles.length > 0" class="role-list">
|
||||
<div v-for="role in staffRoles" :key="role.id" class="role-item">
|
||||
<div class="role-color" :style="{ background: role.color || '#99AAB5' }"></div>
|
||||
<span>{{ role.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="empty-state">No staff roles configured</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="setting-edit">
|
||||
<div class="role-selector">
|
||||
<select v-model="newRoleId" class="input-field">
|
||||
<option value="">Select a role...</option>
|
||||
<option v-for="role in availableRoles" :key="role.id" :value="role.id">
|
||||
{{ role.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="add-btn" @click="addRole" :disabled="!newRoleId || tempStaff.length >= 3">
|
||||
Add Role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tempStaff.length > 0" class="selected-roles">
|
||||
<div v-for="(roleId, idx) in tempStaff" :key="roleId" class="selected-role">
|
||||
<span>{{ getRoleName(roleId) }}</span>
|
||||
<button class="remove-btn" @click="removeRole(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="save-btn" @click="saveStaff" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Role Prompt Setting -->
|
||||
<div class="setting-card">
|
||||
<div class="setting-header">
|
||||
<div class="setting-info">
|
||||
<h3>🧠 AI Role Prompt</h3>
|
||||
<p>Custom system prompt for AI responses (optional, max 1500 characters)</p>
|
||||
</div>
|
||||
<button class="edit-btn" @click="editingAI = !editingAI">
|
||||
{{ editingAI ? 'Cancel' : 'Edit' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!editingAI" class="setting-display">
|
||||
<p v-if="settings.aiRolePrompt" class="ai-prompt-display">{{ settings.aiRolePrompt }}</p>
|
||||
<span v-else class="empty-state">No AI role prompt configured</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="setting-edit">
|
||||
<textarea
|
||||
v-model="tempAIPrompt"
|
||||
maxlength="1500"
|
||||
placeholder="E.g.: You are a friendly server assistant, respond in Spanish, avoid spoilers..."
|
||||
class="textarea-field"
|
||||
rows="6"
|
||||
></textarea>
|
||||
<div class="char-count">{{ tempAIPrompt?.length || 0 }} / 1500</div>
|
||||
<div class="button-group">
|
||||
<button class="save-btn" @click="saveAIPrompt" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading settings...</p>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Toast -->
|
||||
<div v-if="toast.show" class="toast" :class="toast.type">
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { guildService } from '../services/guilds'
|
||||
|
||||
const route = useRoute()
|
||||
const guildId = ref(null)
|
||||
|
||||
// Settings data
|
||||
const settings = ref({
|
||||
prefix: '!',
|
||||
staff: [],
|
||||
aiRolePrompt: null
|
||||
})
|
||||
|
||||
// Available roles from Discord
|
||||
const availableRoles = ref([])
|
||||
const staffRoles = ref([])
|
||||
|
||||
// Editing states
|
||||
const editingPrefix = ref(false)
|
||||
const editingStaff = ref(false)
|
||||
const editingAI = ref(false)
|
||||
|
||||
// Temp values for editing
|
||||
const tempPrefix = ref('')
|
||||
const tempStaff = ref([])
|
||||
const tempAIPrompt = ref('')
|
||||
const newRoleId = ref('')
|
||||
|
||||
// Loading & saving states
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
// Toast notification
|
||||
const toast = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'success'
|
||||
})
|
||||
|
||||
const showToast = (message, type = 'success') => {
|
||||
toast.value = { show: true, message, type }
|
||||
setTimeout(() => {
|
||||
toast.value.show = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const getRoleName = (roleId) => {
|
||||
const role = availableRoles.value.find(r => r.id === roleId)
|
||||
return role ? role.name : roleId
|
||||
}
|
||||
|
||||
const addRole = () => {
|
||||
if (newRoleId.value && tempStaff.value.length < 3 && !tempStaff.value.includes(newRoleId.value)) {
|
||||
tempStaff.value.push(newRoleId.value)
|
||||
newRoleId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeRole = (index) => {
|
||||
tempStaff.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const savePrefix = async () => {
|
||||
if (!tempPrefix.value || tempPrefix.value.length > 10) {
|
||||
showToast('Prefix must be 1-10 characters', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await guildService.updateGuildSettings(guildId.value, { prefix: tempPrefix.value })
|
||||
settings.value.prefix = tempPrefix.value
|
||||
editingPrefix.value = false
|
||||
showToast('Prefix updated successfully!')
|
||||
} catch (error) {
|
||||
showToast('Failed to update prefix', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveStaff = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await guildService.updateGuildSettings(guildId.value, { staff: tempStaff.value })
|
||||
settings.value.staff = [...tempStaff.value]
|
||||
|
||||
// Update staffRoles display
|
||||
staffRoles.value = tempStaff.value.map(roleId => {
|
||||
const role = availableRoles.value.find(r => r.id === roleId)
|
||||
return role || { id: roleId, name: roleId, color: '#99AAB5' }
|
||||
})
|
||||
|
||||
editingStaff.value = false
|
||||
showToast('Staff roles updated successfully!')
|
||||
} catch (error) {
|
||||
showToast('Failed to update staff roles', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveAIPrompt = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await guildService.updateGuildSettings(guildId.value, { aiRolePrompt: tempAIPrompt.value || null })
|
||||
settings.value.aiRolePrompt = tempAIPrompt.value || null
|
||||
editingAI.value = false
|
||||
showToast('AI role prompt updated successfully!')
|
||||
} catch (error) {
|
||||
showToast('Failed to update AI role prompt', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch roles from the Discord bot via guild data
|
||||
const fetchGuildRoles = async () => {
|
||||
try {
|
||||
// First get guild stats which might have the roles, or we fetch from bot
|
||||
// For now, we'll use placeholder roles
|
||||
// In a real implementation, you'd fetch this from /api/guild/:id/roles endpoint
|
||||
availableRoles.value = [
|
||||
{ id: '1', name: 'Admin', color: '#ff3b3b' },
|
||||
{ id: '2', name: 'Moderator', color: '#3b82ff' },
|
||||
{ id: '3', name: 'Helper', color: '#43b581' },
|
||||
{ id: '4', name: 'Member', color: '#99AAB5' }
|
||||
]
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch guild roles:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
guildId.value = route.params.guildId
|
||||
|
||||
if (!guildId.value || guildId.value === 'me') {
|
||||
loading.value = false
|
||||
showToast('Please select a server first', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch settings
|
||||
const data = await guildService.getGuildSettings(guildId.value)
|
||||
if (data) {
|
||||
settings.value = data
|
||||
|
||||
// Fetch guild roles
|
||||
await fetchGuildRoles()
|
||||
|
||||
// Map staff IDs to role objects
|
||||
if (Array.isArray(settings.value.staff)) {
|
||||
staffRoles.value = settings.value.staff.map(roleId => {
|
||||
const role = availableRoles.value.find(r => r.id === roleId)
|
||||
return role || { id: roleId, name: roleId, color: '#99AAB5' }
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
showToast('Failed to load settings', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for edit mode changes to reset temp values
|
||||
const resetTempValues = () => {
|
||||
tempPrefix.value = settings.value.prefix
|
||||
tempStaff.value = [...(settings.value.staff || [])]
|
||||
tempAIPrompt.value = settings.value.aiRolePrompt || ''
|
||||
}
|
||||
|
||||
// Initialize temp values when editing starts
|
||||
const startEditing = (type) => {
|
||||
resetTempValues()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
background: var(--unity-card);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.setting-card:hover {
|
||||
background: var(--unity-card-hover);
|
||||
}
|
||||
|
||||
.setting-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.setting-info h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.setting-info p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.setting-display {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.prefix-display {
|
||||
background: rgba(255, 59, 59, 0.1);
|
||||
color: var(--accent-red);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(255, 59, 59, 0.2);
|
||||
}
|
||||
|
||||
.role-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.role-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ai-prompt-display {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.6;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.setting-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.input-field,
|
||||
.textarea-field {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
outline: none;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.input-field:focus,
|
||||
.textarea-field:focus {
|
||||
border-color: var(--accent-red);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.textarea-field {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-selector select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: rgba(67, 181, 129, 0.2);
|
||||
border: 1px solid rgba(67, 181, 129, 0.3);
|
||||
color: #43b581;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-btn:hover:not(:disabled) {
|
||||
background: rgba(67, 181, 129, 0.3);
|
||||
}
|
||||
|
||||
.add-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selected-roles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selected-role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 59, 59, 0.1);
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 59, 59, 0.2);
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff3b3b;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: rgba(255, 59, 59, 0.2);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: var(--accent-red);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.save-btn:hover:not(:disabled) {
|
||||
background: #d63333;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: var(--accent-red);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: rgba(67, 181, 129, 0.2);
|
||||
border: 1px solid rgba(67, 181, 129, 0.4);
|
||||
color: #43b581;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: rgba(255, 59, 59, 0.2);
|
||||
border: 1px solid rgba(255, 59, 59, 0.4);
|
||||
color: #ff3b3b;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-container {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setting-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="legal-page">
|
||||
<AnimatedBackground />
|
||||
<IslandNavbar />
|
||||
|
||||
<div class="legal-container">
|
||||
<div class="legal-header">
|
||||
@@ -150,13 +149,9 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import AnimatedBackground from '../components/AnimatedBackground.vue';
|
||||
import IslandNavbar from '../components/docs/IslandNavbar.vue';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
|
||||
const { initTheme } = useTheme();
|
||||
|
||||
onMounted(() => {
|
||||
initTheme();
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -31,9 +31,21 @@ export default defineConfig({
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/auth': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// No hacer proxy de /auth/callback porque es manejado por Vue Router
|
||||
bypass: (req, res, options) => {
|
||||
if (req.url === '/auth/callback' || req.url?.startsWith('/auth/callback?')) {
|
||||
return req.url // Deja que Vue Router lo maneje
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
CLOUDFLARE_FIX.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Fix Cloudflare 403 Forbidden en /auth/discord
|
||||
|
||||
## Problema
|
||||
|
||||
Cloudflare WAF (Web Application Firewall) está bloqueando las peticiones a `/auth/discord` con **403 Forbidden** ANTES de que lleguen al servidor backend.
|
||||
|
||||
**Evidencia:**
|
||||
- Frontend correctamente apunta a `https://api.amayo.dev/auth/discord` ✅
|
||||
- Backend tiene bypass de validación Cloudflare para `/auth/*` ✅
|
||||
- `curl https://api.amayo.dev/auth/discord` → **403 Forbidden**
|
||||
- Error viene de Cloudflare, no del backend
|
||||
|
||||
## Solución: Opción 1 (Recomendada) - Page Rules en Cloudflare
|
||||
|
||||
### Pasos:
|
||||
1. Ve a tu Dashboard de Cloudflare → `amayo.dev`
|
||||
2. Click en **Page Rules** (en el menú lateral)
|
||||
3. Click **Create Page Rule**
|
||||
4. Configura la regla:
|
||||
```
|
||||
URL Pattern: api.amayo.dev/auth/*
|
||||
Settings:
|
||||
- Security Level: Off
|
||||
- Disable Performance
|
||||
- Disable Apps
|
||||
```
|
||||
5. **Save and Deploy**
|
||||
|
||||
## Solución: Opción 2 (Temporal) - Desactivar Proxy en api.amayo.dev
|
||||
|
||||
### Pasos:
|
||||
1. Ve a Cloudflare Dashboard → DNS Records
|
||||
2. Encuentra el registro A para `api`
|
||||
3. **Click en la nube naranja** para cambiarla a gris (DNS only)
|
||||
4. Espera 1-2 minutos para propagación
|
||||
5. **Prueba el login**
|
||||
|
||||
⚠️ **IMPORTANTE**: Esto desprotege completamente `api.amayo.dev`. Después de verificar que funciona, puedes volver a activar el proxy (nube naranja) y usar la Opción 1.
|
||||
|
||||
## Solución: Opción 3 - Firewall Rules para permitir /auth/*
|
||||
|
||||
### Pasos:
|
||||
1. Ve a Cloudflare Dashboard → **Security** → **WAF**
|
||||
2. Ir a **Custom Rules** o **Firewall Rules**
|
||||
3. Create Rule:
|
||||
```
|
||||
Rule name: Allow OAuth Routes
|
||||
|
||||
When incoming requests match:
|
||||
Field: URI Path
|
||||
Operator: starts with
|
||||
Value: /auth/
|
||||
|
||||
Then:
|
||||
Action: Allow
|
||||
```
|
||||
4. Deploy and test
|
||||
|
||||
## Verificación
|
||||
|
||||
Después de aplicar CUALQUIERA de las soluciones:
|
||||
|
||||
```bash
|
||||
# Test desde terminal
|
||||
curl -I https://api.amayo.dev/auth/discord
|
||||
|
||||
# Debería devolver: HTTP/2 302 (redirect a Discord)
|
||||
# NO: HTTP/2 403
|
||||
```
|
||||
|
||||
## Probar Login
|
||||
|
||||
1. Abre https://docs.amayo.dev en modo incógnito
|
||||
2. Click "Continue with Discord"
|
||||
3. Debería redirigir a Discord OAuth
|
||||
4. Después del login → dashboard
|
||||
|
||||
---
|
||||
|
||||
**Recomendación Final**: Usar **Opción 1 (Page Rules)** para permitir `/auth/*` sin desproteger todo el API.
|
||||
99
PRODUCTION_ENV_SETUP.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Configuración de Variables de Entorno para Producción
|
||||
|
||||
## Variables Necesarias para OAuth
|
||||
|
||||
Agrega estas variables al archivo `.env` en tu servidor de producción:
|
||||
|
||||
```bash
|
||||
# OAuth Discord - REQUERIDO para producción
|
||||
DISCORD_REDIRECT_URI=https://api.amayo.dev/auth/callback
|
||||
NODE_ENV=production
|
||||
|
||||
# Cookie Domain (opcional, el código ya usa .amayo.dev en producción)
|
||||
# SESSION_COOKIE_DOMAIN=.amayo.dev
|
||||
```
|
||||
|
||||
## Verificar Variables Existentes
|
||||
|
||||
Asegúrate de que estas variables ya estén configuradas:
|
||||
|
||||
```bash
|
||||
# Discord OAuth Credentials (ya deberían estar configuradas)
|
||||
DISCORD_CLIENT_ID=991062751633883136
|
||||
DISCORD_CLIENT_SECRET=tu_client_secret_aqui
|
||||
|
||||
# Redis/DB en IP interna (ya configuradas)
|
||||
REDIS_URL=redis://100.120.146.67:6379
|
||||
REDIS_PASS=tu_password_redis
|
||||
DATABASE_URL=postgresql://user:password@100.120.146.67:5432/amayo
|
||||
```
|
||||
|
||||
## Configuración en Discord Developer Portal
|
||||
|
||||
1. Ve a https://discord.com/developers/applications
|
||||
2. Selecciona tu aplicación (ID: 991062751633883136)
|
||||
3. En "OAuth2" → "Redirects", asegúrate de tener:
|
||||
- ✅ `https://api.amayo.dev/auth/callback`
|
||||
|
||||
## Aplicar Cambios
|
||||
|
||||
```bash
|
||||
# En el servidor de producción
|
||||
cd /ruta/a/tu/proyecto/amayo
|
||||
|
||||
# 1. Editar .env
|
||||
nano .env
|
||||
# Agregar: DISCORD_REDIRECT_URI=https://api.amayo.dev/auth/callback
|
||||
|
||||
# 2. Pull los cambios del código
|
||||
git pull origin main # o el branch que uses
|
||||
|
||||
# 3. Reiniciar el servidor
|
||||
pm2 restart amayo
|
||||
# O si usas otro gestor:
|
||||
# systemctl restart amayo
|
||||
|
||||
# 4. Verificar logs
|
||||
pm2 logs amayo --lines 50
|
||||
```
|
||||
|
||||
## Verificación
|
||||
|
||||
1. Abre el navegador en modo incógnito
|
||||
2. Ve a `https://docs.amayo.dev`
|
||||
3. Click en "Login with Discord"
|
||||
4. Verifica en DevTools → Application → Cookies:
|
||||
- Debe existir `amayo_sid` con:
|
||||
- Domain: `.amayo.dev`
|
||||
- Secure: ✓
|
||||
- HttpOnly: ✓
|
||||
- SameSite: Lax
|
||||
5. Después del login, refresh la página
|
||||
6. La sesión debe persistir
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Si después de aplicar los cambios sigue sin funcionar:
|
||||
|
||||
1. **Verificar variables de entorno**:
|
||||
```bash
|
||||
pm2 env 0 | grep DISCORD
|
||||
# Debe mostrar DISCORD_REDIRECT_URI y DISCORD_CLIENT_ID
|
||||
```
|
||||
|
||||
2. **Verificar logs del servidor**:
|
||||
```bash
|
||||
pm2 logs amayo --lines 100
|
||||
# Buscar errores relacionados con OAuth
|
||||
```
|
||||
|
||||
3. **Verificar NGINX**:
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo tail -f /var/log/nginx/api.amayo.dev.error.log
|
||||
```
|
||||
|
||||
4. **Limpiar cookies del navegador**:
|
||||
- DevTools → Application → Cookies
|
||||
- Borrar todas las cookies de `.amayo.dev`
|
||||
- Intentar login nuevamente
|
||||
326
package-lock.json
generated
@@ -11,17 +11,20 @@
|
||||
"dependencies": {
|
||||
"@google/genai": "1.20.0",
|
||||
"@google/generative-ai": "0.24.1",
|
||||
"@prisma/client": "6.16.2",
|
||||
"@prisma/client": "6.19.0",
|
||||
"@top-gg/sdk": "3.1.6",
|
||||
"@types/ioredis": "4.28.10",
|
||||
"acorn": "8.15.0",
|
||||
"appwrite": "20.1.0",
|
||||
"chrono-node": "2.9.0",
|
||||
"discord-api-types": "0.38.26",
|
||||
"discord.js": "14.25.0",
|
||||
"ejs": "^3.1.10",
|
||||
"ioredis": "5.8.2",
|
||||
"newrelic": "13.4.0",
|
||||
"node-appwrite": "19.1.0",
|
||||
"pino": "9.13.0",
|
||||
"prisma": "6.16.2",
|
||||
"prisma": "6.19.0",
|
||||
"redis": "5.8.2",
|
||||
"shoukaku": "4.2.0",
|
||||
"zod": "4.1.12"
|
||||
@@ -229,6 +232,15 @@
|
||||
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
|
||||
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.20.0.tgz",
|
||||
@@ -308,6 +320,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
|
||||
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -835,9 +853,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.2.tgz",
|
||||
"integrity": "sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz",
|
||||
"integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -857,60 +875,60 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.2.tgz",
|
||||
"integrity": "sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz",
|
||||
"integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"c12": "3.1.0",
|
||||
"deepmerge-ts": "7.1.5",
|
||||
"effect": "3.16.12",
|
||||
"effect": "3.18.4",
|
||||
"empathic": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz",
|
||||
"integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz",
|
||||
"integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.2.tgz",
|
||||
"integrity": "sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz",
|
||||
"integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.16.2",
|
||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"@prisma/fetch-engine": "6.16.2",
|
||||
"@prisma/get-platform": "6.16.2"
|
||||
"@prisma/debug": "6.19.0",
|
||||
"@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
|
||||
"@prisma/fetch-engine": "6.19.0",
|
||||
"@prisma/get-platform": "6.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43.tgz",
|
||||
"integrity": "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==",
|
||||
"version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz",
|
||||
"integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.2.tgz",
|
||||
"integrity": "sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz",
|
||||
"integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.16.2",
|
||||
"@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43",
|
||||
"@prisma/get-platform": "6.16.2"
|
||||
"@prisma/debug": "6.19.0",
|
||||
"@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
|
||||
"@prisma/get-platform": "6.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.2.tgz",
|
||||
"integrity": "sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz",
|
||||
"integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.16.2"
|
||||
"@prisma/debug": "6.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/prisma-fmt-wasm": {
|
||||
@@ -1084,6 +1102,28 @@
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@top-gg/sdk": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@top-gg/sdk/-/sdk-3.1.6.tgz",
|
||||
"integrity": "sha512-qWWYDKAwJHaKaA/5EyLYMzfR76MwCbmKVMSXTPMd9FZFPHuLWJHO+m7Q8dWpWtcnunHa6evRAwlB3p83cht7Ww==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"raw-body": "^2.5.2",
|
||||
"undici": "^5.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@top-gg/sdk/node_modules/undici": {
|
||||
"version": "5.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
|
||||
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
@@ -1119,6 +1159,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ioredis": {
|
||||
"version": "4.28.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz",
|
||||
"integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
|
||||
@@ -1378,6 +1427,15 @@
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
@@ -1633,6 +1691,24 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
@@ -1730,9 +1806,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.16.12",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.16.12.tgz",
|
||||
"integrity": "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==",
|
||||
"version": "3.18.4",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -1834,9 +1910,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz",
|
||||
"integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==",
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
@@ -2199,6 +2275,26 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
@@ -2212,6 +2308,18 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -2251,6 +2359,30 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ioredis": {
|
||||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
|
||||
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ioredis/commands": "1.4.0",
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"denque": "^2.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.isarguments": "^3.1.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.22.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ioredis"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
@@ -2323,9 +2455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@@ -2409,6 +2541,18 @@
|
||||
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isarguments": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.snakecase": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||
@@ -2839,15 +2983,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.16.2",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.2.tgz",
|
||||
"integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz",
|
||||
"integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.16.2",
|
||||
"@prisma/engines": "6.16.2"
|
||||
"@prisma/config": "6.19.0",
|
||||
"@prisma/engines": "6.19.0"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
@@ -2943,6 +3087,21 @@
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
@@ -3005,6 +3164,27 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"redis-errors": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/request-ip": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/request-ip/-/request-ip-3.3.0.tgz",
|
||||
@@ -3095,6 +3275,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
@@ -3107,6 +3293,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shoukaku": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/shoukaku/-/shoukaku-4.2.0.tgz",
|
||||
@@ -3144,6 +3336,21 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamroller": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
|
||||
@@ -3250,10 +3457,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
||||
"license": "MIT"
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
@@ -3392,6 +3611,15 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
11
package.json
@@ -12,9 +12,9 @@
|
||||
"dev:light": "CACHE_MESSAGES_LIMIT=25 CACHE_MEMBERS_LIMIT=50 SWEEP_MESSAGES_LIFETIME_SECONDS=600 SWEEP_MESSAGES_INTERVAL_SECONDS=240 npx tsx watch --clear-screen=false src/main.ts",
|
||||
"dev:mem": "MEMORY_LOG_INTERVAL_SECONDS=120 npx tsx watch src/main.ts",
|
||||
"dev:ultra": "CACHE_MESSAGES_LIMIT=10 CACHE_MEMBERS_LIMIT=25 SWEEP_MESSAGES_LIFETIME_SECONDS=300 SWEEP_MESSAGES_INTERVAL_SECONDS=120 MEMORY_LOG_INTERVAL_SECONDS=60 ENABLE_MEMORY_OPTIMIZER=true NODE_OPTIONS='--max-old-space-size=256 --expose-gc' npx tsx watch --clear-screen=false src/main.ts",
|
||||
"dev:optimized": "MEMORY_LOG_INTERVAL_SECONDS=300 ENABLE_MEMORY_OPTIMIZER=true NODE_OPTIONS='--expose-gc' npx tsx watch src/main.ts",
|
||||
"dev:optimized": "NODE_ENV=development MEMORY_LOG_INTERVAL_SECONDS=300 ENABLE_MEMORY_OPTIMIZER=true NODE_OPTIONS='--expose-gc' npx tsx watch src/main.ts",
|
||||
"start:prod": "NODE_ENV=production NODE_OPTIONS=--max-old-space-size=384 npx tsx src/main.ts",
|
||||
"start:prod-optimized": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true NODE_OPTIONS='--max-old-space-size=512 --expose-gc' npx tsx src/main.ts",
|
||||
"start:prod-optimized": "NODE_ENV=production ENABLE_MEMORY_OPTIMIZER=true npx tsx src/main.ts",
|
||||
"tsc": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"seed:minigames": "npx tsx src/game/minigames/seed.ts",
|
||||
@@ -30,17 +30,20 @@
|
||||
"dependencies": {
|
||||
"@google/genai": "1.20.0",
|
||||
"@google/generative-ai": "0.24.1",
|
||||
"@prisma/client": "6.16.2",
|
||||
"@prisma/client": "6.19.0",
|
||||
"@top-gg/sdk": "3.1.6",
|
||||
"@types/ioredis": "4.28.10",
|
||||
"acorn": "8.15.0",
|
||||
"appwrite": "20.1.0",
|
||||
"chrono-node": "2.9.0",
|
||||
"discord-api-types": "0.38.26",
|
||||
"discord.js": "14.25.0",
|
||||
"ejs": "^3.1.10",
|
||||
"ioredis": "5.8.2",
|
||||
"newrelic": "13.4.0",
|
||||
"node-appwrite": "19.1.0",
|
||||
"pino": "9.13.0",
|
||||
"prisma": "6.16.2",
|
||||
"prisma": "6.19.0",
|
||||
"redis": "5.8.2",
|
||||
"shoukaku": "4.2.0",
|
||||
"zod": "4.1.12"
|
||||
|
||||
@@ -101,6 +101,9 @@ model User {
|
||||
AuditLog AuditLog[]
|
||||
PlayerStatusEffect PlayerStatusEffect[]
|
||||
DeathLog DeathLog[]
|
||||
|
||||
// Suscripción
|
||||
subscription UserSubscription?
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1176,6 +1179,7 @@ model PlaylistTrack {
|
||||
thumbnail String?
|
||||
url String?
|
||||
addedAt DateTime @default(now())
|
||||
order Int @default(0)
|
||||
|
||||
playlist MusicPlaylist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1202,3 +1206,52 @@ model TrackLike {
|
||||
@@index([userId, guildId])
|
||||
@@index([trackId])
|
||||
}
|
||||
|
||||
/**
|
||||
* -----------------------------------------------------------------------------
|
||||
* Sistema de Cupones y Suscripciones
|
||||
* -----------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
model Coupon {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
type String // IMPORT_LIMIT, VOLUME_BOOST, PRO_RECOMMENDATIONS, ALL_ACCESS
|
||||
value Int // Valor numérico (ej. 500 canciones, 200% volumen)
|
||||
maxUses Int @default(1)
|
||||
usedCount Int @default(0)
|
||||
daysValid Int? // Días de duración de la suscripción una vez canjeado
|
||||
expiresAt DateTime? // Fecha de expiración del cupón (si no se usa)
|
||||
|
||||
redemptions CouponRedemption[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String // ID del admin/owner
|
||||
}
|
||||
|
||||
model CouponRedemption {
|
||||
id String @id @default(cuid())
|
||||
couponId String
|
||||
userId String
|
||||
redeemedAt DateTime @default(now())
|
||||
|
||||
coupon Coupon @relation(fields: [couponId], references: [id])
|
||||
|
||||
@@unique([couponId, userId]) // Un usuario solo puede canjear un cupón una vez
|
||||
}
|
||||
|
||||
model UserSubscription {
|
||||
userId String @id
|
||||
|
||||
// Beneficios activos
|
||||
importLimit Int @default(100)
|
||||
maxVolume Int @default(100)
|
||||
recommendationLevel String @default("standard") // standard, pro
|
||||
|
||||
// Expiración de beneficios
|
||||
expiresAt DateTime?
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
61
src/commands/messages/admin/create_coupon.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { CommandMessage } from "../../../core/types/commands";
|
||||
import type Amayo from "../../../core/client";
|
||||
import { CouponService } from "../../../core/services/CouponService";
|
||||
|
||||
const OWNER_ID = "327207082203938818";
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: "createcoupon",
|
||||
type: "message",
|
||||
aliases: ["gen-code", "cc"],
|
||||
description: "Genera un nuevo cupón (Solo Owner)",
|
||||
category: "Admin",
|
||||
usage: "createcoupon <type> <value> [max_uses] [days_valid]",
|
||||
run: async (message, args, client: Amayo) => {
|
||||
if (message.author.id !== OWNER_ID) {
|
||||
await message.reply("❌ No tienes permiso para usar este comando.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length < 2) {
|
||||
await message.reply(
|
||||
"❌ Uso: `!createcoupon <type> <value> [max_uses] [days_valid]`\n" +
|
||||
"Tipos: `IMPORT_LIMIT`, `VOLUME_BOOST`, `PRO_RECOMMENDATIONS`, `ALL_ACCESS`"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = args[0].toUpperCase();
|
||||
const value = parseInt(args[1]);
|
||||
const maxUses = args[2] ? parseInt(args[2]) : 1;
|
||||
const daysValid = args[3] ? parseInt(args[3]) : 30;
|
||||
|
||||
const validTypes = ["IMPORT_LIMIT", "VOLUME_BOOST", "PRO_RECOMMENDATIONS", "ALL_ACCESS"];
|
||||
if (!validTypes.includes(type)) {
|
||||
await message.reply(`❌ Tipo inválido. Tipos válidos: ${validTypes.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const coupon = await CouponService.createCoupon(
|
||||
type,
|
||||
value,
|
||||
maxUses,
|
||||
daysValid,
|
||||
message.author.id
|
||||
);
|
||||
|
||||
await message.reply(
|
||||
`✅ **Cupón Creado Exitosamente**\n` +
|
||||
`🎫 Código: \`${coupon.code}\`\n` +
|
||||
`📦 Tipo: ${type}\n` +
|
||||
`💎 Valor: ${value}\n` +
|
||||
`👥 Usos Máximos: ${maxUses}\n` +
|
||||
`📅 Días de Validez: ${daysValid}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Error creating coupon:", error);
|
||||
await message.reply(`❌ Error al crear cupón: ${error.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
51
src/commands/messages/general/redeem.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CommandMessage } from "../../../core/types/commands";
|
||||
import type Amayo from "../../../core/client";
|
||||
import { CouponService } from "../../../core/services/CouponService";
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: "canjear",
|
||||
type: "message",
|
||||
aliases: ["redeem", "claim", "cupon"],
|
||||
description: "Canjea un código de cupón para obtener beneficios",
|
||||
category: "General",
|
||||
usage: "canjear <código>",
|
||||
run: async (message, args, client: Amayo) => {
|
||||
if (!args[0]) {
|
||||
await message.reply("❌ Debes proporcionar un código de cupón.");
|
||||
return;
|
||||
}
|
||||
|
||||
const code = args[0].toUpperCase();
|
||||
|
||||
try {
|
||||
const result = await CouponService.redeemCoupon(code, message.author.id);
|
||||
|
||||
let benefitText = "";
|
||||
switch (result.type) {
|
||||
case "IMPORT_LIMIT":
|
||||
benefitText = `📥 Límite de importación aumentado a **${result.value} canciones**`;
|
||||
break;
|
||||
case "VOLUME_BOOST":
|
||||
benefitText = `🔊 Volumen máximo aumentado a **${result.value}%**`;
|
||||
break;
|
||||
case "PRO_RECOMMENDATIONS":
|
||||
benefitText = `🧠 Recomendaciones **PRO** activadas`;
|
||||
break;
|
||||
case "ALL_ACCESS":
|
||||
benefitText = `👑 **ACCESO TOTAL** (Límite 500, Volumen 200%, Recomendaciones PRO)`;
|
||||
break;
|
||||
default:
|
||||
benefitText = `Beneficio desconocido`;
|
||||
}
|
||||
|
||||
await message.reply(
|
||||
`✅ **¡Cupón Canjeado Exitosamente!**\n\n` +
|
||||
`${benefitText}\n` +
|
||||
`📅 Validez: **${result.days} días**`
|
||||
);
|
||||
|
||||
} catch (error: any) {
|
||||
await message.reply(`❌ Error al canjear: ${error.message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,12 +1,24 @@
|
||||
import { CommandMessage } from "../../../core/types/commands";
|
||||
import type Amayo from "../../../core/client";
|
||||
import { MusicHistoryService } from "../../../core/services/MusicHistoryService";
|
||||
import { RecommendationEngine } from "../../../core/services/RecommendationEngine";
|
||||
import {
|
||||
SmartRecommendationEngine,
|
||||
WeightProfile,
|
||||
calculateDynamicWeights,
|
||||
} from "../../../core/services/SmartRecommendationEngine";
|
||||
import { queues } from "./play";
|
||||
|
||||
// Track autoplay status per guild
|
||||
// Track autoplay status per guild (ENABLED BY DEFAULT)
|
||||
const autoplayEnabled = new Map<string, boolean>();
|
||||
|
||||
// Helper to check if autoplay is enabled (defaults to true)
|
||||
export function isAutoplayEnabledForGuild(guildId: string): boolean {
|
||||
if (!autoplayEnabled.has(guildId)) {
|
||||
autoplayEnabled.set(guildId, true); // Default: ON
|
||||
}
|
||||
return autoplayEnabled.get(guildId)!;
|
||||
}
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: "autoplay",
|
||||
type: "message",
|
||||
@@ -15,20 +27,22 @@ export const command: CommandMessage = {
|
||||
description:
|
||||
"Activa/desactiva el autoplay inteligente que aprende de tus gustos",
|
||||
category: "Música",
|
||||
usage: "autoplay [on|off]",
|
||||
usage: "autoplay [on|off|config|stats]",
|
||||
run: async (message, args, client: Amayo) => {
|
||||
const guildId = message.guild!.id;
|
||||
const userId = message.author.id;
|
||||
|
||||
// Toggle or set autoplay
|
||||
if (!args.length) {
|
||||
const current = autoplayEnabled.get(guildId) || false;
|
||||
// Toggle autoplay
|
||||
const current = isAutoplayEnabledForGuild(guildId);
|
||||
autoplayEnabled.set(guildId, !current);
|
||||
|
||||
if (!current) {
|
||||
await message.reply(
|
||||
"✅ **Autoplay activado!**\n" +
|
||||
"🎵 El bot agregará canciones automáticamente basadas en tu historial de escucha.\n" +
|
||||
"💡 Mientras más escuches, mejores serán las recomendaciones."
|
||||
"🎵 El bot agregará canciones automáticamente usando el algoritmo de **Puntuación Ponderada**.\n" +
|
||||
"💡 Mientras más escuches, mejores serán las recomendaciones.\n\n" +
|
||||
"📊 Usa `!autoplay stats` para ver cómo funciona el algoritmo."
|
||||
);
|
||||
} else {
|
||||
await message.reply("❌ **Autoplay desactivado.**");
|
||||
@@ -38,61 +52,227 @@ export const command: CommandMessage = {
|
||||
|
||||
const action = args[0].toLowerCase();
|
||||
|
||||
if (action === "on" || action === "enable" || action === "activar") {
|
||||
switch (action) {
|
||||
case "on":
|
||||
case "enable":
|
||||
case "activar":
|
||||
autoplayEnabled.set(guildId, true);
|
||||
await message.reply(
|
||||
"✅ **Autoplay activado!**\n" +
|
||||
"🎵 Recomendaciones personalizadas basadas en tu historial."
|
||||
"🎵 Recomendaciones personalizadas con algoritmo de puntuación ponderada."
|
||||
);
|
||||
} else if (
|
||||
action === "off" ||
|
||||
action === "disable" ||
|
||||
action === "desactivar"
|
||||
) {
|
||||
break;
|
||||
|
||||
case "off":
|
||||
case "disable":
|
||||
case "desactivar":
|
||||
autoplayEnabled.set(guildId, false);
|
||||
await message.reply("❌ **Autoplay desactivado.**");
|
||||
break;
|
||||
|
||||
case "config":
|
||||
case "configurar":
|
||||
// Show or update weights
|
||||
if (args.length === 1) {
|
||||
const history = await MusicHistoryService.getRecentHistory(userId, 100);
|
||||
const weights = await SmartRecommendationEngine.getWeights(userId);
|
||||
const dynamicInfo = SmartRecommendationEngine.getDynamicWeightsInfo(history.length);
|
||||
|
||||
await message.reply(
|
||||
"⚙️ **Configuración del Algoritmo DINÁMICO**\n\n" +
|
||||
`**Perfil:** ${dynamicInfo.profile}\n` +
|
||||
`**Descripción:** ${dynamicInfo.description}\n\n` +
|
||||
`**Fórmula:** Score = (J × Wj) + (A × Wa) + (F × Wf) - (H × Wh)\n\n` +
|
||||
`**Pesos actuales (${history.length} canciones):**\n` +
|
||||
`🏷️ Tags (J): **${(weights.tags * 100).toFixed(0)}%**\n` +
|
||||
`🎤 Artista (A): **${(weights.artist * 100).toFixed(0)}%**\n` +
|
||||
`📺 Fuente/Related (F): **${(weights.source * 100).toFixed(0)}%**\n` +
|
||||
`📜 Penalización Historial (H): **${(weights.history * 100).toFixed(0)}%**\n\n` +
|
||||
`💡 **Ajustar (avanzado):** \`!autoplay config <tags> <artist> <source> <history>\`\n` +
|
||||
`📝 **Ejemplo:** \`!autoplay config 0.4 0.35 0.5 0.25\`\n` +
|
||||
`🔄 **Reset (volver a dinámico):** \`!autoplay config reset\``
|
||||
);
|
||||
} else if (args[1] === "reset") {
|
||||
SmartRecommendationEngine.resetWeights(userId);
|
||||
const history = await MusicHistoryService.getRecentHistory(userId, 100);
|
||||
const dynamicWeights = calculateDynamicWeights(history.length);
|
||||
await message.reply(
|
||||
"✅ **Pesos restaurados a modo DINÁMICO.**\n\n" +
|
||||
`Con tu historial de ${history.length} canciones:\n` +
|
||||
`🏷️ Tags: ${(dynamicWeights.tags * 100).toFixed(0)}%\n` +
|
||||
`🎤 Artista: ${(dynamicWeights.artist * 100).toFixed(0)}%\n` +
|
||||
`📺 Related: ${(dynamicWeights.source * 100).toFixed(0)}%\n` +
|
||||
`📜 Historial: ${(dynamicWeights.history * 100).toFixed(0)}%`
|
||||
);
|
||||
} else if (args.length === 5) {
|
||||
const tags = parseFloat(args[1]);
|
||||
const artist = parseFloat(args[2]);
|
||||
const source = parseFloat(args[3]);
|
||||
const history = parseFloat(args[4]);
|
||||
|
||||
if (
|
||||
isNaN(tags) ||
|
||||
isNaN(artist) ||
|
||||
isNaN(source) ||
|
||||
isNaN(history) ||
|
||||
tags < 0 ||
|
||||
artist < 0 ||
|
||||
source < 0 ||
|
||||
history < 0 ||
|
||||
tags > 1 ||
|
||||
artist > 1 ||
|
||||
source > 1 ||
|
||||
history > 1
|
||||
) {
|
||||
await message.reply(
|
||||
"❌ **Error:** Los pesos deben ser números entre 0 y 1."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sum = tags + artist + source + history;
|
||||
if (Math.abs(sum - 1) > 0.01) {
|
||||
await message.reply(
|
||||
`⚠️ **Advertencia:** La suma de los pesos es ${sum.toFixed(2)} (debería ser ~1.0)\n` +
|
||||
`Los pesos se guardarán de todas formas, pero puede afectar la precisión.`
|
||||
);
|
||||
}
|
||||
|
||||
SmartRecommendationEngine.setWeights(userId, {
|
||||
tags,
|
||||
artist,
|
||||
source,
|
||||
history,
|
||||
});
|
||||
|
||||
await message.reply(
|
||||
"✅ **Pesos personalizados guardados!**\n" +
|
||||
`🏷️ Tags: ${(tags * 100).toFixed(0)}%\n` +
|
||||
`🎤 Artista: ${(artist * 100).toFixed(0)}%\n` +
|
||||
`📺 Related: ${(source * 100).toFixed(0)}%\n` +
|
||||
`📜 Historial: ${(history * 100).toFixed(0)}%`
|
||||
);
|
||||
} else {
|
||||
await message.reply("❌ Uso: `!autoplay [on|off]`");
|
||||
await message.reply(
|
||||
"❌ **Uso:** `!autoplay config <tags> <artist> <source> <history>`\n" +
|
||||
"📝 **Ejemplo:** `!autoplay config 0.4 0.35 0.5 0.25`"
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "stats":
|
||||
case "info":
|
||||
const historyData = await MusicHistoryService.getRecentHistory(userId, 100);
|
||||
const weights = await SmartRecommendationEngine.getWeights(userId);
|
||||
const dynamicInfo = SmartRecommendationEngine.getDynamicWeightsInfo(historyData.length);
|
||||
|
||||
await message.reply(
|
||||
"📊 **Estadísticas del Autoplay DINÁMICO**\n\n" +
|
||||
`**Algoritmo:** Puntuación Ponderada Dinámica\n` +
|
||||
`**Perfil:** ${dynamicInfo.profile} (${historyData.length} canciones)\n` +
|
||||
`**Descripción:** ${dynamicInfo.description}\n\n` +
|
||||
`**Fórmula:** Score = (J × Wj) + (A × Wa) + (F × Wf) - (H × Wh)\n\n` +
|
||||
`**Tus pesos actuales:**\n` +
|
||||
`• **J** (Jaccard - Tags): ${(weights.tags * 100).toFixed(0)}%\n` +
|
||||
`• **A** (Artist - Levenshtein): ${(weights.artist * 100).toFixed(0)}%\n` +
|
||||
`• **F** (Source - Videos Relacionados): ${(weights.source * 100).toFixed(0)}%\n` +
|
||||
`• **H** (History - Penalización): ${(weights.history * 100).toFixed(0)}%\n\n` +
|
||||
`**Cómo funciona:**\n` +
|
||||
`🏷️ **Jaccard:** Compara los tags/géneros entre canciones\n` +
|
||||
`🎤 **Levenshtein:** Mide similitud entre nombres de artistas\n` +
|
||||
`📺 **Source:** Prioriza videos relacionados de YouTube\n` +
|
||||
`📜 **Historial:** Penaliza canciones escuchadas recientemente\n\n` +
|
||||
`**Evolución del algoritmo:**\n` +
|
||||
`🥶 < 10 canciones: Alta dependencia de YouTube (80% Related)\n` +
|
||||
`🌡️ 10-50 canciones: Equilibrio (40% Related, aprendiendo)\n` +
|
||||
`🔥 > 50 canciones: Patrones complejos (10% Related, experto)\n\n` +
|
||||
`💡 Ajusta manualmente con \`!autoplay config\``
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
await message.reply(
|
||||
"❌ **Uso:** `!autoplay [on|off|config|stats]`\n\n" +
|
||||
"**Comandos disponibles:**\n" +
|
||||
"• `!autoplay` - Activar/desactivar\n" +
|
||||
"• `!autoplay stats` - Ver estadísticas\n" +
|
||||
"• `!autoplay config` - Configurar pesos\n" +
|
||||
"• `!autoplay config reset` - Restaurar valores"
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to add autoplay suggestion to queue
|
||||
* Function to add autoplay suggestion to queue using SmartRecommendationEngine
|
||||
* Called from playNextTrack when queue is empty
|
||||
*/
|
||||
export async function addAutoplaySuggestion(
|
||||
guildId: string,
|
||||
userId: string,
|
||||
lastTrack: { title: string; author: string; encoded: string },
|
||||
lastTrack: { title: string; author: string; encoded: string; duration: number },
|
||||
client: Amayo
|
||||
): Promise<boolean> {
|
||||
// Check if autoplay is enabled for this guild
|
||||
if (!autoplayEnabled.get(guildId)) {
|
||||
if (!isAutoplayEnabledForGuild(guildId)) {
|
||||
console.log(`[Autoplay] Disabled for guild ${guildId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[Autoplay] Starting for guild ${guildId}, user ${userId}`);
|
||||
console.log(`[Autoplay] Last track:`, lastTrack);
|
||||
|
||||
try {
|
||||
const isNew = await RecommendationEngine.isNewUser(userId);
|
||||
const recommendation = await RecommendationEngine.getAutoplaySuggestion(
|
||||
userId,
|
||||
{ title: lastTrack.title, author: lastTrack.author },
|
||||
isNew
|
||||
);
|
||||
|
||||
// Search for the recommended song
|
||||
// Search for similar songs
|
||||
const node = [...client.music.nodes.values()][0];
|
||||
if (!node) {
|
||||
console.error("[Autoplay] No music node available");
|
||||
return false;
|
||||
}
|
||||
|
||||
const searchQuery = `ytsearch:${recommendation.query}`;
|
||||
// Get recommendations from YouTube search
|
||||
// EXPANDED SEARCH STRATEGY for maximum artist diversity:
|
||||
// 1. Extract genre/mood from title (ambient, sad, lofi, etc.)
|
||||
// 2. Search by genre alone (no artist name) for diversity
|
||||
// 3. YouTube Music recommendations
|
||||
// 4. Random popular tracks in same genre
|
||||
|
||||
// Extract potential genre/mood keywords from title
|
||||
const genreKeywords = SmartRecommendationEngine.extractTags({
|
||||
title: lastTrack.title,
|
||||
author: lastTrack.author,
|
||||
});
|
||||
|
||||
const primaryGenre = genreKeywords[0] || 'music'; // Fallback to 'music'
|
||||
const secondaryGenre = genreKeywords[1] || '';
|
||||
|
||||
console.log(`[Autoplay] Detected genres:`, genreKeywords.slice(0, 3));
|
||||
|
||||
// MEMORY-OPTIMIZED: 6 strategic queries (down from 10)
|
||||
const queries = [
|
||||
// Genre-only searches for MAXIMUM DIVERSITY
|
||||
`${primaryGenre} music mix`,
|
||||
`${primaryGenre} playlist`,
|
||||
`best ${primaryGenre} songs`,
|
||||
`${primaryGenre} recommendations`,
|
||||
|
||||
// Some artist-specific for continuity
|
||||
`${lastTrack.author} popular`,
|
||||
secondaryGenre ? `${primaryGenre} ${secondaryGenre}` : `discover ${primaryGenre}`,
|
||||
];
|
||||
|
||||
let allCandidates: any[] = [];
|
||||
const seenEncoded = new Set<string>(); // Immediate deduplication
|
||||
|
||||
for (const query of queries) {
|
||||
const searchQuery = `ytsearch:${query}`;
|
||||
const result: any = await node.rest.resolve(searchQuery);
|
||||
|
||||
if (!result || result.loadType === "empty" || result.loadType === "error") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
result &&
|
||||
result.loadType !== "empty" &&
|
||||
result.loadType !== "error"
|
||||
) {
|
||||
let tracks: any[] = [];
|
||||
|
||||
if (result.loadType === "track") {
|
||||
@@ -101,41 +281,263 @@ export async function addAutoplaySuggestion(
|
||||
tracks = result.data.tracks || result.data || [];
|
||||
}
|
||||
|
||||
if (!tracks.length) {
|
||||
// MEMORY-OPTIMIZED: Only 5 tracks per query + immediate dedup
|
||||
for (const track of tracks.slice(0, 5)) {
|
||||
if (!seenEncoded.has(track.encoded)) {
|
||||
seenEncoded.add(track.encoded);
|
||||
allCandidates.push(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Free memory
|
||||
seenEncoded.clear();
|
||||
|
||||
if (!allCandidates.length) {
|
||||
console.error("[Autoplay] No candidates found from searches");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[Autoplay] Found ${allCandidates.length} unique candidates from YouTube (${queries.length} queries)`);
|
||||
|
||||
// NEW: Add candidates from user's history (artists they like but haven't played recently)
|
||||
try {
|
||||
const userHistory = await MusicHistoryService.getRecentHistory(userId, 50);
|
||||
|
||||
// Get unique artists from history
|
||||
const historyArtists = new Map<string, number>();
|
||||
userHistory.forEach((track, index) => {
|
||||
const artist = track.author?.toLowerCase();
|
||||
if (artist && !historyArtists.has(artist)) {
|
||||
historyArtists.set(artist, index); // Store position in history
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out artists that were played in the last 5 tracks (too recent)
|
||||
const recentArtists = new Set(
|
||||
userHistory.slice(0, 5).map(t => t.author?.toLowerCase()).filter(Boolean)
|
||||
);
|
||||
|
||||
const diverseArtists = Array.from(historyArtists.entries())
|
||||
.filter(([artist, position]) => !recentArtists.has(artist) && position >= 5)
|
||||
.sort((a, b) => a[1] - b[1]) // Sort by position (earlier = more liked)
|
||||
.slice(0, 5) // Take top 5 artists from history
|
||||
.map(([artist]) => artist);
|
||||
|
||||
console.log(`[Autoplay] Adding candidates from ${diverseArtists.length} history artists:`, diverseArtists);
|
||||
|
||||
// For each diverse artist, search for 2 popular songs
|
||||
for (const artist of diverseArtists) {
|
||||
const searchQuery = `ytsearch:${artist} popular`;
|
||||
const result: any = await node.rest.resolve(searchQuery);
|
||||
|
||||
if (
|
||||
result &&
|
||||
result.loadType !== "empty" &&
|
||||
result.loadType !== "error"
|
||||
) {
|
||||
let tracks: any[] = [];
|
||||
|
||||
if (result.loadType === "track") {
|
||||
tracks = [result.data];
|
||||
} else if (result.loadType === "search" || result.loadType === "playlist") {
|
||||
tracks = result.data.tracks || result.data || [];
|
||||
}
|
||||
|
||||
// Mark these tracks as from history for scoring boost
|
||||
tracks.forEach(track => {
|
||||
track.isFromHistory = true;
|
||||
});
|
||||
|
||||
// Add top 2 songs from this artist
|
||||
allCandidates.push(...tracks.slice(0, 2));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Autoplay] Total candidates after adding history: ${allCandidates.length}`);
|
||||
} catch (error) {
|
||||
console.error("[Autoplay] Error adding history-based candidates:", error);
|
||||
// Continue with YouTube candidates only
|
||||
}
|
||||
|
||||
console.log(`[Autoplay] Found ${allCandidates.length} candidates`);
|
||||
|
||||
// Extract tags for lastTrack and candidates
|
||||
const lastTrackTags = SmartRecommendationEngine.extractTags({
|
||||
title: lastTrack.title,
|
||||
author: lastTrack.author,
|
||||
});
|
||||
|
||||
const candidates = allCandidates.map((track) => ({
|
||||
title: track.info.title,
|
||||
author: track.info.author,
|
||||
duration: track.info.length,
|
||||
tags: SmartRecommendationEngine.extractTags({
|
||||
title: track.info.title,
|
||||
author: track.info.author,
|
||||
}),
|
||||
encoded: track.encoded,
|
||||
isrc: track.info.isrc || undefined, // Extract ISRC for precise duplicate detection
|
||||
isFromHistory: track.isFromHistory || false, // NEW: Mark if from user history
|
||||
}));
|
||||
|
||||
console.log(`[Autoplay] Prepared ${candidates.length} candidates for scoring`);
|
||||
|
||||
// Use SmartRecommendationEngine to score and select best match
|
||||
// Now with iterative search capability when all candidates are rejected
|
||||
const recommendation = await SmartRecommendationEngine.getSmartRecommendation(
|
||||
userId,
|
||||
{
|
||||
title: lastTrack.title,
|
||||
author: lastTrack.author,
|
||||
duration: lastTrack.duration,
|
||||
tags: lastTrackTags,
|
||||
encoded: lastTrack.encoded,
|
||||
},
|
||||
candidates,
|
||||
{
|
||||
client, // Pass client for additional searches if needed
|
||||
maxSearchAttempts: 10, // Try up to 10 additional searches with progressive broadening
|
||||
}
|
||||
);
|
||||
|
||||
if (!recommendation) {
|
||||
console.error("[Autoplay] No recommendation returned from engine");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[Autoplay] Selected recommendation:`, {
|
||||
title: recommendation.title,
|
||||
score: recommendation.score,
|
||||
breakdown: recommendation.breakdown
|
||||
});
|
||||
|
||||
// Find the original track object
|
||||
const selectedTrack = allCandidates.find(
|
||||
(t) => t.encoded === recommendation.encoded
|
||||
);
|
||||
|
||||
if (!selectedTrack) {
|
||||
console.error("[Autoplay] Could not find selected track in candidates");
|
||||
// For buffered tracks (PRO users), they don't need to be in candidates
|
||||
// Just construct the track object from recommendation
|
||||
const queue = queues.get(guildId);
|
||||
if (queue) {
|
||||
// Create track object from recommendation data
|
||||
const reconstructedTrack = {
|
||||
encoded: recommendation.encoded,
|
||||
info: {
|
||||
title: recommendation.title,
|
||||
author: recommendation.author,
|
||||
length: recommendation.duration,
|
||||
isrc: (recommendation as any).isrc,
|
||||
}
|
||||
};
|
||||
|
||||
queue.push(reconstructedTrack);
|
||||
console.log(`[Autoplay] ✅ Added buffered track to queue. Queue length: ${queue.length}`);
|
||||
return true;
|
||||
} else {
|
||||
console.error(`[Autoplay] ❌ Queue not found for guild ${guildId}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Autoplay] Found selected track, adding to queue`);
|
||||
|
||||
// Add to queue
|
||||
const queue = queues.get(guildId);
|
||||
if (queue) {
|
||||
// Add first suggestion
|
||||
queue.push(tracks[0]);
|
||||
|
||||
// Optionally add 2-3 more for continuous playback
|
||||
const additionalTracks = tracks.slice(1, 4);
|
||||
queue.push(...additionalTracks);
|
||||
|
||||
queue.push(selectedTrack);
|
||||
console.log(`[Autoplay] ✅ Added track to queue. Queue length: ${queue.length}`);
|
||||
return true;
|
||||
} else {
|
||||
console.error(`[Autoplay] ❌ Queue not found for guild ${guildId}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error in autoplay suggestion:", error);
|
||||
console.error("[Autoplay] ❌ Error in smart autoplay suggestion:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Export autoplay status check
|
||||
export function isAutoplayEnabled(guildId: string): boolean {
|
||||
return autoplayEnabled.get(guildId) || false;
|
||||
return isAutoplayEnabledForGuild(guildId);
|
||||
}
|
||||
|
||||
// Export toggle function
|
||||
export function toggleAutoplay(guildId: string): boolean {
|
||||
const current = autoplayEnabled.get(guildId) || false;
|
||||
const current = isAutoplayEnabledForGuild(guildId);
|
||||
autoplayEnabled.set(guildId, !current);
|
||||
return !current;
|
||||
}
|
||||
|
||||
// Export the Map itself for button access
|
||||
export { autoplayEnabled };
|
||||
|
||||
/**
|
||||
* Get similar tracks for the "Similar Songs" select menu
|
||||
* Returns only related tracks (same artist or similar genre) without diversity penalty
|
||||
*/
|
||||
export async function getSimilarTracks(
|
||||
trackInfo: { title: string; author: string; trackId?: string },
|
||||
guildId: string,
|
||||
client: any // Accept client as parameter
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
if (!client) {
|
||||
console.error("[getSimilarTracks] No client provided");
|
||||
return [];
|
||||
}
|
||||
|
||||
const node = [...client.music.nodes.values()][0];
|
||||
if (!node) {
|
||||
console.error("[getSimilarTracks] No music node available");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Search for related tracks (same artist + similar songs)
|
||||
const queries = [
|
||||
`${trackInfo.author} popular songs`,
|
||||
`${trackInfo.title} similar`,
|
||||
];
|
||||
|
||||
let allTracks: any[] = [];
|
||||
|
||||
for (const query of queries) {
|
||||
const searchQuery = `ytsearch:${query}`;
|
||||
const result: any = await node.rest.resolve(searchQuery);
|
||||
|
||||
if (
|
||||
result &&
|
||||
result.loadType !== "empty" &&
|
||||
result.loadType !== "error"
|
||||
) {
|
||||
let tracks: any[] = [];
|
||||
|
||||
if (result.loadType === "track") {
|
||||
tracks = [result.data];
|
||||
} else if (result.loadType === "search" || result.loadType === "playlist") {
|
||||
tracks = result.data.tracks || result.data || [];
|
||||
}
|
||||
|
||||
allTracks.push(...tracks.slice(0, 10)); // Take top 10 from each search
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates by encoded trackId
|
||||
const uniqueTracks = Array.from(
|
||||
new Map(allTracks.map(track => [track.encoded, track])).values()
|
||||
);
|
||||
|
||||
console.log(`[getSimilarTracks] Found ${uniqueTracks.length} similar tracks for ${trackInfo.title}`);
|
||||
|
||||
return uniqueTracks.slice(0, 15); // Return max 15 tracks
|
||||
} catch (error) {
|
||||
console.error("[getSimilarTracks] Error:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
128
src/commands/messages/music/import.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { CommandMessage } from "../../../core/types/commands";
|
||||
import type Amayo from "../../../core/client";
|
||||
import { PlaylistService } from "../../../core/services/PlaylistService";
|
||||
import { SubscriptionService } from "../../../core/services/SubscriptionService";
|
||||
import { DisplayComponentV2Builder } from "../../../core/lib/displayComponents/builders/v2Builder";
|
||||
import { ButtonStyle } from "discord.js";
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: "importar",
|
||||
type: "message",
|
||||
aliases: ["import", "import-playlist"],
|
||||
description: "Importa una playlist de Spotify o YouTube a tus playlists personales",
|
||||
category: "Música",
|
||||
usage: "importar <url> [nombre]",
|
||||
run: async (message, args, client: Amayo) => {
|
||||
if (!args[0]) {
|
||||
await message.reply("❌ Debes proporcionar una URL de playlist válida.");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = args[0];
|
||||
const customName = args.slice(1).join(" ");
|
||||
|
||||
// Basic validation
|
||||
const isSpotify = url.includes("spotify.com") && url.includes("/playlist/");
|
||||
const isYouTube = (url.includes("youtube.com") || url.includes("youtu.be")) && url.includes("list=");
|
||||
|
||||
if (!isSpotify && !isYouTube) {
|
||||
await message.reply("❌ Solo se soportan playlists de Spotify y YouTube.");
|
||||
return;
|
||||
}
|
||||
|
||||
const statusMsg = await message.reply("🔄 Analizando playlist... esto puede tardar unos segundos.");
|
||||
|
||||
try {
|
||||
const node = [...client.music.nodes.values()][0];
|
||||
if (!node) {
|
||||
await statusMsg.edit("❌ No hay nodos de música disponibles.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve playlist
|
||||
const result: any = await node.rest.resolve(url);
|
||||
|
||||
if (!result || result.loadType === "empty" || result.loadType === "error") {
|
||||
await statusMsg.edit("❌ No se pudo cargar la playlist. Verifica que sea pública.");
|
||||
return;
|
||||
}
|
||||
|
||||
let tracks: any[] = [];
|
||||
let playlistName = customName;
|
||||
|
||||
if (result.loadType === "playlist") {
|
||||
tracks = result.data.tracks;
|
||||
if (!playlistName) {
|
||||
playlistName = result.data.info.name;
|
||||
}
|
||||
} else if (result.loadType === "track") {
|
||||
// Single track treated as playlist
|
||||
tracks = [result.data];
|
||||
if (!playlistName) {
|
||||
playlistName = result.data.info.title;
|
||||
}
|
||||
} else if (result.loadType === "search") {
|
||||
// Should not happen for direct URL, but handle anyway
|
||||
tracks = result.data;
|
||||
if (!playlistName) {
|
||||
playlistName = `Importada ${new Date().toLocaleDateString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (tracks.length === 0) {
|
||||
await statusMsg.edit("❌ La playlist está vacía.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// ... inside run function ...
|
||||
|
||||
// Get user limit
|
||||
const MAX_TRACKS = await SubscriptionService.getUserImportLimit(message.author.id);
|
||||
const tracksToImport = tracks.slice(0, MAX_TRACKS);
|
||||
|
||||
await statusMsg.edit(`🔄 Importando **${tracksToImport.length}** canciones a la playlist "**${playlistName}**"...`);
|
||||
|
||||
// Create playlist
|
||||
const playlist = await PlaylistService.createPlaylist(
|
||||
message.author.id,
|
||||
message.guild!.id,
|
||||
playlistName
|
||||
);
|
||||
|
||||
// Add tracks
|
||||
let addedCount = 0;
|
||||
for (const track of tracksToImport) {
|
||||
try {
|
||||
await PlaylistService.addTrackToPlaylist(playlist.id, {
|
||||
trackId: track.info.identifier, // Use Lavalink identifier as trackId
|
||||
title: track.info.title,
|
||||
author: track.info.author,
|
||||
duration: track.info.length || track.info.duration || 0,
|
||||
url: track.info.uri,
|
||||
thumbnail: track.info.artworkUrl || track.info.thumbnail,
|
||||
});
|
||||
addedCount++;
|
||||
} catch (error) {
|
||||
console.error(`Error adding track ${track.info.title}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Success message
|
||||
const embed = {
|
||||
title: "✅ Playlist Importada",
|
||||
description: `Se han importado **${addedCount}** canciones a la playlist **${playlistName}**.\n\nUsa \`!playlist\` para verla y reproducirla.`,
|
||||
color: 0x00ff00,
|
||||
footer: {
|
||||
text: tracks.length > MAX_TRACKS ? `Nota: Se limitó a ${MAX_TRACKS} canciones.` : ""
|
||||
}
|
||||
};
|
||||
|
||||
await statusMsg.edit({ content: "", embeds: [embed] });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error importing playlist:", error);
|
||||
await statusMsg.edit(`❌ Error al importar: ${error.message || "Error desconocido"}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -10,7 +10,10 @@ import { redis } from "../../../core/database/redis";
|
||||
export const queues = new Map<string, any[]>();
|
||||
|
||||
// Track if music is currently playing per guild
|
||||
const nowPlaying = new Map<string, boolean>();
|
||||
export const nowPlaying = new Map<string, boolean>();
|
||||
|
||||
// Track current user ID per guild (for history tracking)
|
||||
export const currentUser = new Map<string, string>();
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: "play",
|
||||
@@ -62,7 +65,24 @@ export const command: CommandMessage = {
|
||||
});
|
||||
|
||||
player.on("end", async (data) => {
|
||||
if (data.reason === "finished") {
|
||||
console.log(`[Player] Track ended. Reason: ${data.reason}`);
|
||||
|
||||
// Track song end in history before playing next
|
||||
if (data.reason === "finished" || data.reason === "stopped") {
|
||||
const userId = currentUser.get(message.guild!.id);
|
||||
if (userId) {
|
||||
try {
|
||||
// IMPORTANT: Await this to ensure history is updated before getting recommendations
|
||||
await MusicHistoryService.trackSongEnd(userId, message.guild!.id);
|
||||
console.log(`[Player] Tracked song end for user ${userId}`);
|
||||
} catch (error) {
|
||||
console.error("[Player] Error tracking song end:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Now call playNextTrack after history is updated
|
||||
console.log(`[Player] Calling playNextTrack for guild ${message.guild!.id}`);
|
||||
try {
|
||||
await playNextTrack(
|
||||
player!,
|
||||
message.channel.id,
|
||||
@@ -70,6 +90,9 @@ export const command: CommandMessage = {
|
||||
client,
|
||||
message.author.id
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Player] Error in playNextTrack:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -131,16 +154,13 @@ export const command: CommandMessage = {
|
||||
|
||||
if (isPlaying) {
|
||||
await message.reply(
|
||||
`✅ **Añadido a la cola:** ${
|
||||
track.info.title
|
||||
}\n⏱️ Duración: ${formatDuration(duration)}\n📍 Posición: #${
|
||||
queue.length
|
||||
`✅ **Añadido a la cola:** ${track.info.title
|
||||
}\n⏱️ Duración: ${formatDuration(duration)}\n📍 Posición: #${queue.length
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
await message.reply(
|
||||
`✅ **Añadido a la cola:** ${
|
||||
track.info.title
|
||||
`✅ **Añadido a la cola:** ${track.info.title
|
||||
}\n⏱️ Duración: ${formatDuration(duration)}`
|
||||
);
|
||||
}
|
||||
@@ -164,7 +184,7 @@ export const command: CommandMessage = {
|
||||
},
|
||||
};
|
||||
|
||||
async function playNextTrack(
|
||||
export async function playNextTrack(
|
||||
player: any,
|
||||
textChannelId: string,
|
||||
guildId: string,
|
||||
@@ -178,25 +198,40 @@ async function playNextTrack(
|
||||
|
||||
if (lastUserId) {
|
||||
try {
|
||||
const lastTrack = player.track;
|
||||
// Use Redis to get last played track (instead of player.track which is null)
|
||||
const redisKey = `music:lasttrack:${guildId}`;
|
||||
const lastTrackJson = await redis.get(redisKey);
|
||||
|
||||
if (lastTrack) {
|
||||
const trackInfo =
|
||||
typeof lastTrack === "string" ? lastTrack : lastTrack;
|
||||
console.log(`[playNextTrack] Checking Redis for last track...`);
|
||||
|
||||
if (!lastTrackJson) {
|
||||
console.log(`[playNextTrack] No last track found in Redis for guild ${guildId}`);
|
||||
} else {
|
||||
const lastTrack = JSON.parse(lastTrackJson);
|
||||
console.log(`[playNextTrack] Last track from Redis:`, {
|
||||
title: lastTrack.info.title,
|
||||
author: lastTrack.info.author,
|
||||
});
|
||||
|
||||
console.log(`[playNextTrack] Calling addAutoplaySuggestion...`);
|
||||
const added = await addAutoplaySuggestion(
|
||||
guildId,
|
||||
lastUserId,
|
||||
{
|
||||
title: (trackInfo as any)?.info?.title || "Unknown",
|
||||
author: (trackInfo as any)?.info?.author || "Unknown",
|
||||
encoded: (trackInfo as any)?.encoded || lastTrack,
|
||||
title: lastTrack.info.title,
|
||||
author: lastTrack.info.author,
|
||||
encoded: lastTrack.encoded,
|
||||
duration: lastTrack.info.length || lastTrack.info.duration || 0,
|
||||
},
|
||||
client
|
||||
);
|
||||
|
||||
console.log(`[playNextTrack] addAutoplaySuggestion returned: ${added}`);
|
||||
|
||||
// FIX: Check the updated queue from the Map after adding suggestions
|
||||
const updatedQueue = queues.get(guildId);
|
||||
if (added && updatedQueue && updatedQueue.length > 0) {
|
||||
console.log(`[playNextTrack] Autoplay added ${updatedQueue.length} tracks, playing next...`);
|
||||
const channel = client.channels.cache.get(textChannelId);
|
||||
if (channel && "send" in channel) {
|
||||
channel.send(
|
||||
@@ -211,6 +246,8 @@ async function playNextTrack(
|
||||
lastUserId
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
console.log(`[playNextTrack] Autoplay did not add tracks. added=${added}, queueLength=${updatedQueue?.length || 0}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -222,8 +259,14 @@ async function playNextTrack(
|
||||
if (channel && "send" in channel) {
|
||||
channel.send("✅ Cola terminada. Desconectando...");
|
||||
}
|
||||
player.connection.disconnect();
|
||||
client.music.players.delete(guildId);
|
||||
|
||||
// Properly disconnect the player using Shoukaku's leaveVoiceChannel
|
||||
try {
|
||||
client.music.leaveVoiceChannel(guildId);
|
||||
} catch (error) {
|
||||
console.error("[playNextTrack] Error disconnecting player:", error);
|
||||
}
|
||||
|
||||
queues.delete(guildId);
|
||||
nowPlaying.delete(guildId);
|
||||
return;
|
||||
@@ -231,9 +274,22 @@ async function playNextTrack(
|
||||
|
||||
const track = queue.shift();
|
||||
|
||||
// Store track in Redis for autoplay (expires in 1 hour)
|
||||
const redisKey = `music:lasttrack:${guildId}`;
|
||||
await redis.set(redisKey, JSON.stringify(track), { EX: 3600 });
|
||||
console.log(`[playNextTrack] Stored track in Redis for autoplay:`, {
|
||||
title: track.info.title,
|
||||
author: track.info.author,
|
||||
});
|
||||
|
||||
// Mark as playing
|
||||
nowPlaying.set(guildId, true);
|
||||
|
||||
// Store current user for history tracking
|
||||
if (lastUserId) {
|
||||
currentUser.set(guildId, lastUserId);
|
||||
}
|
||||
|
||||
if (lastUserId) {
|
||||
try {
|
||||
await MusicHistoryService.trackSongStart(lastUserId, guildId, {
|
||||
@@ -284,7 +340,8 @@ async function playNextTrack(
|
||||
},
|
||||
queueRef?.length || 0,
|
||||
lastUserId,
|
||||
guildId
|
||||
guildId,
|
||||
client // Add client parameter
|
||||
);
|
||||
|
||||
// Send ComponentsV2 with both containers
|
||||
|
||||
95
src/commands/messages/music/playlist.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { CommandMessage } from "../../../core/types/commands";
|
||||
import type Amayo from "../../../core/client";
|
||||
import { PlaylistService } from "../../../core/services/PlaylistService";
|
||||
import { DisplayComponentV2Builder } from "../../../core/lib/displayComponents/builders/v2Builder";
|
||||
import { sendComponentsV2Message } from "../../../core/api/discordAPI";
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: "playlist",
|
||||
type: "message",
|
||||
aliases: ["pl", "playlists", "listas"],
|
||||
cooldown: 3,
|
||||
description: "Muestra tus playlists y permite reproducirlas",
|
||||
category: "Música",
|
||||
usage: "playlist",
|
||||
run: async (message, args, client: Amayo) => {
|
||||
const userId = message.author.id;
|
||||
const guildId = message.guild!.id;
|
||||
|
||||
try {
|
||||
// Get user's playlists
|
||||
const playlists = await PlaylistService.getUserPlaylists(userId, guildId);
|
||||
|
||||
if (playlists.length === 0) {
|
||||
// Build display with DisplayComponentV2Builder
|
||||
const builder = new DisplayComponentV2Builder()
|
||||
.setAccentColor(0x608beb)
|
||||
.addText("📚 **No tienes playlists aún.**")
|
||||
.addText("Crea una usando el botón de abajo o guarda canciones con el botón ❤️ Like.");
|
||||
|
||||
const createButton = DisplayComponentV2Builder.createButton(
|
||||
"music_create_playlist_show",
|
||||
"➕ Crear Playlist",
|
||||
1 // primary
|
||||
);
|
||||
|
||||
builder.addActionRow([createButton]);
|
||||
|
||||
await sendComponentsV2Message(message.channel.id, {
|
||||
components: [builder.toJSON()],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build display with DisplayComponentV2Builder
|
||||
const builder = new DisplayComponentV2Builder()
|
||||
.setAccentColor(0x608beb)
|
||||
.addText("📚 **Tus Playlists**")
|
||||
.addSeparator(1, true); // Add divider
|
||||
|
||||
// Add each playlist as a section with play button accessory
|
||||
for (const playlist of playlists) {
|
||||
const emoji = playlist.isDefault ? "❤️" : "📝";
|
||||
const trackCount = (playlist as any)._count?.tracks || 0;
|
||||
const trackWord = trackCount === 1 ? "canción" : "canciones";
|
||||
|
||||
const playlistText = `${emoji} **${playlist.name}**\n${trackCount} ${trackWord}`;
|
||||
|
||||
// Create play button as accessory
|
||||
const playButton = DisplayComponentV2Builder.createButton(
|
||||
`music_playlist_play:${playlist.id}`,
|
||||
"▶️",
|
||||
2 // secondary
|
||||
);
|
||||
|
||||
builder.addSection(playlistText, playButton);
|
||||
}
|
||||
|
||||
builder.addSeparator(1, true); // Add divider before action buttons
|
||||
|
||||
// Add action buttons (create/delete)
|
||||
const createButton = DisplayComponentV2Builder.createButton(
|
||||
"music_create_playlist_show",
|
||||
"➕ Crear Nueva",
|
||||
1 // primary
|
||||
);
|
||||
|
||||
const deleteButton = DisplayComponentV2Builder.createButton(
|
||||
"music_playlist_delete_show",
|
||||
"🗑️ Eliminar",
|
||||
4 // danger
|
||||
);
|
||||
|
||||
builder.addActionRow([createButton, deleteButton]);
|
||||
|
||||
await sendComponentsV2Message(message.channel.id, {
|
||||
components: [builder.toJSON()],
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error en comando playlist:", error);
|
||||
await message.reply(
|
||||
`❌ Error al obtener playlists: ${error.message || "Error desconocido"}`
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
163
src/commands/messages/music/queue.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { CommandMessage } from "../../../core/types/commands";
|
||||
import type Amayo from "../../../core/client";
|
||||
import { queues } from "./play";
|
||||
import { DisplayComponentV2Builder } from "../../../core/lib/displayComponents/builders/v2Builder";
|
||||
import { sendComponentsV2Message } from "../../../core/api/discordAPI";
|
||||
|
||||
// Store pagination state per guild
|
||||
const queuePages = new Map<string, number>();
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: "queue",
|
||||
type: "message",
|
||||
aliases: ["q", "cola", "list"],
|
||||
cooldown: 3,
|
||||
description: "Muestra la cola de reproducción actual",
|
||||
category: "Música",
|
||||
usage: "queue [página]",
|
||||
run: async (message, args, client: Amayo) => {
|
||||
const guildId = message.guild!.id;
|
||||
const player = client.music.players.get(guildId);
|
||||
const queue = queues.get(guildId);
|
||||
|
||||
if (!player && (!queue || queue.length === 0)) {
|
||||
await message.reply("❌ No hay nada en la cola de reproducción.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current page from args or stored state
|
||||
let page = 1;
|
||||
if (args.length > 0) {
|
||||
const pageArg = parseInt(args[0]);
|
||||
if (!isNaN(pageArg) && pageArg > 0) {
|
||||
page = pageArg;
|
||||
}
|
||||
} else {
|
||||
page = queuePages.get(guildId) || 1;
|
||||
}
|
||||
|
||||
// Get currently playing track
|
||||
const currentTrack = player?.track as any;
|
||||
|
||||
// Build queue list
|
||||
const queueList = queue || [];
|
||||
const itemsPerPage = 10;
|
||||
const totalPages = Math.ceil(queueList.length / itemsPerPage);
|
||||
|
||||
// Validate page number
|
||||
if (page > totalPages && totalPages > 0) {
|
||||
page = totalPages;
|
||||
}
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
|
||||
// Store current page
|
||||
queuePages.set(guildId, page);
|
||||
|
||||
const startIdx = (page - 1) * itemsPerPage;
|
||||
const endIdx = Math.min(startIdx + itemsPerPage, queueList.length);
|
||||
|
||||
// Build display with DisplayComponentV2Builder
|
||||
const builder = new DisplayComponentV2Builder()
|
||||
.setAccentColor(0x608beb)
|
||||
.addText("📋 **En cola**");
|
||||
|
||||
// Add currently playing track
|
||||
if (currentTrack) {
|
||||
const duration = formatDuration(currentTrack.info?.length || 0);
|
||||
const nowPlayingText = `▶️ **Ahora**: ${currentTrack.info?.title || 'Desconocido'} - ${currentTrack.info?.author || 'Desconocido'} (${duration})`;
|
||||
builder.addText(nowPlayingText);
|
||||
builder.addSeparator(1, true);
|
||||
}
|
||||
|
||||
// Add queue items
|
||||
if (queueList.length === 0) {
|
||||
builder.addText("*La cola está vacía*");
|
||||
} else {
|
||||
builder.addText(`**${queueList.length} ${queueList.length === 1 ? 'canción' : 'canciones'} en cola**`);
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const track = queueList[i] as any;
|
||||
const duration = formatDuration(track.info?.length || track.info?.duration || 0);
|
||||
const title = track.info?.title || "Desconocido";
|
||||
const author = track.info?.author || "Desconocido";
|
||||
|
||||
builder.addText(`\`${i + 1}.\` ${title} - ${author} (${duration})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total duration
|
||||
let totalDuration = 0;
|
||||
if (currentTrack) {
|
||||
totalDuration += currentTrack.info?.length || 0;
|
||||
}
|
||||
queueList.forEach((track: any) => {
|
||||
totalDuration += track.info?.length || track.info?.duration || 0;
|
||||
});
|
||||
|
||||
const totalDurationText = formatDuration(totalDuration);
|
||||
|
||||
builder.addSeparator(1, true);
|
||||
builder.addText(`⏱️ **Duración total**: ${totalDurationText}`);
|
||||
|
||||
if (totalPages > 0) {
|
||||
builder.addText(`📄 Página ${page}/${totalPages}`);
|
||||
}
|
||||
|
||||
// Create navigation and clear buttons
|
||||
const buttons: any[] = [];
|
||||
|
||||
if (totalPages > 1) {
|
||||
buttons.push(
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_prev",
|
||||
"◀️",
|
||||
2, // secondary
|
||||
page === 1 // disabled if first page
|
||||
),
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_next",
|
||||
"▶️",
|
||||
2, // secondary
|
||||
page === totalPages // disabled if last page
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (queueList.length > 0) {
|
||||
buttons.push(
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_clear",
|
||||
"🗑️ Limpiar",
|
||||
4 // danger
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (buttons.length > 0) {
|
||||
builder.addActionRow(buttons);
|
||||
}
|
||||
|
||||
await sendComponentsV2Message(message.channel.id, {
|
||||
components: [builder.toJSON()],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to MM:SS or HH:MM:SS
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Export pagination state for button handlers
|
||||
export { queuePages };
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommandMessage } from "../../../core/types/commands";
|
||||
import type Amayo from "../../../core/client";
|
||||
import { SubscriptionService } from "../../../core/services/SubscriptionService";
|
||||
|
||||
export const command: CommandMessage = {
|
||||
name: "volume",
|
||||
@@ -41,9 +42,10 @@ export const command: CommandMessage = {
|
||||
}
|
||||
|
||||
const volume = parseInt(args[0]);
|
||||
const maxVolume = await SubscriptionService.getMaxVolume(message.author.id);
|
||||
|
||||
if (isNaN(volume) || volume < 0 || volume > 100) {
|
||||
await message.reply("❌ El volumen debe ser un número entre 0 y 100.");
|
||||
if (isNaN(volume) || volume < 0 || volume > maxVolume) {
|
||||
await message.reply(`❌ El volumen debe ser un número entre 0 y ${maxVolume}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,14 @@ export default {
|
||||
|
||||
// Reusar el builder esperando un objeto con guild y author
|
||||
const fakeMessage: any = { guild: interaction.guild, author: interaction.user };
|
||||
const panel = await buildLeaderboardPanel(fakeMessage, isAdmin);
|
||||
await interaction.message.edit({ components: [panel] });
|
||||
const components = await buildLeaderboardPanel(fakeMessage, isAdmin);
|
||||
|
||||
// buildLeaderboardPanel returns an array [panel, row], so pass it directly
|
||||
await interaction.message.edit({
|
||||
// @ts-ignore Flag de componentes V2
|
||||
flags: 32768,
|
||||
components: Array.isArray(components) ? components : [components]
|
||||
});
|
||||
} catch (e) {
|
||||
// @ts-ignore
|
||||
logger.error('Error refrescando leaderboard:', e);
|
||||
|
||||
29
src/components/buttons/music/music_create_playlist_show.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export default {
|
||||
customId: "music_create_playlist_show",
|
||||
run: async (interaction: any, client: any) => {
|
||||
// Show the create playlist modal
|
||||
const modal = {
|
||||
title: "Crear Nueva Playlist",
|
||||
custom_id: "music_create_playlist",
|
||||
components: [
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
{
|
||||
type: 4,
|
||||
custom_id: "playlist_name",
|
||||
label: "Nombre de la Playlist",
|
||||
style: 1,
|
||||
min_length: 1,
|
||||
max_length: 50,
|
||||
placeholder: "Mi Playlist Favorita",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await interaction.showModal(modal);
|
||||
},
|
||||
};
|
||||
53
src/components/buttons/music/music_playlist_delete_show.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { PlaylistService } from "../../../core/services/PlaylistService";
|
||||
|
||||
export default {
|
||||
customId: "music_playlist_delete_show",
|
||||
run: async (interaction: any, client: any) => {
|
||||
const userId = interaction.user.id;
|
||||
const guildId = interaction.guild!.id;
|
||||
|
||||
try {
|
||||
// Get user's playlists (excluding default "Me gusta")
|
||||
const playlists = await PlaylistService.getUserPlaylists(userId, guildId);
|
||||
const deletablePlaylists = playlists.filter((p: any) => !p.isDefault);
|
||||
|
||||
if (deletablePlaylists.length === 0) {
|
||||
await interaction.reply({
|
||||
content: "❌ No tienes playlists para eliminar.\n\n*Nota: La playlist \"Me gusta\" no se puede eliminar.*",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create select menu with playlists
|
||||
const options = deletablePlaylists.map((playlist: any) => ({
|
||||
label: playlist.name,
|
||||
value: playlist.id,
|
||||
description: `${playlist._count?.tracks || 0} canciones`,
|
||||
emoji: { name: "📝" },
|
||||
}));
|
||||
|
||||
const selectMenu = {
|
||||
type: 1,
|
||||
components: [{
|
||||
type: 3,
|
||||
custom_id: "music_playlist_delete_confirm",
|
||||
placeholder: "Selecciona la playlist a eliminar",
|
||||
options: options
|
||||
}]
|
||||
};
|
||||
|
||||
await interaction.reply({
|
||||
content: "🗑️ **Eliminar Playlist**\n\nSelecciona la playlist que deseas eliminar:",
|
||||
components: [selectMenu],
|
||||
flags: 64,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error en music_playlist_delete_show:", error);
|
||||
await interaction.reply({
|
||||
content: `❌ Error: ${error.message || "Error desconocido"}`,
|
||||
flags: 64,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
194
src/components/buttons/music/music_playlist_play.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { PlaylistService } from "../../../core/services/PlaylistService";
|
||||
import { queues, playNextTrack, nowPlaying, currentUser } from "../../../commands/messages/music/play";
|
||||
import { MusicHistoryService } from "../../../core/services/MusicHistoryService";
|
||||
|
||||
export default {
|
||||
customId: "music_playlist_play",
|
||||
run: async (interaction: any, client: any) => {
|
||||
try {
|
||||
// Extract playlist ID from customId (format: music_playlist_play:playlistId)
|
||||
const playlistId = interaction.customId.split(":")[1];
|
||||
|
||||
if (!playlistId) {
|
||||
await interaction.reply({
|
||||
content: "❌ ID de playlist inválido.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get playlist with tracks
|
||||
const playlist = await PlaylistService.getPlaylist(playlistId);
|
||||
|
||||
if (!playlist) {
|
||||
await interaction.reply({
|
||||
content: "❌ Playlist no encontrada.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user owns the playlist
|
||||
if (playlist.userId !== interaction.user.id) {
|
||||
await interaction.reply({
|
||||
content: "❌ No tienes permiso para reproducir esta playlist.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if playlist has tracks
|
||||
if (!playlist.tracks || playlist.tracks.length === 0) {
|
||||
await interaction.reply({
|
||||
content: "❌ Esta playlist está vacía.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is in voice channel
|
||||
const member = interaction.member;
|
||||
if (!member?.voice?.channel) {
|
||||
await interaction.reply({
|
||||
content: "❌ Debes estar en un canal de voz para reproducir una playlist.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = interaction.guild!.id;
|
||||
const voiceChannelId = member.voice.channel.id;
|
||||
|
||||
// Get or create player
|
||||
let player = client.music.players.get(guildId);
|
||||
|
||||
if (!player) {
|
||||
// Create new player using client.music.joinVoiceChannel
|
||||
player = await client.music.joinVoiceChannel({
|
||||
guildId,
|
||||
channelId: voiceChannelId,
|
||||
shardId: interaction.guild!.shardId,
|
||||
deaf: true,
|
||||
});
|
||||
|
||||
// Register event listeners for the new player
|
||||
player.on("end", async (data) => {
|
||||
console.log(`[Player] Track ended. Reason: ${data.reason}`);
|
||||
|
||||
// Track song end in history before playing next
|
||||
if (data.reason === "finished" || data.reason === "stopped") {
|
||||
const userId = currentUser.get(guildId);
|
||||
if (userId) {
|
||||
try {
|
||||
await MusicHistoryService.trackSongEnd(userId, guildId);
|
||||
console.log(`[Player] Tracked song end for user ${userId}`);
|
||||
} catch (error) {
|
||||
console.error("[Player] Error tracking song end:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Now call playNextTrack after history is updated
|
||||
console.log(`[Player] Calling playNextTrack for guild ${guildId}`);
|
||||
try {
|
||||
await playNextTrack(
|
||||
player!,
|
||||
interaction.channel.id,
|
||||
guildId,
|
||||
client,
|
||||
interaction.user.id
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Player] Error in playNextTrack:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
player.on("closed", () => {
|
||||
queues.delete(guildId);
|
||||
nowPlaying.delete(guildId);
|
||||
});
|
||||
}
|
||||
|
||||
// Get or create queue
|
||||
let queue = queues.get(guildId);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
queues.set(guildId, queue);
|
||||
}
|
||||
|
||||
// Reply immediately to avoid timeout
|
||||
await interaction.reply({
|
||||
content: `🔄 Cargando playlist **${playlist.name}**... (${playlist.tracks.length} ${playlist.tracks.length === 1 ? 'canción' : 'canciones'})`,
|
||||
flags: 64,
|
||||
});
|
||||
|
||||
// Get the first node for searching tracks
|
||||
const node = [...client.music.nodes.values()][0];
|
||||
if (!node) {
|
||||
await interaction.editReply({
|
||||
content: "❌ No hay nodos de música disponibles.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Search and resolve each track from the playlist
|
||||
let resolvedCount = 0;
|
||||
for (const track of playlist.tracks) {
|
||||
try {
|
||||
// Search for the track using title and author
|
||||
const searchQuery = `ytsearch:${track.title} ${track.author}`;
|
||||
const result: any = await node.rest.resolve(searchQuery);
|
||||
|
||||
if (result && result.data && result.data.length > 0) {
|
||||
const resolvedTrack = result.data[0];
|
||||
queue.push({
|
||||
encoded: resolvedTrack.encoded,
|
||||
info: resolvedTrack.info,
|
||||
});
|
||||
resolvedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error resolving track ${track.title}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedCount === 0) {
|
||||
await interaction.editReply({
|
||||
content: "❌ No se pudo cargar ninguna canción de la playlist.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If nothing is playing, start playing
|
||||
const isPlaying = player.track !== null;
|
||||
|
||||
if (!isPlaying && queue.length > 0) {
|
||||
await playNextTrack(
|
||||
player,
|
||||
interaction.channel.id,
|
||||
guildId,
|
||||
client,
|
||||
interaction.user.id
|
||||
);
|
||||
}
|
||||
|
||||
const emoji = playlist.isDefault ? "❤️" : "📝";
|
||||
await interaction.editReply({
|
||||
content: `${emoji} **Reproduciendo playlist**: ${playlist.name}\n📋 ${resolvedCount}/${playlist.tracks.length} ${resolvedCount === 1 ? 'canción cargada' : 'canciones cargadas'} a la cola.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error en music_playlist_play:", error);
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: `❌ Error al reproducir playlist: ${error.message || "Error desconocido"}`,
|
||||
flags: 64,
|
||||
});
|
||||
} catch {
|
||||
// If reply fails, try editReply
|
||||
await interaction.editReply({
|
||||
content: `❌ Error al reproducir playlist: ${error.message || "Error desconocido"}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
26
src/components/buttons/music/music_queue_clear.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { queues } from "../../../commands/messages/music/play";
|
||||
|
||||
export default {
|
||||
customId: "music_queue_clear",
|
||||
run: async (interaction: any, client: any) => {
|
||||
const guildId = interaction.guild!.id;
|
||||
const queue = queues.get(guildId);
|
||||
|
||||
if (!queue || queue.length === 0) {
|
||||
await interaction.reply({
|
||||
content: "❌ La cola ya está vacía.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the queue
|
||||
const clearedCount = queue.length;
|
||||
queues.set(guildId, []);
|
||||
|
||||
await interaction.update({
|
||||
content: `🗑️ **Cola limpiada!** Se eliminaron ${clearedCount} ${clearedCount === 1 ? 'canción' : 'canciones'}.`,
|
||||
components: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
107
src/components/buttons/music/music_queue_next.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { queues } from "../../../commands/messages/music/play";
|
||||
import { queuePages } from "../../../commands/messages/music/queue";
|
||||
import { DisplayComponentV2Builder } from "../../../core/lib/displayComponents/builders/v2Builder";
|
||||
|
||||
export default {
|
||||
customId: "music_queue_next",
|
||||
run: async (interaction: any, client: any) => {
|
||||
const guildId = interaction.guild!.id;
|
||||
const player = client.music.players.get(guildId);
|
||||
const queue = queues.get(guildId);
|
||||
|
||||
if (!player && (!queue || queue.length === 0)) {
|
||||
await interaction.reply({
|
||||
content: "❌ No hay nada en la cola de reproducción.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current page and go to next
|
||||
const queueList = queue || [];
|
||||
const itemsPerPage = 10;
|
||||
const totalPages = Math.ceil(queueList.length / itemsPerPage);
|
||||
|
||||
let page = queuePages.get(guildId) || 1;
|
||||
page = Math.min(totalPages, page + 1);
|
||||
queuePages.set(guildId, page);
|
||||
|
||||
// Rebuild queue message with DisplayComponentV2Builder
|
||||
const currentTrack = player?.track as any;
|
||||
const startIdx = (page - 1) * itemsPerPage;
|
||||
const endIdx = Math.min(startIdx + itemsPerPage, queueList.length);
|
||||
|
||||
const builder = new DisplayComponentV2Builder()
|
||||
.setAccentColor(0x608beb)
|
||||
.addText("📋 **En cola**");
|
||||
|
||||
if (currentTrack) {
|
||||
const duration = formatDuration(currentTrack.info?.length || 0);
|
||||
const nowPlayingText = `▶️ **Ahora**: ${currentTrack.info?.title || 'Desconocido'} - ${currentTrack.info?.author || 'Desconocido'} (${duration})`;
|
||||
builder.addText(nowPlayingText);
|
||||
builder.addSeparator(1, true);
|
||||
}
|
||||
|
||||
builder.addText(`**${queueList.length} ${queueList.length === 1 ? 'canción' : 'canciones'} en cola**`);
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const track = queueList[i] as any;
|
||||
const duration = formatDuration(track.info?.length || track.info?.duration || 0);
|
||||
const title = track.info?.title || "Desconocido";
|
||||
const author = track.info?.author || "Desconocido";
|
||||
|
||||
builder.addText(`\`${i + 1}.\` ${title} - ${author} (${duration})`);
|
||||
}
|
||||
|
||||
let totalDuration = 0;
|
||||
if (currentTrack) {
|
||||
totalDuration += currentTrack.info?.length || 0;
|
||||
}
|
||||
queueList.forEach((track: any) => {
|
||||
totalDuration += track.info?.length || track.info?.duration || 0;
|
||||
});
|
||||
|
||||
const totalDurationText = formatDuration(totalDuration);
|
||||
|
||||
builder.addSeparator(1, true);
|
||||
builder.addText(`⏱️ **Duración total**: ${totalDurationText}`);
|
||||
builder.addText(`📄 Página ${page}/${totalPages}`);
|
||||
|
||||
const buttons = [
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_prev",
|
||||
"◀️",
|
||||
2,
|
||||
page === 1
|
||||
),
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_next",
|
||||
"▶️",
|
||||
2,
|
||||
page === totalPages
|
||||
),
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_clear",
|
||||
"🗑️ Limpiar",
|
||||
4
|
||||
)
|
||||
];
|
||||
|
||||
builder.addActionRow(buttons);
|
||||
|
||||
await interaction.update({
|
||||
components: [builder.toJSON()],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
106
src/components/buttons/music/music_queue_prev.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { queues } from "../../../commands/messages/music/play";
|
||||
import { queuePages } from "../../../commands/messages/music/queue";
|
||||
import { DisplayComponentV2Builder } from "../../../core/lib/displayComponents/builders/v2Builder";
|
||||
|
||||
export default {
|
||||
customId: "music_queue_prev",
|
||||
run: async (interaction: any, client: any) => {
|
||||
const guildId = interaction.guild!.id;
|
||||
const player = client.music.players.get(guildId);
|
||||
const queue = queues.get(guildId);
|
||||
|
||||
if (!player && (!queue || queue.length === 0)) {
|
||||
await interaction.reply({
|
||||
content: "❌ No hay nada en la cola de reproducción.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current page and go to previous
|
||||
let page = queuePages.get(guildId) || 1;
|
||||
page = Math.max(1, page - 1);
|
||||
queuePages.set(guildId, page);
|
||||
|
||||
// Rebuild queue message with DisplayComponentV2Builder
|
||||
const currentTrack = player?.track as any;
|
||||
const queueList = queue || [];
|
||||
const itemsPerPage = 10;
|
||||
const totalPages = Math.ceil(queueList.length / itemsPerPage);
|
||||
const startIdx = (page - 1) * itemsPerPage;
|
||||
const endIdx = Math.min(startIdx + itemsPerPage, queueList.length);
|
||||
|
||||
const builder = new DisplayComponentV2Builder()
|
||||
.setAccentColor(0x608beb)
|
||||
.addText("📋 **En cola**");
|
||||
|
||||
if (currentTrack) {
|
||||
const duration = formatDuration(currentTrack.info?.length || 0);
|
||||
const nowPlayingText = `▶️ **Ahora**: ${currentTrack.info?.title || 'Desconocido'} - ${currentTrack.info?.author || 'Desconocido'} (${duration})`;
|
||||
builder.addText(nowPlayingText);
|
||||
builder.addSeparator(1, true);
|
||||
}
|
||||
|
||||
builder.addText(`**${queueList.length} ${queueList.length === 1 ? 'canción' : 'canciones'} en cola**`);
|
||||
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const track = queueList[i] as any;
|
||||
const duration = formatDuration(track.info?.length || track.info?.duration || 0);
|
||||
const title = track.info?.title || "Desconocido";
|
||||
const author = track.info?.author || "Desconocido";
|
||||
|
||||
builder.addText(`\`${i + 1}.\` ${title} - ${author} (${duration})`);
|
||||
}
|
||||
|
||||
let totalDuration = 0;
|
||||
if (currentTrack) {
|
||||
totalDuration += currentTrack.info?.length || 0;
|
||||
}
|
||||
queueList.forEach((track: any) => {
|
||||
totalDuration += track.info?.length || track.info?.duration || 0;
|
||||
});
|
||||
|
||||
const totalDurationText = formatDuration(totalDuration);
|
||||
|
||||
builder.addSeparator(1, true);
|
||||
builder.addText(`⏱️ **Duración total**: ${totalDurationText}`);
|
||||
builder.addText(`📄 Página ${page}/${totalPages}`);
|
||||
|
||||
const buttons = [
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_prev",
|
||||
"◀️",
|
||||
2,
|
||||
page === 1
|
||||
),
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_next",
|
||||
"▶️",
|
||||
2,
|
||||
page === totalPages
|
||||
),
|
||||
DisplayComponentV2Builder.createButton(
|
||||
"music_queue_clear",
|
||||
"🗑️ Limpiar",
|
||||
4
|
||||
)
|
||||
];
|
||||
|
||||
builder.addActionRow(buttons);
|
||||
|
||||
await interaction.update({
|
||||
components: [builder.toJSON()],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes % 60).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cycleRepeatMode, getRepeatMode, getRepeatModeLabel } from "../../../core/services/MusicStateService";
|
||||
|
||||
export default {
|
||||
customId: "music_repeat",
|
||||
run: async (interaction: any, client: any) => {
|
||||
@@ -35,21 +37,25 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle repeat mode (this is a simple implementation - you might want to store this)
|
||||
// For now, this will just restart the current track
|
||||
const currentTrack = player.track;
|
||||
if (currentTrack) {
|
||||
await player.playTrack({ encodedTrack: currentTrack });
|
||||
await interaction.reply({
|
||||
content: "🔁 **Repitiendo canción actual...**",
|
||||
ephemeral: true,
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
content: "❌ No hay ninguna canción reproduciéndose actualmente.",
|
||||
ephemeral: true,
|
||||
});
|
||||
// Cycle repeat mode
|
||||
const newMode = cycleRepeatMode(guild.id);
|
||||
|
||||
let message = "";
|
||||
switch (newMode) {
|
||||
case 'one':
|
||||
message = "🔂 **Repeat: One** - La canción actual se repetirá.";
|
||||
break;
|
||||
case 'all':
|
||||
message = "🔁 **Repeat: All** - Toda la cola se repetirá.";
|
||||
break;
|
||||
default:
|
||||
message = "🔁 **Repeat: Off** - Repetición desactivada.";
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
content: message,
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error en music_repeat button:", error);
|
||||
await interaction.reply({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { queues } from "../../../commands/messages/music/play";
|
||||
import { toggleShuffle, isShuffleEnabled } from "../../../core/services/MusicStateService";
|
||||
|
||||
export default {
|
||||
customId: "music_shuffle",
|
||||
@@ -37,7 +38,11 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get queue
|
||||
// Toggle shuffle state
|
||||
const newState = toggleShuffle(guild.id);
|
||||
|
||||
if (newState) {
|
||||
// Get queue and shuffle it
|
||||
const queue = queues.get(guild.id);
|
||||
|
||||
if (!queue || queue.length === 0) {
|
||||
@@ -55,11 +60,16 @@ export default {
|
||||
}
|
||||
|
||||
await interaction.reply({
|
||||
content: `🔀 **Cola mezclada!** ${queue.length} cancion${
|
||||
queue.length === 1 ? "" : "es"
|
||||
content: `🔀 **Shuffle activado!** ${queue.length} cancion${queue.length === 1 ? "" : "es"
|
||||
} reorganizadas aleatoriamente.`,
|
||||
ephemeral: true,
|
||||
});
|
||||
} else {
|
||||
await interaction.reply({
|
||||
content: "🔀 **Shuffle desactivado.**",
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error en music_shuffle button:", error);
|
||||
await interaction.reply({
|
||||
|
||||
@@ -3,27 +3,10 @@ import {
|
||||
ModalSubmitInteraction,
|
||||
MessageFlags,
|
||||
EmbedBuilder,
|
||||
User,
|
||||
Collection,
|
||||
Snowflake,
|
||||
} from "discord.js";
|
||||
import { prisma } from "../../core/database/prisma";
|
||||
import { hasManageGuildOrStaff } from "../../core/lib/permissions";
|
||||
|
||||
interface UserSelectComponent {
|
||||
custom_id: string;
|
||||
type: number;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface ComponentData {
|
||||
components?: ComponentData[];
|
||||
component?: ComponentData;
|
||||
custom_id?: string;
|
||||
type?: number;
|
||||
values?: string[];
|
||||
}
|
||||
|
||||
export default {
|
||||
customId: "ld_points_modal",
|
||||
run: async (interaction: ModalSubmitInteraction) => {
|
||||
@@ -53,87 +36,25 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
// Obtener valores del modal con manejo seguro de errores
|
||||
// Read values from modal text inputs
|
||||
let userId: string = "";
|
||||
let userName: string = "";
|
||||
let totalInput: string = "";
|
||||
let selectedUsers: ReturnType<
|
||||
typeof interaction.components.getSelectedUsers
|
||||
> = null;
|
||||
|
||||
try {
|
||||
selectedUsers = interaction.components.getSelectedUsers("user_select");
|
||||
// Get user ID from text input
|
||||
userId = interaction.fields.getTextInputValue("user_id_input");
|
||||
// Get points modification from text input
|
||||
totalInput = interaction.fields.getTextInputValue("points_input");
|
||||
|
||||
if (!selectedUsers || selectedUsers.size === 0) {
|
||||
// Fallback: intentar obtener los IDs directamente de los datos raw
|
||||
const rawData = (interaction as any).data?.components as
|
||||
| ComponentData[]
|
||||
| undefined;
|
||||
if (rawData) {
|
||||
const userSelectComponent = findUserSelectComponent(
|
||||
rawData,
|
||||
"user_select"
|
||||
);
|
||||
if (
|
||||
userSelectComponent?.values?.length &&
|
||||
userSelectComponent.values.length > 0
|
||||
) {
|
||||
userId = userSelectComponent.values[0];
|
||||
logger.info(
|
||||
`🔄 Fallback: UserId extraído de datos raw: ${userId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return interaction.reply({
|
||||
content: "❌ Debes seleccionar un usuario del leaderboard.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const selectedUser = Array.from(selectedUsers.values())[0] as User;
|
||||
if (selectedUser) {
|
||||
userId = selectedUser.id;
|
||||
userName = selectedUser.tag ?? selectedUser.username ?? userId;
|
||||
}
|
||||
}
|
||||
logger.info(`🔍 Input recibido - UserId: ${userId}, Puntos: ${totalInput}`);
|
||||
} catch (error) {
|
||||
// @ts-ignore
|
||||
logger.error(
|
||||
"Error procesando UserSelect, intentando fallback:",
|
||||
String(error)
|
||||
);
|
||||
|
||||
// Fallback más agresivo: obtener directamente de los datos raw
|
||||
try {
|
||||
const rawData = (interaction as any).data?.components as
|
||||
| ComponentData[]
|
||||
| undefined;
|
||||
const userSelectComponent = findUserSelectComponent(
|
||||
rawData,
|
||||
"user_select"
|
||||
);
|
||||
|
||||
if (
|
||||
userSelectComponent?.values?.length &&
|
||||
userSelectComponent.values.length > 0
|
||||
) {
|
||||
userId = userSelectComponent.values[0];
|
||||
logger.info(`🔄 Fallback agresivo: UserId extraído: ${userId}`);
|
||||
} else {
|
||||
throw new Error("No se pudo extraer userId de los datos raw");
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
// @ts-ignore
|
||||
logger.error("Falló el fallback:", String(fallbackError));
|
||||
logger.error({ error }, "Error al leer campos del modal");
|
||||
return interaction.reply({
|
||||
content:
|
||||
"❌ Error procesando la selección de usuario. Inténtalo de nuevo.",
|
||||
content: "❌ Error al leer los campos del formulario.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🔍 Input recibido: ${totalInput}`);
|
||||
logger.info(`🔍 UserId extraído: ${userId}`);
|
||||
|
||||
if (!totalInput) {
|
||||
return interaction.reply({
|
||||
@@ -144,25 +65,30 @@ export default {
|
||||
|
||||
if (!userId) {
|
||||
return interaction.reply({
|
||||
content: "❌ Error al identificar el usuario seleccionado.",
|
||||
content: "❌ Debes ingresar el ID del usuario.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
|
||||
// Si no tenemos userName, intentar obtenerlo del servidor
|
||||
if (!userName) {
|
||||
// Validar que el userId sea un número válido
|
||||
if (!/^\d{17,20}$/.test(userId)) {
|
||||
return interaction.reply({
|
||||
content: "❌ El ID del usuario debe ser un número de 17-20 dígitos.",
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
}
|
||||
|
||||
// Intentar obtener el nombre del usuario
|
||||
try {
|
||||
const targetMember = await interaction.guild.members.fetch(userId);
|
||||
userName = targetMember.displayName || targetMember.user.username;
|
||||
} catch (error) {
|
||||
// @ts-ignore
|
||||
logger.warn(
|
||||
`No se pudo obtener info del usuario ${userId}:`,
|
||||
String(error)
|
||||
{ error },
|
||||
`No se pudo obtener info del usuario ${userId}`
|
||||
);
|
||||
userName = `Usuario ${userId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ ARREGLO: Asegurar que el User exista en la base de datos antes de crear PartnershipStats
|
||||
await prisma.user.upsert({
|
||||
@@ -346,9 +272,7 @@ export default {
|
||||
flags: MessageFlags.Ephemeral,
|
||||
});
|
||||
} catch (error) {
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
logger.error("❌ Error en ldPointsModal:", String(error));
|
||||
logger.error({ error }, "❌ Error en ldPointsModal");
|
||||
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({
|
||||
@@ -360,32 +284,3 @@ export default {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Función auxiliar para buscar componentes UserSelect en datos raw
|
||||
function findUserSelectComponent(
|
||||
components: ComponentData[] | undefined,
|
||||
customId: string
|
||||
): UserSelectComponent | null {
|
||||
if (!components) return null;
|
||||
|
||||
for (const comp of components) {
|
||||
if (comp.components) {
|
||||
const found = findUserSelectComponent(comp.components, customId);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
if (comp.component) {
|
||||
if (comp.component.custom_id === customId) {
|
||||
return comp.component as UserSelectComponent;
|
||||
}
|
||||
const found = findUserSelectComponent([comp.component], customId);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
if (comp.custom_id === customId) {
|
||||
return comp as UserSelectComponent;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
276
src/components/selectMenus/music_history_select.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { queues, playNextTrack } from "../../commands/messages/music/play";
|
||||
import { MusicHistoryService } from "../../core/services/MusicHistoryService";
|
||||
import { createNowPlayingMessage } from "../../core/utils/musicMessages";
|
||||
import { sendComponentsV2Message } from "../../core/api/discordAPI";
|
||||
import { redis } from "../../core/database/redis";
|
||||
|
||||
// Track current user ID per guild (for history tracking)
|
||||
const currentUser = new Map<string, string>();
|
||||
|
||||
export default {
|
||||
customId: "music_history_select",
|
||||
run: async (interaction: any, client: any) => {
|
||||
try {
|
||||
// Defer reply immediately to prevent timeout
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const member = interaction.member;
|
||||
const guild = interaction.guild!;
|
||||
const selectedTrackId = interaction.values[0];
|
||||
|
||||
// Check if user is in voice channel
|
||||
if (!member.voice.channel) {
|
||||
await interaction.editReply({
|
||||
content: "❌ Debes estar en un canal de voz para reproducir música.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user's history to find the selected track
|
||||
const history = await MusicHistoryService.getRecentHistory(
|
||||
interaction.user.id,
|
||||
50
|
||||
);
|
||||
|
||||
console.log("[music_history_select] Selected trackId:", selectedTrackId);
|
||||
console.log("[music_history_select] History length:", history.length);
|
||||
if (history.length > 0) {
|
||||
console.log("[music_history_select] First track:", {
|
||||
trackId: history[0].trackId,
|
||||
title: history[0].title,
|
||||
author: history[0].author,
|
||||
combined: `${history[0].title}:${history[0].author}`
|
||||
});
|
||||
}
|
||||
|
||||
// Find the track in history
|
||||
// Compare by:
|
||||
// 1. Exact trackId match
|
||||
// 2. Truncated trackId match (first 100 chars, Discord limit)
|
||||
// 3. title:author combination
|
||||
const selectedTrack = history.find((track) => {
|
||||
const trackId = track.trackId || "";
|
||||
const combined = `${track.title}:${track.author}`;
|
||||
|
||||
return (
|
||||
trackId === selectedTrackId ||
|
||||
trackId.substring(0, 100) === selectedTrackId ||
|
||||
combined === selectedTrackId
|
||||
);
|
||||
});
|
||||
|
||||
console.log("[music_history_select] Found track:", selectedTrack ? "YES" : "NO");
|
||||
|
||||
if (!selectedTrack) {
|
||||
await interaction.reply({
|
||||
content: "❌ No se pudo encontrar esa canción en tu historial.",
|
||||
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get or create player
|
||||
let player = client.music.players.get(guild.id);
|
||||
|
||||
if (!player) {
|
||||
// Create player if it doesn't exist
|
||||
player = await client.music.joinVoiceChannel({
|
||||
guildId: guild.id,
|
||||
channelId: member.voice.channel.id,
|
||||
shardId: guild.shardId,
|
||||
deaf: true,
|
||||
});
|
||||
|
||||
// Configure event listeners for autoplay
|
||||
player.on("end", async (data) => {
|
||||
console.log(`[Player] Track ended. Reason: ${data.reason}`);
|
||||
|
||||
// Track song end in history before playing next
|
||||
if (data.reason === "finished" || data.reason === "stopped") {
|
||||
const userId = currentUser.get(guild.id);
|
||||
if (userId) {
|
||||
try {
|
||||
await MusicHistoryService.trackSongEnd(userId, guild.id);
|
||||
console.log(`[Player] Tracked song end for user ${userId}`);
|
||||
} catch (error) {
|
||||
console.error("[Player] Error tracking song end:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger autoplay for both 'finished' (natural end) and 'stopped' (manual skip)
|
||||
if (data.reason === "finished" || data.reason === "stopped") {
|
||||
console.log(`[Player] Calling playNextTrack for guild ${guild.id}`);
|
||||
try {
|
||||
await playNextTrack(
|
||||
player!,
|
||||
interaction.channel.id,
|
||||
guild.id,
|
||||
client,
|
||||
interaction.user.id
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Player] Error in playNextTrack:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
player.on("closed", () => {
|
||||
queues.delete(guild.id);
|
||||
currentUser.delete(guild.id);
|
||||
console.log(`[Player] Player closed for guild ${guild.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is in same voice channel as bot
|
||||
const botVoiceChannel = guild.members.cache.get(client.user!.id)?.voice
|
||||
.channelId;
|
||||
if (botVoiceChannel && botVoiceChannel !== member.voice.channel.id) {
|
||||
await interaction.reply({
|
||||
content: "❌ Debes estar en el mismo canal de voz que el bot.",
|
||||
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for the track on YouTube
|
||||
const node = [...client.music.nodes.values()][0];
|
||||
if (!node) {
|
||||
await interaction.reply({
|
||||
content: "❌ No hay ningún nodo de música disponible.",
|
||||
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const searchQuery = `ytsearch:${selectedTrack.title} ${selectedTrack.author}`;
|
||||
const result: any = await node.rest.resolve(searchQuery);
|
||||
|
||||
if (
|
||||
!result ||
|
||||
result.loadType === "empty" ||
|
||||
result.loadType === "error"
|
||||
) {
|
||||
await interaction.reply({
|
||||
content: "❌ No se pudo encontrar la canción en YouTube.",
|
||||
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first track from search results
|
||||
let track;
|
||||
if (result.loadType === "track") {
|
||||
track = result.data;
|
||||
} else if (result.loadType === "search") {
|
||||
track = result.data[0];
|
||||
}
|
||||
|
||||
if (!track) {
|
||||
await interaction.reply({
|
||||
content: "❌ No se encontraron resultados.",
|
||||
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to queue
|
||||
let queue = queues.get(guild.id);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
queues.set(guild.id, queue);
|
||||
}
|
||||
|
||||
queue.push(track);
|
||||
|
||||
await interaction.editReply({
|
||||
content: `✅ **Añadido del historial:** ${selectedTrack.title} - ${selectedTrack.author}\n📍 Posición en cola: #${queue.length}`,
|
||||
|
||||
});
|
||||
|
||||
// If nothing is playing, start playing
|
||||
if (!player.track) {
|
||||
const nextTrack = queue.shift();
|
||||
if (nextTrack) {
|
||||
// Store current user for history tracking
|
||||
currentUser.set(guild.id, interaction.user.id);
|
||||
|
||||
// Start tracking song in history (create active session)
|
||||
try {
|
||||
const trackInfo = nextTrack.info || nextTrack;
|
||||
await MusicHistoryService.trackSongStart(interaction.user.id, guild.id, {
|
||||
trackId: nextTrack.encoded,
|
||||
title: trackInfo.title,
|
||||
author: trackInfo.author,
|
||||
duration: trackInfo.length || trackInfo.duration || 0,
|
||||
source: "youtube",
|
||||
});
|
||||
console.log(`[music_history_select] Started tracking song: ${trackInfo.title}`);
|
||||
} catch (error) {
|
||||
console.error("[music_history_select] Error tracking song start:", error);
|
||||
}
|
||||
|
||||
await player.playTrack({ encodedTrack: nextTrack.encoded });
|
||||
|
||||
// Send Now Playing message
|
||||
try {
|
||||
// Delete previous "Now Playing" message if it exists
|
||||
if (redis && redis.isOpen) {
|
||||
const previousMessageKey = `music:nowplaying:${guild.id}`;
|
||||
const previousMessageId = await redis.get(previousMessageKey);
|
||||
|
||||
if (previousMessageId) {
|
||||
try {
|
||||
const channel = await client.channels.fetch(interaction.channel.id);
|
||||
if (channel && "messages" in channel) {
|
||||
await channel.messages.delete(previousMessageId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Could not delete previous message, might be already deleted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trackInfo = nextTrack.info || nextTrack;
|
||||
const containers = await createNowPlayingMessage(
|
||||
{
|
||||
title: trackInfo.title,
|
||||
author: trackInfo.author,
|
||||
duration: trackInfo.length || trackInfo.duration || 0,
|
||||
trackId: nextTrack.encoded,
|
||||
thumbnail: trackInfo.artworkUrl || trackInfo.thumbnail || "",
|
||||
},
|
||||
queue.length,
|
||||
interaction.user.id,
|
||||
guild.id,
|
||||
client // Add client parameter for similar songs menu
|
||||
);
|
||||
|
||||
const sentMessage = await sendComponentsV2Message(interaction.channel.id, {
|
||||
components: containers as any,
|
||||
});
|
||||
|
||||
// Store new message ID in Redis for future deletion
|
||||
if (redis && redis.isOpen && sentMessage?.id) {
|
||||
const messageKey = `music:nowplaying:${guild.id}`;
|
||||
await redis.set(messageKey, sentMessage.id, { EX: 3600 }); // Expire after 1 hour
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending Now Playing message:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error en music_history_select:", error);
|
||||
|
||||
// Try to edit reply if deferred, otherwise just log
|
||||
try {
|
||||
await interaction.editReply({
|
||||
content: `❌ Error: ${error.message || "Error desconocido"}`,
|
||||
});
|
||||
} catch (replyError) {
|
||||
console.error("Could not send error message:", replyError);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
59
src/components/selectMenus/music_playlist_delete_confirm.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { PlaylistService } from "../../core/services/PlaylistService";
|
||||
|
||||
export default {
|
||||
customId: "music_playlist_delete_confirm",
|
||||
run: async (interaction: any, client: any) => {
|
||||
try {
|
||||
const playlistId = interaction.values[0];
|
||||
|
||||
if (!playlistId) {
|
||||
await interaction.reply({
|
||||
content: "❌ ID de playlist inválido.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get playlist to verify ownership
|
||||
const playlist = await PlaylistService.getPlaylist(playlistId);
|
||||
|
||||
if (!playlist) {
|
||||
await interaction.reply({
|
||||
content: "❌ Playlist no encontrada.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlist.userId !== interaction.user.id) {
|
||||
await interaction.reply({
|
||||
content: "❌ No tienes permiso para eliminar esta playlist.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (playlist.isDefault) {
|
||||
await interaction.reply({
|
||||
content: "❌ No puedes eliminar la playlist \"Me gusta\".",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the playlist
|
||||
await PlaylistService.deletePlaylist(playlistId);
|
||||
|
||||
await interaction.update({
|
||||
content: `🗑️ **Playlist eliminada**: ${playlist.name}`,
|
||||
components: [],
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error en music_playlist_delete_confirm:", error);
|
||||
await interaction.reply({
|
||||
content: `❌ Error al eliminar playlist: ${error.message || "Error desconocido"}`,
|
||||
flags: 64,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
99
src/components/selectMenus/music_similar_select.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { queues, playNextTrack } from "../../commands/messages/music/play";
|
||||
import { redis } from "../../core/database/redis";
|
||||
|
||||
export default {
|
||||
customId: "music_similar",
|
||||
run: async (interaction: any, client: any) => {
|
||||
const guild = interaction.guild;
|
||||
if (!guild) {
|
||||
await interaction.reply({
|
||||
content: "❌ Este comando solo funciona en servidores.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedTrackId = interaction.values[0];
|
||||
console.log(`[music_similar_select] Selected trackId: ${selectedTrackId}`);
|
||||
|
||||
try {
|
||||
// Get the similar tracks from Redis
|
||||
const redisKey = `music:similar:${guild.id}`;
|
||||
const similarTracksJson = await redis.get(redisKey);
|
||||
|
||||
if (!similarTracksJson) {
|
||||
await interaction.reply({
|
||||
content: "❌ No se encontraron canciones similares. Intenta reproducir una canción primero.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const similarTracks = JSON.parse(similarTracksJson);
|
||||
console.log(`[music_similar_select] Similar tracks length: ${similarTracks.length}`);
|
||||
console.log(`[music_similar_select] Looking for trackId: ${selectedTrackId}`);
|
||||
console.log(`[music_similar_select] First track encoded: ${similarTracks[0]?.encoded}`);
|
||||
|
||||
// Find the selected track (handle truncated encoded IDs)
|
||||
const selectedTrack = similarTracks.find(
|
||||
(track: any) => track.encoded.startsWith(selectedTrackId) || track.encoded === selectedTrackId
|
||||
);
|
||||
|
||||
if (!selectedTrack) {
|
||||
console.log(`[music_similar_select] Available tracks:`, similarTracks.map((t: any) => ({
|
||||
title: t.info.title,
|
||||
encoded: t.encoded.substring(0, 100)
|
||||
})));
|
||||
await interaction.reply({
|
||||
content: "❌ No se encontró la canción seleccionada.",
|
||||
flags: 64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[music_similar_select] Found track: ${selectedTrack.info.title}`);
|
||||
|
||||
// Get or create queue
|
||||
let queue = queues.get(guild.id);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
queues.set(guild.id, queue);
|
||||
}
|
||||
|
||||
// Add track to queue
|
||||
queue.push(selectedTrack);
|
||||
|
||||
const player = client.music.players.get(guild.id);
|
||||
|
||||
// If nothing is playing, start playing
|
||||
if (!player || !player.track) {
|
||||
await interaction.reply({
|
||||
content: `✅ Reproduciendo: **${selectedTrack.info.title}**`,
|
||||
flags: 64,
|
||||
});
|
||||
|
||||
if (player) {
|
||||
await playNextTrack(
|
||||
player,
|
||||
interaction.channel!.id,
|
||||
guild.id,
|
||||
client,
|
||||
interaction.user.id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Already playing, just add to queue
|
||||
await interaction.reply({
|
||||
content: `✅ **${selectedTrack.info.title}** añadida a la cola (posición #${queue.length})`,
|
||||
flags: 64,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[music_similar_select] Error:", error);
|
||||
await interaction.reply({
|
||||
content: "❌ Error al reproducir la canción similar.",
|
||||
flags: 64,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -75,15 +75,13 @@ class Amayo extends Client {
|
||||
new Connectors.DiscordJS(this),
|
||||
[
|
||||
{
|
||||
name: "Main",
|
||||
url: "192.168.0.150:2333",
|
||||
auth: "youshallnotpass",
|
||||
name: "main",
|
||||
url: "100.120.146.67:2333",
|
||||
auth: "zrc8YsnAKtLop0090.",
|
||||
},
|
||||
],
|
||||
{
|
||||
moveOnDisconnect: false,
|
||||
resumable: false,
|
||||
resumableTimeout: 30,
|
||||
reconnectTries: 2,
|
||||
restTimeout: 10000,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import {
|
||||
getDatabases,
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
isGuildCacheConfigured,
|
||||
} from "../api/appwrite";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import logger from "../lib/logger";
|
||||
import { Query } from "node-appwrite";
|
||||
import { redis } from "./redis";
|
||||
|
||||
const GUILD_CACHE_TTL = 300; // 5 minutos en segundos
|
||||
|
||||
@@ -19,7 +13,7 @@ export interface GuildConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la configuración de un guild desde caché o base de datos
|
||||
* Obtiene la configuración de un guild desde caché (Redis) o base de datos
|
||||
*/
|
||||
export async function getGuildConfig(
|
||||
guildId: string,
|
||||
@@ -27,54 +21,19 @@ export async function getGuildConfig(
|
||||
prisma: PrismaClient
|
||||
): Promise<GuildConfig> {
|
||||
try {
|
||||
// Intentar obtener desde Appwrite
|
||||
if (isGuildCacheConfigured()) {
|
||||
const databases = getDatabases();
|
||||
if (databases) {
|
||||
try {
|
||||
const doc = await databases.getDocument(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
guildId
|
||||
);
|
||||
// Intentar obtener desde Redis
|
||||
const cacheKey = `guild:config:${guildId}`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
|
||||
// Verificar si el documento ha expirado
|
||||
const expiresAt = new Date(doc.expiresAt);
|
||||
if (expiresAt > new Date()) {
|
||||
if (cached) {
|
||||
logger.debug(
|
||||
{ guildId },
|
||||
"✅ Guild config obtenida desde caché (Appwrite)"
|
||||
"✅ Guild config obtenida desde caché (Redis)"
|
||||
);
|
||||
return {
|
||||
id: doc.guildId,
|
||||
name: doc.name,
|
||||
prefix: doc.prefix || null,
|
||||
};
|
||||
} else {
|
||||
// Documento expirado, eliminarlo
|
||||
await databases.deleteDocument(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
guildId
|
||||
);
|
||||
logger.debug(
|
||||
{ guildId },
|
||||
"🗑️ Caché expirada eliminada de Appwrite"
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Si es 404, el documento no existe, continuar
|
||||
if (error?.code !== 404) {
|
||||
logger.error(
|
||||
{ error, guildId },
|
||||
"❌ Error al leer caché de guild en Appwrite"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, guildId }, "❌ Error al acceder a Appwrite");
|
||||
logger.error({ error, guildId }, "❌ Error al leer caché de guild en Redis");
|
||||
}
|
||||
|
||||
// Si no está en caché, hacer upsert en la base de datos
|
||||
@@ -94,68 +53,25 @@ export async function getGuildConfig(
|
||||
prefix: guild.prefix,
|
||||
};
|
||||
|
||||
// Guardar en caché de Appwrite
|
||||
// Guardar en caché de Redis
|
||||
try {
|
||||
if (isGuildCacheConfigured()) {
|
||||
const databases = getDatabases();
|
||||
if (databases) {
|
||||
const expiresAt = new Date(Date.now() + GUILD_CACHE_TTL * 1000);
|
||||
|
||||
await databases.createDocument(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
guildId, // usar guildId como document ID para que sea único
|
||||
{
|
||||
guildId: guild.id,
|
||||
name: guild.name,
|
||||
prefix: guild.prefix || "",
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
}
|
||||
const cacheKey = `guild:config:${guildId}`;
|
||||
await redis.set(
|
||||
cacheKey,
|
||||
JSON.stringify(config),
|
||||
{ EX: GUILD_CACHE_TTL }
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
{ guildId },
|
||||
"✅ Guild config guardada en caché (Appwrite)"
|
||||
"✅ Guild config guardada en caché (Redis)"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Si el documento ya existe (409), actualizarlo
|
||||
if (error?.code === 409) {
|
||||
try {
|
||||
const databases = getDatabases();
|
||||
if (databases) {
|
||||
const expiresAt = new Date(Date.now() + GUILD_CACHE_TTL * 1000);
|
||||
|
||||
await databases.updateDocument(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
guildId,
|
||||
{
|
||||
name: guild.name,
|
||||
prefix: guild.prefix || "",
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
{ guildId },
|
||||
"♻️ Guild config actualizada en caché (Appwrite)"
|
||||
);
|
||||
}
|
||||
} catch (updateError) {
|
||||
logger.error(
|
||||
{ error: updateError, guildId },
|
||||
"❌ Error al actualizar caché en Appwrite"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, guildId },
|
||||
"❌ Error al guardar caché en Appwrite"
|
||||
"❌ Error al guardar caché en Redis"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
@@ -175,130 +91,48 @@ export async function getGuildConfig(
|
||||
*/
|
||||
export async function invalidateGuildCache(guildId: string): Promise<void> {
|
||||
try {
|
||||
if (isGuildCacheConfigured()) {
|
||||
const databases = getDatabases();
|
||||
if (databases) {
|
||||
await databases.deleteDocument(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
guildId
|
||||
);
|
||||
logger.debug({ guildId }, "🗑️ Caché de guild invalidada (Appwrite)");
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Si es 404, el documento ya no existe
|
||||
if (error?.code !== 404) {
|
||||
const cacheKey = `guild:config:${guildId}`;
|
||||
await redis.del(cacheKey);
|
||||
logger.debug({ guildId }, "🗑️ Caché de guild invalidada (Redis)");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, guildId },
|
||||
"❌ Error al invalidar caché de guild en Appwrite"
|
||||
"❌ Error al invalidar caché de guild en Redis"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza directamente el caché de un guild (útil después de updates)
|
||||
*/
|
||||
export async function updateGuildCache(config: GuildConfig): Promise<void> {
|
||||
try {
|
||||
if (isGuildCacheConfigured()) {
|
||||
const databases = getDatabases();
|
||||
if (databases) {
|
||||
const expiresAt = new Date(Date.now() + GUILD_CACHE_TTL * 1000);
|
||||
|
||||
try {
|
||||
await databases.updateDocument(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
config.id,
|
||||
{
|
||||
name: config.name,
|
||||
prefix: config.prefix || "",
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
}
|
||||
const cacheKey = `guild:config:${config.id}`;
|
||||
await redis.set(
|
||||
cacheKey,
|
||||
JSON.stringify(config),
|
||||
{ EX: GUILD_CACHE_TTL }
|
||||
);
|
||||
logger.debug(
|
||||
{ guildId: config.id },
|
||||
"♻️ Caché de guild actualizada (Appwrite)"
|
||||
"♻️ Caché de guild actualizada (Redis)"
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Si no existe (404), crearlo
|
||||
if (error?.code === 404) {
|
||||
await databases.createDocument(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
config.id,
|
||||
{
|
||||
guildId: config.id,
|
||||
name: config.name,
|
||||
prefix: config.prefix || "",
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
}
|
||||
);
|
||||
logger.debug(
|
||||
{ guildId: config.id },
|
||||
"✅ Caché de guild creada (Appwrite)"
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, guildId: config.id },
|
||||
"❌ Error al actualizar caché de guild en Appwrite"
|
||||
"❌ Error al actualizar caché de guild en Redis"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia documentos expirados de la caché (ejecutar periódicamente)
|
||||
* Limpia documentos expirados de la caché
|
||||
* Nota: Redis maneja la expiración automáticamente con TTL,
|
||||
* por lo que esta función ya no es necesaria pero se mantiene
|
||||
* por compatibilidad
|
||||
*/
|
||||
export async function cleanExpiredGuildCache(): Promise<void> {
|
||||
try {
|
||||
if (isGuildCacheConfigured()) {
|
||||
const databases = getDatabases();
|
||||
if (databases) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Buscar documentos que hayan expirado
|
||||
const expired = await databases.listDocuments(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
[
|
||||
Query.lessThan("expiresAt", now),
|
||||
Query.limit(100), // Límite para evitar sobrecarga
|
||||
]
|
||||
);
|
||||
|
||||
// Eliminar documentos expirados
|
||||
for (const doc of expired.documents) {
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
APPWRITE_DATABASE_ID,
|
||||
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||
doc.$id
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, docId: doc.$id },
|
||||
"❌ Error al eliminar documento expirado"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (expired.documents.length > 0) {
|
||||
logger.info(
|
||||
{ count: expired.documents.length },
|
||||
"🧹 Documentos expirados eliminados de caché"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "❌ Error al limpiar caché expirada en Appwrite");
|
||||
}
|
||||
// Redis maneja la expiración automáticamente con TTL
|
||||
// No es necesario hacer nada aquí
|
||||
logger.debug("✅ Redis maneja la expiración automáticamente (TTL)");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DisplayComponentSeparator,
|
||||
DisplayComponentSection,
|
||||
DisplayComponentThumbnail,
|
||||
DisplayComponentNonContainer,
|
||||
COMPONENT_TYPES,
|
||||
} from "../../../types/displayComponents";
|
||||
|
||||
@@ -23,7 +24,7 @@ export class DisplayComponentV2Builder {
|
||||
return this;
|
||||
}
|
||||
|
||||
public addComponent(component: DisplayComponent): this {
|
||||
public addComponent(component: DisplayComponentNonContainer): this {
|
||||
this.container.components.push(component);
|
||||
return this;
|
||||
}
|
||||
@@ -48,7 +49,7 @@ export class DisplayComponentV2Builder {
|
||||
}
|
||||
|
||||
public addSection(
|
||||
contentOrComponents: string | DisplayComponent[] = [],
|
||||
contentOrComponents: string | DisplayComponentText[] = [],
|
||||
accessory?: any
|
||||
): this {
|
||||
const components = Array.isArray(contentOrComponents)
|
||||
@@ -170,9 +171,10 @@ export class DisplayComponentV2Builder {
|
||||
* Use with addActionRow() to add multiple buttons in one row
|
||||
*/
|
||||
public static createButton(
|
||||
customId: string,
|
||||
label: string,
|
||||
style: number,
|
||||
customId: string,
|
||||
disabled?: boolean,
|
||||
emoji?: { id?: string; name: string; animated?: boolean }
|
||||
): any {
|
||||
const button: any = {
|
||||
@@ -189,6 +191,10 @@ export class DisplayComponentV2Builder {
|
||||
button.emoji = emoji;
|
||||
}
|
||||
|
||||
if (disabled !== undefined) {
|
||||
button.disabled = disabled;
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
|
||||
300
src/core/services/AiPlaylistBuffer.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* AI Playlist Buffer - Smart Pre-loading for Premium Users
|
||||
*
|
||||
* Strategy:
|
||||
* - PRO users: Pre-load 3 AI-selected tracks in one batch
|
||||
* - Before last track plays, reload next 3 tracks in background
|
||||
* - Adds variation to avoid repetitive playlists
|
||||
* - Eliminates waiting time between songs
|
||||
*/
|
||||
|
||||
import { AiSelectorService } from "./AiSelectorService";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
interface Track {
|
||||
title: string;
|
||||
author: string;
|
||||
duration?: number;
|
||||
tags?: string[];
|
||||
encoded?: string;
|
||||
}
|
||||
|
||||
interface BufferedTrack extends Track {
|
||||
bufferIndex: number; // Position in buffer (0, 1, 2)
|
||||
generatedAt: number; // Timestamp
|
||||
}
|
||||
|
||||
export class AiPlaylistBuffer {
|
||||
// Buffer storage: userId -> array of buffered tracks
|
||||
private static buffers: Map<string, BufferedTrack[]> = new Map();
|
||||
|
||||
// Track which buffer index is being played: userId -> currentIndex
|
||||
private static playbackIndex: Map<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* Get next track from buffer for user
|
||||
* If buffer is low (last track), trigger background reload
|
||||
*
|
||||
* @param userId - User ID
|
||||
* @param currentTrack - Currently playing track
|
||||
* @param history - User's listening history
|
||||
* @param candidates - Available candidate tracks
|
||||
* @param depth - Recursion depth (internal, prevents infinite loops)
|
||||
* @returns Track to play, or null if buffer is empty
|
||||
*/
|
||||
static async getNextTrack(
|
||||
userId: string,
|
||||
currentTrack: Track,
|
||||
history: Track[],
|
||||
candidates: Track[],
|
||||
depth: number = 0
|
||||
): Promise<Track | null> {
|
||||
// SAFETY: Prevent infinite recursion (max 3 attempts)
|
||||
const MAX_RECURSION_DEPTH = 3;
|
||||
if (depth >= MAX_RECURSION_DEPTH) {
|
||||
logger.error(
|
||||
{ userId, depth, candidatesCount: candidates.length },
|
||||
"⚠️ Max recursion depth reached in getNextTrack, buffer reload failed repeatedly"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = this.buffers.get(userId) || [];
|
||||
const currentIndex = this.playbackIndex.get(userId) || 0;
|
||||
|
||||
// Check if we're on the last track in buffer
|
||||
if (currentIndex === buffer.length - 1 && buffer.length > 0) {
|
||||
logger.info({ userId }, "🔄 Last track in buffer, pre-loading next batch in background");
|
||||
|
||||
// Trigger background reload (don't await)
|
||||
this.reloadBuffer(userId, currentTrack, history, candidates).catch(err => {
|
||||
logger.warn({ userId, error: err.message }, "Background buffer reload failed");
|
||||
});
|
||||
}
|
||||
|
||||
// Get current track from buffer
|
||||
if (buffer.length > 0 && currentIndex < buffer.length) {
|
||||
const track = buffer[currentIndex];
|
||||
|
||||
// Increment index for next call
|
||||
this.playbackIndex.set(userId, currentIndex + 1);
|
||||
|
||||
logger.info({
|
||||
userId,
|
||||
track: `${track.author} - ${track.title}`,
|
||||
bufferPosition: `${currentIndex + 1}/${buffer.length}`,
|
||||
}, "🎵 Serving track from AI buffer");
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
// Buffer is empty, need to load
|
||||
logger.info({ userId, attempt: depth + 1 }, "📥 Buffer empty, loading initial batch...");
|
||||
|
||||
// Defensive check: ensure we have candidates before attempting reload
|
||||
if (!candidates || candidates.length === 0) {
|
||||
logger.warn({ userId }, "⚠️ No candidates available for buffer reload");
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.reloadBuffer(userId, currentTrack, history, candidates);
|
||||
|
||||
// Verify buffer was populated after reload
|
||||
const newBuffer = this.buffers.get(userId) || [];
|
||||
if (newBuffer.length === 0) {
|
||||
logger.warn(
|
||||
{ userId, attempt: depth + 1 },
|
||||
"⚠️ Buffer reload failed to populate any tracks, returning null"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try again after successful reload (increment depth)
|
||||
return this.getNextTrack(userId, currentTrack, history, candidates, depth + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload buffer with 3 new AI-selected tracks
|
||||
* Uses variation to avoid repetitive playlists
|
||||
* Now tracks artist diversity to prevent same-artist repetition
|
||||
*/
|
||||
private static async reloadBuffer(
|
||||
userId: string,
|
||||
currentTrack: Track,
|
||||
history: Track[],
|
||||
candidates: Track[]
|
||||
): Promise<void> {
|
||||
logger.info({ userId, candidatesCount: candidates.length }, "🤖 AI generating 3-track batch...");
|
||||
|
||||
// Extract recent artists from history for diversity
|
||||
const recentArtists = this.extractRecentArtists(history);
|
||||
logger.info({ userId, recentArtists: recentArtists.slice(0, 5) }, "📊 Recent artists for diversity check");
|
||||
|
||||
const newBuffer: BufferedTrack[] = [];
|
||||
const usedCandidates = new Set<string>();
|
||||
const usedArtistsInBuffer = new Set<string>(); // Track artists used in THIS buffer
|
||||
|
||||
// Generate 3 tracks with variation
|
||||
for (let i = 0; i < 3; i++) {
|
||||
// Add variation: shuffle candidates slightly for each iteration
|
||||
const variedCandidates = this.addVariation(candidates, i);
|
||||
|
||||
// Step 1: Filter out recently played artists (PREVENTIVE - before AI sees them)
|
||||
const recentArtistsLower = new Set(recentArtists.map(a => a.toLowerCase()));
|
||||
const diverseCandidates = variedCandidates.filter(c => {
|
||||
const artistLower = c.author.toLowerCase();
|
||||
return !recentArtistsLower.has(artistLower);
|
||||
});
|
||||
|
||||
// Step 2: Filter out already selected tracks AND artists already used in buffer
|
||||
const availableCandidates = diverseCandidates.filter(c => {
|
||||
const candidateKey = c.encoded || `${c.author}-${c.title}`;
|
||||
const artistLower = c.author.toLowerCase();
|
||||
|
||||
// Exclude if: 1) track already used, OR 2) artist already in buffer
|
||||
return !usedCandidates.has(candidateKey) && !usedArtistsInBuffer.has(artistLower);
|
||||
});
|
||||
|
||||
if (availableCandidates.length === 0) {
|
||||
logger.warn({
|
||||
userId,
|
||||
iteration: i,
|
||||
totalCandidates: variedCandidates.length,
|
||||
afterDiversityFilter: diverseCandidates.length,
|
||||
afterBufferFilter: availableCandidates.length,
|
||||
}, "No more candidates available for buffer (after all filters)");
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
userId,
|
||||
iteration: i,
|
||||
filteredOut: variedCandidates.length - availableCandidates.length,
|
||||
remaining: availableCandidates.length,
|
||||
}, "🎯 Candidates filtered (removed recent artists)");
|
||||
|
||||
// Combine recent artists from history + artists already used in this buffer
|
||||
const allRecentArtists = [...recentArtists, ...Array.from(usedArtistsInBuffer)];
|
||||
|
||||
// Get AI selection with artist diversity context (AI will only see diverse candidates)
|
||||
const selected = await AiSelectorService.selectBestTrack(
|
||||
currentTrack,
|
||||
history,
|
||||
availableCandidates, // Only diverse candidates
|
||||
allRecentArtists // Still pass for prompt context
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
// Mark track and artist as used
|
||||
usedCandidates.add(selected.encoded || `${selected.author}-${selected.title}`);
|
||||
usedArtistsInBuffer.add(selected.author.toLowerCase());
|
||||
|
||||
// Add to buffer
|
||||
newBuffer.push({
|
||||
...selected,
|
||||
bufferIndex: i,
|
||||
generatedAt: Date.now(),
|
||||
});
|
||||
|
||||
// Update currentTrack and history for next iteration (continuity)
|
||||
currentTrack = selected;
|
||||
history = [selected, ...history.slice(0, 4)]; // Keep last 5
|
||||
|
||||
logger.info({
|
||||
userId,
|
||||
iteration: i + 1,
|
||||
track: `${selected.author} - ${selected.title}`,
|
||||
artist: selected.author,
|
||||
}, "✅ Added to buffer");
|
||||
}
|
||||
}
|
||||
|
||||
// Replace buffer and reset index
|
||||
this.buffers.set(userId, newBuffer);
|
||||
this.playbackIndex.set(userId, 0);
|
||||
|
||||
// Validate buffer population
|
||||
if (newBuffer.length === 0) {
|
||||
logger.error({
|
||||
userId,
|
||||
candidatesCount: candidates.length,
|
||||
historyLength: history.length,
|
||||
}, "❌ Buffer reload failed: No tracks could be added (all filtered or AI failed)");
|
||||
} else {
|
||||
logger.info({
|
||||
userId,
|
||||
bufferSize: newBuffer.length,
|
||||
tracks: newBuffer.map(t => `${t.author} - ${t.title}`),
|
||||
artists: newBuffer.map(t => t.author),
|
||||
}, "🎉 Buffer reloaded successfully");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique recent artists from history
|
||||
* Returns up to 10 most recent unique artists
|
||||
*/
|
||||
private static extractRecentArtists(history: Track[]): string[] {
|
||||
const artistsSet = new Set<string>();
|
||||
const artists: string[] = [];
|
||||
|
||||
for (const track of history) {
|
||||
const artistLower = track.author?.toLowerCase();
|
||||
if (artistLower && !artistsSet.has(artistLower)) {
|
||||
artistsSet.add(artistLower);
|
||||
artists.push(track.author); // Keep original case for display
|
||||
|
||||
// Limit to 10 most recent unique artists
|
||||
if (artists.length >= 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
return artists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add variation to candidates to avoid repetitive playlists
|
||||
* Shuffles candidates slightly based on iteration
|
||||
*/
|
||||
private static addVariation(candidates: Track[], iteration: number): Track[] {
|
||||
// Clone array
|
||||
const varied = [...candidates];
|
||||
|
||||
// Add controlled randomness based on iteration
|
||||
// Iteration 0: No shuffle (best picks)
|
||||
// Iteration 1: Slight shuffle (introduce variety)
|
||||
// Iteration 2: More shuffle (maximum variety)
|
||||
const shuffleAmount = iteration * 2; // 0, 2, 4
|
||||
|
||||
for (let i = 0; i < shuffleAmount && i < varied.length - 1; i++) {
|
||||
// Swap current with a random position ahead
|
||||
const swapIndex = i + Math.floor(Math.random() * (varied.length - i));
|
||||
[varied[i], varied[swapIndex]] = [varied[swapIndex], varied[i]];
|
||||
}
|
||||
|
||||
return varied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear buffer for user (e.g., when they skip or change playlist)
|
||||
*/
|
||||
static clearBuffer(userId: string): void {
|
||||
this.buffers.delete(userId);
|
||||
this.playbackIndex.delete(userId);
|
||||
logger.info({ userId }, "🗑️ Buffer cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffer status for debugging
|
||||
*/
|
||||
static getBufferStatus(userId: string): { size: number; currentIndex: number; tracks: string[] } {
|
||||
const buffer = this.buffers.get(userId) || [];
|
||||
const currentIndex = this.playbackIndex.get(userId) || 0;
|
||||
|
||||
return {
|
||||
size: buffer.length,
|
||||
currentIndex,
|
||||
tracks: buffer.map(t => `${t.author} - ${t.title}`),
|
||||
};
|
||||
}
|
||||
}
|
||||
384
src/core/services/AiRecommendationIntegration.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* INTEGRATION EXAMPLE: AI Selector Service with Smart Recommendation Engine
|
||||
*
|
||||
* This file shows how to integrate the AiSelectorService into your existing
|
||||
* music recommendation flow.
|
||||
*/
|
||||
|
||||
import { SmartRecommendationEngine } from "./SmartRecommendationEngine";
|
||||
import { AiSelectorService } from "./AiSelectorService";
|
||||
import { MusicHistoryService } from "./MusicHistoryService";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
// Import TrackCandidate type from SmartRecommendationEngine to avoid type conflicts
|
||||
type TrackCandidate = {
|
||||
title: string;
|
||||
author: string;
|
||||
duration: number;
|
||||
tags: string[];
|
||||
encoded: string;
|
||||
isFromRelated?: boolean;
|
||||
isFromHistory?: boolean;
|
||||
isrc?: string;
|
||||
};
|
||||
|
||||
// Use TrackCandidate as Track for consistency
|
||||
type Track = TrackCandidate;
|
||||
|
||||
/**
|
||||
* OPTION 1: Use AI as a post-filter on top candidates
|
||||
*
|
||||
* Strategy: Get top 10 mathematical candidates, then let AI choose the best one
|
||||
* This is the RECOMMENDED approach - balances performance and quality
|
||||
*/
|
||||
export async function getAiEnhancedRecommendation(
|
||||
userId: string,
|
||||
lastTrack: Track,
|
||||
candidates: Track[],
|
||||
client: any
|
||||
): Promise<Track | null> {
|
||||
try {
|
||||
// Step 1: Get Top 10 mathematical candidates
|
||||
const topCandidates: Track[] = [];
|
||||
const MAX_TOP_CANDIDATES = 10;
|
||||
|
||||
logger.info({ userId }, "Step 1: Getting top mathematical candidates");
|
||||
|
||||
// Get scored candidates from SmartRecommendationEngine
|
||||
const history = await MusicHistoryService.getRecentHistory(userId, 100);
|
||||
|
||||
for (let i = 0; i < Math.min(MAX_TOP_CANDIDATES, candidates.length); i++) {
|
||||
const recommendation = await SmartRecommendationEngine.getSmartRecommendation(
|
||||
userId,
|
||||
lastTrack,
|
||||
candidates,
|
||||
{ client, history }
|
||||
);
|
||||
|
||||
if (recommendation) {
|
||||
topCandidates.push(recommendation);
|
||||
// Remove selected candidate to get different options
|
||||
candidates = candidates.filter(c => c.encoded !== recommendation.encoded);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) break;
|
||||
}
|
||||
|
||||
if (topCandidates.length === 0) {
|
||||
logger.warn({ userId }, "No mathematical candidates found");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
userId,
|
||||
candidatesCount: topCandidates.length
|
||||
}, "Step 2: Sending top candidates to AI");
|
||||
|
||||
// Step 2: Let AI choose the best from top candidates
|
||||
const recentHistory = history.slice(0, 5); // Last 5 for context
|
||||
const aiSelection = await AiSelectorService.selectBestTrack(
|
||||
lastTrack,
|
||||
recentHistory,
|
||||
topCandidates
|
||||
);
|
||||
|
||||
if (aiSelection) {
|
||||
logger.info({
|
||||
userId,
|
||||
track: `${aiSelection.author} - ${aiSelection.title}`,
|
||||
method: "AI Enhanced"
|
||||
}, "✨ AI-enhanced recommendation selected");
|
||||
}
|
||||
|
||||
return aiSelection as Track;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error({ userId, error: error.message }, "AI-enhanced recommendation failed");
|
||||
|
||||
// Critical fallback: Use pure mathematical recommendation
|
||||
logger.info({ userId }, "Falling back to pure mathematical recommendation");
|
||||
return SmartRecommendationEngine.getSmartRecommendation(
|
||||
userId,
|
||||
lastTrack,
|
||||
candidates,
|
||||
{ client }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTION 2: Use AI only for close-score candidates
|
||||
*
|
||||
* Strategy: If top 3 candidates have similar scores (within 10%), use AI as tiebreaker
|
||||
* This minimizes AI calls while maximizing impact
|
||||
*/
|
||||
export async function getAiTiebreakerRecommendation(
|
||||
userId: string,
|
||||
lastTrack: Track,
|
||||
candidates: Track[],
|
||||
client: any
|
||||
): Promise<Track | null> {
|
||||
try {
|
||||
// Get ALL scored candidates
|
||||
const history = await MusicHistoryService.getRecentHistory(userId, 100);
|
||||
|
||||
// Score all candidates (WITHOUT selecting yet)
|
||||
const scoredCandidates = await Promise.all(
|
||||
candidates.slice(0, 50).map(async (candidate) => {
|
||||
const scored = await SmartRecommendationEngine.getSmartRecommendation(
|
||||
userId,
|
||||
lastTrack,
|
||||
[candidate], // Score individually
|
||||
{ client, history }
|
||||
);
|
||||
return scored;
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out nulls and sort by score
|
||||
const validCandidates = scoredCandidates
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (validCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topScore = validCandidates[0].score;
|
||||
const topThree = validCandidates.slice(0, 3);
|
||||
|
||||
// Check if top 3 are within 10% of each other
|
||||
const scoreRange = topThree[topThree.length - 1].score / topScore;
|
||||
const scoresAreClose = scoreRange >= 0.9; // Within 10%
|
||||
|
||||
if (scoresAreClose && topThree.length > 1) {
|
||||
logger.info({
|
||||
userId,
|
||||
topScores: topThree.map(t => t.score),
|
||||
scoreRange: (scoreRange * 100).toFixed(1) + "%"
|
||||
}, "Scores are close, using AI as tiebreaker");
|
||||
|
||||
// Use AI to break the tie
|
||||
const recentHistory = history.slice(0, 5);
|
||||
const aiSelection = await AiSelectorService.selectBestTrack(
|
||||
lastTrack,
|
||||
recentHistory,
|
||||
topThree
|
||||
);
|
||||
|
||||
if (aiSelection) {
|
||||
logger.info({
|
||||
userId,
|
||||
track: `${aiSelection.author} - ${aiSelection.title}`,
|
||||
method: "AI Tiebreaker"
|
||||
}, "🎯 AI resolved close scores");
|
||||
return aiSelection as Track;
|
||||
}
|
||||
}
|
||||
|
||||
// If scores aren't close OR AI failed, use top mathematical candidate
|
||||
logger.info({
|
||||
userId,
|
||||
track: `${validCandidates[0].author} - ${validCandidates[0].title}`,
|
||||
score: validCandidates[0].score,
|
||||
method: "Pure Mathematical"
|
||||
}, "📊 Using top mathematical candidate");
|
||||
|
||||
return validCandidates[0];
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error({ userId, error: error.message }, "Tiebreaker recommendation failed");
|
||||
|
||||
// Fallback to basic recommendation
|
||||
return SmartRecommendationEngine.getSmartRecommendation(
|
||||
userId,
|
||||
lastTrack,
|
||||
candidates,
|
||||
{ client }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPTION 3: Tiered AI access based on user subscription
|
||||
*
|
||||
* Strategy:
|
||||
* - Free users: LIMITED AI access (fewer candidates, basic context)
|
||||
* - Premium users: FULL AI PRO access (more candidates, rich context, better prompts)
|
||||
*
|
||||
* Free Tier Limitations:
|
||||
* - Max 5 candidates sent to AI (vs 10 for Premium)
|
||||
* - Max 3 history tracks for context (vs 5 for Premium)
|
||||
* - Shorter timeout (2s vs 3s)
|
||||
*
|
||||
* Premium Tier Benefits:
|
||||
* - More candidates analyzed by AI
|
||||
* - Richer historical context
|
||||
* - Longer processing time allowed
|
||||
* - Priority features in future updates
|
||||
*/
|
||||
async function getTieredAiRecommendation(
|
||||
userId: string,
|
||||
lastTrack: Track,
|
||||
candidates: Track[],
|
||||
client: any,
|
||||
isPremium: boolean = false
|
||||
): Promise<Track | null> {
|
||||
try {
|
||||
const tier = isPremium ? "PREMIUM" : "FREE";
|
||||
logger.info({ userId, tier }, `<EFBFBD> Getting ${tier} AI recommendation`);
|
||||
|
||||
// Step 1: Get top mathematical candidates
|
||||
const history = await MusicHistoryService.getRecentHistory(userId, 100);
|
||||
|
||||
// Tier-specific limits
|
||||
const MAX_CANDIDATES = isPremium ? 10 : 5; // Premium gets 2x candidates
|
||||
const MAX_HISTORY = isPremium ? 5 : 3; // Premium gets more context
|
||||
const AI_TIMEOUT = isPremium ? 3000 : 2000; // Premium gets more processing time
|
||||
|
||||
logger.info({
|
||||
userId,
|
||||
tier,
|
||||
limits: {
|
||||
candidates: MAX_CANDIDATES,
|
||||
history: MAX_HISTORY,
|
||||
timeout: `${AI_TIMEOUT}ms`
|
||||
}
|
||||
}, "Using tiered AI limits");
|
||||
|
||||
// Get top candidates using mathematical scoring
|
||||
const topCandidates: Track[] = [];
|
||||
|
||||
for (let i = 0; i < Math.min(MAX_CANDIDATES, candidates.length); i++) {
|
||||
const recommendation = await SmartRecommendationEngine.getSmartRecommendation(
|
||||
userId,
|
||||
lastTrack,
|
||||
candidates,
|
||||
{ client, history }
|
||||
);
|
||||
|
||||
if (recommendation) {
|
||||
topCandidates.push(recommendation);
|
||||
// Remove selected to get different options
|
||||
candidates = candidates.filter(c => c.encoded !== recommendation.encoded);
|
||||
}
|
||||
|
||||
if (candidates.length === 0) break;
|
||||
}
|
||||
|
||||
if (topCandidates.length === 0) {
|
||||
logger.warn({ userId }, "No mathematical candidates found");
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
userId,
|
||||
tier,
|
||||
candidatesCount: topCandidates.length
|
||||
}, `Sending ${topCandidates.length} top candidates to AI`);
|
||||
|
||||
// Step 2: Let AI choose with tier-specific settings
|
||||
const recentHistory = history.slice(0, MAX_HISTORY);
|
||||
|
||||
// Override AI timeout based on tier
|
||||
const originalTimeout = (AiSelectorService as any).DEFAULT_TIMEOUT;
|
||||
(AiSelectorService as any).DEFAULT_TIMEOUT = AI_TIMEOUT;
|
||||
|
||||
const aiSelection = await AiSelectorService.selectBestTrack(
|
||||
lastTrack,
|
||||
recentHistory,
|
||||
topCandidates
|
||||
);
|
||||
|
||||
// Restore original timeout
|
||||
(AiSelectorService as any).DEFAULT_TIMEOUT = originalTimeout;
|
||||
|
||||
if (aiSelection) {
|
||||
logger.info({
|
||||
userId,
|
||||
tier,
|
||||
track: `${aiSelection.author} - ${aiSelection.title}`,
|
||||
method: isPremium ? "AI Pro Enhanced" : "AI Free Enhanced"
|
||||
}, `✨ ${tier} AI recommendation selected`);
|
||||
}
|
||||
|
||||
return aiSelection as Track;
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error({ userId, error: error.message }, "Tiered AI recommendation failed");
|
||||
|
||||
// Critical fallback: Use pure mathematical recommendation
|
||||
logger.info({ userId }, "Falling back to pure mathematical recommendation");
|
||||
return SmartRecommendationEngine.getSmartRecommendation(
|
||||
userId,
|
||||
lastTrack,
|
||||
candidates,
|
||||
{ client }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy function for backward compatibility
|
||||
* @deprecated Use getTieredAiRecommendation instead
|
||||
*/
|
||||
export async function getSubscriptionBasedRecommendation(
|
||||
userId: string,
|
||||
lastTrack: Track,
|
||||
candidates: Track[],
|
||||
client: any,
|
||||
isPro: boolean = false
|
||||
): Promise<Track | null> {
|
||||
logger.warn("getSubscriptionBasedRecommendation is deprecated, use getTieredAiRecommendation");
|
||||
return getTieredAiRecommendation(userId, lastTrack, candidates, client, isPro);
|
||||
}
|
||||
|
||||
/**
|
||||
* RECOMMENDED USAGE in your bot command:
|
||||
*
|
||||
* ```typescript
|
||||
* // In your music autoplay/recommendation command:
|
||||
* import { SubscriptionService } from "../services/SubscriptionService";
|
||||
*
|
||||
* // Check user subscription tier
|
||||
* const isPremium = await SubscriptionService.isPremiumUser(userId);
|
||||
*
|
||||
* // Get AI recommendation with tiered access
|
||||
* const nextTrack = await getTieredAiRecommendation(
|
||||
* userId,
|
||||
* currentTrack,
|
||||
* candidates, // From your existing Related Videos + History search
|
||||
* client, // Your Lavalink client
|
||||
* isPremium // true = AI Pro, false = AI Free
|
||||
* );
|
||||
*
|
||||
* if (nextTrack) {
|
||||
* // Play the track
|
||||
* queue.add(nextTrack);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* TIER COMPARISON:
|
||||
*
|
||||
* FREE TIER (AI Limited):
|
||||
* - ✓ AI-powered selection
|
||||
* - ✓ Up to 5 candidates analyzed
|
||||
* - ✓ Last 3 songs context
|
||||
* - ✓ 2 second timeout
|
||||
*
|
||||
* PREMIUM TIER (AI Pro):
|
||||
* - ✓ Full AI-powered selection
|
||||
* - ✓ Up to 10 candidates analyzed (2x more)
|
||||
* - ✓ Last 5 songs context (richer history)
|
||||
* - ✓ 3 second timeout (50% more processing)
|
||||
* - ✓ Priority features in future updates
|
||||
*/
|
||||
|
||||
// Export all strategies
|
||||
export {
|
||||
getTieredAiRecommendation,
|
||||
getTieredAiRecommendation as default,
|
||||
getAiEnhancedRecommendation as aiEnhanced,
|
||||
getAiTiebreakerRecommendation as aiTiebreaker,
|
||||
getSubscriptionBasedRecommendation as subscriptionBased, // Deprecated
|
||||
};
|
||||
340
src/core/services/AiSelectorService.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* AI Selector Service - Cultural Vibe-Based Music Selection
|
||||
*
|
||||
* This service takes mathematically selected candidates and queries a private AI API
|
||||
* to choose the best track based on cultural context, mood, and vibe.
|
||||
*
|
||||
* Critical Feature: Resilient fallback - music never stops if AI fails
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
interface Track {
|
||||
title: string;
|
||||
author: string;
|
||||
duration?: number;
|
||||
tags?: string[];
|
||||
encoded?: string;
|
||||
}
|
||||
|
||||
interface AiSelectionRequest {
|
||||
currentTrack: Track;
|
||||
history: Track[];
|
||||
candidates: Track[];
|
||||
}
|
||||
|
||||
// API response can be plain text or JSON
|
||||
interface AiSelectionResponse {
|
||||
selectedTitle?: string;
|
||||
selectedAuthor?: string;
|
||||
reason?: string;
|
||||
// Or it might be a plain string that we need to parse
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export class AiSelectorService {
|
||||
private static axiosInstance: AxiosInstance;
|
||||
private static readonly DEFAULT_TIMEOUT = 60000; // 60 seconds (adjusted for actual AI response time)
|
||||
private static readonly MAX_CANDIDATES_FOR_AI = 10; // Limit candidates sent to AI
|
||||
private static readonly MAX_HISTORY_FOR_AI = 5; // Last 5 songs for context
|
||||
|
||||
/**
|
||||
* Initialize the Axios instance with configured timeout
|
||||
*/
|
||||
private static getAxiosInstance(): AxiosInstance {
|
||||
// Force recreation to pick up new config (in case port changed)
|
||||
const apiUrl = process.env.AI_API_URL || "http://100.120.146.67:3001";
|
||||
|
||||
// Recreate if URL changed
|
||||
if (this.axiosInstance && this.axiosInstance.defaults.baseURL !== apiUrl) {
|
||||
logger.info({ oldUrl: this.axiosInstance.defaults.baseURL, newUrl: apiUrl }, "AI API URL changed, recreating axios instance");
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: apiUrl,
|
||||
timeout: this.DEFAULT_TIMEOUT,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.axiosInstance) {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: apiUrl,
|
||||
timeout: this.DEFAULT_TIMEOUT,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ apiUrl }, "AI Selector Service initialized");
|
||||
}
|
||||
|
||||
return this.axiosInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best track from candidates using AI analysis
|
||||
*
|
||||
* @param currentTrack - The track currently playing
|
||||
* @param history - Last 5 tracks played (for context)
|
||||
* @param candidates - Pre-filtered candidates from mathematical scoring
|
||||
* @param recentArtists - Optional list of recently played artists to avoid (for diversity)
|
||||
* @returns Selected track or null if no valid selection
|
||||
*/
|
||||
static async selectBestTrack(
|
||||
currentTrack: Track,
|
||||
history: Track[],
|
||||
candidates: Track[],
|
||||
recentArtists?: string[]
|
||||
): Promise<Track | null> {
|
||||
// RESILIENCE: If no candidates, return null immediately
|
||||
if (!candidates || candidates.length === 0) {
|
||||
logger.warn("No candidates provided to AI selector");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prepare fallback (first candidate) before attempting AI call
|
||||
const fallbackTrack = candidates[0];
|
||||
logger.debug(
|
||||
{ fallback: `${fallbackTrack.author} - ${fallbackTrack.title}` },
|
||||
"Fallback track prepared"
|
||||
);
|
||||
|
||||
try {
|
||||
// Limit candidates to avoid overwhelming the AI
|
||||
const limitedCandidates = candidates.slice(0, this.MAX_CANDIDATES_FOR_AI);
|
||||
const limitedHistory = history.slice(0, this.MAX_HISTORY_FOR_AI);
|
||||
|
||||
// Build efficient prompt for DJ AI
|
||||
const query = this.buildDJPrompt(currentTrack, limitedHistory, limitedCandidates, recentArtists);
|
||||
|
||||
// Log the prompt being sent to AI
|
||||
logger.info(
|
||||
{
|
||||
prompt: query.substring(0, 200) + "...", // First 200 chars
|
||||
candidatesCount: limitedCandidates.length,
|
||||
},
|
||||
"🤖 Sending prompt to AI"
|
||||
);
|
||||
|
||||
// Make the AI API call with timeout protection
|
||||
const client = this.getAxiosInstance();
|
||||
const response = await client.post<AiSelectionResponse>("/ask", {
|
||||
query // Your API expects { "query": "..." }
|
||||
});
|
||||
|
||||
// Log the full AI response
|
||||
logger.info(
|
||||
{
|
||||
response: response.data,
|
||||
statusCode: response.status,
|
||||
},
|
||||
"🤖 AI Response received"
|
||||
);
|
||||
|
||||
|
||||
// Parse response - expect just a number
|
||||
let selectedIndex = -1;
|
||||
|
||||
// Check if response is an object with a 'response' property
|
||||
if (response.data && typeof response.data === 'object' && 'response' in response.data) {
|
||||
const responseText = String(response.data.response).trim();
|
||||
const numberMatch = responseText.match(/(\d+)/);
|
||||
if (numberMatch) {
|
||||
selectedIndex = parseInt(numberMatch[1]) - 1;
|
||||
} else {
|
||||
logger.warn({ response: response.data }, "AI response object doesn't contain a number");
|
||||
return this.handleFallback(fallbackTrack, "Invalid AI response format");
|
||||
}
|
||||
} else if (typeof response.data === 'string') {
|
||||
// Extract number from response (e.g., "3", "The answer is 3", "3.", etc.)
|
||||
const numberMatch = (response.data as string).match(/(\d+)/);
|
||||
if (numberMatch) {
|
||||
selectedIndex = parseInt(numberMatch[1]) - 1; // Convert to 0-indexed
|
||||
} else {
|
||||
logger.warn({ response: response.data }, "AI returned non-numeric response");
|
||||
return this.handleFallback(fallbackTrack, "Invalid AI response format");
|
||||
}
|
||||
} else if (typeof response.data === 'number') {
|
||||
selectedIndex = response.data - 1; // Convert to 0-indexed
|
||||
} else {
|
||||
logger.warn({ response: response.data }, "AI returned unexpected response type");
|
||||
return this.handleFallback(fallbackTrack, "Invalid AI response type");
|
||||
}
|
||||
|
||||
// Validate index
|
||||
if (selectedIndex < 0 || selectedIndex >= limitedCandidates.length) {
|
||||
logger.warn({ selectedIndex, candidatesLength: limitedCandidates.length }, "AI returned invalid index");
|
||||
return this.handleFallback(fallbackTrack, "AI index out of range");
|
||||
}
|
||||
|
||||
// Get the selected track directly by index
|
||||
const selectedTrack = limitedCandidates[selectedIndex];
|
||||
|
||||
if (!selectedTrack) {
|
||||
logger.warn(
|
||||
{
|
||||
selectedIndex,
|
||||
candidatesLength: limitedCandidates.length,
|
||||
reason: "Track not found at selected index"
|
||||
},
|
||||
"AI selected track not in candidate list"
|
||||
);
|
||||
return this.handleFallback(fallbackTrack, "AI selection not in candidates");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
selected: `${selectedTrack.author} - ${selectedTrack.title}`,
|
||||
index: selectedIndex + 1, // Show 1-indexed
|
||||
source: "AI",
|
||||
},
|
||||
"🎵 AI selected track successfully"
|
||||
);
|
||||
|
||||
return selectedTrack;
|
||||
|
||||
} catch (error: any) {
|
||||
// CRITICAL: Catch ALL errors and fallback gracefully
|
||||
const errorType = this.categorizeError(error);
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
error: error.message,
|
||||
type: errorType,
|
||||
fallback: `${fallbackTrack.author} - ${fallbackTrack.title}`,
|
||||
},
|
||||
"AI selection failed, using fallback"
|
||||
);
|
||||
|
||||
return this.handleFallback(fallbackTrack, errorType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal, fast prompt for the DJ AI
|
||||
* Only asks for a number to reduce CPU usage and response time
|
||||
* Now includes artist diversity instructions
|
||||
*/
|
||||
private static buildDJPrompt(
|
||||
currentTrack: Track,
|
||||
history: Track[],
|
||||
candidates: Track[],
|
||||
recentArtists?: string[]
|
||||
): string {
|
||||
// Minimal candidates list (just numbers and basic info)
|
||||
const candidatesList = candidates
|
||||
.map((track, i) => `${i + 1}. ${track.author} - ${track.title}`)
|
||||
.join("\n");
|
||||
|
||||
// Build artist diversity instruction
|
||||
let diversityInstruction = "";
|
||||
if (recentArtists && recentArtists.length > 0) {
|
||||
// Remove duplicates and limit to 10 most recent
|
||||
const uniqueArtists = [...new Set(recentArtists)].slice(0, 10);
|
||||
diversityInstruction = `\n⚠️ AVOID these recently played artists: ${uniqueArtists.join(", ")}\n`;
|
||||
}
|
||||
|
||||
// Enhanced prompt with diversity instructions
|
||||
const prompt = `You are a music DJ. Pick the BEST next song after "${currentTrack.author} - ${currentTrack.title}"
|
||||
${diversityInstruction}
|
||||
Options:
|
||||
${candidatesList}
|
||||
|
||||
CRITICAL: Prioritize artist diversity. Avoid repeating artists unless absolutely necessary.
|
||||
Reply ONLY with number (1-${candidates.length}). No explanation.`;
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a track in candidates that matches AI's selection
|
||||
* Uses fuzzy matching to handle minor discrepancies
|
||||
*/
|
||||
private static findMatchingTrack(
|
||||
candidates: Track[],
|
||||
selectedTitle: string,
|
||||
selectedAuthor: string
|
||||
): Track | null {
|
||||
const normalizeString = (str: string) =>
|
||||
str.toLowerCase().trim().replace(/[^\w\s]/g, "");
|
||||
|
||||
const normalizedTitle = normalizeString(selectedTitle);
|
||||
const normalizedAuthor = normalizeString(selectedAuthor);
|
||||
|
||||
// Try exact match first
|
||||
let match = candidates.find(
|
||||
(track) =>
|
||||
normalizeString(track.title) === normalizedTitle &&
|
||||
normalizeString(track.author) === normalizedAuthor
|
||||
);
|
||||
|
||||
if (match) return match;
|
||||
|
||||
// Try title-only match (more lenient)
|
||||
match = candidates.find(
|
||||
(track) => normalizeString(track.title) === normalizedTitle
|
||||
);
|
||||
|
||||
if (match) {
|
||||
logger.debug(
|
||||
{
|
||||
aiAuthor: selectedAuthor,
|
||||
foundAuthor: match.author
|
||||
},
|
||||
"Matched track by title only (author mismatch)"
|
||||
);
|
||||
return match;
|
||||
}
|
||||
|
||||
// No match found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fallback scenario and log appropriately
|
||||
*/
|
||||
private static handleFallback(fallbackTrack: Track, reason: string): Track {
|
||||
logger.info(
|
||||
{
|
||||
fallback: `${fallbackTrack.author} - ${fallbackTrack.title}`,
|
||||
reason,
|
||||
source: "Mathematical",
|
||||
},
|
||||
"🎲 Using mathematical fallback"
|
||||
);
|
||||
|
||||
return fallbackTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize error for better logging and debugging
|
||||
*/
|
||||
private static categorizeError(error: any): string {
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return "Timeout";
|
||||
}
|
||||
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
|
||||
return "Connection Failed";
|
||||
}
|
||||
if (error.response) {
|
||||
return `HTTP ${error.response.status}`;
|
||||
}
|
||||
return "Unknown Error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for the AI service
|
||||
* @returns true if AI service is reachable, false otherwise
|
||||
*/
|
||||
static async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const client = this.getAxiosInstance();
|
||||
await client.get("/health", { timeout: 2000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/core/services/CouponService.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { prisma } from "../database/prisma";
|
||||
import { SubscriptionService } from "./SubscriptionService";
|
||||
import crypto from "crypto";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
export class CouponService {
|
||||
/**
|
||||
* Generate a secure coupon code
|
||||
* Format: AMAYO-XXXX-XXXX-CSUM
|
||||
*/
|
||||
static generateCode(): string {
|
||||
const randomBytes = crypto.randomBytes(4).toString("hex").toUpperCase(); // 8 chars
|
||||
const part1 = randomBytes.substring(0, 4);
|
||||
const part2 = randomBytes.substring(4, 8);
|
||||
|
||||
// Simple checksum
|
||||
const checksum = crypto
|
||||
.createHash("md5")
|
||||
.update(`AMAYO-${part1}-${part2}`)
|
||||
.digest("hex")
|
||||
.substring(0, 4)
|
||||
.toUpperCase();
|
||||
|
||||
return `AMAYO-${part1}-${part2}-${checksum}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new coupon
|
||||
*/
|
||||
static async createCoupon(
|
||||
type: string,
|
||||
value: number,
|
||||
maxUses: number,
|
||||
daysValid: number | null,
|
||||
createdBy: string
|
||||
) {
|
||||
const code = this.generateCode();
|
||||
|
||||
return await prisma.coupon.create({
|
||||
data: {
|
||||
code,
|
||||
type,
|
||||
value,
|
||||
maxUses,
|
||||
daysValid,
|
||||
createdBy,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a coupon
|
||||
*/
|
||||
static async redeemCoupon(code: string, userId: string) {
|
||||
// 1. Find coupon
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { code },
|
||||
});
|
||||
|
||||
if (!coupon) {
|
||||
throw new Error("Cupón inválido.");
|
||||
}
|
||||
|
||||
// 2. Check limits
|
||||
if (coupon.usedCount >= coupon.maxUses) {
|
||||
throw new Error("Este cupón ya ha alcanzado su límite de usos.");
|
||||
}
|
||||
|
||||
if (coupon.expiresAt && coupon.expiresAt < new Date()) {
|
||||
throw new Error("Este cupón ha expirado.");
|
||||
}
|
||||
|
||||
// 3. Check if user already redeemed
|
||||
const existingRedemption = await prisma.couponRedemption.findUnique({
|
||||
where: {
|
||||
couponId_userId: {
|
||||
couponId: coupon.id,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRedemption) {
|
||||
throw new Error("Ya has canjeado este cupón.");
|
||||
}
|
||||
|
||||
// 4. Apply benefits
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Ensure user exists in database
|
||||
await tx.user.upsert({
|
||||
where: { id: userId },
|
||||
update: {},
|
||||
create: { id: userId }
|
||||
});
|
||||
|
||||
// Record redemption
|
||||
await tx.couponRedemption.create({
|
||||
data: {
|
||||
couponId: coupon.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Increment usage
|
||||
await tx.coupon.update({
|
||||
where: { id: coupon.id },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
// Update subscription
|
||||
const updateData: any = { daysToAdd: coupon.daysValid || 30 }; // Default 30 days if not specified
|
||||
|
||||
switch (coupon.type) {
|
||||
case "IMPORT_LIMIT":
|
||||
updateData.importLimit = coupon.value;
|
||||
break;
|
||||
case "VOLUME_BOOST":
|
||||
updateData.maxVolume = coupon.value;
|
||||
break;
|
||||
case "PRO_RECOMMENDATIONS":
|
||||
updateData.recommendationLevel = "pro";
|
||||
break;
|
||||
case "ALL_ACCESS":
|
||||
updateData.importLimit = 500;
|
||||
updateData.maxVolume = 200;
|
||||
updateData.recommendationLevel = "pro";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Tipo de cupón desconocido.");
|
||||
}
|
||||
|
||||
// Use SubscriptionService logic (but we need to call it outside transaction or replicate logic)
|
||||
// Replicating logic here for transaction safety
|
||||
const currentSub = await tx.userSubscription.findUnique({ where: { userId } });
|
||||
let expiresAt = currentSub?.expiresAt;
|
||||
|
||||
if (updateData.daysToAdd) {
|
||||
const now = new Date();
|
||||
const baseDate = (expiresAt && expiresAt > now) ? expiresAt : now;
|
||||
expiresAt = new Date(baseDate.getTime() + updateData.daysToAdd * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
await tx.userSubscription.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
importLimit: updateData.importLimit || 100,
|
||||
maxVolume: updateData.maxVolume || 100,
|
||||
recommendationLevel: updateData.recommendationLevel || "standard",
|
||||
expiresAt
|
||||
},
|
||||
update: {
|
||||
importLimit: updateData.importLimit,
|
||||
maxVolume: updateData.maxVolume,
|
||||
recommendationLevel: updateData.recommendationLevel,
|
||||
expiresAt
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: coupon.type,
|
||||
value: coupon.value,
|
||||
days: coupon.daysValid
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface TrackInfo {
|
||||
author: string;
|
||||
duration: number;
|
||||
source?: string;
|
||||
isrc?: string; // ISRC code for precise duplicate detection
|
||||
}
|
||||
|
||||
interface ListeningSession {
|
||||
|
||||
83
src/core/services/MusicStateService.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* MusicStateService - Manages music player state (shuffle, repeat modes)
|
||||
* Stores state in memory per guild
|
||||
*/
|
||||
|
||||
export type RepeatMode = 'off' | 'one' | 'all';
|
||||
|
||||
// State storage
|
||||
const shuffleEnabled = new Map<string, boolean>();
|
||||
const repeatMode = new Map<string, RepeatMode>();
|
||||
|
||||
/**
|
||||
* Shuffle State Management
|
||||
*/
|
||||
export function isShuffleEnabled(guildId: string): boolean {
|
||||
return shuffleEnabled.get(guildId) || false;
|
||||
}
|
||||
|
||||
export function toggleShuffle(guildId: string): boolean {
|
||||
const current = isShuffleEnabled(guildId);
|
||||
const newState = !current;
|
||||
shuffleEnabled.set(guildId, newState);
|
||||
return newState;
|
||||
}
|
||||
|
||||
export function setShuffleEnabled(guildId: string, enabled: boolean): void {
|
||||
shuffleEnabled.set(guildId, enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Repeat Mode Management
|
||||
*/
|
||||
export function getRepeatMode(guildId: string): RepeatMode {
|
||||
return repeatMode.get(guildId) || 'off';
|
||||
}
|
||||
|
||||
export function cycleRepeatMode(guildId: string): RepeatMode {
|
||||
const current = getRepeatMode(guildId);
|
||||
let next: RepeatMode;
|
||||
|
||||
if (current === 'off') {
|
||||
next = 'one';
|
||||
} else if (current === 'one') {
|
||||
next = 'all';
|
||||
} else {
|
||||
next = 'off';
|
||||
}
|
||||
|
||||
repeatMode.set(guildId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setRepeatMode(guildId: string, mode: RepeatMode): void {
|
||||
repeatMode.set(guildId, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repeat mode emoji for display
|
||||
*/
|
||||
export function getRepeatModeEmoji(mode: RepeatMode): string {
|
||||
switch (mode) {
|
||||
case 'one':
|
||||
return '🔂'; // Repeat one
|
||||
case 'all':
|
||||
return '🔁'; // Repeat all
|
||||
default:
|
||||
return '🔁'; // Default repeat icon
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repeat mode label for display
|
||||
*/
|
||||
export function getRepeatModeLabel(mode: RepeatMode): string {
|
||||
switch (mode) {
|
||||
case 'one':
|
||||
return 'Repeat: One';
|
||||
case 'all':
|
||||
return 'Repeat: All';
|
||||
default:
|
||||
return 'Repeat';
|
||||
}
|
||||
}
|
||||
827
src/core/services/SmartRecommendationEngine.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* Smart Recommendation Engine using DYNAMIC Weighted Score Algorithm
|
||||
*
|
||||
* Formula: Score = (J × Wj) + (A × Wa) + (F × Wf) - (H × Wh)
|
||||
* Where:
|
||||
* J = Jaccard similarity (tags)
|
||||
* A = Artist similarity (Levenshtein)
|
||||
* F = Source factor (1 if from Related Videos, 0 otherwise)
|
||||
* H = History penalty factor
|
||||
* W = DYNAMIC weights (adapt based on history length)
|
||||
*
|
||||
* Strategy: "Variable Confidence"
|
||||
* - Few songs (< 10): Trust YouTube's "Related Videos" (high Wf)
|
||||
* - Many songs (> 50): Trust user patterns (high Wj, Wa, Wh)
|
||||
*/
|
||||
|
||||
import {
|
||||
jaccardSimilarity,
|
||||
artistSimilarity,
|
||||
durationSimilarity,
|
||||
cosineSimilarity,
|
||||
} from "../utils/similarity";
|
||||
import { MusicHistoryService } from "./MusicHistoryService";
|
||||
import { SubscriptionService } from "./SubscriptionService";
|
||||
import { AiSelectorService } from "./AiSelectorService";
|
||||
import { AiPlaylistBuffer } from "./AiPlaylistBuffer";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
// Base weights (will be scaled dynamically)
|
||||
export const BASE_WEIGHTS = {
|
||||
tags: 0.3, // Jaccard (tags/genre) - REDUCED to make room for cosine
|
||||
artist: 0.3, // Levenshtein (artist) - REDUCED
|
||||
cosine: 0.2, // Cosine (Title+Author+Tags) - NEW
|
||||
source: 0.5, // Source factor (Related Videos)
|
||||
history: 0.25, // History penalty
|
||||
};
|
||||
|
||||
// Dynamic weight profiles
|
||||
interface WeightProfile {
|
||||
tags: number;
|
||||
artist: number;
|
||||
cosine: number; // NEW
|
||||
source: number;
|
||||
history: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate dynamic weights based on user's history length
|
||||
*
|
||||
* Cold Start (< 10 songs): Heavy reliance on Related Videos
|
||||
* Warming Up (10-50 songs): Balanced approach
|
||||
* Experienced (> 50 songs): Pattern-based with history penalty
|
||||
*/
|
||||
function calculateDynamicWeights(historyLength: number): WeightProfile {
|
||||
if (historyLength < 10) {
|
||||
// COLD START: Trust YouTube's algorithm
|
||||
return {
|
||||
tags: 0.1, // Low
|
||||
artist: 0.1, // Low
|
||||
cosine: 0.1, // Low - NEW
|
||||
source: 0.8, // HIGH
|
||||
history: 0.0, // No penalty
|
||||
};
|
||||
} else if (historyLength < 50) {
|
||||
// WARMING UP: Balanced approach
|
||||
return {
|
||||
tags: 0.25, // Medium
|
||||
artist: 0.2, // Medium
|
||||
cosine: 0.2, // Medium - NEW
|
||||
source: 0.4, // Medium
|
||||
history: 0.15, // Light penalty
|
||||
};
|
||||
} else {
|
||||
// EXPERIENCED: Pattern recognition
|
||||
return {
|
||||
tags: 0.35, // High
|
||||
artist: 0.25, // High
|
||||
cosine: 0.25, // High - NEW
|
||||
source: 0.3, // Medium
|
||||
history: 0.3, // Strong penalty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration per user (overrides if set)
|
||||
const userWeights = new Map<string, WeightProfile>();
|
||||
|
||||
interface TrackCandidate {
|
||||
title: string;
|
||||
author: string;
|
||||
duration: number;
|
||||
tags: string[];
|
||||
encoded: string;
|
||||
isFromRelated?: boolean; // Track if from "Related Videos"
|
||||
isFromHistory?: boolean; // Track if from user's history (NEW)
|
||||
isrc?: string; // ISRC code for precise duplicate detection
|
||||
}
|
||||
|
||||
interface ScoredTrack extends TrackCandidate {
|
||||
score: number;
|
||||
breakdown: {
|
||||
jaccard: number;
|
||||
artist: number;
|
||||
cosine: number; // NEW
|
||||
source: number;
|
||||
history: number;
|
||||
duration: number;
|
||||
weights: WeightProfile; // NEW: Show which weights were used
|
||||
};
|
||||
}
|
||||
|
||||
export class SmartRecommendationEngine {
|
||||
/**
|
||||
* Get minimum score threshold based on attempt number
|
||||
* Progressively lowers standards to ensure playback continues
|
||||
*/
|
||||
private static getMinScoreThreshold(attemptNumber: number, maxAttempts: number): number {
|
||||
// OPTIMIZED: More permissive thresholds for better autoplay
|
||||
// Attempt 1-2: Medium standards (score > 0.05)
|
||||
if (attemptNumber <= 2) return 0.05;
|
||||
|
||||
// Attempts 3-5: Be flexible (score > 0.02)
|
||||
if (attemptNumber <= 5) return 0.02;
|
||||
|
||||
// Attempts 6+: Accept anything with minimal similarity (score > 0)
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get smart recommendation using DYNAMIC weighted score algorithm
|
||||
* Now supports iterative candidate search when all candidates are rejected
|
||||
*/
|
||||
static async getSmartRecommendation(
|
||||
userId: string,
|
||||
lastTrack: TrackCandidate,
|
||||
candidates: TrackCandidate[],
|
||||
options?: {
|
||||
client?: any; // Lavalink client for additional searches
|
||||
maxSearchAttempts?: number; // Max number of additional search attempts (default: 10)
|
||||
history?: any[]; // For testing: provide history directly
|
||||
}
|
||||
): Promise<ScoredTrack | null> {
|
||||
if (!candidates.length) return null;
|
||||
|
||||
// Get user subscription level
|
||||
const recLevel = await SubscriptionService.getRecommendationLevel(userId);
|
||||
const isPro = recLevel === "pro";
|
||||
|
||||
// Pro users get more search attempts
|
||||
const defaultMaxAttempts = isPro ? 20 : 10;
|
||||
const maxAttempts = options?.maxSearchAttempts || defaultMaxAttempts;
|
||||
|
||||
if (isPro) {
|
||||
logger.info({ userId }, "🌟 Using PRO recommendation engine (increased search depth)");
|
||||
}
|
||||
|
||||
let currentCandidates = [...candidates];
|
||||
let attemptCount = 0;
|
||||
|
||||
// Get user's listening history (use provided history or fetch from service)
|
||||
const recentHistory = options?.history || await MusicHistoryService.getRecentHistory(
|
||||
userId,
|
||||
100 // Get more history to determine experience level
|
||||
);
|
||||
|
||||
// Calculate DYNAMIC weights based on history length
|
||||
const historyLength = recentHistory.length;
|
||||
const weights = userWeights.get(userId) || calculateDynamicWeights(historyLength);
|
||||
|
||||
logger.info(
|
||||
{ userId, historyLength, weights },
|
||||
`Using dynamic weights for user (${historyLength} songs in history)`
|
||||
);
|
||||
|
||||
// Iterative search loop
|
||||
while (attemptCount < maxAttempts) {
|
||||
// Get dynamic threshold for this attempt
|
||||
const minScore = this.getMinScoreThreshold(attemptCount + 1, maxAttempts);
|
||||
|
||||
// Score all candidates
|
||||
const scoredTracks = currentCandidates
|
||||
.map((track) =>
|
||||
this.scoreTrack(track, lastTrack, recentHistory, weights)
|
||||
)
|
||||
.filter((track) => track.score > minScore) // Use dynamic threshold
|
||||
.sort((a, b) => b.score - a.score); // Sort by score (highest first)
|
||||
|
||||
// If we found valid candidates, try AI selection first (tiered by subscription)
|
||||
if (scoredTracks.length > 0) {
|
||||
// Check if user is premium for AI tier selection
|
||||
const recLevel = await SubscriptionService.getRecommendationLevel(userId);
|
||||
const isPremium = recLevel === "pro";
|
||||
|
||||
// Tier-specific limits for AI
|
||||
const MAX_AI_CANDIDATES = isPremium ? 10 : 5;
|
||||
const MAX_AI_HISTORY = isPremium ? 5 : 3;
|
||||
|
||||
// Get top candidates for AI (limited by tier)
|
||||
const topCandidatesForAI = scoredTracks.slice(0, MAX_AI_CANDIDATES);
|
||||
|
||||
// Get best mathematical candidate (always available immediately)
|
||||
const topTrack = scoredTracks[0];
|
||||
|
||||
// PRO USERS: Use buffered playlist (3 tracks pre-loaded)
|
||||
if (isPremium) {
|
||||
try {
|
||||
const aiHistory = recentHistory.slice(0, MAX_AI_HISTORY);
|
||||
const bufferedTrack = await AiPlaylistBuffer.getNextTrack(
|
||||
userId,
|
||||
lastTrack,
|
||||
aiHistory,
|
||||
topCandidatesForAI
|
||||
);
|
||||
|
||||
if (bufferedTrack) {
|
||||
logger.info(
|
||||
{
|
||||
userId,
|
||||
tier: "PREMIUM",
|
||||
track: bufferedTrack.title,
|
||||
score: (bufferedTrack as any).score?.toFixed(3) || "N/A",
|
||||
method: "AI Buffered",
|
||||
bufferStatus: AiPlaylistBuffer.getBufferStatus(userId),
|
||||
},
|
||||
`🎵 AI buffered track (Pro tier)`
|
||||
);
|
||||
|
||||
return bufferedTrack as ScoredTrack;
|
||||
}
|
||||
} catch (bufferError: any) {
|
||||
logger.warn(
|
||||
{
|
||||
userId,
|
||||
error: bufferError.message,
|
||||
},
|
||||
"AI buffer failed, using mathematical fallback"
|
||||
);
|
||||
// Continue to fallback below
|
||||
}
|
||||
}
|
||||
|
||||
// FREE USERS: Hybrid mode - return math immediately, AI in background
|
||||
else {
|
||||
// IMMEDIATE: Return mathematical result
|
||||
logger.info(
|
||||
{
|
||||
userId,
|
||||
tier: "FREE",
|
||||
track: topTrack.title,
|
||||
score: topTrack.score.toFixed(3),
|
||||
method: "Mathematical (AI processing in background)",
|
||||
},
|
||||
`🎲 Immediate mathematical recommendation`
|
||||
);
|
||||
|
||||
// BACKGROUND: Start AI selection for next recommendation (don't await)
|
||||
const aiHistory = recentHistory.slice(0, MAX_AI_HISTORY);
|
||||
|
||||
// Extract recent artists for diversity
|
||||
const recentArtists = aiHistory.map(h => h.author).filter(Boolean);
|
||||
|
||||
AiSelectorService.selectBestTrack(
|
||||
lastTrack,
|
||||
aiHistory,
|
||||
topCandidatesForAI,
|
||||
recentArtists // Pass recent artists for diversity
|
||||
).then(aiSelection => {
|
||||
if (aiSelection) {
|
||||
logger.info(
|
||||
{
|
||||
userId,
|
||||
track: aiSelection.title,
|
||||
method: "AI Background Complete",
|
||||
},
|
||||
`✨ AI processing completed (ready for next recommendation)`
|
||||
);
|
||||
// Result will be used in next call via cache/buffer
|
||||
}
|
||||
}).catch(aiError => {
|
||||
logger.debug(
|
||||
{
|
||||
userId,
|
||||
error: aiError.message,
|
||||
},
|
||||
"Background AI processing failed (no impact on playback)"
|
||||
);
|
||||
});
|
||||
|
||||
// Return mathematical result immediately
|
||||
return topTrack;
|
||||
}
|
||||
|
||||
// Fallback: Use top mathematical candidate (if PRO buffer fails)
|
||||
logger.info(
|
||||
{
|
||||
userId,
|
||||
track: topTrack.title,
|
||||
score: topTrack.score.toFixed(3),
|
||||
breakdown: topTrack.breakdown,
|
||||
historyLength,
|
||||
searchAttempt: attemptCount + 1,
|
||||
method: "Mathematical",
|
||||
},
|
||||
"Smart recommendation selected with dynamic weights"
|
||||
);
|
||||
|
||||
return topTrack;
|
||||
}
|
||||
|
||||
// No valid candidates found, try to fetch more if client is available
|
||||
if (!options?.client) {
|
||||
console.log(`[SmartRecommendation] No valid candidates found and no client available for additional search`);
|
||||
return null;
|
||||
}
|
||||
|
||||
attemptCount++;
|
||||
console.log(`[SmartRecommendation] Attempt ${attemptCount}/${maxAttempts}: All candidates rejected, searching for more...`);
|
||||
|
||||
// Search for more candidates using different queries
|
||||
const newCandidates = await this.searchAdditionalCandidates(
|
||||
options.client,
|
||||
lastTrack,
|
||||
attemptCount
|
||||
);
|
||||
|
||||
if (newCandidates.length === 0) {
|
||||
console.log(`[SmartRecommendation] No additional candidates found on attempt ${attemptCount}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[SmartRecommendation] Found ${newCandidates.length} additional candidates on attempt ${attemptCount}`);
|
||||
currentCandidates = newCandidates;
|
||||
}
|
||||
|
||||
console.log(`[SmartRecommendation] Max search attempts (${maxAttempts}) reached, no valid recommendation found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for additional candidates using Lavalink when all initial candidates are rejected
|
||||
* Uses progressively broader search queries on each attempt
|
||||
*/
|
||||
private static async searchAdditionalCandidates(
|
||||
client: any,
|
||||
lastTrack: TrackCandidate,
|
||||
attemptNumber: number
|
||||
): Promise<TrackCandidate[]> {
|
||||
try {
|
||||
const node = [...client.music.nodes.values()][0];
|
||||
if (!node) {
|
||||
console.error("[searchAdditionalCandidates] No music node available");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract genre/tags from last track for broader searches
|
||||
const tags = this.extractTags(lastTrack);
|
||||
const mainGenre = tags[0] || "music"; // Use first tag as main genre
|
||||
const moodTag = tags.find(t =>
|
||||
["happy", "sad", "chill", "energetic", "calm", "dark", "upbeat"].includes(t)
|
||||
);
|
||||
|
||||
// Progressive search strategies (get broader with each attempt)
|
||||
const searchQueries = [
|
||||
// Attempt 1: Similar genre + artist
|
||||
[`${mainGenre} popular songs`, `${lastTrack.author} similar`],
|
||||
// Attempt 2: Genre trending
|
||||
[`${mainGenre} trending`, `${mainGenre} hits`],
|
||||
// Attempt 3: Mood + genre (if mood exists)
|
||||
moodTag ? [`${moodTag} ${mainGenre} music`, `${moodTag} songs`] : [`${mainGenre} playlist`, `${mainGenre} mix`],
|
||||
// Attempt 4: Genre playlists
|
||||
[`${mainGenre} playlist`, `best ${mainGenre} songs`],
|
||||
// Attempt 5: Genre compilations
|
||||
[`${mainGenre} mix`, `${mainGenre} compilation`],
|
||||
// Attempt 6: Popular genre tracks
|
||||
[`popular ${mainGenre}`, `top ${mainGenre} tracks`],
|
||||
// Attempt 7: First tag only (more specific)
|
||||
[`${mainGenre} music`, `${mainGenre}`],
|
||||
// Attempt 8: Generic chill/relaxing (fallback)
|
||||
[`chill music`, `relaxing music`],
|
||||
// Attempt 9: Very broad popular
|
||||
[`popular music`, `trending songs`],
|
||||
// Attempt 10: Absolute fallback
|
||||
[`music`, `top songs`],
|
||||
];
|
||||
|
||||
const queries = searchQueries[attemptNumber - 1] || searchQueries[9];
|
||||
let allTracks: any[] = [];
|
||||
|
||||
for (const query of queries) {
|
||||
const searchQuery = `ytsearch:${query}`;
|
||||
const result: any = await node.rest.resolve(searchQuery);
|
||||
|
||||
if (
|
||||
result &&
|
||||
result.loadType !== "empty" &&
|
||||
result.loadType !== "error"
|
||||
) {
|
||||
let tracks: any[] = [];
|
||||
|
||||
if (result.loadType === "track") {
|
||||
tracks = [result.data];
|
||||
} else if (result.loadType === "search" || result.loadType === "playlist") {
|
||||
tracks = result.data.tracks || result.data || [];
|
||||
}
|
||||
|
||||
allTracks.push(...tracks.slice(0, 15)); // Take top 15 from each query
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and convert to TrackCandidate format
|
||||
const uniqueTracks = Array.from(
|
||||
new Map(allTracks.map(track => [track.encoded, track])).values()
|
||||
);
|
||||
|
||||
const candidates: TrackCandidate[] = uniqueTracks.map(track => ({
|
||||
title: track.info.title,
|
||||
author: track.info.author,
|
||||
duration: track.info.length || track.info.duration || 0,
|
||||
tags: this.extractTags({
|
||||
title: track.info.title,
|
||||
author: track.info.author,
|
||||
}),
|
||||
encoded: track.encoded,
|
||||
isrc: track.info.isrc,
|
||||
}));
|
||||
|
||||
return candidates.slice(0, 30); // Return max 30 candidates
|
||||
} catch (error) {
|
||||
console.error("[searchAdditionalCandidates] Error:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate weighted score for a candidate track
|
||||
* NEW FORMULA: Score = (J × Wj) + (A × Wa) + (F × Wf) - (H × Wh) - (D × Wd)
|
||||
* Where D = Artist Diversity Penalty (NEW)
|
||||
*/
|
||||
private static scoreTrack(
|
||||
candidate: TrackCandidate,
|
||||
lastTrack: TrackCandidate,
|
||||
history: any[],
|
||||
weights: WeightProfile
|
||||
): ScoredTrack {
|
||||
// 1. Jaccard Similarity (Tags)
|
||||
const jaccardScore = jaccardSimilarity(lastTrack.tags, candidate.tags);
|
||||
|
||||
// 2. Artist Similarity (Levenshtein)
|
||||
const artistScore = artistSimilarity(lastTrack.author, candidate.author);
|
||||
|
||||
// 3. Cosine Similarity (Title + Author + Tags) - NEW
|
||||
// Combine all text features into one string for cosine comparison
|
||||
const lastTrackText = `${lastTrack.title} ${lastTrack.author} ${lastTrack.tags.join(" ")}`;
|
||||
const candidateText = `${candidate.title} ${candidate.author} ${candidate.tags.join(" ")}`;
|
||||
const cosineScore = cosineSimilarity(lastTrackText, candidateText);
|
||||
|
||||
// 3. Source Factor
|
||||
// Prioritize: History (0.8) > Related Videos (0.5) > Regular (0.0)
|
||||
let sourceScore = 0.0;
|
||||
if (candidate.isFromHistory) {
|
||||
sourceScore = 0.8; // High boost for history-based diverse artists
|
||||
} else if (candidate.isFromRelated) {
|
||||
sourceScore = 0.5; // Medium boost for Related Videos
|
||||
}
|
||||
|
||||
// 4. History Penalty (track-specific)
|
||||
const historyPenalty = this.calculateHistoryPenalty(candidate, history);
|
||||
|
||||
// 5. Artist Diversity Penalty (NEW - prevents same artist repetition)
|
||||
const artistDiversityPenalty = this.calculateArtistDiversityPenalty(candidate, history);
|
||||
|
||||
// 6. Duration Filter (not in weighted sum, but used for filtering)
|
||||
const durationScore = durationSimilarity(
|
||||
lastTrack.duration,
|
||||
candidate.duration
|
||||
);
|
||||
|
||||
// If duration is incompatible, return 0 score
|
||||
if (durationScore === 0) {
|
||||
return {
|
||||
...candidate,
|
||||
score: 0,
|
||||
breakdown: {
|
||||
jaccard: jaccardScore,
|
||||
artist: artistScore,
|
||||
cosine: cosineScore,
|
||||
source: sourceScore,
|
||||
history: historyPenalty,
|
||||
duration: durationScore,
|
||||
weights,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate final weighted score with NEW FORMULA
|
||||
// Score = (J × Wj) + (A × Wa) + (C × Wc) + (F × Wf) - (H × Wh) - (D × Wd)
|
||||
// Artist diversity penalty uses same weight as history for consistency
|
||||
|
||||
// OPTIMIZED: Only reject if artist was JUST played (T=0) AND appears very frequently
|
||||
// Reduced threshold from 0.95 to 0.98 for more lenient filtering
|
||||
if (artistDiversityPenalty > 0.98 && historyPenalty > 0.9) {
|
||||
console.log(`[ArtistDiversity] REJECTED ${candidate.author}: just played (penalty ${artistDiversityPenalty.toFixed(3)})`);
|
||||
return {
|
||||
...candidate,
|
||||
score: 0, // Reject only if JUST played
|
||||
breakdown: {
|
||||
jaccard: jaccardScore,
|
||||
artist: artistScore,
|
||||
cosine: cosineScore,
|
||||
source: sourceScore,
|
||||
history: historyPenalty + artistDiversityPenalty,
|
||||
duration: durationScore,
|
||||
weights,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// OPTIMIZED: Reduced artist diversity multiplier from 1.5 to 0.8
|
||||
const finalScore =
|
||||
jaccardScore * weights.tags +
|
||||
artistScore * weights.artist +
|
||||
cosineScore * weights.cosine +
|
||||
sourceScore * weights.source -
|
||||
historyPenalty * weights.history - // Track-specific penalty
|
||||
artistDiversityPenalty * weights.history * 0.8; // Reduced multiplier for less harsh penalties
|
||||
|
||||
// Debug log for history-based candidates
|
||||
if (candidate.isFromHistory) {
|
||||
console.log(`[HistoryCandidate] ${candidate.author}: score=${finalScore.toFixed(3)}, jaccard=${jaccardScore.toFixed(2)}, artist=${artistScore.toFixed(2)}, cosine=${cosineScore.toFixed(2)}, source=${sourceScore}, penalty=${(historyPenalty + artistDiversityPenalty).toFixed(3)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
score: Math.max(0, finalScore), // Ensure non-negative
|
||||
breakdown: {
|
||||
jaccard: jaccardScore,
|
||||
artist: artistScore,
|
||||
cosine: cosineScore,
|
||||
source: sourceScore,
|
||||
history: historyPenalty + artistDiversityPenalty, // Combined for display
|
||||
duration: durationScore,
|
||||
weights,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate history penalty (exponential decay)
|
||||
* Returns HIGH value if recently played (to be SUBTRACTED from score)
|
||||
* Uses ISRC for precise duplicate detection when available
|
||||
*/
|
||||
private static calculateHistoryPenalty(
|
||||
candidate: TrackCandidate,
|
||||
history: any[]
|
||||
): number {
|
||||
// Find when this track was last played
|
||||
const lastPlayIndex = history.findIndex((h) => {
|
||||
// 1. MASTER CHECK: If both have ISRC, use it for perfect comparison
|
||||
if (candidate.isrc && h.isrc) {
|
||||
return candidate.isrc === h.isrc;
|
||||
}
|
||||
|
||||
// 2. FALLBACK: If no ISRC, use traditional comparison (Title + Author)
|
||||
// Use case-insensitive comparison for robustness
|
||||
return (
|
||||
h.title?.toLowerCase() === candidate.title.toLowerCase() &&
|
||||
h.author?.toLowerCase() === candidate.author.toLowerCase()
|
||||
);
|
||||
});
|
||||
|
||||
if (lastPlayIndex === -1) {
|
||||
return 0.0; // Never played = no penalty
|
||||
}
|
||||
|
||||
// Exponential decay based on position in history
|
||||
// More recent = HIGHER penalty (because we SUBTRACT it)
|
||||
// Position 0 (most recent) = 1.0 (maximum penalty)
|
||||
// Position 10 = ~0.37
|
||||
// Position 20 = ~0.14
|
||||
// Position 50+ = ~0.0
|
||||
return Math.exp(-lastPlayIndex / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate artist diversity penalty using CDF of Exponential Distribution
|
||||
* Formula: P(t≤T) = 1 - e^(-λT)
|
||||
*
|
||||
* Returns HIGH value if artist appears frequently in recent history
|
||||
* This penalty is ADDED to the history penalty to promote artist diversity
|
||||
*
|
||||
* @param candidate - The track being evaluated
|
||||
* @param history - User's listening history
|
||||
* @param lambda - Decay rate (default 0.15, higher = faster decay)
|
||||
* @returns Penalty value between 0 and 1
|
||||
*/
|
||||
private static calculateArtistDiversityPenalty(
|
||||
candidate: TrackCandidate,
|
||||
history: any[],
|
||||
lambda: number = 0.25 // OPTIMIZED: Increased from 0.15 to 0.25 for faster decay
|
||||
): number {
|
||||
if (history.length === 0) return 0.0;
|
||||
|
||||
// Count how many times this artist appears in recent history (last 20 tracks)
|
||||
const recentHistory = history.slice(0, 20);
|
||||
const artistAppearances = recentHistory.filter(
|
||||
(h) => h.author?.toLowerCase() === candidate.author.toLowerCase()
|
||||
);
|
||||
|
||||
console.log(`[ArtistDiversity] Checking ${candidate.author}: ${artistAppearances.length} appearances in last ${recentHistory.length} tracks`);
|
||||
|
||||
if (artistAppearances.length === 0) {
|
||||
return 0.0; // Artist never played recently = no penalty
|
||||
}
|
||||
|
||||
// Find position of most recent appearance
|
||||
const mostRecentIndex = recentHistory.findIndex(
|
||||
(h) => h.author?.toLowerCase() === candidate.author.toLowerCase()
|
||||
);
|
||||
|
||||
// T = "time" since last appearance (in number of songs)
|
||||
const T = mostRecentIndex;
|
||||
|
||||
// Calculate CDF: P(t≤T) = 1 - e^(-λT)
|
||||
// This gives us the probability that the artist should be "allowed" again
|
||||
// We invert it to get the penalty
|
||||
const allowProbability = 1 - Math.exp(-lambda * T);
|
||||
|
||||
// Penalty is inverse: if artist was just played (T=0), penalty is high (1.0)
|
||||
// If artist was played long ago (T=20), penalty is low (~0.05)
|
||||
const basePenalty = 1 - allowProbability;
|
||||
|
||||
// OPTIMIZED: Reduced frequency multiplier for less harsh penalties
|
||||
// Changed from 0.3 to 0.15 and max from 2.0 to 1.5
|
||||
const frequencyMultiplier = Math.min(1.5, 1 + (artistAppearances.length - 1) * 0.15);
|
||||
|
||||
const finalPenalty = Math.min(1.0, basePenalty * frequencyMultiplier);
|
||||
|
||||
console.log(`[ArtistDiversity] ${candidate.author}: T=${T}, basePenalty=${basePenalty.toFixed(3)}, freq=${frequencyMultiplier.toFixed(2)}, final=${finalPenalty.toFixed(3)}`);
|
||||
|
||||
return finalPenalty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tags from track info
|
||||
* Uses YouTube description, title keywords, and genre detection
|
||||
*/
|
||||
static extractTags(track: {
|
||||
title: string;
|
||||
author: string;
|
||||
description?: string;
|
||||
}): string[] {
|
||||
const tags = new Set<string>();
|
||||
|
||||
// Common genre keywords in titles (expanded to 80+ genres)
|
||||
const genreKeywords = [
|
||||
// Rock & Metal
|
||||
"rock", "metal", "hard rock", "soft rock", "prog rock", "progressive rock",
|
||||
"psychedelic", "shoegaze", "grunge", "punk", "ska",
|
||||
|
||||
// Pop
|
||||
"pop", "k-pop", "j-pop", "synth-pop", "electropop", "dream pop", "indie pop",
|
||||
|
||||
// Hip Hop & Urban
|
||||
"hip hop", "rap", "trap", "drill", "grime", "afrobeat", "dancehall",
|
||||
|
||||
// Electronic & EDM
|
||||
"electronic", "edm", "house", "techno", "dubstep", "trance",
|
||||
"drum and bass", "dnb", "future bass", "synthwave", "vaporwave", "wave",
|
||||
"chiptune", "glitch", "breakbeat", "garage",
|
||||
|
||||
// Lofi & Chill
|
||||
"lo-fi", "lofi", "chillhop", "chillout", "chill", "downtempo", "trip-hop",
|
||||
"ambient", "atmospheric", "ethereal",
|
||||
|
||||
// Latin
|
||||
"reggaeton", "salsa", "bachata", "cumbia", "merengue", "dembow",
|
||||
"corridos", "banda", "mariachi", "latin",
|
||||
|
||||
// Jazz & Blues
|
||||
"jazz", "blues", "soul", "funk", "disco", "r&b", "smooth jazz", "bebop",
|
||||
|
||||
// Classical & Traditional
|
||||
"classical", "orchestra", "symphony", "opera", "baroque", "romantic",
|
||||
|
||||
// World & Spiritual
|
||||
"reggae", "country", "folk", "gospel", "spiritual", "meditation",
|
||||
"new age", "world music", "celtic", "flamenco",
|
||||
|
||||
// Other Genres
|
||||
"acoustic", "indie", "alternative", "soundtrack", "ost", "anime", "gaming",
|
||||
|
||||
// Modifiers
|
||||
"live", "remix", "cover", "instrumental", "nightcore", "slowed", "reverb",
|
||||
"8d", "bass boosted", "sped up", "acoustic version", "piano version",
|
||||
];
|
||||
|
||||
const titleLower = track.title.toLowerCase();
|
||||
const authorLower = track.author.toLowerCase();
|
||||
const descLower = track.description?.toLowerCase() || "";
|
||||
|
||||
// Extract from title
|
||||
for (const keyword of genreKeywords) {
|
||||
if (
|
||||
titleLower.includes(keyword) ||
|
||||
authorLower.includes(keyword) ||
|
||||
descLower.includes(keyword)
|
||||
) {
|
||||
tags.add(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mood/vibe keywords (expanded)
|
||||
const moodKeywords = [
|
||||
// Emotions
|
||||
"happy", "sad", "melancholic", "nostalgic", "romantic", "emotional",
|
||||
"angry", "peaceful", "joyful", "hopeful", "lonely", "dreamy",
|
||||
|
||||
// Energy levels
|
||||
"energetic", "calm", "upbeat", "mellow", "intense", "powerful",
|
||||
"gentle", "soft", "hard", "heavy", "light", "smooth",
|
||||
|
||||
// Vibes
|
||||
"chill", "relaxing", "groovy", "funky", "epic", "dark",
|
||||
"aesthetic", "vibes", "atmospheric", "ethereal",
|
||||
|
||||
// Activities
|
||||
"party", "workout", "study", "sleep", "focus", "gaming",
|
||||
"driving", "cooking", "reading", "meditation", "yoga",
|
||||
|
||||
// Descriptors
|
||||
"aggressive", "motivational", "inspiring", "soothing", "haunting",
|
||||
"beautiful", "stunning", "amazing", "fire", "banger",
|
||||
];
|
||||
|
||||
for (const keyword of moodKeywords) {
|
||||
if (titleLower.includes(keyword) || descLower.includes(keyword)) {
|
||||
tags.add(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract context/time keywords
|
||||
const contextKeywords = [
|
||||
"night", "late night", "midnight", "morning", "evening",
|
||||
"sunrise", "sunset", "dawn", "dusk",
|
||||
"rain", "rainy", "sunny", "cloudy", "storm",
|
||||
"winter", "summer", "spring", "fall", "autumn",
|
||||
"christmas", "halloween", "valentine",
|
||||
];
|
||||
|
||||
for (const keyword of contextKeywords) {
|
||||
if (titleLower.includes(keyword) || descLower.includes(keyword)) {
|
||||
tags.add(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
// Add artist as a tag (for artist-based similarity)
|
||||
tags.add(authorLower);
|
||||
|
||||
return Array.from(tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom weights for a user (override dynamic calculation)
|
||||
*/
|
||||
static setWeights(
|
||||
userId: string,
|
||||
weights: Partial<WeightProfile>
|
||||
): void {
|
||||
const current = userWeights.get(userId) || { ...BASE_WEIGHTS };
|
||||
userWeights.set(userId, { ...current, ...weights });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weights for a user (dynamic or custom)
|
||||
*/
|
||||
static async getWeights(userId: string): Promise<WeightProfile> {
|
||||
// If user has custom weights, return them
|
||||
if (userWeights.has(userId)) {
|
||||
return userWeights.get(userId)!;
|
||||
}
|
||||
|
||||
// Otherwise, calculate dynamic weights based on history
|
||||
const history = await MusicHistoryService.getRecentHistory(userId, 100);
|
||||
return calculateDynamicWeights(history.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset weights to dynamic calculation
|
||||
*/
|
||||
static resetWeights(userId: string): void {
|
||||
userWeights.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dynamic weights info (for display)
|
||||
*/
|
||||
static getDynamicWeightsInfo(historyLength: number): {
|
||||
profile: string;
|
||||
weights: WeightProfile;
|
||||
description: string;
|
||||
} {
|
||||
const weights = calculateDynamicWeights(historyLength);
|
||||
|
||||
if (historyLength < 10) {
|
||||
return {
|
||||
profile: "🥶 Arranque en Frío",
|
||||
weights,
|
||||
description: "Confiando en recomendaciones de YouTube (alta dependencia de videos relacionados)"
|
||||
};
|
||||
} else if (historyLength < 50) {
|
||||
return {
|
||||
profile: "🌡️ Calentando",
|
||||
weights,
|
||||
description: "Aprendiendo tus patrones (equilibrio entre YouTube y tu historial)"
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
profile: "🔥 Experto",
|
||||
weights,
|
||||
description: "Conociendo tus gustos a fondo (enfoque en patrones y evitando repeticiones)"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for config display
|
||||
export { WeightProfile, calculateDynamicWeights };
|
||||
109
src/core/services/SubscriptionService.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { prisma } from "../database/prisma";
|
||||
import logger from "../lib/logger";
|
||||
|
||||
export class SubscriptionService {
|
||||
/**
|
||||
* Get user's active subscription
|
||||
* Checks expiration and cleans up if expired
|
||||
*/
|
||||
static async getUserSubscription(userId: string) {
|
||||
const sub = await prisma.userSubscription.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!sub) return null;
|
||||
|
||||
// Check expiration
|
||||
if (sub.expiresAt && sub.expiresAt < new Date()) {
|
||||
// Expired: Reset to defaults or delete?
|
||||
// Let's reset to defaults but keep the record
|
||||
return await prisma.userSubscription.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
importLimit: 100,
|
||||
maxVolume: 100,
|
||||
recommendationLevel: "standard",
|
||||
expiresAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's playlist import limit
|
||||
* Default: 100
|
||||
*/
|
||||
static async getUserImportLimit(userId: string): Promise<number> {
|
||||
const sub = await this.getUserSubscription(userId);
|
||||
return sub?.importLimit || 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's max volume limit
|
||||
* Default: 100
|
||||
*/
|
||||
static async getMaxVolume(userId: string): Promise<number> {
|
||||
const sub = await this.getUserSubscription(userId);
|
||||
return sub?.maxVolume || 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's recommendation level
|
||||
* Default: "standard"
|
||||
*/
|
||||
static async getRecommendationLevel(userId: string): Promise<string> {
|
||||
const sub = await this.getUserSubscription(userId);
|
||||
return sub?.recommendationLevel || "standard";
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create user subscription
|
||||
*/
|
||||
static async updateSubscription(
|
||||
userId: string,
|
||||
data: {
|
||||
importLimit?: number;
|
||||
maxVolume?: number;
|
||||
recommendationLevel?: string;
|
||||
daysToAdd?: number;
|
||||
}
|
||||
) {
|
||||
// Ensure user exists in database
|
||||
await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
update: {},
|
||||
create: { id: userId }
|
||||
});
|
||||
|
||||
const current = await this.getUserSubscription(userId);
|
||||
|
||||
let expiresAt = current?.expiresAt;
|
||||
|
||||
// If adding days
|
||||
if (data.daysToAdd) {
|
||||
const now = new Date();
|
||||
// If already has expiration in future, add to it. Else start from now.
|
||||
const baseDate = (expiresAt && expiresAt > now) ? expiresAt : now;
|
||||
expiresAt = new Date(baseDate.getTime() + data.daysToAdd * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
return await prisma.userSubscription.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
importLimit: data.importLimit || 100,
|
||||
maxVolume: data.maxVolume || 100,
|
||||
recommendationLevel: data.recommendationLevel || "standard",
|
||||
expiresAt,
|
||||
},
|
||||
update: {
|
||||
importLimit: data.importLimit, // Only update if provided
|
||||
maxVolume: data.maxVolume,
|
||||
recommendationLevel: data.recommendationLevel,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { DisplayComponentV2Builder } from "../lib/displayComponents/builders/v2Builder";
|
||||
import { LikeService } from "../services/LikeService";
|
||||
import { PlaylistService } from "../services/PlaylistService";
|
||||
import { MusicHistoryService } from "../services/MusicHistoryService";
|
||||
import { isAutoplayEnabledForGuild } from "../../commands/messages/music/autoplay";
|
||||
import { isShuffleEnabled, getRepeatMode, getRepeatModeLabel, getRepeatModeEmoji } from "../services/MusicStateService";
|
||||
|
||||
interface TrackInfo {
|
||||
title: string;
|
||||
@@ -18,7 +21,8 @@ export async function createNowPlayingMessage(
|
||||
trackInfo: TrackInfo,
|
||||
queueLength: number,
|
||||
userId: string,
|
||||
guildId: string
|
||||
guildId: string,
|
||||
client: any // Add client parameter
|
||||
) {
|
||||
// Create consistent trackId from title and author (same format as music_like.ts)
|
||||
const consistentTrackId = Buffer.from(
|
||||
@@ -45,14 +49,14 @@ export async function createNowPlayingMessage(
|
||||
[
|
||||
{
|
||||
type: 10,
|
||||
content: `**Añadido a la cola**\\n${trackInfo.title}\\n${trackInfo.author} • Duración: ${durationText}`,
|
||||
content: `**Añadido a la cola**\n${trackInfo.title}\n${trackInfo.author} • Duración: ${durationText}`,
|
||||
},
|
||||
],
|
||||
{ type: 11, media: { url: trackInfo.thumbnail } }
|
||||
);
|
||||
} else {
|
||||
infoContainer.addText(
|
||||
`**Añadido a la cola**\\n${trackInfo.title}\\n${trackInfo.author} • Duración: ${durationText}`
|
||||
`**Añadido a la cola**\n${trackInfo.title}\n${trackInfo.author} • Duración: ${durationText}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,39 +68,64 @@ export async function createNowPlayingMessage(
|
||||
|
||||
// Create buttons
|
||||
const likeButton = DisplayComponentV2Builder.createButton(
|
||||
isLiked ? "Te gusta" : "Like",
|
||||
isLiked ? 2 : 1,
|
||||
isLiked ? "music_unlike" : "music_like",
|
||||
isLiked ? "Te gusta" : "Like",
|
||||
isLiked ? 1 : 2, // 2 = secondary (gray) when liked, 1 = primary (blue) when not liked
|
||||
false,
|
||||
{ name: "❤️" }
|
||||
);
|
||||
|
||||
const repeatButton = DisplayComponentV2Builder.createButton(
|
||||
"Repeat",
|
||||
1,
|
||||
"music_repeat",
|
||||
{ name: "🔁" }
|
||||
// Check shuffle state
|
||||
const shuffleActive = isShuffleEnabled(guildId);
|
||||
const shuffleButton = DisplayComponentV2Builder.createButton(
|
||||
"music_shuffle",
|
||||
"Shuffle",
|
||||
shuffleActive ? 3 : 1, // 3 = success (green) when active, 1 = primary (blue) when inactive
|
||||
false,
|
||||
{ name: "🔀" }
|
||||
);
|
||||
|
||||
const shuffleButton = DisplayComponentV2Builder.createButton(
|
||||
"Shuffle",
|
||||
1,
|
||||
"music_shuffle",
|
||||
{ name: "🔂" }
|
||||
// Check repeat mode
|
||||
const repeatModeValue = getRepeatMode(guildId);
|
||||
let repeatStyle = 1; // Default: blue
|
||||
if (repeatModeValue === 'one') repeatStyle = 3; // Green for repeat one
|
||||
if (repeatModeValue === 'all') repeatStyle = 4; // Red for repeat all
|
||||
|
||||
const repeatButton = DisplayComponentV2Builder.createButton(
|
||||
"music_repeat",
|
||||
getRepeatModeLabel(repeatModeValue),
|
||||
repeatStyle,
|
||||
false,
|
||||
{ name: getRepeatModeEmoji(repeatModeValue) }
|
||||
);
|
||||
|
||||
// Check if autoplay is enabled for this guild
|
||||
const isAutoplayEnabled = isAutoplayEnabledForGuild(guildId);
|
||||
|
||||
const autoplayButton = DisplayComponentV2Builder.createButton(
|
||||
"Autoplay",
|
||||
1,
|
||||
"music_autoplay_toggle",
|
||||
"Autoplay",
|
||||
isAutoplayEnabled ? 3 : 1, // 3 = success (green) when enabled, 1 = primary (blue) when disabled
|
||||
false,
|
||||
{ name: "⏩" }
|
||||
);
|
||||
|
||||
// Add ActionRow with buttons
|
||||
// Add Skip button
|
||||
const skipButton = DisplayComponentV2Builder.createButton(
|
||||
"music_skip",
|
||||
"Skip",
|
||||
2, // 2 = secondary (gray)
|
||||
false,
|
||||
{ name: "⏭️" }
|
||||
);
|
||||
|
||||
// Add ActionRow with buttons (max 5 buttons per row)
|
||||
buttonsContainer.addActionRow([
|
||||
likeButton,
|
||||
repeatButton,
|
||||
shuffleButton,
|
||||
repeatButton,
|
||||
autoplayButton,
|
||||
skipButton,
|
||||
]);
|
||||
|
||||
// Get user's playlists for SelectMenu
|
||||
@@ -132,6 +161,92 @@ export async function createNowPlayingMessage(
|
||||
// Add SelectMenu in its own ActionRow
|
||||
buttonsContainer.addActionRow([selectMenu]);
|
||||
|
||||
// Get user's listening history for second SelectMenu
|
||||
try {
|
||||
const history = await MusicHistoryService.getRecentHistory(userId, 50);
|
||||
|
||||
// Remove duplicates by trackId (keep most recent)
|
||||
const uniqueTracksMap = new Map();
|
||||
for (const track of history) {
|
||||
// Skip tracks without title or author
|
||||
if (!track.title || !track.author) continue;
|
||||
|
||||
const trackId = track.trackId || `${track.title}:${track.author}`;
|
||||
if (!uniqueTracksMap.has(trackId)) {
|
||||
uniqueTracksMap.set(trackId, track);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and take first 25 (Discord limit)
|
||||
const uniqueTracks = Array.from(uniqueTracksMap.values()).slice(0, 25);
|
||||
|
||||
// Only create history menu if there are valid tracks
|
||||
if (uniqueTracks.length > 0) {
|
||||
// Create history select menu options with validation
|
||||
const historyOptions = uniqueTracks
|
||||
.filter(track => track.title && track.author) // Ensure valid data
|
||||
.map(track => ({
|
||||
label: track.title.substring(0, 100), // Discord limit
|
||||
value: (track.trackId || `${track.title}:${track.author}`).substring(0, 100), // Discord limit
|
||||
description: track.author.substring(0, 100),
|
||||
emoji: { name: '🎵' }
|
||||
}));
|
||||
|
||||
// Only add if we have valid options
|
||||
if (historyOptions.length > 0) {
|
||||
const historyMenu = DisplayComponentV2Builder.createSelectMenu(
|
||||
'music_history_select',
|
||||
'Reproducir del historial',
|
||||
historyOptions
|
||||
);
|
||||
|
||||
// Add history SelectMenu in its own ActionRow
|
||||
buttonsContainer.addActionRow([historyMenu]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading history for select menu:", error);
|
||||
// Continue without history menu if there's an error
|
||||
}
|
||||
|
||||
// Add "Similar Songs" select menu (like YouTube's related videos)
|
||||
try {
|
||||
const { getSimilarTracks } = await import("../../commands/messages/music/autoplay.js");
|
||||
const similarTracks = await getSimilarTracks(trackInfo, guildId, client);
|
||||
|
||||
if (similarTracks && similarTracks.length > 0) {
|
||||
// Store similar tracks in Redis for the select menu handler
|
||||
const { redis } = await import("../../core/database/redis.js");
|
||||
const redisKey = `music:similar:${guildId}`;
|
||||
await redis.set(redisKey, JSON.stringify(similarTracks), { EX: 3600 }); // Expire after 1 hour
|
||||
console.log(`[musicMessages] Stored ${similarTracks.length} similar tracks in Redis for guild ${guildId}`);
|
||||
|
||||
// Create similar songs select menu options
|
||||
const similarOptions = similarTracks
|
||||
.slice(0, 15) // Limit to 15 similar tracks
|
||||
.map(track => ({
|
||||
label: track.info.title.substring(0, 100), // Discord limit
|
||||
value: track.encoded.substring(0, 100), // Discord limit
|
||||
description: track.info.author.substring(0, 100),
|
||||
emoji: { name: '🔗' } // Link emoji for "related"
|
||||
}));
|
||||
|
||||
if (similarOptions.length > 0) {
|
||||
const similarMenu = DisplayComponentV2Builder.createSelectMenu(
|
||||
'music_similar',
|
||||
'🎵 Canciones similares',
|
||||
similarOptions
|
||||
);
|
||||
|
||||
// Add similar SelectMenu in its own ActionRow
|
||||
buttonsContainer.addActionRow([similarMenu]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading similar tracks for select menu:", error);
|
||||
// Continue without similar menu if there's an error
|
||||
}
|
||||
|
||||
// Return both containers
|
||||
return [infoContainer.toJSON(), buttonsContainer.toJSON()];
|
||||
}
|
||||
|
||||
230
src/core/utils/similarity.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Similarity Utilities for Smart Music Recommendations
|
||||
* Implements Jaccard, Levenshtein, and duration similarity functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate Jaccard similarity between two sets of tags
|
||||
* J(A,B) = |A ∩ B| / |A ∪ B|
|
||||
*
|
||||
* @param tags1 - First set of tags
|
||||
* @param tags2 - Second set of tags
|
||||
* @returns Similarity score between 0 and 1
|
||||
*/
|
||||
export function jaccardSimilarity(tags1: string[], tags2: string[]): number {
|
||||
if (!tags1.length && !tags2.length) return 1; // Both empty = identical
|
||||
if (!tags1.length || !tags2.length) return 0; // One empty = no similarity
|
||||
|
||||
const set1 = new Set(tags1.map(t => t.toLowerCase()));
|
||||
const set2 = new Set(tags2.map(t => t.toLowerCase()));
|
||||
|
||||
// Intersection
|
||||
const intersection = new Set([...set1].filter(tag => set2.has(tag)));
|
||||
|
||||
// Union
|
||||
const union = new Set([...set1, ...set2]);
|
||||
|
||||
return intersection.size / union.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
* (minimum number of single-character edits)
|
||||
*
|
||||
* @param str1 - First string
|
||||
* @param str2 - Second string
|
||||
* @returns Edit distance
|
||||
*/
|
||||
export function levenshteinDistance(str1: string, str2: string): number {
|
||||
const s1 = str1.toLowerCase();
|
||||
const s2 = str2.toLowerCase();
|
||||
|
||||
if (s1 === s2) return 0;
|
||||
if (!s1.length) return s2.length;
|
||||
if (!s2.length) return s1.length;
|
||||
|
||||
// Create distance matrix
|
||||
const matrix: number[][] = [];
|
||||
|
||||
// Initialize first column
|
||||
for (let i = 0; i <= s2.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
|
||||
// Initialize first row
|
||||
for (let j = 0; j <= s1.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for (let i = 1; i <= s2.length; i++) {
|
||||
for (let j = 1; j <= s1.length; j++) {
|
||||
if (s2.charAt(i - 1) === s1.charAt(j - 1)) {
|
||||
matrix[i][j] = matrix[i - 1][j - 1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j - 1] + 1, // substitution
|
||||
matrix[i][j - 1] + 1, // insertion
|
||||
matrix[i - 1][j] + 1 // deletion
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[s2.length][s1.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate normalized Levenshtein similarity (0-1 scale)
|
||||
*
|
||||
* @param str1 - First string
|
||||
* @param str2 - Second string
|
||||
* @returns Similarity score between 0 and 1
|
||||
*/
|
||||
export function normalizedLevenshtein(str1: string, str2: string): number {
|
||||
const maxLength = Math.max(str1.length, str2.length);
|
||||
if (maxLength === 0) return 1; // Both empty strings
|
||||
|
||||
const distance = levenshteinDistance(str1, str2);
|
||||
return 1 - (distance / maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two artists are similar
|
||||
* Handles exact matches and fuzzy matching
|
||||
*
|
||||
* @param artist1 - First artist name
|
||||
* @param artist2 - Second artist name
|
||||
* @param threshold - Minimum similarity score (default: 0.7)
|
||||
* @returns Similarity score between 0 and 1
|
||||
*/
|
||||
export function artistSimilarity(
|
||||
artist1: string,
|
||||
artist2: string,
|
||||
threshold: number = 0.7
|
||||
): number {
|
||||
// Exact match (case insensitive)
|
||||
if (artist1.toLowerCase() === artist2.toLowerCase()) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Fuzzy match using Levenshtein
|
||||
const similarity = normalizedLevenshtein(artist1, artist2);
|
||||
|
||||
// Return 0 if below threshold to avoid weak matches
|
||||
return similarity >= threshold ? similarity : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate duration compatibility score
|
||||
* Songs with similar duration maintain better flow
|
||||
*
|
||||
* @param duration1 - First song duration in ms
|
||||
* @param duration2 - Second song duration in ms
|
||||
* @param tolerance - Acceptable variation percentage (default: 0.3 = ±30%)
|
||||
* @returns Similarity score between 0 and 1
|
||||
*/
|
||||
export function durationSimilarity(
|
||||
duration1: number,
|
||||
duration2: number,
|
||||
tolerance: number = 0.3
|
||||
): number {
|
||||
if (duration1 === 0 || duration2 === 0) return 0.5; // Unknown duration
|
||||
|
||||
const ratio = Math.min(duration1, duration2) / Math.max(duration1, duration2);
|
||||
|
||||
// Perfect match
|
||||
if (ratio === 1) return 1;
|
||||
|
||||
// Calculate how far from perfect within tolerance
|
||||
const minAcceptable = 1 - tolerance;
|
||||
|
||||
if (ratio < minAcceptable) return 0; // Too different
|
||||
|
||||
// Linear scale from minAcceptable (0) to 1.0 (1)
|
||||
return (ratio - minAcceptable) / tolerance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract common tags between artist names
|
||||
* Useful for finding "featuring" or "vs" collaborations
|
||||
*
|
||||
* @param artist1 - First artist name
|
||||
* @param artist2 - Second artist name
|
||||
* @returns Array of common words in artist names
|
||||
*/
|
||||
export function extractCommonArtistTokens(
|
||||
artist1: string,
|
||||
artist2: string
|
||||
): string[] {
|
||||
const normalize = (str: string) =>
|
||||
str
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter(word => word.length > 2); // Ignore small words like "ft", "vs"
|
||||
|
||||
const tokens1 = new Set(normalize(artist1));
|
||||
const tokens2 = new Set(normalize(artist2));
|
||||
|
||||
return [...tokens1].filter(token => tokens2.has(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Cosine Similarity between two strings
|
||||
* Useful for comparing combined text (Title + Author + Tags)
|
||||
*
|
||||
* @param text1 - First text
|
||||
* @param text2 - Second text
|
||||
* @returns Similarity score between 0 and 1
|
||||
*/
|
||||
export function cosineSimilarity(text1: string, text2: string): number {
|
||||
const tokenize = (text: string) => {
|
||||
return text.toLowerCase()
|
||||
.replace(/[^\w\s]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length > 1); // Ignore single chars
|
||||
};
|
||||
|
||||
const tokens1 = tokenize(text1);
|
||||
const tokens2 = tokenize(text2);
|
||||
|
||||
if (!tokens1.length && !tokens2.length) return 1;
|
||||
if (!tokens1.length || !tokens2.length) return 0;
|
||||
|
||||
// Create unique vocabulary
|
||||
const vocabulary = new Set([...tokens1, ...tokens2]);
|
||||
|
||||
// Create frequency vectors
|
||||
const vector1 = new Map<string, number>();
|
||||
const vector2 = new Map<string, number>();
|
||||
|
||||
for (const term of vocabulary) {
|
||||
vector1.set(term, 0);
|
||||
vector2.set(term, 0);
|
||||
}
|
||||
|
||||
for (const token of tokens1) vector1.set(token, (vector1.get(token) || 0) + 1);
|
||||
for (const token of tokens2) vector2.set(token, (vector2.get(token) || 0) + 1);
|
||||
|
||||
// Calculate dot product
|
||||
let dotProduct = 0;
|
||||
let mag1 = 0;
|
||||
let mag2 = 0;
|
||||
|
||||
for (const term of vocabulary) {
|
||||
const v1 = vector1.get(term) || 0;
|
||||
const v2 = vector2.get(term) || 0;
|
||||
|
||||
dotProduct += v1 * v2;
|
||||
mag1 += v1 * v1;
|
||||
mag2 += v2 * v2;
|
||||
}
|
||||
|
||||
mag1 = Math.sqrt(mag1);
|
||||
mag2 = Math.sqrt(mag2);
|
||||
|
||||
if (mag1 === 0 || mag2 === 0) return 0;
|
||||
|
||||
return dotProduct / (mag1 * mag2);
|
||||
}
|
||||
116
src/events/boostHandler.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Events, Message, MessageType } from "discord.js";
|
||||
import { prisma } from "../core/database/prisma";
|
||||
import { CouponService } from "../core/services/CouponService";
|
||||
import logger from "../core/lib/logger";
|
||||
|
||||
const TARGET_GUILD_ID = "1316592320954630144";
|
||||
|
||||
export default {
|
||||
name: Events.MessageCreate,
|
||||
execute: async (message: Message) => {
|
||||
// Ignore if not in target guild or system message
|
||||
if (!message.guild || message.guild.id !== TARGET_GUILD_ID) return;
|
||||
|
||||
// Check for boost message types
|
||||
// Check for boost message types (8, 9, 10, 11 correspond to boost messages in Discord API)
|
||||
const boostTypes = [8, 9, 10, 11];
|
||||
|
||||
if (!boostTypes.includes(message.type)) return;
|
||||
|
||||
const userId = message.author.id;
|
||||
const guildId = message.guild.id;
|
||||
|
||||
logger.info(`[BoostHandler] Detected boost by user ${userId} in guild ${guildId}`);
|
||||
|
||||
try {
|
||||
// Upsert PlayerState to track boost count
|
||||
// We use a transaction to ensure atomic read-update
|
||||
await prisma.$transaction(async (tx) => {
|
||||
let playerState = await tx.playerState.findUnique({
|
||||
where: { userId_guildId: { userId, guildId } }
|
||||
});
|
||||
|
||||
if (!playerState) {
|
||||
playerState = await tx.playerState.create({
|
||||
data: {
|
||||
userId,
|
||||
guildId,
|
||||
metadata: { boostCount: 0 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const metadata = (playerState.metadata as any) || { boostCount: 0 };
|
||||
const currentBoosts = (metadata.boostCount || 0) + 1;
|
||||
metadata.boostCount = currentBoosts;
|
||||
|
||||
await tx.playerState.update({
|
||||
where: { id: playerState.id },
|
||||
data: { metadata }
|
||||
});
|
||||
|
||||
logger.info(`[BoostHandler] User ${userId} boost count updated to ${currentBoosts}`);
|
||||
|
||||
// Reward Logic
|
||||
if (currentBoosts === 1) {
|
||||
// 1 Boost: VOLUME_BOOST
|
||||
const coupon = await CouponService.createCoupon(
|
||||
"VOLUME_BOOST",
|
||||
200, // 200% Volume
|
||||
1,
|
||||
30, // 30 days validity? User didn't specify, assuming 30
|
||||
"SYSTEM_BOOST_REWARD"
|
||||
);
|
||||
|
||||
try {
|
||||
await message.author.send(
|
||||
`🎉 **¡Gracias por boostear el servidor!** 🎉\n\n` +
|
||||
`Como recompensa por tu **1er Boost**, aquí tienes un cupón de **Volumen al 200%**:\n` +
|
||||
`\`${coupon.code}\`\n\n` +
|
||||
`Canjéalo en el dashboard: https://docs.amayo.dev`
|
||||
);
|
||||
logger.info(`[BoostHandler] Sent VOLUME_BOOST coupon to ${userId}`);
|
||||
} catch (dmError) {
|
||||
logger.warn(`[BoostHandler] Failed to DM user ${userId}: ${dmError}`);
|
||||
if (message.channel.isTextBased()) {
|
||||
const channel = message.channel as any; // Cast to any to avoid type issues with PartialGroupDMChannel
|
||||
if (channel.send) {
|
||||
await channel.send(`🎉 <@${userId}> ¡Gracias por el boost! Te envié un cupón, pero tienes los DMs cerrados. Contacta a soporte.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (currentBoosts === 2) {
|
||||
// 2 Boosts: ALL_ACCESS + 30 Days
|
||||
const coupon = await CouponService.createCoupon(
|
||||
"ALL_ACCESS",
|
||||
1,
|
||||
1,
|
||||
30, // 30 Days
|
||||
"SYSTEM_BOOST_REWARD"
|
||||
);
|
||||
|
||||
try {
|
||||
await message.author.send(
|
||||
`🚀 **¡INCREÍBLE! Gracias por tu 2do Boost!** 🚀\n\n` +
|
||||
`Has desbloqueado **ALL ACCESS** por 30 días. Disfruta de todos los beneficios premium:\n` +
|
||||
`\`${coupon.code}\`\n\n` +
|
||||
`Canjéalo en el dashboard: https://docs.amayo.dev`
|
||||
);
|
||||
logger.info(`[BoostHandler] Sent ALL_ACCESS coupon to ${userId}`);
|
||||
} catch (dmError) {
|
||||
logger.warn(`[BoostHandler] Failed to DM user ${userId}: ${dmError}`);
|
||||
if (message.channel.isTextBased()) {
|
||||
const channel = message.channel as any;
|
||||
if (channel.send) {
|
||||
await channel.send(`🚀 <@${userId}> ¡Gracias por tu 2do boost! Te envié un cupón especial, pero tienes los DMs cerrados.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, `[BoostHandler] Error processing boost for user ${userId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
19
src/events/guildCreate.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Events, Guild } from "discord.js";
|
||||
import { safeUpsertGuild } from "../server/lib/utils";
|
||||
import logger from "../core/lib/logger";
|
||||
|
||||
export default {
|
||||
name: Events.GuildCreate,
|
||||
execute: async (guild: Guild) => {
|
||||
logger.info(`[GuildCreate] Joined guild: ${guild.name} (${guild.id})`);
|
||||
try {
|
||||
await safeUpsertGuild({
|
||||
id: guild.id,
|
||||
name: guild.name
|
||||
});
|
||||
logger.info(`[GuildCreate] Guild upserted to DB: ${guild.name}`);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, `[GuildCreate] Failed to upsert guild ${guild.name}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
25
src/events/guildDelete.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Events, Guild } from "discord.js";
|
||||
import { prisma } from "../core/database/prisma";
|
||||
import logger from "../core/lib/logger";
|
||||
|
||||
export default {
|
||||
name: Events.GuildDelete,
|
||||
execute: async (guild: Guild) => {
|
||||
logger.info(`[GuildDelete] Left guild: ${guild.name} (${guild.id})`);
|
||||
try {
|
||||
// Use deleteMany instead of delete to avoid errors when guild doesn't exist
|
||||
// deleteMany won't throw if the record is not found
|
||||
const result = await prisma.guild.deleteMany({
|
||||
where: { id: guild.id }
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
logger.info(`[GuildDelete] Successfully removed guild ${guild.id} from database`);
|
||||
} else {
|
||||
logger.info(`[GuildDelete] Guild ${guild.id} was not in database`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`[GuildDelete] Failed to delete guild ${guild.id}`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -50,7 +50,15 @@ bot.on(Events.InteractionCreate, async (interaction: BaseInteraction) => {
|
||||
// 🔹 Botones
|
||||
if (interaction.isButton()) {
|
||||
//@ts-ignore
|
||||
const btn = buttons.get(interaction.customId);
|
||||
// Primero intentar búsqueda exacta
|
||||
let btn = buttons.get(interaction.customId);
|
||||
|
||||
// Si no se encuentra, intentar búsqueda por prefijo (para botones dinámicos)
|
||||
if (!btn) {
|
||||
const prefix = interaction.customId.split(":")[0];
|
||||
btn = buttons.get(prefix);
|
||||
}
|
||||
|
||||
if (btn) await btn.run(interaction, bot);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { bot } from "../main";
|
||||
import { ActivityType, Events } from "discord.js";
|
||||
import logger from "../core/lib/logger";
|
||||
import { safeUpsertGuild } from "../server/lib/utils";
|
||||
|
||||
bot.on(Events.ClientReady, () => {
|
||||
logger.info("Ready!");
|
||||
|
||||
// Sync guilds to DB
|
||||
logger.info(`Syncing ${bot.guilds.cache.size} guilds to DB...`);
|
||||
for (const [id, guild] of bot.guilds.cache) {
|
||||
safeUpsertGuild({
|
||||
id: guild.id,
|
||||
name: guild.name
|
||||
}).catch(err => logger.error({ err }, `Failed to sync guild ${guild.name}`));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🛡️ HANDLER GLOBAL PARA ERRORES DE DISCORD.JS
|
||||
// ============================================
|
||||
|
||||
92
src/integrations/topgg/TopggService.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Api } from "@top-gg/sdk";
|
||||
import logger from "../../core/lib/logger";
|
||||
import type Amayo from "../../core/client";
|
||||
|
||||
export class TopggService {
|
||||
private api: Api;
|
||||
private bot: Amayo;
|
||||
private autopostInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(token: string, bot: Amayo) {
|
||||
this.api = new Api(token);
|
||||
this.bot = bot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post bot stats to top.gg
|
||||
*/
|
||||
async postStats() {
|
||||
try {
|
||||
const serverCount = this.bot.guilds.cache.size;
|
||||
|
||||
logger.info(`[Top.gg] Posting stats: ${serverCount} servers`);
|
||||
|
||||
await this.api.postStats({
|
||||
serverCount,
|
||||
// shardId: 0, // Si usas sharding
|
||||
// shardCount: 1,
|
||||
});
|
||||
|
||||
logger.info(`[Top.gg] ✅ Stats posted successfully`);
|
||||
} catch (error) {
|
||||
logger.error({ error }, "[Top.gg] Error posting stats");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-posting stats every 24 hours
|
||||
*/
|
||||
startAutopost() {
|
||||
if (this.autopostInterval) {
|
||||
logger.warn("[Top.gg] Autopost already running");
|
||||
return;
|
||||
}
|
||||
|
||||
// Post immediately on start
|
||||
this.postStats();
|
||||
|
||||
// Then post every 24 hours (86400000 ms)
|
||||
this.autopostInterval = setInterval(() => {
|
||||
this.postStats();
|
||||
}, 24 * 60 * 60 * 1000);
|
||||
|
||||
logger.info("[Top.gg] ✅ Autopost started (every 24 hours)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-posting
|
||||
*/
|
||||
stopAutopost() {
|
||||
if (this.autopostInterval) {
|
||||
clearInterval(this.autopostInterval);
|
||||
this.autopostInterval = null;
|
||||
logger.info("[Top.gg] Autopost stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has voted
|
||||
*/
|
||||
async hasVoted(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const voted = await this.api.hasVoted(userId);
|
||||
return voted;
|
||||
} catch (error) {
|
||||
logger.error({ error }, "[Top.gg] Error checking vote");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bot stats from top.gg
|
||||
*/
|
||||
async getBotStats() {
|
||||
try {
|
||||
const stats = await this.api.getBot(this.bot.user!.id);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
logger.error({ error }, "[Top.gg] Error getting bot stats");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||