This commit is contained in:
Shnimlz
2025-12-01 18:59:48 +00:00
parent 9c20ca0930
commit 7661b2b8b1
117 changed files with 17954 additions and 3591 deletions

68
.env Normal file
View 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
View 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
View File

@@ -1,6 +1,5 @@
# Dependencias
node_modules
.env
.env.test
qodana.yaml

4
AmayoWeb/.env Normal file
View 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
View File

@@ -15,7 +15,6 @@ coverage
*.local
# Environment variables
.env
.env.local
.env.*.local

View 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
View 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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -6,12 +6,8 @@
<script setup>
import { onMounted } from 'vue'
import { useTheme } from './composables/useTheme'
const { initTheme } = useTheme()
onMounted(() => {
initTheme()
})
</script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 350 KiB

View File

@@ -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;

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>
Vues
<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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
}
}

View File

@@ -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',
}
}
}
}

View File

@@ -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
}
]
})

View File

@@ -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'
}
}

View File

@@ -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)

View 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
}
}

View 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 []
}
}
}

View 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
}
}
}

View File

@@ -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)
}
})

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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
View 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
View 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
View File

@@ -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",

View File

@@ -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"

View File

@@ -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])
}

View 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}`);
}
},
};

View 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}`);
}
},
};

View File

@@ -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 [];
}
}

View 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"}`);
}
},
};

View File

@@ -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

View 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"}`
);
}
},
};

View 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 };

View File

@@ -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;
}

View File

@@ -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);

View 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);
},
};

View 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,
});
}
},
};

View 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"}`,
});
}
}
},
};

View 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: [],
});
},
};

View 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")}`;
}

View 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")}`;
}

View File

@@ -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({

View File

@@ -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({

View File

@@ -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;
}

View 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);
}
}
},
};

View 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,
});
}
},
};

View 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,
});
}
},
};

View File

@@ -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,
}

View File

@@ -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)");
}

View File

@@ -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;
}

View 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}`),
};
}
}

View 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
};

View 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;
}
}
}

View 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
};
}
}

View File

@@ -16,6 +16,7 @@ interface TrackInfo {
author: string;
duration: number;
source?: string;
isrc?: string; // ISRC code for precise duplicate detection
}
interface ListeningSession {

View 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';
}
}

View 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 };

View 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,
},
});
}
}

View File

@@ -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()];
}

View 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
View 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
View 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
View 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);
}
}
};

View File

@@ -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);
}

View File

@@ -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
// ============================================

View 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;
}
}
}

Some files were not shown because too many files have changed in this diff Show More