add landing and subscription plans

Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 23:49:19 +01:00
parent 05f0455744
commit 6ec8855c00
79 changed files with 8839 additions and 361 deletions

View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.vite/
npm-debug.log*
.env
.env.local
*.log

View File

@@ -0,0 +1,44 @@
# Configuración del Backend
Este documento explica cómo configurar el frontend para conectarse a diferentes backends.
## Configuración mediante Variables de Entorno
El frontend usa la variable de entorno `VITE_API_BASE_URL` para determinar la URL del backend.
### Para desarrollo local (por defecto)
Si no defines `VITE_API_BASE_URL`, el frontend usará `/api` que será manejado por el proxy de Vite configurado en `vite.config.js` (redirige a `http://localhost:3001`).
### Para backend en producción
Crea un archivo `.env.local` en la raíz del directorio `frontend` con el siguiente contenido:
```bash
VITE_API_BASE_URL=https://wb.pribyte.cloud/api
```
**Nota:** Los archivos `.env.local` están en `.gitignore` y no se subirán al repositorio.
## Cómo usar
1. **Desarrollo local:**
- No necesitas hacer nada, funciona por defecto con el proxy de Vite
2. **Backend en producción:**
- Crea el archivo `.env.local` con la URL del backend
- Reinicia el servidor de desarrollo (`npm run dev`) para que cargue las nuevas variables
## Ejemplo de archivo `.env.local`
```bash
# Backend en producción
VITE_API_BASE_URL=https://wb.pribyte.cloud/api
```
## Notas importantes
- Las variables de entorno que empiezan con `VITE_` son expuestas al código del cliente
- Después de modificar `.env.local`, necesitas reiniciar el servidor de desarrollo
- El WebSocket también se configurará automáticamente basándose en la URL del backend

29
web/dashboard/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM node:18-alpine AS builder
WORKDIR /app
# Copiar archivos de dependencias
COPY package.json package-lock.json* ./
# Instalar dependencias
RUN npm ci
# Copiar código fuente
COPY . .
# Construir aplicación
RUN npm run build
# Stage de producción - servir con nginx
FROM nginx:alpine
# Copiar archivos construidos
COPY --from=builder /app/dist /usr/share/nginx/html
# Copiar configuración de nginx (se puede sobrescribir con volumen)
COPY nginx-dashboard.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

34
web/dashboard/index.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wallabicher Dashboard</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<!-- Apple Touch Icon (iOS) -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<!-- Android Chrome Icons -->
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png" />
<!-- Manifest for PWA -->
<link rel="manifest" href="/manifest.json" />
<!-- Theme colors -->
<meta name="theme-color" content="#0284c7" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Wallabicher" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# SPA routing - todas las rutas del dashboard
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

49
web/dashboard/nginx.conf Normal file
View File

@@ -0,0 +1,49 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket proxy
location /ws {
proxy_pass http://backend:3001;
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-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

2622
web/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "wallabicher-dashboard",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@heroicons/vue": "^2.1.1",
"axios": "^1.6.0",
"chart.js": "^4.4.0",
"vue": "^3.3.4",
"vue-chartjs": "^5.2.0",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"vite": "^5.0.0"
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -0,0 +1,24 @@
{
"name": "Wallabicher Dashboard",
"short_name": "Wallabicher",
"description": "Dashboard para monitorear artículos de Wallapop y Vinted",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#0284c7",
"background_color": "#ffffff",
"display": "standalone",
"start_url": "/",
"scope": "/",
"gcm_sender_id": "103953800507"
}

100
web/dashboard/public/sw.js Normal file
View File

@@ -0,0 +1,100 @@
// Service Worker para notificaciones push
const CACHE_NAME = 'wallabicher-v1';
// Instalar Service Worker
self.addEventListener('install', (event) => {
console.log('Service Worker instalado');
self.skipWaiting();
});
// Activar Service Worker
self.addEventListener('activate', (event) => {
console.log('Service Worker activado');
event.waitUntil(self.clients.claim());
});
// Manejar mensajes push
self.addEventListener('push', (event) => {
console.log('Push recibido:', event);
let notificationData = {
title: 'Wallabicher',
body: 'Tienes nuevas notificaciones',
icon: '/android-chrome-192x192.png',
badge: '/android-chrome-192x192.png',
tag: 'wallabicher-notification',
requireInteraction: false,
data: {}
};
// Si hay datos en el push, usarlos
if (event.data) {
try {
const data = event.data.json();
if (data.title) notificationData.title = data.title;
if (data.body) notificationData.body = data.body;
if (data.icon) notificationData.icon = data.icon;
if (data.image) notificationData.image = data.image;
if (data.url) notificationData.data.url = data.url;
if (data.platform) notificationData.data.platform = data.platform;
if (data.price) notificationData.data.price = data.price;
if (data.currency) notificationData.data.currency = data.currency;
// Usar tag único para agrupar notificaciones por artículo
if (data.id) notificationData.tag = `article-${data.id}`;
} catch (e) {
// Si no es JSON, usar como texto
notificationData.body = event.data.text();
}
}
event.waitUntil(
self.registration.showNotification(notificationData.title, {
body: notificationData.body,
icon: notificationData.icon,
badge: notificationData.badge,
image: notificationData.image,
tag: notificationData.tag,
requireInteraction: notificationData.requireInteraction,
data: notificationData.data,
actions: notificationData.data.url ? [
{
action: 'open',
title: 'Ver artículo'
},
{
action: 'close',
title: 'Cerrar'
}
] : []
})
);
});
// Manejar clics en notificaciones
self.addEventListener('notificationclick', (event) => {
console.log('Notificación clickeada:', event);
event.notification.close();
if (event.action === 'close') {
return;
}
// Si hay una URL, abrirla
if (event.notification.data.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
} else {
// Si no hay URL, abrir la app
event.waitUntil(
clients.openWindow('/')
);
}
});
// Manejar notificaciones cerradas
self.addEventListener('notificationclose', (event) => {
console.log('Notificación cerrada:', event);
});

437
web/dashboard/src/App.vue Normal file
View File

@@ -0,0 +1,437 @@
<template>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<!-- Sidebar - Solo mostrar si no estamos en login -->
<template v-if="$route.path !== '/login'">
<!-- Sidebar -->
<aside
class="fixed top-0 left-0 z-40 h-screen transition-all duration-300 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shadow-lg"
:class="sidebarCollapsed ? 'w-20' : 'w-64'"
>
<!-- Logo -->
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
<router-link v-if="!sidebarCollapsed" to="/dashboard" class="flex items-center space-x-3 flex-1 min-w-0 group">
<div class="flex-shrink-0 w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
<img
src="/logo.jpg"
alt="Wallabicher Logo"
class="w-full h-full object-cover"
/>
</div>
<div class="flex-1 min-w-0">
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100 truncate">Wallabicher</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">Admin Panel</p>
</div>
</router-link>
<router-link v-else to="/dashboard" class="flex items-center justify-center w-full group">
<div class="w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
<img
src="/logo.jpg"
alt="Wallabicher Logo"
class="w-full h-full object-cover"
/>
</div>
</router-link>
<button
@click="sidebarCollapsed = !sidebarCollapsed"
class="flex-shrink-0 p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ml-2"
:title="sidebarCollapsed ? 'Expandir sidebar' : 'Colapsar sidebar'"
>
<Bars3Icon v-if="sidebarCollapsed" class="w-5 h-5" />
<XMarkIcon v-else class="w-5 h-5" />
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 group"
:class="
$route.path === item.path
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-400 shadow-sm'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
"
>
<component :is="item.icon" class="flex-shrink-0 w-5 h-5" :class="sidebarCollapsed ? 'mx-auto' : 'mr-3'" />
<span v-if="!sidebarCollapsed" class="flex-1">{{ item.name }}</span>
<span
v-if="!sidebarCollapsed && $route.path === item.path"
class="ml-2 w-2 h-2 bg-primary-600 rounded-full"
></span>
</router-link>
</nav>
<!-- Sidebar Footer -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-2 mb-3" :class="sidebarCollapsed ? 'justify-center' : ''">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-gradient-to-br from-green-400 to-green-500 rounded-full flex items-center justify-center">
<div class="w-3 h-3 bg-white rounded-full" :class="wsConnected ? 'opacity-100' : 'opacity-0'"></div>
</div>
</div>
<div v-if="!sidebarCollapsed" class="flex-1 min-w-0">
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Estado del sistema</p>
</div>
</div>
</div>
</aside>
</template>
<!-- Main Content -->
<div class="transition-all duration-300" :class="$route.path === '/login' ? '' : (sidebarCollapsed ? 'ml-20' : 'ml-64')">
<!-- Header - Solo mostrar si no estamos en login -->
<header v-if="$route.path !== '/login'" class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div class="flex items-center justify-between h-16 px-6">
<!-- Breadcrumbs -->
<div class="flex items-center space-x-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ getCurrentPageTitle() }}
</h2>
</div>
<!-- Header Actions -->
<div class="flex items-center space-x-2">
<!-- Push Notifications -->
<button
v-if="!pushEnabled"
@click="enablePushNotifications"
class="relative p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Activar notificaciones push"
>
<BellIcon class="w-5 h-5" />
</button>
<button
v-else
@click="disablePushNotifications"
class="relative p-2 rounded-lg text-green-600 dark:text-green-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Desactivar notificaciones push"
>
<BellSlashIcon class="w-5 h-5" />
<span class="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full"></span>
</button>
<!-- Dark Mode Toggle -->
<button
@click="toggleDarkMode"
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Alternar modo oscuro"
>
<SunIcon v-if="isDark" class="w-5 h-5" />
<MoonIcon v-else class="w-5 h-5" />
</button>
<!-- Logout -->
<button
@click="handleLogout()"
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Desconectar"
>
<ArrowRightOnRectangleIcon class="w-5 h-5" />
</button>
</div>
</div>
</header>
<!-- Page Content -->
<main :class="$route.path === '/login' ? '' : 'p-6 pb-20'">
<router-view />
</main>
</div>
<!-- Footer Fixed - Solo mostrar si no estamos en login -->
<footer v-if="$route.path !== '/login'" class="fixed bottom-0 left-0 right-0 z-30 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 py-3 shadow-sm" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
<div class="px-6">
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
© {{ new Date().getFullYear() }} Wallabicher. Todos los derechos reservados.
</p>
</div>
</footer>
<!-- Toast notifications container -->
<ToastContainer />
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue';
import {
HomeIcon,
DocumentTextIcon,
HeartIcon,
Cog6ToothIcon,
CogIcon,
UserGroupIcon,
DocumentMagnifyingGlassIcon,
ShieldExclamationIcon,
ClockIcon,
CreditCardIcon,
Bars3Icon,
XMarkIcon,
SunIcon,
MoonIcon,
BellIcon,
BellSlashIcon,
ArrowRightOnRectangleIcon,
} from '@heroicons/vue/24/outline';
import pushNotificationService from './services/pushNotifications';
import authService from './services/auth';
import { useRouter } from 'vue-router';
import api from './services/api';
import ToastContainer from './components/ToastContainer.vue';
const allNavItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon, adminOnly: false },
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon, adminOnly: false },
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false },
{ path: '/subscription', name: 'Suscripción', icon: CreditCardIcon, adminOnly: false },
{ path: '/settings', name: 'Configuración', icon: CogIcon, adminOnly: false },
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: true },
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
{ path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true },
{ path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true },
];
const router = useRouter();
const wsConnected = ref(false);
const sidebarCollapsed = ref(false);
const darkMode = ref(false);
const pushEnabled = ref(false);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
let ws = null;
const isDark = computed(() => darkMode.value);
const isAuthenticated = computed(() => authService.hasCredentials());
// Filtrar navItems según el rol del usuario
const navItems = computed(() => {
return allNavItems.filter(item => {
// Si requiere admin y el usuario no es admin, excluir
if (item.adminOnly && !isAdmin.value) {
return false;
}
return true;
});
});
function toggleDarkMode() {
darkMode.value = !darkMode.value;
if (darkMode.value) {
document.documentElement.classList.add('dark');
localStorage.setItem('darkMode', 'true');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('darkMode', 'false');
}
}
function initDarkMode() {
const saved = localStorage.getItem('darkMode');
if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
darkMode.value = true;
document.documentElement.classList.add('dark');
} else {
darkMode.value = false;
document.documentElement.classList.remove('dark');
}
}
async function enablePushNotifications() {
try {
const success = await pushNotificationService.init();
if (success) {
pushEnabled.value = true;
// Mostrar notificación simple usando la API de notificaciones del navegador
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Notificaciones activadas', {
body: 'Ahora recibirás notificaciones push cuando haya nuevos artículos',
icon: '/android-chrome-192x192.png',
});
}
} else {
alert('No se pudieron activar las notificaciones push. Verifica que los permisos estén habilitados.');
}
} catch (error) {
console.error('Error activando notificaciones push:', error);
alert('Error activando notificaciones push: ' + error.message);
}
}
async function disablePushNotifications() {
try {
await pushNotificationService.unsubscribe();
pushEnabled.value = false;
// Mostrar notificación simple
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Notificaciones desactivadas', {
body: 'Ya no recibirás notificaciones push',
icon: '/android-chrome-192x192.png',
});
}
} catch (error) {
console.error('Error desactivando notificaciones push:', error);
}
}
async function checkPushStatus() {
const hasSubscription = await pushNotificationService.checkSubscription();
pushEnabled.value = hasSubscription;
}
function getCurrentPageTitle() {
const currentItem = navItems.value.find(item => item.path === router.currentRoute.value.path);
return currentItem ? currentItem.name : 'Dashboard';
}
function handleAuthChange() {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
// Reconectar websocket cuando cambie la autenticación (login)
if (authService.hasCredentials()) {
connectWebSocket();
}
}
async function handleLogout() {
// Cerrar conexión WebSocket antes de hacer logout
if (ws) {
ws.close();
ws = null;
wsConnected.value = false;
}
// Llamar al endpoint de logout e invalidar token
await authService.logout();
// Redirigir a login después del logout
router.push('/login');
// Disparar evento para que los componentes se actualicen
window.dispatchEvent(new CustomEvent('auth-logout'));
// Mostrar mensaje informativo
console.log('Sesión cerrada correctamente');
}
onMounted(async () => {
initDarkMode();
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
await checkPushStatus();
// Escuchar eventos de autenticación
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
// Si hay credenciales, validar y conectar websocket
if (authService.hasCredentials()) {
// Validar si el token sigue siendo válido
const isValid = await authService.validateSession();
if (!isValid) {
// Si el token expiró, limpiar sesión y redirigir a login
authService.clearSession();
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
if (router.currentRoute.value.path !== '/login') {
router.push('/login');
}
} else {
// Solo conectar websocket si el token es válido
connectWebSocket();
}
}
});
onUnmounted(() => {
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
if (ws) {
ws.close();
}
});
function connectWebSocket() {
// Cerrar conexión existente si hay una
if (ws) {
ws.close();
ws = null;
}
// Verificar si hay token de autenticación
const token = authService.getToken();
if (!token) {
console.log('No hay token de autenticación, no se conectará WebSocket');
wsConnected.value = false;
return;
}
let wsUrl;
// Si hay una URL de API configurada, usarla para WebSocket también
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
if (apiBaseUrl && apiBaseUrl !== '/api') {
// Extraer el host de la URL de la API y construir la URL del WebSocket
try {
const url = new URL(apiBaseUrl);
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`;
} catch (e) {
// Si falla el parsing, usar la configuración por defecto
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
}
} else {
// Use relative path so Vite proxy can handle it
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
}
ws = new WebSocket(wsUrl);
ws.onopen = () => {
wsConnected.value = true;
console.log('WebSocket conectado');
};
ws.onclose = (event) => {
wsConnected.value = false;
// Si el cierre fue por autenticación fallida (código 1008), no reintentar
if (event.code === 1008) {
console.log('WebSocket cerrado: autenticación fallida');
// Si el token aún existe, intentar reconectar después de un delay más largo
// para dar tiempo a que el usuario se autentique de nuevo
if (authService.hasCredentials()) {
setTimeout(connectWebSocket, 5000);
}
} else {
// Para otros errores, reintentar después de 3 segundos si hay token
if (authService.hasCredentials()) {
console.log('WebSocket desconectado, reintentando...');
setTimeout(connectWebSocket, 3000);
}
}
};
ws.onerror = (error) => {
console.error('Error WebSocket:', error);
wsConnected.value = false;
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
};
}
</script>

View File

@@ -0,0 +1,308 @@
<template>
<div
class="card hover:shadow-lg transition-all duration-300"
:class="{
'highlight-new': isNew,
'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700': isNew
}"
>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
<!-- Imagen del artículo -->
<div class="flex-shrink-0 self-center sm:self-start">
<div
v-if="article.images && article.images.length > 0"
class="w-24 h-24 sm:w-32 sm:h-32 relative cursor-pointer hover:opacity-80 transition-opacity"
@click="goToDetail"
>
<img
:src="article.images[0]"
:alt="article.title || 'Sin título'"
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-lg"
@error="($event) => handleImageError($event)"
/>
</div>
<div
v-else
class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
@click="goToDetail"
>
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
</div>
</div>
<!-- Información del artículo -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between mb-2">
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span
class="px-2 py-1 text-xs font-semibold rounded flex-shrink-0"
:class="
article.platform === 'wallapop'
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
: 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
"
>
{{ article.platform?.toUpperCase() || 'N/A' }}
</span>
<span v-if="article.username" class="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300 flex-shrink-0" title="Usuario">
👤 {{ article.username }}
</span>
<span v-if="article.worker_name" class="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300 flex-shrink-0" title="Worker">
{{ article.worker_name }}
</span>
<span v-if="article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
{{ formatDate(article.notifiedAt) }}
</span>
<span v-if="article.addedAt && !article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
Añadido: {{ formatDate(article.addedAt) }}
</span>
</div>
<h3
class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2 cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
:title="article.title"
@click="goToDetail"
>
{{ article.title || 'Sin título' }}
</h3>
<div v-if="article.price !== null && article.price !== undefined" class="mb-2">
<span class="text-xl font-bold text-primary-600 dark:text-primary-400">
{{ article.price }} {{ article.currency || '€' }}
</span>
</div>
<div class="space-y-1 text-xs sm:text-sm text-gray-600 dark:text-gray-400 mb-2">
<div v-if="article.location" class="flex flex-wrap items-center">
<span class="font-medium">📍 Localidad:</span>
<span class="ml-2">{{ article.location }}</span>
</div>
<div v-if="article.allows_shipping !== null && article.allows_shipping !== undefined" class="flex flex-wrap items-center">
<span class="font-medium">🚚 Envío:</span>
<span class="ml-2">{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}</span>
</div>
<div v-if="article.modified_at" class="flex flex-wrap items-center">
<span class="font-medium">🕒 Modificado:</span>
<span class="ml-2 break-all">{{ article.modified_at }}</span>
</div>
</div>
<p v-if="article.description" class="text-xs sm:text-sm text-gray-700 dark:text-gray-300 mb-2 overflow-hidden line-clamp-2">
{{ article.description }}
</p>
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-3">
<a
v-if="article.url"
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 text-xs sm:text-sm font-medium break-all"
>
🔗 Ver anuncio
</a>
<span class="text-xs text-gray-400 dark:text-gray-500 break-all">
ID: {{ article.id }}
</span>
<button
v-if="showRemoveButton"
@click="$emit('remove', article.platform, article.id)"
class="btn btn-danger text-xs sm:text-sm"
>
Eliminar
</button>
<button
v-if="!showRemoveButton && isAuthenticated && !isAdding"
@click="handleAddFavorite"
class="btn text-xs sm:text-sm flex items-center gap-1"
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
:disabled="favoriteStatus"
:title="favoriteStatus ? 'Ya está en favoritos' : 'Añadir a favoritos'"
>
<HeartIconSolid v-if="favoriteStatus" class="w-4 h-4" />
<HeartIcon v-else class="w-4 h-4" />
{{ favoriteStatus ? 'En favoritos' : 'Añadir a favoritos' }}
</button>
<button
v-if="!showRemoveButton && isAuthenticated && isAdding"
disabled
class="btn btn-secondary text-xs sm:text-sm opacity-50 cursor-not-allowed"
>
<span class="inline-block animate-spin mr-1"></span>
Añadiendo...
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { HeartIcon } from '@heroicons/vue/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
import authService from '../services/auth';
import api from '../services/api';
const router = useRouter();
const props = defineProps({
article: {
type: Object,
required: true,
},
showRemoveButton: {
type: Boolean,
default: false,
},
isFavorite: {
type: Boolean,
default: false,
},
isNew: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['remove', 'added']);
const isAdding = ref(false);
const isAuthenticated = ref(false);
// Usar is_favorite directamente del artículo, con fallback a props.isFavorite
const favoriteStatus = ref(props.article?.is_favorite ?? props.isFavorite);
// Verificar autenticación al montar y cuando cambie
function checkAuth() {
isAuthenticated.value = authService.hasCredentials();
}
function formatDate(timestamp) {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString('es-ES');
}
function handleImageError(event) {
// Si la imagen falla al cargar, reemplazar con placeholder
event.target.onerror = null; // Prevenir bucle infinito
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
}
async function handleAddFavorite() {
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value) {
return;
}
if (!props.article.platform || !props.article.id) {
alert('Error: El artículo no tiene platform o id válidos');
return;
}
isAdding.value = true;
try {
// El backend solo necesita platform e id
const favorite = {
platform: props.article.platform,
id: String(props.article.id), // Asegurar que sea string
};
await api.addFavorite(favorite);
favoriteStatus.value = true;
// Emitir evento para que el componente padre pueda actualizar si es necesario
emit('added', props.article.platform, props.article.id);
} catch (error) {
console.error('Error añadiendo a favoritos:', error);
// El interceptor de API ya maneja el error 401 mostrando el modal de login
if (error.response?.status === 404) {
alert('El artículo no se encontró en la base de datos. Asegúrate de que el artículo esté en la lista de notificados.');
} else if (error.response?.status === 400) {
alert('Error: ' + (error.response?.data?.error || 'Datos inválidos'));
} else if (error.response?.status !== 401) {
const errorMessage = error.response?.data?.error || error.message || 'Error desconocido';
alert('Error al añadir a favoritos: ' + errorMessage);
}
} finally {
isAdding.value = false;
}
}
function handleAuthChange() {
checkAuth();
// Actualizar favoriteStatus basado en el artículo (que viene del backend)
if (props.article) {
favoriteStatus.value = props.article.is_favorite ?? false;
} else {
favoriteStatus.value = false;
}
}
function goToDetail() {
if (props.article.platform && props.article.id) {
router.push(`/articles/${props.article.platform}/${props.article.id}`);
}
}
onMounted(() => {
checkAuth();
// Inicializar favoriteStatus desde el artículo
if (props.article) {
favoriteStatus.value = props.article.is_favorite ?? false;
}
// Escuchar cambios en la autenticación
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
});
onUnmounted(() => {
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
});
</script>
<style scoped>
@keyframes highlight-pulse {
0% {
background-color: rgb(239 246 255);
box-shadow: 0 0 0 0 rgb(59 130 246 / 0.7), 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
50% {
background-color: rgb(219 234 254);
box-shadow: 0 0 20px 5px rgb(59 130 246 / 0.5), 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
100% {
background-color: rgb(239 246 255);
box-shadow: 0 0 0 0 rgb(59 130 246 / 0), 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
}
@keyframes highlight-pulse-dark {
0% {
background-color: rgb(30 58 138 / 0.3);
box-shadow: 0 0 0 0 rgb(96 165 250 / 0.7), 0 4px 6px -1px rgb(0 0 0 / 0.3);
}
50% {
background-color: rgb(30 64 175 / 0.5);
box-shadow: 0 0 20px 5px rgb(96 165 250 / 0.5), 0 10px 15px -3px rgb(0 0 0 / 0.3);
}
100% {
background-color: rgb(30 58 138 / 0.15);
box-shadow: 0 0 0 0 rgb(96 165 250 / 0), 0 4px 6px -1px rgb(0 0 0 / 0.3);
}
}
.highlight-new {
animation: highlight-pulse 2s ease-out;
border-width: 2px;
}
.dark .highlight-new {
animation: highlight-pulse-dark 2s ease-out;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="fixed top-20 right-6 z-50">
<!-- Botón para cerrar todos los toasts -->
<button
v-if="toasts.length > 0"
@click="clearAllToasts"
class="mb-3 ml-auto flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
title="Cerrar todas las notificaciones"
>
<XMarkIcon class="w-4 h-4" />
Cerrar todas ({{ toasts.length }})
</button>
<transition-group
name="toast"
tag="div"
class="space-y-3"
>
<ToastNotification
v-for="toast in toasts"
:key="toast.id"
:toast="toast"
@close="removeToast(toast.id)"
/>
</transition-group>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { XMarkIcon } from '@heroicons/vue/24/outline';
import ToastNotification from './ToastNotification.vue';
const toasts = ref([]);
let toastIdCounter = 0;
const toastTimeouts = new Map();
function addToast(article) {
const id = ++toastIdCounter;
toasts.value.push({
id,
...article,
});
// Limpiar timeout anterior si existe (por si acaso)
if (toastTimeouts.has(id)) {
clearTimeout(toastTimeouts.get(id));
}
// Auto-remover después de 5 segundos
const timeoutId = setTimeout(() => {
removeToast(id);
}, 5000);
toastTimeouts.set(id, timeoutId);
}
function removeToast(id) {
// Limpiar timeout si existe
if (toastTimeouts.has(id)) {
clearTimeout(toastTimeouts.get(id));
toastTimeouts.delete(id);
}
// Eliminar directamente - Vue transition-group manejará la animación
const index = toasts.value.findIndex(t => t.id === id);
if (index > -1) {
toasts.value.splice(index, 1);
}
}
function clearAllToasts() {
// Limpiar todos los timeouts
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
toastTimeouts.clear();
// Eliminar todos los toasts
toasts.value = [];
}
// Escuchar eventos de WebSocket para nuevos artículos
function handleWebSocketMessage(event) {
const data = event.detail;
// Manejar notificaciones de artículos nuevos
if (data.type === 'new_articles' && data.data) {
// Mostrar toasts para cada artículo nuevo
for (const article of data.data) {
addToast(article);
}
}
}
onMounted(() => {
window.addEventListener('ws-message', handleWebSocketMessage);
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWebSocketMessage);
// Limpiar todos los timeouts de toasts
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
toastTimeouts.clear();
});
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(100%);
}
.toast-leave-to {
opacity: 0;
transform: translateX(100%);
}
.toast-move {
transition: transform 0.3s ease;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 max-w-sm min-w-[320px] backdrop-blur-sm cursor-pointer hover:shadow-2xl hover:scale-[1.02] transition-all duration-200"
@click="goToDetail"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<img
v-if="toast.images"
:src="toast.images[0]"
:alt="toast.title"
class="w-12 h-12 object-cover rounded-lg"
@error="($event) => $event.target.style.display = 'none'"
/>
<div v-else class="w-12 h-12 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-lg flex items-center justify-center">
<span class="text-gray-400 text-lg">📦</span>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span
class="px-2 py-0.5 text-[10px] font-bold rounded-md uppercase tracking-wide"
:class="toast.platform === 'wallapop' ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300'"
>
{{ toast.platform?.toUpperCase() }}
</span>
</div>
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm mb-1 line-clamp-1 leading-tight">
{{ toast.title || 'Nuevo artículo' }}
</h4>
<p v-if="toast.price" class="text-base font-bold text-primary-600 dark:text-primary-400 mb-2">
{{ toast.price }} {{ toast.currency || '' }}
</p>
<a
v-if="toast.url"
:href="toast.url"
target="_blank"
rel="noopener noreferrer"
@click.stop
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline inline-flex items-center gap-1 font-medium"
>
Ver artículo
</a>
</div>
<button
@click.stop="$emit('close')"
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm leading-none p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors z-10"
title="Cerrar"
>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const props = defineProps({
toast: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const router = useRouter();
function goToDetail() {
if (props.toast.platform && props.toast.id) {
router.push(`/articles/${props.toast.platform}/${props.toast.id}`);
}
}
</script>

117
web/dashboard/src/main.js Normal file
View File

@@ -0,0 +1,117 @@
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import Dashboard from './views/Dashboard.vue';
import Articles from './views/Articles.vue';
import ArticleDetail from './views/ArticleDetail.vue';
import Favorites from './views/Favorites.vue';
import Workers from './views/Workers.vue';
import Users from './views/Users.vue';
import Settings from './views/Settings.vue';
import Subscription from './views/Subscription.vue';
import Logs from './views/Logs.vue';
import RateLimiter from './views/RateLimiter.vue';
import Sessions from './views/Sessions.vue';
import Login from './views/Login.vue';
import './style.css';
import authService from './services/auth';
const routes = [
{ path: '/login', component: Login, name: 'login' },
{ path: '/', component: Dashboard, meta: { requiresAuth: true } }, // Redirige a /dashboard
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
{ path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } },
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
{ path: '/workers', component: Workers, meta: { requiresAuth: true } },
{ path: '/subscription', component: Subscription, meta: { requiresAuth: true } },
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true } },
{ path: '/logs', component: Logs, meta: { requiresAuth: true } },
{ path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } },
{ path: '/sessions', component: Sessions, meta: { requiresAuth: true } },
];
const router = createRouter({
history: createWebHistory('/dashboard'),
routes,
});
// Guard de navegación para verificar autenticación
router.beforeEach(async (to, from, next) => {
// Si la ruta es /login y ya está autenticado, redirigir al dashboard
if (to.path === '/login') {
if (authService.hasCredentials()) {
const isValid = await authService.validateSession();
if (isValid) {
next('/');
return;
}
}
next();
return;
}
// Si accede a la raíz y está autenticado, redirigir al dashboard
if (to.path === '/') {
if (authService.hasCredentials()) {
const isValid = await authService.validateSession();
if (isValid) {
next();
return;
}
}
// Si no está autenticado, redirigir a login
next('/login');
return;
}
// Para todas las demás rutas, verificar autenticación
if (to.meta.requiresAuth) {
// Verificar si hay token almacenado
if (!authService.hasCredentials()) {
// No hay token, redirigir a login
next('/login');
return;
}
// Hay token, validar si sigue siendo válido
const isValid = await authService.validateSession();
if (!isValid) {
// Token inválido o expirado, redirigir a login
next('/login');
return;
}
// Verificar si requiere permisos de administrador
if (to.meta.requiresAdmin) {
const isAdmin = authService.isAdmin();
if (!isAdmin) {
// No es admin, redirigir a dashboard (raíz relativa)
next('/');
return;
}
}
}
// Continuar la navegación
next();
});
const app = createApp(App);
app.use(router);
app.mount('#app');
// Registrar Service Worker automáticamente al cargar la app
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker registrado:', registration.scope);
} catch (error) {
console.error('Error registrando Service Worker:', error);
}
});
}

View File

@@ -0,0 +1,197 @@
import axios from 'axios';
import authService from './auth';
// Usar variable de entorno si está disponible, sino usar '/api' (proxy en desarrollo)
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api';
console.log('baseURL', baseURL);
const api = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
// Interceptor para añadir autenticación a las peticiones
api.interceptors.request.use(
(config) => {
const authHeader = authService.getAuthHeader();
if (authHeader) {
config.headers.Authorization = authHeader;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor para manejar errores 401 (no autenticado)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Disparar evento personalizado para mostrar diálogo de login
window.dispatchEvent(new CustomEvent('auth-required', {
detail: {
message: 'Se requiere autenticación para esta acción',
config: error.config
}
}));
}
return Promise.reject(error);
}
);
export default {
// Estadísticas
async getStats() {
const response = await api.get('/stats');
return response.data;
},
// Workers
async getWorkers() {
const response = await api.get('/workers');
return response.data;
},
async updateWorkers(workers) {
const response = await api.put('/workers', workers);
return response.data;
},
// Favoritos
async getFavorites() {
const response = await api.get('/favorites');
return response.data;
},
async addFavorite(favorite) {
const response = await api.post('/favorites', favorite);
return response.data;
},
async removeFavorite(platform, id) {
const response = await api.delete(`/favorites/${platform}/${id}`);
return response.data;
},
// Artículos
async getArticles(limit = 100, offset = 0, additionalParams = {}) {
const params = { limit, offset, ...additionalParams };
const response = await api.get('/articles', { params });
return response.data;
},
async getArticleFacets() {
const response = await api.get('/articles/facets');
return response.data;
},
async searchArticles(query, mode = 'AND') {
const response = await api.get('/articles/search', {
params: { q: query, mode: mode },
});
return response.data;
},
async getArticle(platform, id) {
const response = await api.get(`/articles/${platform}/${id}`);
return response.data;
},
// Logs
async getLogs(limit = 500, sinceLine = null) {
const params = { limit };
if (sinceLine !== null && sinceLine > 0) {
params.since = sinceLine;
}
const response = await api.get('/logs', { params });
return response.data;
},
// Configuración
async getConfig() {
const response = await api.get('/config');
return response.data;
},
// Telegram
async getTelegramConfig() {
const response = await api.get('/telegram/config');
return response.data;
},
async setTelegramConfig(config) {
const response = await api.put('/telegram/config', config);
return response.data;
},
async getTelegramThreads() {
const response = await api.get('/telegram/threads');
return response.data;
},
// Usuarios
async getUsers() {
const response = await api.get('/users');
return response.data;
},
async createUser(userData) {
const response = await api.post('/users', userData);
return response.data;
},
async deleteUser(username) {
const response = await api.delete(`/users/${username}`);
return response.data;
},
async changePassword(passwordData) {
const response = await api.post('/users/change-password', passwordData);
return response.data;
},
// Admin - Rate Limiter
async getRateLimiterInfo() {
const response = await api.get('/admin/rate-limiter');
return response.data;
},
// Admin - Sessions
async getSessions() {
const response = await api.get('/admin/sessions');
return response.data;
},
async deleteSession(token) {
const response = await api.delete(`/admin/sessions/${token}`);
return response.data;
},
// Suscripciones
async getSubscriptionPlans() {
const response = await api.get('/subscription/plans');
return response.data;
},
async getMySubscription() {
const response = await api.get('/subscription/me');
return response.data;
},
async updateSubscription(subscriptionData) {
const response = await api.put('/subscription/me', subscriptionData);
return response.data;
},
// Admin: Actualizar suscripción de otro usuario
async updateUserSubscription(username, subscriptionData) {
const response = await api.put(`/subscription/${username}`, subscriptionData);
return response.data;
},
};

View File

@@ -0,0 +1,230 @@
// Servicio de autenticación para gestionar tokens
import { getDeviceFingerprint } from './fingerprint.js';
const AUTH_STORAGE_KEY = 'wallabicher_token';
const USERNAME_STORAGE_KEY = 'wallabicher_username';
const ROLE_STORAGE_KEY = 'wallabicher_role';
class AuthService {
constructor() {
this.token = this.loadToken();
this.username = this.loadUsername();
this.role = this.loadRole();
}
// Cargar token desde localStorage
loadToken() {
try {
return localStorage.getItem(AUTH_STORAGE_KEY) || '';
} catch (error) {
console.error('Error cargando token:', error);
return '';
}
}
// Cargar username desde localStorage
loadUsername() {
try {
return localStorage.getItem(USERNAME_STORAGE_KEY) || '';
} catch (error) {
console.error('Error cargando username:', error);
return '';
}
}
// Guardar token, username y role en localStorage
saveSession(token, username, role = 'user') {
try {
this.token = token;
this.username = username;
this.role = role;
localStorage.setItem(AUTH_STORAGE_KEY, token);
localStorage.setItem(USERNAME_STORAGE_KEY, username);
localStorage.setItem(ROLE_STORAGE_KEY, role);
return true;
} catch (error) {
console.error('Error guardando sesión:', error);
return false;
}
}
// Cargar role desde localStorage
loadRole() {
try {
return localStorage.getItem(ROLE_STORAGE_KEY) || 'user';
} catch (error) {
console.error('Error cargando role:', error);
return 'user';
}
}
// Eliminar token, username y role
clearSession() {
try {
this.token = '';
this.username = '';
this.role = 'user';
localStorage.removeItem(AUTH_STORAGE_KEY);
localStorage.removeItem(USERNAME_STORAGE_KEY);
localStorage.removeItem(ROLE_STORAGE_KEY);
return true;
} catch (error) {
console.error('Error eliminando sesión:', error);
return false;
}
}
// Obtener token actual
getToken() {
return this.token;
}
// Obtener username actual
getUsername() {
return this.username;
}
// Obtener role actual
getRole() {
return this.role;
}
// Verificar si es admin
isAdmin() {
return this.role === 'admin';
}
// Verificar si hay sesión activa (token guardado)
hasCredentials() {
return !!this.token;
}
// Generar header de autenticación Bearer
getAuthHeader() {
if (!this.token) {
return null;
}
return `Bearer ${this.token}`;
}
// Hacer login (llamar al endpoint de login)
async login(username, password) {
try {
// Obtener fingerprint del dispositivo
let fingerprintData = null;
try {
fingerprintData = await getDeviceFingerprint();
} catch (error) {
console.warn('Error obteniendo fingerprint, continuando sin él:', error);
}
const requestBody = {
username,
password,
};
// Agregar fingerprint si está disponible
if (fingerprintData) {
requestBody.fingerprint = fingerprintData.fingerprint;
requestBody.deviceInfo = fingerprintData.deviceInfo;
}
const response = await fetch('/api/users/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Error en login');
}
if (data.success && data.token) {
const role = data.role || 'user';
this.saveSession(data.token, data.username, role);
return { success: true, token: data.token, username: data.username, role };
}
throw new Error('Respuesta inválida del servidor');
} catch (error) {
console.error('Error en login:', error);
throw error;
}
}
// Hacer logout (llamar al endpoint de logout)
async logout() {
try {
const token = this.token;
if (token) {
try {
await fetch('/api/users/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
} catch (error) {
// Si falla el logout en el servidor, aún así limpiar localmente
console.error('Error al cerrar sesión en el servidor:', error);
}
}
this.clearSession();
return true;
} catch (error) {
console.error('Error en logout:', error);
this.clearSession(); // Limpiar localmente de todas formas
return false;
}
}
// Verificar si el token sigue siendo válido
async validateSession() {
if (!this.token) {
return false;
}
try {
const response = await fetch('/api/users/me', {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.token}`,
},
});
if (response.ok) {
const data = await response.json();
if (data.success && data.authenticated) {
// Actualizar role si está disponible
if (data.role) {
this.role = data.role;
localStorage.setItem(ROLE_STORAGE_KEY, data.role);
}
return true;
}
}
// Si el token es inválido, limpiar sesión
if (response.status === 401) {
this.clearSession();
}
return false;
} catch (error) {
console.error('Error validando sesión:', error);
return false;
}
}
}
// Exportar instancia singleton
const authService = new AuthService();
export default authService;

View File

@@ -0,0 +1,154 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs';
let fpPromise = null;
let cachedFingerprint = null;
let cachedDeviceInfo = null;
/**
* Inicializa FingerprintJS (solo una vez)
*/
function initFingerprintJS() {
if (!fpPromise) {
fpPromise = FingerprintJS.load();
}
return fpPromise;
}
/**
* Obtiene el fingerprint del dispositivo
* @returns {Promise<{fingerprint: string, deviceInfo: Object}>}
*/
export async function getDeviceFingerprint() {
// Si ya tenemos el fingerprint en caché, devolverlo
if (cachedFingerprint && cachedDeviceInfo) {
return {
fingerprint: cachedFingerprint,
deviceInfo: cachedDeviceInfo,
};
}
try {
const fp = await initFingerprintJS();
const result = await fp.get();
// Extraer información del dispositivo desde los componentes
const deviceInfo = extractDeviceInfo(result.components);
cachedFingerprint = result.visitorId;
cachedDeviceInfo = deviceInfo;
return {
fingerprint: result.visitorId,
deviceInfo: deviceInfo,
};
} catch (error) {
console.error('Error obteniendo fingerprint:', error);
// Fallback: generar un fingerprint básico
return {
fingerprint: generateFallbackFingerprint(),
deviceInfo: {
browser: navigator.userAgent.includes('Chrome') ? 'Chrome' :
navigator.userAgent.includes('Firefox') ? 'Firefox' :
navigator.userAgent.includes('Safari') ? 'Safari' : 'Unknown',
os: navigator.platform,
device: 'Unknown',
},
};
}
}
/**
* Extrae información legible del dispositivo desde los componentes de FingerprintJS
* @param {Object} components - Componentes de FingerprintJS
* @returns {Object} Información del dispositivo
*/
function extractDeviceInfo(components) {
const info = {
browser: 'Unknown',
browserVersion: '',
os: 'Unknown',
osVersion: '',
device: 'Unknown',
screenResolution: '',
timezone: '',
language: navigator.language || '',
};
// Información del navegador
if (components.browserName) {
info.browser = components.browserName.value || 'Unknown';
}
if (components.browserVersion) {
info.browserVersion = components.browserVersion.value || '';
}
// Información del sistema operativo
if (components.os) {
info.os = components.os.value || 'Unknown';
}
if (components.osVersion) {
info.osVersion = components.osVersion.value || '';
}
// Información del dispositivo
if (components.deviceMemory) {
info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile';
}
if (components.platform) {
const platform = components.platform.value?.toLowerCase() || '';
if (platform.includes('mobile') || platform.includes('android') || platform.includes('iphone')) {
info.device = 'Mobile';
} else if (platform.includes('tablet') || platform.includes('ipad')) {
info.device = 'Tablet';
} else {
info.device = 'Desktop';
}
}
// Resolución de pantalla
if (components.screenResolution) {
const res = components.screenResolution.value;
if (res && res.length >= 2) {
info.screenResolution = `${res[0]}x${res[1]}`;
}
}
// Zona horaria
if (components.timezone) {
info.timezone = components.timezone.value || '';
}
return info;
}
/**
* Genera un fingerprint básico como fallback
* @returns {string} Hash del fingerprint
*/
function generateFallbackFingerprint() {
const data = [
navigator.userAgent,
navigator.language,
navigator.platform,
screen.width + 'x' + screen.height,
new Date().getTimezoneOffset(),
].join('|');
// Simple hash (no usar en producción, solo como fallback)
let hash = 0;
for (let i = 0; i < data.length; i++) {
const char = data.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash).toString(36);
}
/**
* Limpia el caché del fingerprint (útil para testing)
*/
export function clearFingerprintCache() {
cachedFingerprint = null;
cachedDeviceInfo = null;
}

View File

@@ -0,0 +1,195 @@
// Servicio para manejar notificaciones push
class PushNotificationService {
constructor() {
this.registration = null;
this.subscription = null;
}
// Registrar Service Worker
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
this.registration = registration;
console.log('Service Worker registrado:', registration.scope);
return registration;
} catch (error) {
console.error('Error registrando Service Worker:', error);
return null;
}
} else {
console.warn('Service Workers no están soportados en este navegador');
return null;
}
}
// Solicitar permisos de notificación
async requestPermission() {
if (!('Notification' in window)) {
console.warn('Este navegador no soporta notificaciones');
return 'denied';
}
if (Notification.permission === 'granted') {
return 'granted';
}
if (Notification.permission === 'denied') {
return 'denied';
}
try {
const permission = await Notification.requestPermission();
return permission;
} catch (error) {
console.error('Error solicitando permiso:', error);
return 'denied';
}
}
// Suscribirse a notificaciones push
async subscribe() {
if (!this.registration) {
await this.registerServiceWorker();
}
if (!this.registration) {
throw new Error('No se pudo registrar el Service Worker');
}
try {
// Verificar si ya existe una suscripción
this.subscription = await this.registration.pushManager.getSubscription();
if (this.subscription) {
console.log('Ya existe una suscripción push');
return this.subscription;
}
// Obtener la clave pública del servidor
const response = await fetch('/api/push/public-key');
const { publicKey } = await response.json();
// Convertir la clave pública a formato ArrayBuffer
const applicationServerKey = this.urlBase64ToUint8Array(publicKey);
// Crear nueva suscripción
this.subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
console.log('Suscripción push creada:', this.subscription);
// Enviar la suscripción al servidor
await this.sendSubscriptionToServer(this.subscription);
return this.subscription;
} catch (error) {
console.error('Error suscribiéndose a push:', error);
throw error;
}
}
// Enviar suscripción al servidor
async sendSubscriptionToServer(subscription) {
try {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
});
if (!response.ok) {
throw new Error('Error enviando suscripción al servidor');
}
console.log('Suscripción enviada al servidor');
return await response.json();
} catch (error) {
console.error('Error enviando suscripción:', error);
throw error;
}
}
// Cancelar suscripción
async unsubscribe() {
if (this.subscription) {
try {
await this.subscription.unsubscribe();
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.subscription),
});
this.subscription = null;
console.log('Suscripción cancelada');
return true;
} catch (error) {
console.error('Error cancelando suscripción:', error);
return false;
}
}
return false;
}
// Verificar estado de suscripción
async checkSubscription() {
if (!this.registration) {
await this.registerServiceWorker();
}
if (!this.registration) {
return false;
}
try {
this.subscription = await this.registration.pushManager.getSubscription();
return !!this.subscription;
} catch (error) {
console.error('Error verificando suscripción:', error);
return false;
}
}
// Convertir clave pública de base64 URL a Uint8Array
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Inicializar todo el proceso
async init() {
const permission = await this.requestPermission();
if (permission === 'granted') {
await this.registerServiceWorker();
try {
await this.subscribe();
return true;
} catch (error) {
console.error('Error inicializando notificaciones push:', error);
return false;
}
}
return false;
}
}
// Exportar instancia singleton
export default new PushNotificationService();

228
web/dashboard/src/style.css Normal file
View File

@@ -0,0 +1,228 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-gray-200 dark:border-gray-700;
}
body {
@apply bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Scrollbar styling */
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-800;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
}
@layer components {
/* Metronic-style Cards */
.card {
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm dark:shadow-lg p-6 border border-gray-200 dark:border-gray-700 transition-shadow duration-200;
}
.card:hover {
@apply shadow-md dark:shadow-xl;
}
.card-header {
@apply border-b border-gray-200 dark:border-gray-700 pb-4 mb-4;
}
.card-title {
@apply text-lg font-bold text-gray-900 dark:text-gray-100;
}
.card-subtitle {
@apply text-sm text-gray-500 dark:text-gray-400 mt-1;
}
/* Buttons */
.btn {
@apply px-4 py-2.5 rounded-lg font-semibold text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600 focus:ring-primary-500 shadow-sm hover:shadow-md;
}
.btn-secondary {
@apply bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 focus:ring-gray-500;
}
.btn-danger {
@apply bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800 focus:ring-red-500 shadow-sm hover:shadow-md;
}
.btn-success {
@apply bg-green-600 dark:bg-green-700 text-white hover:bg-green-700 dark:hover:bg-green-800 focus:ring-green-500 shadow-sm hover:shadow-md;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
.btn-lg {
@apply px-6 py-3 text-base;
}
/* Inputs */
.input {
@apply w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 transition-all duration-200;
}
.input:focus {
@apply shadow-sm;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold;
}
.badge-primary {
@apply bg-primary-100 dark:bg-primary-900/50 text-primary-700 dark:text-primary-300;
}
.badge-success {
@apply bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300;
}
.badge-danger {
@apply bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300;
}
.badge-warning {
@apply bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300;
}
.badge-info {
@apply bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300;
}
/* Stats Cards */
.stat-card {
@apply card relative overflow-hidden;
}
.stat-card::before {
content: '';
@apply absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-primary-500/10 to-primary-600/10 rounded-full -mr-10 -mt-10;
}
/* Tables */
.table {
@apply w-full border-collapse;
}
.table thead {
@apply bg-gray-50 dark:bg-gray-800;
}
.table th {
@apply px-6 py-3 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700;
}
.table td {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700;
}
.table tbody tr:hover {
@apply bg-gray-50 dark:bg-gray-800/50;
}
/* Line clamp utility */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Sidebar transitions */
.sidebar-collapsed {
transition: margin-left 0.3s ease;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
/* Toast transitions */
.toast-enter-active {
animation: slide-in 0.3s ease-out;
}
.toast-leave-active {
animation: slide-out 0.3s ease-in;
}
.toast-move {
transition: transform 0.3s ease;
}

View File

@@ -0,0 +1,476 @@
<template>
<div class="max-w-7xl mx-auto">
<!-- Botón de volver -->
<div class="mb-3">
<button
@click="$router.back()"
class="btn btn-secondary flex items-center gap-2 text-sm"
>
<ArrowLeftIcon class="w-4 h-4" />
Volver
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="card text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando artículo...</p>
</div>
<!-- Error -->
<div v-else-if="error" class="card text-center py-12">
<ExclamationTriangleIcon class="w-12 h-12 text-red-500 mx-auto mb-4" />
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{{ error }}</p>
<button @click="$router.back()" class="btn btn-secondary mt-4">Volver</button>
</div>
<!-- Contenido del artículo -->
<div v-else-if="article" class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Columna izquierda: Carousel de imágenes -->
<div class="lg:col-span-2">
<div class="card p-0 overflow-hidden">
<!-- Carousel principal -->
<div v-if="article.images && article.images.length > 0" class="relative">
<!-- Imagen principal -->
<div class="relative aspect-[4/3] bg-gray-100 dark:bg-gray-900 overflow-hidden">
<img
:src="article.images[currentImageIndex]"
:alt="`Imagen ${currentImageIndex + 1} de ${article.title}`"
class="w-full h-full object-contain transition-opacity duration-300"
@error="handleImageError"
/>
<!-- Botones de navegación -->
<button
v-if="article.images.length > 1"
@click="previousImage"
class="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
:disabled="article.images.length <= 1"
>
<ChevronLeftIcon class="w-6 h-6" />
</button>
<button
v-if="article.images.length > 1"
@click="nextImage"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
:disabled="article.images.length <= 1"
>
<ChevronRightIcon class="w-6 h-6" />
</button>
<!-- Indicador de posición -->
<div
v-if="article.images.length > 1"
class="absolute bottom-3 left-1/2 -translate-x-1/2 bg-black/50 text-white px-3 py-1 rounded-full text-xs font-medium z-10"
>
{{ currentImageIndex + 1 }} / {{ article.images.length }}
</div>
<!-- Botón para ampliar -->
<button
@click="openImageModal(article.images[currentImageIndex])"
class="absolute top-3 right-3 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
>
<MagnifyingGlassPlusIcon class="w-5 h-5" />
</button>
</div>
<!-- Miniaturas -->
<div
v-if="article.images.length > 1"
class="p-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700"
>
<div class="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
<button
v-for="(image, index) in article.images"
:key="index"
@click="currentImageIndex = index"
class="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
:class="
currentImageIndex === index
? 'border-primary-600 dark:border-primary-400 ring-2 ring-primary-200 dark:ring-primary-800'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
"
>
<img
:src="image"
:alt="`Miniatura ${index + 1}`"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</button>
</div>
</div>
</div>
<div v-else class="aspect-[4/3] bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span class="text-gray-400 dark:text-gray-500">Sin imágenes disponibles</span>
</div>
</div>
</div>
<!-- Columna derecha: Información del artículo -->
<div class="lg:col-span-1 space-y-4">
<!-- Header compacto -->
<div class="card">
<div class="flex flex-wrap items-center gap-2 mb-3">
<span
class="px-2 py-1 text-xs font-semibold rounded"
:class="
article.platform === 'wallapop'
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
: 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
"
>
{{ article.platform?.toUpperCase() || 'N/A' }}
</span>
<span v-if="article.username" class="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300">
👤 {{ article.username }}
</span>
<span v-if="article.worker_name" class="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300">
{{ article.worker_name }}
</span>
</div>
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-3 line-clamp-2">
{{ article.title || 'Sin título' }}
</h1>
<div v-if="article.price !== null && article.price !== undefined" class="mb-4">
<span class="text-3xl font-bold text-primary-600 dark:text-primary-400">
{{ article.price }} {{ article.currency || '€' }}
</span>
</div>
<div class="flex flex-col gap-2">
<a
v-if="article.url"
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="btn btn-primary flex items-center justify-center gap-2 text-sm"
>
<ArrowTopRightOnSquareIcon class="w-4 h-4" />
Ver anuncio original
</a>
<button
v-if="isAuthenticated && !isAdding"
@click="handleAddFavorite"
class="btn flex items-center justify-center gap-2 text-sm"
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
:disabled="favoriteStatus"
>
<HeartIconSolid v-if="favoriteStatus" class="w-4 h-4" />
<HeartIcon v-else class="w-4 h-4" />
{{ favoriteStatus ? 'En favoritos' : 'Añadir a favoritos' }}
</button>
<button
v-if="isAuthenticated && isAdding"
disabled
class="btn btn-secondary opacity-50 cursor-not-allowed text-sm"
>
<span class="inline-block animate-spin mr-1"></span>
Añadiendo...
</button>
</div>
</div>
<!-- Información compacta -->
<div class="card">
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Información</h2>
<div class="space-y-3">
<div v-if="article.location" class="flex items-start gap-2">
<MapPinIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Localidad</p>
<p class="text-sm text-gray-900 dark:text-gray-100 truncate">{{ article.location }}</p>
</div>
</div>
<div v-if="article.allows_shipping !== null && article.allows_shipping !== undefined" class="flex items-start gap-2">
<TruckIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Envío</p>
<p class="text-sm text-gray-900 dark:text-gray-100">
{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}
</p>
</div>
</div>
<div v-if="article.modified_at" class="flex items-start gap-2">
<ClockIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Modificado</p>
<p class="text-xs text-gray-900 dark:text-gray-100">{{ formatDateShort(article.modified_at) }}</p>
</div>
</div>
<div v-if="article.notifiedAt" class="flex items-start gap-2">
<BellIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div>
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Notificado</p>
<p class="text-xs text-gray-900 dark:text-gray-100">{{ formatDateShort(article.notifiedAt) }}</p>
</div>
</div>
<div class="flex items-start gap-2">
<HashtagIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">ID</p>
<p class="text-xs text-gray-900 dark:text-gray-100 font-mono truncate">{{ article.id }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Descripción (debajo del grid principal) -->
<div v-if="article && article.description" class="mt-4">
<div class="card">
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Descripción</h2>
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-h-48 overflow-y-auto">
{{ article.description }}
</p>
</div>
</div>
<!-- Modal de imagen -->
<div
v-if="selectedImage"
@click="selectedImage = null"
class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4 cursor-pointer"
>
<div class="relative max-w-6xl max-h-full w-full" @click.stop>
<button
@click="selectedImage = null"
class="absolute -top-12 right-0 text-white hover:text-gray-300 transition-colors z-10"
>
<XMarkIcon class="w-8 h-8" />
</button>
<img
:src="selectedImage"
alt="Imagen ampliada"
class="max-w-full max-h-[90vh] object-contain rounded-lg mx-auto"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import api from '../services/api';
import authService from '../services/auth';
import {
ArrowLeftIcon,
ArrowTopRightOnSquareIcon,
HeartIcon,
MagnifyingGlassPlusIcon,
MapPinIcon,
TruckIcon,
ClockIcon,
BellIcon,
HashtagIcon,
XMarkIcon,
ExclamationTriangleIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/vue/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
const route = useRoute();
const router = useRouter();
const article = ref(null);
const loading = ref(true);
const error = ref(null);
const selectedImage = ref(null);
const currentImageIndex = ref(0);
const isAuthenticated = ref(false);
const favoriteStatus = ref(false);
const isAdding = ref(false);
function formatDateShort(timestamp) {
if (!timestamp) return 'N/A';
const date = new Date(timestamp);
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatDate(timestamp) {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function handleImageError(event) {
event.target.onerror = null;
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
}
function openImageModal(image) {
selectedImage.value = image;
}
function nextImage() {
if (article.value?.images && article.value.images.length > 0) {
currentImageIndex.value = (currentImageIndex.value + 1) % article.value.images.length;
}
}
function previousImage() {
if (article.value?.images && article.value.images.length > 0) {
currentImageIndex.value = currentImageIndex.value === 0
? article.value.images.length - 1
: currentImageIndex.value - 1;
}
}
function handleKeydown(event) {
if (!article.value?.images || article.value.images.length <= 1) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
previousImage();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
nextImage();
} else if (event.key === 'Escape' && selectedImage.value) {
selectedImage.value = null;
}
}
function checkAuth() {
isAuthenticated.value = authService.hasCredentials();
}
async function handleAddFavorite() {
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value || !article.value) {
return;
}
isAdding.value = true;
try {
const favorite = {
platform: article.value.platform,
id: String(article.value.id),
};
await api.addFavorite(favorite);
favoriteStatus.value = true;
} catch (error) {
console.error('Error añadiendo a favoritos:', error);
if (error.response?.status === 404) {
alert('El artículo no se encontró en la base de datos.');
} else if (error.response?.status === 400) {
alert('Error: ' + (error.response?.data?.error || 'Datos inválidos'));
} else if (error.response?.status !== 401) {
const errorMessage = error.response?.data?.error || error.message || 'Error desconocido';
alert('Error al añadir a favoritos: ' + errorMessage);
}
} finally {
isAdding.value = false;
}
}
function handleAuthChange() {
checkAuth();
// Actualizar favoriteStatus basado en el artículo (que viene del backend)
if (article.value) {
favoriteStatus.value = article.value.is_favorite ?? false;
} else {
favoriteStatus.value = false;
}
}
async function loadArticle() {
loading.value = true;
error.value = null;
try {
const platform = route.params.platform;
const id = route.params.id;
if (!platform || !id) {
error.value = 'Parámetros inválidos';
loading.value = false;
return;
}
article.value = await api.getArticle(platform, id);
if (!article.value) {
error.value = 'Artículo no encontrado';
} else {
currentImageIndex.value = 0;
checkAuth();
// Actualizar favoriteStatus desde el artículo (que viene del backend con is_favorite)
favoriteStatus.value = article.value.is_favorite ?? false;
}
} catch (err) {
console.error('Error cargando artículo:', err);
if (err.response?.status === 404) {
error.value = 'Artículo no encontrado';
} else if (err.response?.status === 403) {
error.value = 'No tienes permiso para ver este artículo';
} else {
error.value = 'Error al cargar el artículo: ' + (err.message || 'Error desconocido');
}
} finally {
loading.value = false;
}
}
onMounted(() => {
loadArticle();
checkAuth();
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
document.removeEventListener('keydown', handleKeydown);
});
</script>
<style scoped>
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4;
}
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 inline-flex items-center justify-center;
}
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white border border-primary-600;
}
.btn-secondary {
@apply bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,545 @@
<template>
<div>
<div class="mb-4 sm:mb-6">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Artículos Notificados</h1>
<p v-if="currentUser" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<span v-if="isAdmin">Todos los artículos</span>
<span v-else>Tus artículos</span>
<span class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
</p>
</div>
</div>
<!-- Búsqueda y Filtros -->
<div class="card p-4 mb-4">
<!-- Campo de búsqueda -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<MagnifyingGlassIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
Búsqueda
</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input
v-model="searchQuery"
type="text"
placeholder="Buscar artículos por título, descripción, precio, localidad..."
class="input pr-10 pl-10"
@input="searchQuery = $event.target.value"
/>
<MagnifyingGlassIcon class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<span v-if="searching" class="absolute right-3 top-1/2 transform -translate-y-1/2">
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
</span>
<button
v-else-if="searchQuery"
@click="searchQuery = ''"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title="Limpiar búsqueda"
>
<XMarkIcon class="w-5 h-5" />
</button>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Modo:</label>
<select
v-model="searchMode"
class="input text-sm w-24"
title="AND: todas las palabras deben estar presentes | OR: al menos una palabra debe estar presente"
>
<option value="AND">AND</option>
<option value="OR">OR</option>
</select>
</div>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span v-if="searchMode === 'AND'">Todas las palabras deben estar presentes</span>
<span v-else>Al menos una palabra debe estar presente</span>
</p>
</div>
<!-- Separador -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mb-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<FunnelIcon class="w-5 h-5 text-primary-600" />
Filtros
<span v-if="hasActiveFilters" class="ml-2 px-2 py-0.5 bg-primary-600 text-white rounded-full text-xs font-semibold">
{{ activeFiltersCount }}
</span>
</h2>
<button
v-if="hasActiveFilters"
@click="clearAllFilters"
class="btn btn-secondary text-sm"
>
<XMarkIcon class="w-4 h-4 mr-1" />
Limpiar todo
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div class="flex flex-col">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<ServerIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
Plataforma
</label>
<select
v-model="selectedPlatform"
@change="loadArticles"
class="input text-sm w-full"
>
<option value="">Todas las plataformas</option>
<option v-for="platform in facets.platforms" :key="platform" :value="platform">
{{ platform === 'wallapop' ? 'Wallapop' : platform === 'vinted' ? 'Vinted' : platform }}
</option>
</select>
</div>
<div v-if="isAdmin" class="flex flex-col">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<UserIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
Usuario
</label>
<select
v-model="selectedUsername"
@change="loadArticles"
class="input text-sm w-full"
>
<option value="">Todos los usuarios</option>
<option v-for="username in availableUsernames" :key="username" :value="username">
{{ username }}
</option>
</select>
</div>
<div class="flex flex-col">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<BriefcaseIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
Worker
</label>
<select
v-model="selectedWorker"
@change="loadArticles"
class="input text-sm w-full"
>
<option value="">Todos los workers</option>
<option v-for="worker in availableWorkers" :key="worker" :value="worker">
{{ worker }}
</option>
</select>
</div>
</div>
<div class="flex items-center gap-2 mt-4">
<button @click="loadArticles" class="btn btn-primary whitespace-nowrap">
<ArrowPathIcon class="w-4 h-4 mr-1" />
Actualizar
</button>
</div>
</div>
</div>
<!-- Filtros activos (chips) -->
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 mt-3 mb-4">
<span
v-if="selectedPlatform"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
>
<ServerIcon class="w-3.5 h-3.5" />
{{ selectedPlatform === 'wallapop' ? 'Wallapop' : 'Vinted' }}
<button
@click="selectedPlatform = ''; loadArticles()"
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
title="Eliminar filtro"
>
<XMarkIcon class="w-3.5 h-3.5" />
</button>
</span>
<span
v-if="selectedUsername"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
>
<UserIcon class="w-3.5 h-3.5" />
{{ selectedUsername }}
<button
@click="selectedUsername = ''; loadArticles()"
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
title="Eliminar filtro"
>
<XMarkIcon class="w-3.5 h-3.5" />
</button>
</span>
<span
v-if="selectedWorker"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
>
<BriefcaseIcon class="w-3.5 h-3.5" />
{{ selectedWorker }}
<button
@click="selectedWorker = ''; loadArticles()"
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
title="Eliminar filtro"
>
<XMarkIcon class="w-3.5 h-3.5" />
</button>
</span>
</div>
</div>
<div v-if="loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando artículos...</p>
</div>
<div v-else-if="filteredArticles.length === 0 && !searchQuery" class="card text-center py-12">
<p class="text-gray-600 dark:text-gray-400">No hay artículos para mostrar</p>
</div>
<div v-else-if="searching" class="card text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Buscando artículos...</p>
</div>
<div v-else-if="filteredArticles.length === 0 && searchQuery && !searching" class="card text-center py-12">
<p class="text-gray-600 dark:text-gray-400">No se encontraron artículos que coincidan con "{{ searchQuery }}"</p>
<button @click="searchQuery = ''" class="btn btn-secondary mt-4">Limpiar búsqueda</button>
</div>
<div v-else class="space-y-4">
<ArticleCard
v-for="article in filteredArticles"
:key="`${article.platform}-${article.id}`"
:article="article"
:is-new="newArticleIds.has(`${article.platform}-${article.id}`)"
/>
<div v-if="!searchQuery" class="flex justify-center space-x-2 mt-6">
<button
@click="loadMore"
:disabled="allArticles.length >= total"
class="btn btn-secondary"
:class="{ 'opacity-50 cursor-not-allowed': allArticles.length >= total }"
>
Cargar más
</button>
</div>
<p class="text-center text-xs sm:text-sm text-gray-500 mt-4 px-2">
<span v-if="searchQuery">
Mostrando {{ filteredArticles.length }} resultados de búsqueda
<span class="block sm:inline sm:ml-2 text-xs text-primary-600">(de {{ total }} artículos totales)</span>
</span>
<span v-else>
Mostrando {{ filteredArticles.length }} de {{ total }} artículos
<span class="block sm:inline sm:ml-2 text-xs text-gray-400">(Actualización automática cada 30s)</span>
</span>
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import ArticleCard from '../components/ArticleCard.vue';
import {
FunnelIcon,
XMarkIcon,
ServerIcon,
UserIcon,
BriefcaseIcon,
ArrowPathIcon,
MagnifyingGlassIcon
} from '@heroicons/vue/24/outline';
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const allArticles = ref([]);
const searchResults = ref([]);
const loading = ref(true);
const searching = ref(false);
const total = ref(0);
const offset = ref(0);
const limit = 50;
const selectedPlatform = ref('');
const selectedUsername = ref('');
const selectedWorker = ref('');
const searchQuery = ref('');
const searchMode = ref('AND'); // 'AND' o 'OR'
const autoPollInterval = ref(null);
const searchTimeout = ref(null);
const POLL_INTERVAL = 30000; // 30 segundos
const SEARCH_DEBOUNCE = 500; // 500ms de debounce para búsqueda
// Rastreo de artículos nuevos para efectos visuales
const existingArticleIds = ref(new Set());
const newArticleIds = ref(new Set());
// Facets obtenidos del backend
const facets = ref({
platforms: [],
usernames: [],
workers: []
});
// Usar facets del backend en lugar de calcular desde artículos cargados
const availableUsernames = computed(() => {
return facets.value.usernames || [];
});
const availableWorkers = computed(() => {
return facets.value.workers || [];
});
// Artículos que se muestran (búsqueda o lista normal)
const filteredArticles = computed(() => {
if (searchQuery.value.trim()) {
return searchResults.value;
}
return allArticles.value;
});
// Computed para filtros activos
const hasActiveFilters = computed(() => {
return !!(selectedPlatform.value || selectedUsername.value || selectedWorker.value);
});
const activeFiltersCount = computed(() => {
let count = 0;
if (selectedPlatform.value) count++;
if (selectedUsername.value) count++;
if (selectedWorker.value) count++;
return count;
});
function clearAllFilters() {
selectedPlatform.value = '';
selectedUsername.value = '';
selectedWorker.value = '';
loadArticles();
}
async function loadFacets() {
try {
const data = await api.getArticleFacets();
facets.value = {
platforms: data.platforms || [],
usernames: data.usernames || [],
workers: data.workers || []
};
} catch (error) {
console.error('Error cargando facets:', error);
}
}
async function loadArticles(reset = true, silent = false) {
// Guardar IDs existentes antes de cargar (para detectar artículos nuevos en polling)
let previousIds = new Set();
if (silent && reset) {
// Para polling automático, guardar los IDs de los artículos actuales antes de resetear
allArticles.value.forEach(article => {
previousIds.add(`${article.platform}-${article.id}`);
});
} else if (!reset) {
// Para cargar más, usar los IDs ya existentes
previousIds = new Set(existingArticleIds.value);
}
if (reset) {
offset.value = 0;
if (!silent) {
// Solo limpiar si no es polling silencioso
newArticleIds.value.clear();
}
}
if (!silent) {
loading.value = true;
}
try {
// Construir query params para filtros
const params = { limit, offset: offset.value };
if (selectedPlatform.value) params.platform = selectedPlatform.value;
// Solo permitir filtrar por username si es admin
if (selectedUsername.value && isAdmin.value) {
params.username = selectedUsername.value;
}
if (selectedWorker.value) params.worker_name = selectedWorker.value;
const data = await api.getArticles(limit, offset.value, params);
let filtered = data.articles;
// El filtro de plataforma se aplica en el backend ahora, pero mantenemos compatibilidad
if (selectedPlatform.value) {
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
}
// Si es polling silencioso, detectar artículos nuevos comparando con los anteriores
if (silent && reset && previousIds.size > 0) {
newArticleIds.value.clear(); // Limpiar IDs anteriores
filtered.forEach(article => {
const articleId = `${article.platform}-${article.id}`;
if (!previousIds.has(articleId)) {
newArticleIds.value.add(articleId);
}
});
// Limpiar IDs de artículos nuevos después de 5 segundos
if (newArticleIds.value.size > 0) {
setTimeout(() => {
newArticleIds.value.clear();
}, 5000);
}
}
// Actualizar el Set de IDs existentes
if (reset) {
existingArticleIds.value.clear();
}
filtered.forEach(article => {
existingArticleIds.value.add(`${article.platform}-${article.id}`);
});
if (reset) {
allArticles.value = filtered;
offset.value = limit;
} else {
allArticles.value.push(...filtered);
offset.value += limit;
}
total.value = data.total;
} catch (error) {
console.error('Error cargando artículos:', error);
} finally {
if (!silent) {
loading.value = false;
}
}
}
function handleAuthChange() {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
// Si no es admin, no permitir filtrar por username
if (!isAdmin.value && selectedUsername.value) {
selectedUsername.value = '';
}
if (currentUser.value) {
loadFacets(); // Recargar facets cuando cambie el usuario
loadArticles();
}
}
function loadMore() {
loadArticles(false);
}
function handleWSMessage(event) {
const data = event.detail;
if (data.type === 'articles_updated') {
loadFacets(); // Actualizar facets cuando se actualicen los artículos
loadArticles();
}
}
async function searchArticles(query) {
if (!query.trim()) {
searchResults.value = [];
searching.value = false;
return;
}
searching.value = true;
try {
const data = await api.searchArticles(query, searchMode.value);
let filtered = data.articles || [];
// Aplicar filtros si están seleccionados
if (selectedPlatform.value) {
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
}
if (selectedUsername.value) {
filtered = filtered.filter(a => a.username === selectedUsername.value);
}
if (selectedWorker.value) {
filtered = filtered.filter(a => a.worker_name === selectedWorker.value);
}
searchResults.value = filtered;
} catch (error) {
console.error('Error buscando artículos:', error);
searchResults.value = [];
} finally {
searching.value = false;
}
}
// Watch para buscar cuando cambie searchQuery o searchMode (con debounce)
watch([searchQuery, searchMode], ([newQuery]) => {
// Limpiar timeout anterior
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
// Si está vacío, limpiar resultados
if (!newQuery.trim()) {
searchResults.value = [];
return;
}
// Añadir debounce antes de buscar
searchTimeout.value = setTimeout(() => {
searchArticles(newQuery);
}, SEARCH_DEBOUNCE);
});
onMounted(() => {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
// Si no es admin, no permitir filtrar por username
if (!isAdmin.value && selectedUsername.value) {
selectedUsername.value = '';
}
loadFacets(); // Cargar facets primero
loadArticles();
window.addEventListener('ws-message', handleWSMessage);
window.addEventListener('auth-logout', handleAuthChange);
window.addEventListener('auth-login', handleAuthChange);
// Iniciar autopoll para actualizar automáticamente
autoPollInterval.value = setInterval(() => {
loadArticles(true, true); // Reset silencioso cada 30 segundos
loadFacets(); // Actualizar facets también
}, POLL_INTERVAL);
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWSMessage);
window.removeEventListener('auth-logout', handleAuthChange);
window.removeEventListener('auth-login', handleAuthChange);
// Limpiar el intervalo cuando el componente se desmonte
if (autoPollInterval.value) {
clearInterval(autoPollInterval.value);
autoPollInterval.value = null;
}
// Limpiar el timeout de búsqueda
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
searchTimeout.value = null;
}
});
</script>

View File

@@ -0,0 +1,303 @@
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<span v-if="isAdmin">Resumen general del sistema (estadísticas de todos los usuarios)</span>
<span v-else>Tu resumen personal</span>
<span v-if="currentUser" class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
</p>
</div>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Workers Card -->
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg">
<Cog6ToothIcon class="w-7 h-7 text-white" />
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Workers Activos</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ stats.activeWorkers }}<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ stats.totalWorkers }}</span>
</p>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">Estado del sistema</span>
<span :class="stats.activeWorkers > 0 ? 'badge badge-success' : 'badge badge-danger'">
{{ stats.activeWorkers > 0 ? 'Activo' : 'Inactivo' }}
</span>
</div>
</div>
</div>
<!-- Favorites Card -->
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg">
<HeartIcon class="w-7 h-7 text-white" />
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Favoritos</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalFavorites }}</p>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<router-link to="/favorites" class="text-xs text-primary-600 dark:text-primary-400 hover:underline font-medium flex items-center">
Ver todos
</router-link>
</div>
</div>
<!-- Articles Card -->
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
<DocumentTextIcon class="w-7 h-7 text-white" />
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Artículos Notificados</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalNotified }}</p>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<router-link to="/articles" class="text-xs text-primary-600 dark:text-primary-400 hover:underline font-medium flex items-center">
Ver todos
</router-link>
</div>
</div>
<!-- Platforms Card -->
<div class="stat-card">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
<ChartBarIcon class="w-7 h-7 text-white" />
</div>
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Plataformas</p>
<div class="flex items-center space-x-3">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
<span class="text-blue-600 dark:text-blue-400">W:</span> {{ stats.platforms?.wallapop || 0 }}
</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
<span class="text-green-600 dark:text-green-400">V:</span> {{ stats.platforms?.vinted || 0 }}
</span>
</div>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400">Total de plataformas activas</span>
</div>
</div>
</div>
<!-- Charts and Quick Actions -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Platform Distribution -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Distribución por Plataforma</h3>
<p class="card-subtitle">Artículos notificados por plataforma</p>
</div>
<div class="space-y-6">
<!-- Wallapop -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-2">
<span class="w-3 h-3 bg-primary-600 rounded-full"></span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Wallapop</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ stats.platforms?.wallapop || 0 }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
({{ getPercentage(stats.platforms?.wallapop || 0, stats.totalNotified) }}%)
</span>
</div>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
class="bg-gradient-to-r from-primary-500 to-primary-600 h-3 rounded-full transition-all duration-500 shadow-sm"
:style="{
width: `${getPercentage(stats.platforms?.wallapop || 0, stats.totalNotified)}%`,
}"
></div>
</div>
</div>
<!-- Vinted -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center space-x-2">
<span class="w-3 h-3 bg-green-600 rounded-full"></span>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Vinted</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ stats.platforms?.vinted || 0 }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
({{ getPercentage(stats.platforms?.vinted || 0, stats.totalNotified) }}%)
</span>
</div>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
class="bg-gradient-to-r from-green-500 to-green-600 h-3 rounded-full transition-all duration-500 shadow-sm"
:style="{
width: `${getPercentage(stats.platforms?.vinted || 0, stats.totalNotified)}%`,
}"
></div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Accesos Rápidos</h3>
<p class="card-subtitle">Navegación rápida a secciones principales</p>
</div>
<div class="space-y-3">
<router-link
to="/articles"
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center group-hover:bg-blue-200 dark:group-hover:bg-blue-900/70 transition-colors">
<DocumentTextIcon class="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Artículos</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Ver todos los artículos notificados</p>
</div>
</div>
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
</router-link>
<router-link
to="/favorites"
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center group-hover:bg-green-200 dark:group-hover:bg-green-900/70 transition-colors">
<HeartIcon class="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Favoritos</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Gestionar artículos favoritos</p>
</div>
</div>
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
</router-link>
<router-link
to="/workers"
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-primary-100 dark:bg-primary-900/50 rounded-lg flex items-center justify-center group-hover:bg-primary-200 dark:group-hover:bg-primary-900/70 transition-colors">
<Cog6ToothIcon class="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Workers</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Configurar y gestionar workers</p>
</div>
</div>
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import {
Cog6ToothIcon,
HeartIcon,
DocumentTextIcon,
ChartBarIcon,
ArrowRightIcon,
} from '@heroicons/vue/24/outline';
const stats = ref({
totalWorkers: 0,
activeWorkers: 0,
totalFavorites: 0,
totalNotified: 0,
platforms: {},
});
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
function getPercentage(value, total) {
if (!total || total === 0) return 0;
return Math.round((value / total) * 100);
}
async function loadStats() {
try {
stats.value = await api.getStats();
// Verificar si el usuario es admin (se puede inferir de si ve todas las estadísticas)
// O podemos añadir un endpoint para verificar el rol
} catch (error) {
console.error('Error cargando estadísticas:', error);
}
}
function handleAuthChange() {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
if (currentUser.value) {
loadStats();
}
}
function handleWSMessage(event) {
const data = event.detail;
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
loadStats();
}
}
let interval = null;
onMounted(() => {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
loadStats();
window.addEventListener('ws-message', handleWSMessage);
window.addEventListener('auth-logout', handleAuthChange);
window.addEventListener('auth-login', handleAuthChange);
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
window.removeEventListener('ws-message', handleWSMessage);
window.removeEventListener('auth-logout', handleAuthChange);
window.removeEventListener('auth-login', handleAuthChange);
});
</script>

View File

@@ -0,0 +1,133 @@
<template>
<div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Favoritos</h1>
<p v-if="currentUser" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Tus favoritos
<span class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
</p>
</div>
<button @click="loadFavorites" class="btn btn-primary self-start sm:self-auto">
Actualizar
</button>
</div>
<div v-if="loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando favoritos...</p>
</div>
<div v-else-if="!currentUser" class="card text-center py-12">
<HeartIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-600 dark:text-gray-400 text-lg">Inicia sesión para ver tus favoritos</p>
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
Necesitas estar autenticado para ver y gestionar tus favoritos
</p>
</div>
<div v-else-if="favorites.length === 0" class="card text-center py-12">
<HeartIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-600 dark:text-gray-400 text-lg">No tienes favoritos aún</p>
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
Los artículos que marques como favoritos aparecerán aquí
</p>
</div>
<div v-else class="space-y-4">
<ArticleCard
v-for="favorite in favorites"
:key="`${favorite.platform}-${favorite.id}`"
:article="favorite"
:show-remove-button="true"
@remove="removeFavorite"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import { HeartIcon } from '@heroicons/vue/24/outline';
import ArticleCard from '../components/ArticleCard.vue';
const favorites = ref([]);
const loading = ref(true);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
async function loadFavorites() {
// Solo cargar si hay usuario autenticado
if (!currentUser.value) {
favorites.value = [];
loading.value = false;
return;
}
loading.value = true;
try {
favorites.value = await api.getFavorites();
} catch (error) {
console.error('Error cargando favoritos:', error);
// Si hay error de autenticación, limpiar favoritos
if (error.response?.status === 401) {
favorites.value = [];
currentUser.value = null;
}
} finally {
loading.value = false;
}
}
async function removeFavorite(platform, id) {
if (!confirm('¿Estás seguro de que quieres eliminar este favorito?')) {
return;
}
try {
await api.removeFavorite(platform, id);
await loadFavorites();
} catch (error) {
console.error('Error eliminando favorito:', error);
alert('Error al eliminar el favorito');
}
}
function handleWSMessage(event) {
const data = event.detail;
if (data.type === 'favorites_updated') {
// Solo actualizar si es para el usuario actual
if (data.username === currentUser.value) {
favorites.value = data.data;
}
}
}
function handleAuthChange() {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
if (currentUser.value) {
loadFavorites();
}
}
onMounted(() => {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
loadFavorites();
window.addEventListener('ws-message', handleWSMessage);
window.addEventListener('auth-logout', handleAuthChange);
window.addEventListener('auth-login', handleAuthChange);
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWSMessage);
window.removeEventListener('auth-logout', handleAuthChange);
window.removeEventListener('auth-login', handleAuthChange);
});
</script>

View File

@@ -0,0 +1,300 @@
<template>
<div class="min-h-screen bg-white dark:bg-gray-900 flex">
<!-- Left Panel - Branding -->
<div class="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary-600 via-primary-700 to-primary-800 relative overflow-hidden">
<!-- Decorative Elements -->
<div class="absolute inset-0 opacity-10">
<div class="absolute top-0 left-0 w-96 h-96 bg-white rounded-full -translate-x-1/2 -translate-y-1/2 blur-3xl"></div>
<div class="absolute bottom-0 right-0 w-96 h-96 bg-primary-300 rounded-full translate-x-1/2 translate-y-1/2 blur-3xl"></div>
<div class="absolute top-1/2 left-1/2 w-64 h-64 bg-primary-400 rounded-full -translate-x-1/2 -translate-y-1/2 blur-3xl"></div>
</div>
<!-- Content -->
<div class="relative z-10 flex flex-col justify-between p-12 text-white">
<div>
<div class="flex items-center space-x-3 mb-8">
<div class="w-14 h-14 bg-white/20 backdrop-blur-sm rounded-xl overflow-hidden ring-2 ring-white/30 shadow-xl">
<img
src="/logo.jpg"
alt="Wallabicher Logo"
class="w-full h-full object-cover"
/>
</div>
<div>
<h1 class="text-2xl font-bold">Wallabicher</h1>
<p class="text-sm text-white/80">Admin Panel</p>
</div>
</div>
</div>
<div class="space-y-6">
<div>
<h2 class="text-4xl font-bold mb-4 leading-tight">
Bienvenido de vuelta
</h2>
<p class="text-lg text-white/90 leading-relaxed">
Gestiona y monitoriza tus búsquedas de productos con nuestro panel de administración profesional.
</p>
</div>
<!-- Features -->
<div class="space-y-4 pt-4 border-t border-white/20">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<div>
<p class="font-semibold text-white">Monitoreo en tiempo real</p>
<p class="text-sm text-white/80">Recibe notificaciones instantáneas de nuevos artículos</p>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<div>
<p class="font-semibold text-white">Gestión avanzada</p>
<p class="text-sm text-white/80">Control total sobre workers, usuarios y configuraciones</p>
</div>
</div>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<div>
<p class="font-semibold text-white">Seguridad empresarial</p>
<p class="text-sm text-white/80">Autenticación robusta y gestión de permisos</p>
</div>
</div>
</div>
</div>
<div class="text-sm text-white/70">
© {{ new Date().getFullYear() }} Wallabicher. Todos los derechos reservados.
</div>
</div>
</div>
<!-- Right Panel - Login Form -->
<div class="flex-1 flex items-center justify-center p-8 bg-gray-50 dark:bg-gray-900">
<div class="w-full max-w-md">
<!-- Mobile Logo -->
<div class="lg:hidden flex items-center justify-center space-x-3 mb-8">
<div class="w-12 h-12 bg-primary-600 rounded-xl overflow-hidden ring-2 ring-primary-500/50 shadow-lg">
<img
src="/logo.jpg"
alt="Wallabicher Logo"
class="w-full h-full object-cover"
/>
</div>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Wallabicher</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">Admin Panel</p>
</div>
</div>
<!-- Login Card -->
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
<div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Iniciar Sesión
</h2>
<p class="text-gray-600 dark:text-gray-400">
Ingresa tus credenciales para acceder al panel
</p>
</div>
<!-- Error Message -->
<div
v-if="loginError"
class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 animate-shake"
>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-red-800 dark:text-red-300">
{{ loginError }}
</p>
</div>
</div>
</div>
<!-- Login Form -->
<form @submit.prevent="handleLogin" class="space-y-6">
<!-- Username Field -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Usuario
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
<input
v-model="loginForm.username"
type="text"
class="input w-full pl-12 pr-4 py-3 text-base"
placeholder="Ingresa tu usuario"
required
autocomplete="username"
:disabled="loginLoading"
/>
</div>
</div>
<!-- Password Field -->
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Contraseña
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<input
v-model="loginForm.password"
type="password"
class="input w-full pl-12 pr-4 py-3 text-base"
placeholder="Ingresa tu contraseña"
required
autocomplete="current-password"
:disabled="loginLoading"
/>
</div>
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
v-model="loginForm.remember"
type="checkbox"
id="remember"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 transition-colors cursor-pointer"
:disabled="loginLoading"
/>
<label for="remember" class="ml-2 block text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
Recordar sesión
</label>
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
class="w-full btn btn-primary py-3.5 text-base font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98]"
:disabled="loginLoading"
>
<span v-if="!loginLoading" class="flex items-center justify-center">
<span>Iniciar Sesión</span>
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
</span>
<span v-else class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Iniciando sesión...
</span>
</button>
</form>
<!-- Footer -->
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
¿Necesitas ayuda? Contacta con el administrador del sistema
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import authService from '../services/auth';
const router = useRouter();
const loginError = ref('');
const loginLoading = ref(false);
const loginForm = ref({
username: '',
password: '',
remember: true,
});
async function handleLogin() {
loginError.value = '';
loginLoading.value = true;
if (!loginForm.value.username || !loginForm.value.password) {
loginError.value = 'Usuario y contraseña son requeridos';
loginLoading.value = false;
return;
}
try {
await authService.login(
loginForm.value.username,
loginForm.value.password
);
// Si llegamos aquí, el login fue exitoso
// Redirigir al dashboard
router.push('/dashboard');
// Disparar evento para que App.vue se actualice
window.dispatchEvent(new CustomEvent('auth-login'));
} catch (error) {
console.error('Error en login:', error);
loginError.value = error.message || 'Usuario o contraseña incorrectos';
authService.clearSession();
} finally {
loginLoading.value = false;
}
}
onMounted(() => {
// Si ya está autenticado, redirigir al dashboard
if (authService.hasCredentials()) {
router.push('/dashboard');
return;
}
// Cargar username guardado si existe
const username = authService.getUsername();
if (username) {
loginForm.value.username = username;
}
});
</script>
<style scoped>
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<div>
<div class="flex flex-col gap-4 mb-4 sm:mb-6">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Logs del Sistema</h1>
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:space-x-4">
<select v-model="logLevel" @change="" class="input text-sm sm:text-base" style="width: 100%; min-width: 160px;">
<option value="">Todos los niveles</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="DEBUG">DEBUG</option>
</select>
<button @click="loadLogs(true, followLatestLog)" class="btn btn-primary text-sm sm:text-base whitespace-nowrap">
Actualizar
</button>
</div>
</div>
<!-- Panel de configuración de auto-refresh -->
<div class="card p-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="autoRefresh"
@change="handleAutoRefreshChange"
class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
>
<span class="font-medium text-gray-700 dark:text-gray-300">Auto-refresh</span>
</label>
<div v-if="autoRefresh" class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 flex-1">
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">Intervalo:</label>
<input
type="number"
v-model.number="refreshIntervalSeconds"
@change="updateRefreshInterval"
min="1"
max="300"
class="input text-sm w-20"
>
<span class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">segundos</span>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="followLatestLog"
class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
>
<span class="text-sm text-gray-700 dark:text-gray-300">Seguir último log</span>
</label>
</div>
</div>
</div>
</div>
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
<DocumentMagnifyingGlassIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
Solo los administradores pueden ver los logs del sistema
</p>
</div>
<div v-else class="card p-2 sm:p-6">
<div
ref="logsContainer"
class="bg-gray-900 text-green-400 font-mono text-xs sm:text-sm p-3 sm:p-4 rounded-lg overflow-x-auto max-h-[400px] sm:max-h-[600px] overflow-y-auto"
>
<div v-if="loading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-green-400"></div>
<p class="mt-2 text-gray-400">Cargando logs...</p>
</div>
<div v-else-if="filteredLogs.length === 0" class="text-gray-500">
No hay logs disponibles
</div>
<div v-else>
<div
v-for="(log, index) in filteredLogs"
:key="index"
class="mb-1"
:class="getLogColor(log)"
>
{{ log }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import { DocumentMagnifyingGlassIcon } from '@heroicons/vue/24/outline';
const logs = ref([]);
const loading = ref(true);
const logLevel = ref('');
const autoRefresh = ref(false);
const refreshIntervalSeconds = ref(5);
const followLatestLog = ref(true);
const logsContainer = ref(null);
const lastLineNumber = ref(-1); // Número de la última línea leída
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const accessDenied = ref(false);
let refreshInterval = null;
const filteredLogs = computed(() => {
if (!logLevel.value) {
return logs.value;
}
return logs.value.filter(log => log.includes(logLevel.value));
});
function getLogColor(log) {
if (log.includes('ERROR')) return 'text-red-400';
if (log.includes('WARNING')) return 'text-yellow-400';
if (log.includes('INFO')) return 'text-blue-400';
if (log.includes('DEBUG')) return 'text-gray-400';
return 'text-green-400';
}
async function loadLogs(forceReload = false, shouldScroll = null) {
// Verificar que el usuario es admin antes de cargar logs
if (!isAdmin.value) {
accessDenied.value = true;
loading.value = false;
return;
}
accessDenied.value = false;
// Si shouldScroll es null, usar la configuración de followLatestLog
const shouldAutoScroll = shouldScroll !== null ? shouldScroll : followLatestLog.value;
// Guardar la posición del scroll antes de actualizar
const previousScrollTop = logsContainer.value?.scrollTop || 0;
const wasAtBottom = logsContainer.value
? logsContainer.value.scrollTop + logsContainer.value.clientHeight >= logsContainer.value.scrollHeight - 10
: false;
// Solo mostrar loader en carga inicial o recarga forzada
const isInitialLoad = forceReload || lastLineNumber.value === -1;
if (isInitialLoad) {
loading.value = true;
}
try {
// Si es carga inicial o forzada, no enviar sinceLine (cargar últimas líneas)
// Si es actualización incremental, enviar lastLineNumber + 1 para obtener solo las nuevas
const sinceLine = isInitialLoad ? null : lastLineNumber.value + 1;
const data = await api.getLogs(500, sinceLine);
const newLogs = data.logs || [];
const newLastLineNumber = data.lastLineNumber !== undefined ? data.lastLineNumber : -1;
if (isInitialLoad) {
// Carga inicial o recarga forzada: reemplazar todo
logs.value = newLogs;
lastLineNumber.value = newLastLineNumber;
await nextTick();
if (logsContainer.value && shouldAutoScroll) {
// Scroll al final (abajo) donde están los logs más recientes
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
}
} else {
// Actualización incremental: añadir solo las líneas nuevas al final
if (newLogs.length > 0 && newLastLineNumber > lastLineNumber.value) {
// Añadir las nuevas líneas al final
// Limitar el número total de logs para evitar crecimiento infinito
const maxLogs = 1000;
logs.value = [...logs.value, ...newLogs].slice(-maxLogs);
// Actualizar el número de la última línea leída
lastLineNumber.value = newLastLineNumber;
await nextTick();
// Ajustar el scroll
if (logsContainer.value) {
if (shouldAutoScroll) {
// Si debe seguir el último log, ir al final (abajo)
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
} else if (wasAtBottom) {
// Si estaba abajo, mantenerlo abajo
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
}
// Si no estaba abajo y no sigue logs, mantener posición (no hacer nada)
}
}
}
} catch (error) {
console.error('Error cargando logs:', error);
// Si hay error 403, es porque no es admin
if (error.response?.status === 403) {
accessDenied.value = true;
}
loading.value = false;
} finally {
if (isInitialLoad) {
loading.value = false;
}
}
}
function handleAutoRefreshChange() {
updateRefreshInterval();
}
function updateRefreshInterval() {
// Limpiar intervalo anterior
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
// Crear nuevo intervalo si auto-refresh está activado
if (autoRefresh.value && refreshIntervalSeconds.value > 0) {
refreshInterval = setInterval(() => {
if (autoRefresh.value) {
// Actualización incremental (no forzada)
loadLogs(false, followLatestLog.value);
}
}, refreshIntervalSeconds.value * 1000);
}
}
// Nota: El scroll ahora se maneja dentro de loadLogs para mejor control
function handleWSMessage(event) {
const data = event.detail;
// Ya no escuchamos logs_updated porque usamos polling con números de línea
}
onMounted(() => {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
loadLogs(true, true); // Primera carga forzada siempre hace scroll
window.addEventListener('ws-message', handleWSMessage);
// Inicializar auto-refresh si está activado
if (autoRefresh.value && isAdmin.value) {
updateRefreshInterval();
}
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
window.removeEventListener('ws-message', handleWSMessage);
});
</script>

View File

@@ -0,0 +1,209 @@
<template>
<div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Rate Limiter</h1>
<div class="flex flex-wrap gap-2">
<button
@click="loadRateLimiterInfo(true)"
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
>
🔄 Actualizar
</button>
</div>
</div>
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
<ShieldExclamationIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
Solo los administradores pueden ver el rate limiter
</p>
</div>
<div v-else>
<!-- Estadísticas -->
<div v-if="rateLimiterInfo && rateLimiterInfo.enabled" class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="card p-4">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total de Claves</div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ rateLimiterInfo.stats?.totalBlocks || 0 }}
</div>
</div>
<div class="card p-4">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Bloqueos Activos</div>
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ rateLimiterInfo.stats?.activeBlocks || 0 }}
</div>
</div>
<div class="card p-4">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Tipo</div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100 capitalize">
{{ rateLimiterInfo.type }}
</div>
</div>
</div>
<!-- Configuración -->
<div v-if="rateLimiterInfo && rateLimiterInfo.config" class="card p-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Configuración</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Puntos</div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ rateLimiterInfo.config.points }}
</div>
</div>
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Duración (seg)</div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ rateLimiterInfo.config.duration }}
</div>
</div>
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Duración Bloqueo (seg)</div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ rateLimiterInfo.config.blockDuration }}
</div>
</div>
</div>
</div>
<!-- Mensaje si no está habilitado -->
<div v-if="rateLimiterInfo && !rateLimiterInfo.enabled" class="card p-6 text-center">
<ShieldExclamationIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Rate Limiter Deshabilitado</p>
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
{{ rateLimiterInfo.message || 'El rate limiter no está configurado' }}
</p>
</div>
<!-- Nota para rate limiter en memoria -->
<div v-if="rateLimiterInfo && rateLimiterInfo.note" class="card p-4 mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
<p class="text-sm text-yellow-800 dark:text-yellow-200">{{ rateLimiterInfo.note }}</p>
</div>
<!-- Lista de bloqueos -->
<div v-if="rateLimiterInfo && rateLimiterInfo.blocks && rateLimiterInfo.blocks.length > 0" class="card p-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Bloqueos</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Clave/IP
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Estado
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Puntos Restantes
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Total Hits
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Tiempo Restante
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="block in rateLimiterInfo.blocks" :key="block.key">
<td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-gray-100">
{{ block.key }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
:class="block.isBlocked
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
>
{{ block.isBlocked ? 'Bloqueado' : 'Activo' }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ block.remainingPoints }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ block.totalHits }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ formatTimeRemaining(block.msBeforeNext) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="rateLimiterInfo && rateLimiterInfo.enabled" class="card p-6 text-center">
<p class="text-gray-600 dark:text-gray-400">No hay bloqueos registrados</p>
</div>
<div v-if="loading" class="card p-6 text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando información del rate limiter...</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import { ShieldExclamationIcon } from '@heroicons/vue/24/outline';
const rateLimiterInfo = ref(null);
const loading = ref(true);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const accessDenied = ref(false);
function formatTimeRemaining(ms) {
if (!ms || ms <= 0) return 'N/A';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
async function loadRateLimiterInfo(showLoading = false) {
if (!isAdmin.value) {
accessDenied.value = true;
loading.value = false;
return;
}
accessDenied.value = false;
if (showLoading) {
loading.value = true;
}
try {
const data = await api.getRateLimiterInfo();
rateLimiterInfo.value = data;
} catch (error) {
console.error('Error cargando información del rate limiter:', error);
if (error.response?.status === 403) {
accessDenied.value = true;
}
} finally {
loading.value = false;
}
}
onMounted(() => {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
loadRateLimiterInfo(true);
});
</script>

View File

@@ -0,0 +1,261 @@
<template>
<div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Sesiones</h1>
<div class="flex flex-wrap gap-2">
<button
@click="loadSessions(true)"
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
>
🔄 Actualizar
</button>
</div>
</div>
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
<UserGroupIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
Solo los administradores pueden ver las sesiones
</p>
</div>
<div v-else>
<!-- Estadísticas -->
<div v-if="sessionsData && sessionsData.stats" class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div class="card p-4">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Sesiones</div>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{{ sessionsData.stats.total || 0 }}
</div>
</div>
<div class="card p-4">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Activas</div>
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
{{ sessionsData.stats.active || 0 }}
</div>
</div>
<div class="card p-4">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Expiradas</div>
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
{{ sessionsData.stats.expired || 0 }}
</div>
</div>
<div class="card p-4">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Usuarios Únicos</div>
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
{{ Object.keys(sessionsData.stats.byUser || {}).length }}
</div>
</div>
</div>
<!-- Sesiones por usuario -->
<div v-if="sessionsData && sessionsData.stats && sessionsData.stats.byUser && Object.keys(sessionsData.stats.byUser).length > 0" class="card p-4 mb-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Sesiones por Usuario</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div
v-for="(count, username) in sessionsData.stats.byUser"
:key="username"
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ username }}</span>
<span class="px-2 py-1 text-sm font-semibold rounded bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
{{ count }}
</span>
</div>
</div>
</div>
<!-- Lista de sesiones -->
<div v-if="sessionsData && sessionsData.sessions && sessionsData.sessions.length > 0" class="card p-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Todas las Sesiones</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Usuario
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Dispositivo
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Token
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Creada
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Expira
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Estado
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Acciones
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="session in sessionsData.sessions" :key="session.token">
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{{ session.username }}
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
<div v-if="session.deviceInfo" class="space-y-1">
<div class="font-medium">
{{ formatDeviceInfo(session.deviceInfo) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ session.deviceInfo.os || 'Unknown OS' }}
<span v-if="session.deviceInfo.osVersion"> {{ session.deviceInfo.osVersion }}</span>
</div>
<div v-if="session.deviceInfo.ip" class="text-xs text-gray-400 dark:text-gray-500 font-mono">
IP: {{ session.deviceInfo.ip }}
</div>
</div>
<div v-else class="text-gray-400 dark:text-gray-500 italic">
Sin información
</div>
</td>
<td class="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-400">
<span class="truncate max-w-xs inline-block" :title="session.token">
{{ session.token.substring(0, 16) }}...
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ formatDate(session.createdAt) }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ formatDate(session.expiresAt) }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
:class="session.isExpired
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
>
{{ session.isExpired ? 'Expirada' : 'Activa' }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
<button
@click="confirmDeleteSession(session.token)"
class="btn btn-danger text-xs"
:disabled="session.isExpired"
title="Eliminar sesión"
>
🗑 Eliminar
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="sessionsData && sessionsData.sessions && sessionsData.sessions.length === 0" class="card p-6 text-center">
<UserGroupIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p class="text-gray-600 dark:text-gray-400">No hay sesiones registradas</p>
</div>
<div v-if="loading" class="card p-6 text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando sesiones...</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
import { UserGroupIcon } from '@heroicons/vue/24/outline';
const sessionsData = ref(null);
const loading = ref(true);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const accessDenied = ref(false);
function formatDate(dateString) {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleString('es-ES', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function formatDeviceInfo(deviceInfo) {
if (!deviceInfo) return 'Unknown';
const parts = [];
if (deviceInfo.browser) {
parts.push(deviceInfo.browser);
if (deviceInfo.browserVersion) {
parts.push(deviceInfo.browserVersion.split('.')[0]); // Solo versión mayor
}
}
if (deviceInfo.device && deviceInfo.device !== 'Desktop') {
parts.push(`(${deviceInfo.device})`);
}
return parts.join(' ') || 'Unknown';
}
async function loadSessions(showLoading = false) {
if (!isAdmin.value) {
accessDenied.value = true;
loading.value = false;
return;
}
accessDenied.value = false;
if (showLoading) {
loading.value = true;
}
try {
const data = await api.getSessions();
sessionsData.value = data;
} catch (error) {
console.error('Error cargando sesiones:', error);
if (error.response?.status === 403) {
accessDenied.value = true;
}
} finally {
loading.value = false;
}
}
async function confirmDeleteSession(token) {
if (!confirm('¿Estás seguro de que quieres eliminar esta sesión?')) {
return;
}
try {
await api.deleteSession(token);
// Recargar sesiones después de eliminar
await loadSessions(false);
} catch (error) {
console.error('Error eliminando sesión:', error);
alert('Error al eliminar la sesión: ' + (error.response?.data?.error || error.message));
}
}
onMounted(() => {
currentUser.value = authService.getUsername() || null;
isAdmin.value = authService.isAdmin();
loadSessions(true);
});
</script>

View File

@@ -0,0 +1,314 @@
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Configuración</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Gestiona tu configuración personal y preferencias
</p>
</div>
</div>
<!-- Cambiar Contraseña -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Cambiar Contraseña</h3>
<p class="card-subtitle">Actualiza tu contraseña de acceso</p>
</div>
<form @submit.prevent="handleChangePassword" class="space-y-4">
<div v-if="passwordError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ passwordError }}
</div>
<div v-if="passwordSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
{{ passwordSuccess }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña actual <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.currentPassword"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nueva contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.newPassword"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 6 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmar nueva contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="passwordForm.newPasswordConfirm"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="submit"
class="btn btn-primary"
:disabled="loadingAction"
>
{{ loadingAction ? 'Cambiando...' : 'Cambiar Contraseña' }}
</button>
</div>
</form>
</div>
<!-- Configuración de Telegram -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Configuración de Telegram</h3>
<p class="card-subtitle">Configura tu bot de Telegram y canal para recibir notificaciones</p>
</div>
<form @submit.prevent="saveTelegramConfig" class="space-y-4">
<div v-if="telegramError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ telegramError }}
</div>
<div v-if="telegramSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
{{ telegramSuccess }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Token del Bot <span class="text-red-500">*</span>
</label>
<input
v-model="telegramForm.token"
type="password"
class="input"
placeholder="Ej: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
required
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Obtén tu token desde @BotFather en Telegram
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Canal o Grupo <span class="text-red-500">*</span>
</label>
<input
v-model="telegramForm.channel"
type="text"
class="input"
placeholder="Ej: @micanal o -1001234567890"
required
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Usa @nombrecanal para canales públicos o el ID numérico para grupos/canales privados
</p>
</div>
<div class="flex items-center">
<input
v-model="telegramForm.enable_polling"
type="checkbox"
id="enable_polling"
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<label for="enable_polling" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Habilitar polling del bot (para comandos /favs, /threads, etc.)
</label>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="submit"
class="btn btn-primary"
:disabled="loadingAction"
>
{{ loadingAction ? 'Guardando...' : 'Guardar Configuración' }}
</button>
</div>
</form>
</div>
<!-- Información de la cuenta -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Información de la Cuenta</h3>
<p class="card-subtitle">Datos de tu cuenta</p>
</div>
<div class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nombre de usuario</p>
<p class="text-base text-gray-900 dark:text-gray-100 font-semibold">{{ currentUser }}</p>
</div>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rol</p>
<span
:class="isAdmin ? 'badge badge-warning' : 'badge badge-primary'"
>
{{ isAdmin ? 'Administrador' : 'Usuario' }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
const loadingAction = ref(false);
const passwordError = ref('');
const passwordSuccess = ref('');
const telegramError = ref('');
const telegramSuccess = ref('');
const passwordForm = ref({
currentPassword: '',
newPassword: '',
newPasswordConfirm: '',
});
const telegramForm = ref({
token: '',
channel: '',
enable_polling: false
});
const currentUser = computed(() => {
return authService.getUsername() || '';
});
const isAdmin = computed(() => {
return authService.isAdmin();
});
async function handleChangePassword() {
passwordError.value = '';
passwordSuccess.value = '';
loadingAction.value = true;
if (!passwordForm.value.currentPassword || !passwordForm.value.newPassword || !passwordForm.value.newPasswordConfirm) {
passwordError.value = 'Todos los campos son requeridos';
loadingAction.value = false;
return;
}
if (passwordForm.value.newPassword.length < 6) {
passwordError.value = 'La nueva contraseña debe tener al menos 6 caracteres';
loadingAction.value = false;
return;
}
if (passwordForm.value.newPassword !== passwordForm.value.newPasswordConfirm) {
passwordError.value = 'Las contraseñas no coinciden';
loadingAction.value = false;
return;
}
try {
await api.changePassword({
currentPassword: passwordForm.value.currentPassword,
newPassword: passwordForm.value.newPassword,
});
passwordSuccess.value = 'Contraseña actualizada correctamente. Por favor, inicia sesión nuevamente.';
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
setTimeout(async () => {
await authService.logout();
// Recargar página para forzar nuevo login
window.location.reload();
}, 2000);
} catch (error) {
console.error('Error cambiando contraseña:', error);
if (error.response?.data?.error) {
passwordError.value = error.response.data.error;
} else {
passwordError.value = 'Error cambiando contraseña. Intenta de nuevo.';
}
} finally {
loadingAction.value = false;
}
}
async function loadTelegramConfig() {
try {
const config = await api.getTelegramConfig();
if (config) {
telegramForm.value = {
token: config.token || '',
channel: config.channel || '',
enable_polling: config.enable_polling || false
};
}
} catch (error) {
console.error('Error cargando configuración de Telegram:', error);
telegramError.value = 'Error cargando la configuración de Telegram';
}
}
async function saveTelegramConfig() {
telegramError.value = '';
telegramSuccess.value = '';
if (!telegramForm.value.token || !telegramForm.value.channel) {
telegramError.value = 'Token y canal son requeridos';
return;
}
loadingAction.value = true;
try {
await api.setTelegramConfig(telegramForm.value);
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
setTimeout(() => {
telegramSuccess.value = '';
}, 3000);
} catch (error) {
console.error('Error guardando configuración de Telegram:', error);
if (error.response?.data?.error) {
telegramError.value = error.response.data.error;
} else {
telegramError.value = 'Error al guardar la configuración de Telegram';
}
} finally {
loadingAction.value = false;
}
}
onMounted(() => {
loadTelegramConfig();
});
</script>

View File

@@ -0,0 +1,264 @@
<template>
<div class="space-y-6">
<!-- Page Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Suscripción</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Gestiona tu plan de suscripción y límites
</p>
</div>
</div>
<!-- Current Plan Card -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Plan Actual</h3>
<p class="card-subtitle">Tu plan de suscripción actual y uso</p>
</div>
<div v-if="loading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
</div>
<div v-else-if="subscription && subscription.subscription && subscription.subscription.plan" class="space-y-6">
<!-- Plan Info -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="p-6 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-xl border border-primary-200 dark:border-primary-800">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Plan</h4>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ subscription.subscription.plan?.name || 'Gratis' }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ subscription.subscription.plan?.description || 'Plan gratuito' }}</p>
</div>
<div class="p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-xl border border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Estado</h4>
<span
:class="subscription.subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
class="text-lg font-semibold"
>
{{ subscription.subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
</span>
<p v-if="subscription.subscription?.currentPeriodEnd" class="text-sm text-gray-600 dark:text-gray-400 mt-2">
Renovación: {{ formatDate(subscription.subscription.currentPeriodEnd) }}
</p>
</div>
</div>
<!-- Usage Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Búsquedas activas</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ subscription.usage?.workers || 0 }} / {{ subscription.usage?.maxWorkers || 'N/A' }}
</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-primary-600 to-teal-600 h-2 rounded-full transition-all duration-300"
:style="{ width: getUsagePercentage(subscription.usage?.workers || 0, subscription.usage?.maxWorkers) + '%' }"
></div>
</div>
</div>
<div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plataformas disponibles</h4>
<div class="flex flex-wrap gap-2">
<span
v-for="platform in (subscription.subscription.plan?.limits?.platforms || [])"
:key="platform"
class="badge badge-primary"
>
{{ platform }}
</span>
</div>
</div>
</div>
<!-- Features -->
<div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Características incluidas</h4>
<ul class="grid grid-cols-1 md:grid-cols-2 gap-2">
<li v-for="feature in (subscription.subscription.plan?.features || [])" :key="feature" class="flex items-start">
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ feature }}</span>
</li>
</ul>
</div>
</div>
<div v-else-if="subscription && (!subscription.subscription || !subscription.subscription.plan)" class="text-center py-8">
<p class="text-gray-600 dark:text-gray-400">No se pudo cargar la información del plan. Por favor, recarga la página.</p>
</div>
</div>
<!-- Available Plans -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Planes Disponibles</h3>
<p class="card-subtitle">Información de los planes disponibles</p>
</div>
<!-- Mensaje informativo -->
<div class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex items-start">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h4 class="text-sm font-semibold text-yellow-800 dark:text-yellow-300 mb-1">Cambio de plan temporalmente deshabilitado</h4>
<p class="text-sm text-yellow-700 dark:text-yellow-400">
El cambio de plan no está disponible por el momento. Si necesitas cambiar tu plan, contacta con el administrador.
</p>
</div>
</div>
</div>
<div v-if="loadingPlans" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
<div v-else-if="plans && plans.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div
v-for="plan in plans"
:key="plan.id"
class="p-6 rounded-xl border-2 transition-all duration-300 opacity-75"
:class="
plan.id === subscription?.subscription?.planId
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-gray-800'
"
>
<div class="text-center mb-4">
<h4 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-1">{{ plan.name }}</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{{ plan.description }}</p>
<div class="mb-4">
<span class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{{ plan.price.monthly.toFixed(2) }}
</span>
<span class="text-gray-600 dark:text-gray-400">/mes</span>
</div>
</div>
<ul class="space-y-2 mb-6">
<li v-for="feature in plan.features.slice(0, 3)" :key="feature" class="flex items-start text-sm">
<svg class="w-4 h-4 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span class="text-gray-700 dark:text-gray-300">{{ feature }}</span>
</li>
</ul>
<button
v-if="plan.id !== subscription?.subscription?.planId"
class="w-full btn btn-primary opacity-50 cursor-not-allowed"
disabled
title="Cambio de plan temporalmente deshabilitado"
>
No disponible
</button>
<div v-else class="w-full text-center py-2 px-4 bg-primary-100 dark:bg-primary-900/50 text-primary-700 dark:text-primary-400 rounded-lg font-semibold">
Plan Actual
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import api from '../services/api';
const subscription = ref(null);
const plans = ref([]);
const loading = ref(true);
const loadingPlans = ref(true);
const upgrading = ref(false);
function formatDate(date) {
if (!date) return 'N/A';
return new Date(date).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function getUsagePercentage(current, max) {
if (max === 'Ilimitado' || max === -1) return 0;
if (typeof max === 'string') return 0;
if (max === 0) return 0;
return Math.min((current / max) * 100, 100);
}
async function loadSubscription() {
try {
loading.value = true;
const data = await api.getMySubscription();
// Asegurar que la estructura de datos sea correcta
if (data && !data.plan && data.subscription) {
// Si viene la estructura del backend directamente
subscription.value = data;
} else if (data) {
subscription.value = data;
} else {
subscription.value = null;
}
} catch (error) {
console.error('Error cargando suscripción:', error);
if (error.response?.status === 401) {
// El usuario no está autenticado, se manejará automáticamente
subscription.value = null;
return;
}
subscription.value = null;
// Mostrar error genérico (solo en consola, no interrumpir la UI)
} finally {
loading.value = false;
}
}
async function loadPlans() {
try {
loadingPlans.value = true;
const data = await api.getSubscriptionPlans();
plans.value = data.plans || [];
} catch (error) {
console.error('Error cargando planes:', error);
// Error al cargar planes (solo en consola)
} finally {
loadingPlans.value = false;
}
}
async function upgradePlan(planId) {
if (upgrading.value) return;
if (!confirm(`¿Estás seguro de que quieres cambiar a el plan ${plans.value.find(p => p.id === planId)?.name || planId}?`)) {
return;
}
try {
upgrading.value = true;
await api.updateSubscription({ planId });
// Plan actualizado correctamente
await loadSubscription();
} catch (error) {
console.error('Error actualizando plan:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al actualizar el plan';
alert(errorMessage);
} finally {
upgrading.value = false;
}
}
onMounted(() => {
loadSubscription();
loadPlans();
});
</script>

View File

@@ -0,0 +1,706 @@
<template>
<div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Usuarios</h1>
<div class="flex flex-wrap gap-2">
<button
v-if="isAdmin"
@click="showAddModal = true"
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
>
+ Crear Usuario
</button>
</div>
</div>
<div v-if="!isAdmin" class="card text-center py-12">
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold mb-2">Acceso Denegado</p>
<p class="text-gray-500 dark:text-gray-500 text-sm">
Solo los administradores pueden acceder a esta sección.
</p>
<router-link to="/settings" class="btn btn-primary mt-4 inline-block">
Ir a Configuración
</router-link>
</div>
<div v-else-if="loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando usuarios...</p>
</div>
<div v-else class="space-y-4">
<!-- Lista de usuarios -->
<div v-if="users.length > 0" class="grid grid-cols-1 gap-4">
<div
v-for="user in users"
:key="user.username"
class="card hover:shadow-lg transition-shadow"
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
<span
v-if="user.username === currentUser"
class="px-2 py-1 text-xs font-semibold rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
>
</span>
<span
v-if="user.role === 'admin'"
class="px-2 py-1 text-xs font-semibold rounded bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
>
Admin
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400 mb-3">
<div v-if="user.createdAt">
<span class="font-medium">Creado:</span>
<span class="ml-2">{{ formatDate(user.createdAt) }}</span>
</div>
<div v-if="user.createdBy">
<span class="font-medium">Por:</span>
<span class="ml-2">{{ user.createdBy }}</span>
</div>
<div v-if="user.updatedAt">
<span class="font-medium">Actualizado:</span>
<span class="ml-2">{{ formatDate(user.updatedAt) }}</span>
</div>
</div>
<!-- Información de suscripción -->
<div v-if="userSubscriptions[user.username]" class="mt-3 p-3 bg-gradient-to-r from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
<div class="flex items-center justify-between">
<div>
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300">Plan:</span>
<span class="ml-2 text-sm font-bold text-primary-700 dark:text-primary-400">
{{ userSubscriptions[user.username].plan?.name || 'Gratis' }}
</span>
</div>
<span
:class="userSubscriptions[user.username].subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
class="text-xs"
>
{{ userSubscriptions[user.username].subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
</span>
</div>
<div v-if="userSubscriptions[user.username].usage" class="mt-2 text-xs text-gray-600 dark:text-gray-400">
<span>Uso: {{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }} búsquedas</span>
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<button
v-if="isAdmin"
@click="openSubscriptionModal(user.username)"
class="btn btn-primary text-xs sm:text-sm"
title="Gestionar suscripción"
>
💳 Plan
</button>
<button
v-if="user.username !== currentUser && isAdmin"
@click="confirmDeleteUser(user.username)"
class="btn btn-danger text-xs sm:text-sm"
title="Eliminar usuario"
>
🗑 Eliminar
</button>
</div>
</div>
</div>
</div>
<div v-else class="card text-center py-12">
<p class="text-gray-600 dark:text-gray-400 mb-4">No hay usuarios configurados</p>
<button @click="showAddModal = true" class="btn btn-primary">
+ Crear primer usuario
</button>
</div>
</div>
<!-- Modal para crear/editar usuario -->
<div
v-if="showAddModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeAddModal"
>
<div class="card max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Crear Usuario</h2>
<button
@click="closeAddModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<form @submit.prevent="handleCreateUser" class="space-y-4">
<div v-if="addError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ addError }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Nombre de usuario <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.username"
type="text"
class="input"
placeholder="nuevo_usuario"
required
minlength="3"
autocomplete="username"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 3 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.password"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Mínimo 6 caracteres
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirmar contraseña <span class="text-red-500">*</span>
</label>
<input
v-model="userForm.passwordConfirm"
type="password"
class="input"
placeholder="••••••••"
required
minlength="6"
autocomplete="new-password"
/>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeAddModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Creando...' : 'Crear Usuario' }}
</button>
</div>
</form>
</div>
</div>
<!-- Modal para gestionar suscripción (solo admin) -->
<div
v-if="showSubscriptionModal && selectedUserForSubscription"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="closeSubscriptionModal"
>
<div class="card max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
Gestionar Suscripción: {{ selectedUserForSubscription }}
</h2>
<button
@click="closeSubscriptionModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<div v-if="loadingSubscription" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
</div>
<div v-else class="space-y-6">
<!-- Información actual -->
<div v-if="selectedUserSubscription && selectedUserSubscription.subscription" class="p-4 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-xl border border-primary-200 dark:border-primary-800">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plan Actual</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{ selectedUserSubscription.subscription?.plan?.name || 'Gratis' }}
</p>
</div>
<div>
<p class="text-xs text-gray-600 dark:text-gray-400">Estado</p>
<span
:class="selectedUserSubscription.subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
>
{{ selectedUserSubscription.subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
</span>
</div>
<div v-if="selectedUserSubscription.usage">
<p class="text-xs text-gray-600 dark:text-gray-400">Búsquedas</p>
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ selectedUserSubscription.usage.workers }} / {{ selectedUserSubscription.usage.maxWorkers === 'Ilimitado' ? '∞' : selectedUserSubscription.usage.maxWorkers }}
</p>
</div>
</div>
</div>
<!-- Cambiar plan -->
<div>
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Cambiar Plan</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
v-for="plan in availablePlans"
:key="plan.id"
@click="subscriptionForm.planId = plan.id"
class="p-4 rounded-lg border-2 text-left transition-all"
:class="
subscriptionForm.planId === plan.id
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-700'
"
>
<div class="flex items-center justify-between">
<div>
<p class="font-bold text-gray-900 dark:text-gray-100">{{ plan.name }}</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">{{ plan.description }}</p>
</div>
<div v-if="subscriptionForm.planId === plan.id" class="text-primary-600 dark:text-primary-400">
</div>
</div>
</button>
</div>
</div>
<!-- Formulario de actualización -->
<form @submit.prevent="handleUpdateUserSubscription" class="space-y-4">
<div v-if="subscriptionError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
{{ subscriptionError }}
</div>
<div v-if="subscriptionSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
{{ subscriptionSuccess }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Estado de la suscripción
</label>
<select
v-model="subscriptionForm.status"
class="input"
>
<option value="active">Activo</option>
<option value="inactive">Inactivo</option>
<option value="cancelled">Cancelado</option>
</select>
</div>
<div class="flex items-center">
<input
v-model="subscriptionForm.cancelAtPeriodEnd"
type="checkbox"
id="cancelAtPeriodEnd"
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<label for="cancelAtPeriodEnd" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Cancelar al final del período
</label>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeSubscriptionModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Actualizando...' : 'Actualizar Suscripción' }}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal de confirmación para eliminar -->
<div
v-if="userToDelete"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
@click.self="userToDelete = null"
>
<div class="card max-w-md w-full">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Confirmar Eliminación</h2>
<button
@click="userToDelete = null"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
</div>
<p class="text-gray-700 dark:text-gray-300 mb-4">
¿Estás seguro de que deseas eliminar al usuario <strong>{{ userToDelete }}</strong>?
</p>
<p class="text-sm text-red-600 dark:text-red-400 mb-4">
Esta acción no se puede deshacer.
</p>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="userToDelete = null"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
@click="handleDeleteUser"
class="btn btn-danger text-sm sm:text-base"
:disabled="loadingAction"
>
{{ loadingAction ? 'Eliminando...' : 'Eliminar Usuario' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
const users = ref([]);
const loading = ref(true);
const loadingAction = ref(false);
const showAddModal = ref(false);
const showSubscriptionModal = ref(false);
const userToDelete = ref(null);
const selectedUserForSubscription = ref(null);
const loadingSubscription = ref(false);
const userSubscriptions = ref({});
const availablePlans = ref([]);
const subscriptionError = ref('');
const subscriptionSuccess = ref('');
const addError = ref('');
const userForm = ref({
username: '',
password: '',
passwordConfirm: '',
});
const subscriptionForm = ref({
planId: 'free',
status: 'active',
cancelAtPeriodEnd: false,
});
const selectedUserSubscription = ref(null);
const isAuthenticated = computed(() => authService.hasCredentials());
const currentUser = computed(() => {
return authService.getUsername() || '';
});
const isAdmin = computed(() => {
return authService.isAdmin();
});
function formatDate(dateString) {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch (e) {
return dateString;
}
}
async function loadUsers() {
loading.value = true;
try {
const data = await api.getUsers();
users.value = data.users || [];
// Cargar información de suscripción para todos los usuarios (solo si es admin)
if (isAdmin.value) {
await loadAllUserSubscriptions();
}
} catch (error) {
console.error('Error cargando usuarios:', error);
// El modal de login se manejará automáticamente desde App.vue
} finally {
loading.value = false;
}
}
async function loadAllUserSubscriptions() {
// Cargar suscripciones de todos los usuarios en paralelo
const subscriptionPromises = users.value.map(async (user) => {
try {
// Usar el endpoint de admin para obtener suscripción de cualquier usuario
const response = await fetch(`/api/subscription/${user.username}`, {
method: 'GET',
headers: {
'Authorization': authService.getAuthHeader(),
},
});
if (response.ok) {
const data = await response.json();
userSubscriptions.value[user.username] = data;
}
} catch (error) {
console.error(`Error cargando suscripción de ${user.username}:`, error);
}
});
await Promise.all(subscriptionPromises);
}
async function loadAvailablePlans() {
try {
const data = await api.getSubscriptionPlans();
availablePlans.value = data.plans || [];
} catch (error) {
console.error('Error cargando planes:', error);
}
}
async function openSubscriptionModal(username) {
selectedUserForSubscription.value = username;
showSubscriptionModal.value = true;
loadingSubscription.value = true;
subscriptionError.value = '';
subscriptionSuccess.value = '';
try {
// Cargar planes disponibles
await loadAvailablePlans();
// Cargar suscripción del usuario seleccionado
const response = await fetch(`/api/subscription/${username}`, {
method: 'GET',
headers: {
'Authorization': authService.getAuthHeader(),
},
});
if (response.ok) {
const data = await response.json();
selectedUserSubscription.value = data;
subscriptionForm.value = {
planId: data.subscription?.planId || 'free',
status: data.subscription?.status || 'active',
cancelAtPeriodEnd: data.subscription?.cancelAtPeriodEnd || false,
};
} else {
// Si no hay suscripción, usar valores por defecto
selectedUserSubscription.value = {
subscription: {
planId: 'free',
status: 'active',
plan: { name: 'Gratis', description: 'Plan gratuito' }
},
usage: { workers: 0, maxWorkers: 2 },
};
subscriptionForm.value = {
planId: 'free',
status: 'active',
cancelAtPeriodEnd: false,
};
}
} catch (error) {
console.error('Error cargando suscripción:', error);
subscriptionError.value = 'Error al cargar información de suscripción';
} finally {
loadingSubscription.value = false;
}
}
function closeSubscriptionModal() {
showSubscriptionModal.value = false;
selectedUserForSubscription.value = null;
selectedUserSubscription.value = null;
subscriptionError.value = '';
subscriptionSuccess.value = '';
subscriptionForm.value = {
planId: 'free',
status: 'active',
cancelAtPeriodEnd: false,
};
}
async function handleUpdateUserSubscription() {
if (!selectedUserForSubscription.value) return;
subscriptionError.value = '';
subscriptionSuccess.value = '';
loadingAction.value = true;
try {
await api.updateUserSubscription(selectedUserForSubscription.value, {
planId: subscriptionForm.value.planId,
status: subscriptionForm.value.status,
cancelAtPeriodEnd: subscriptionForm.value.cancelAtPeriodEnd,
});
subscriptionSuccess.value = 'Suscripción actualizada correctamente';
// Actualizar la información en la lista
await loadAllUserSubscriptions();
// Recargar la información del modal después de un momento
setTimeout(async () => {
await openSubscriptionModal(selectedUserForSubscription.value);
}, 1000);
} catch (error) {
console.error('Error actualizando suscripción:', error);
subscriptionError.value = error.response?.data?.error || 'Error al actualizar la suscripción';
} finally {
loadingAction.value = false;
}
}
async function handleCreateUser() {
addError.value = '';
loadingAction.value = true;
if (!userForm.value.username || !userForm.value.password || !userForm.value.passwordConfirm) {
addError.value = 'Todos los campos son requeridos';
loadingAction.value = false;
return;
}
if (userForm.value.username.length < 3) {
addError.value = 'El nombre de usuario debe tener al menos 3 caracteres';
loadingAction.value = false;
return;
}
if (userForm.value.password.length < 6) {
addError.value = 'La contraseña debe tener al menos 6 caracteres';
loadingAction.value = false;
return;
}
if (userForm.value.password !== userForm.value.passwordConfirm) {
addError.value = 'Las contraseñas no coinciden';
loadingAction.value = false;
return;
}
try {
await api.createUser({
username: userForm.value.username,
password: userForm.value.password,
});
closeAddModal();
await loadUsers();
} catch (error) {
console.error('Error creando usuario:', error);
if (error.response?.data?.error) {
addError.value = error.response.data.error;
} else {
addError.value = 'Error creando usuario. Intenta de nuevo.';
}
} finally {
loadingAction.value = false;
}
}
async function handleDeleteUser() {
if (!userToDelete.value) return;
loadingAction.value = true;
try {
await api.deleteUser(userToDelete.value);
userToDelete.value = null;
await loadUsers();
} catch (error) {
console.error('Error eliminando usuario:', error);
alert(error.response?.data?.error || 'Error eliminando usuario. Intenta de nuevo.');
} finally {
loadingAction.value = false;
}
}
function confirmDeleteUser(username) {
userToDelete.value = username;
}
function closeAddModal() {
showAddModal.value = false;
addError.value = '';
userForm.value = {
username: '',
password: '',
passwordConfirm: '',
};
}
function handleAuthLogout() {
// Cuando el usuario se desconecta globalmente, limpiar datos
users.value = [];
showAddModal.value = false;
userToDelete.value = null;
addError.value = '';
}
onMounted(() => {
loadUsers();
window.addEventListener('auth-logout', handleAuthLogout);
// Escuchar evento de login exitoso para recargar usuarios
window.addEventListener('auth-login', () => {
loadUsers();
});
});
onUnmounted(() => {
window.removeEventListener('auth-logout', handleAuthLogout);
window.removeEventListener('auth-login', loadUsers);
});
</script>

View File

@@ -0,0 +1,804 @@
<template>
<div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Workers</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Tus workers personalizados
<span v-if="currentUser" class="font-medium text-gray-700 dark:text-gray-300">(Usuario: {{ currentUser }})</span>
</p>
</div>
<div class="flex flex-wrap gap-2">
<button @click="showGeneralModal = true" class="btn btn-secondary text-xs sm:text-sm">
Configuración General
</button>
<button @click="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
+ Añadir Worker
</button>
</div>
</div>
<div v-if="loading" class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando workers...</p>
</div>
<div v-else class="space-y-4">
<!-- Workers activos -->
<div v-if="activeWorkers.length > 0">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Workers Activos ({{ activeWorkers.length }})</h2>
<div class="grid grid-cols-1 gap-4">
<div
v-for="(worker, index) in activeWorkers"
:key="index"
class="card hover:shadow-lg transition-shadow"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ worker.name }}</h3>
<span class="px-2 py-1 text-xs font-semibold rounded bg-green-100 text-green-800">
Activo
</span>
<span class="px-2 py-1 text-xs font-semibold rounded" :class="worker.platform === 'wallapop' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'">
{{ (worker.platform || 'wallapop').toUpperCase() }}
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 text-xs sm:text-sm mb-3">
<div>
<span class="text-gray-600 block mb-1">Búsqueda:</span>
<p class="font-medium">{{ worker.search_query }}</p>
</div>
<div v-if="worker.min_price || worker.max_price">
<span class="text-gray-600 block mb-1">Precio:</span>
<p class="font-medium">
{{ worker.min_price || '0' }} - {{ worker.max_price || '∞' }}
</p>
</div>
<div v-if="worker.thread_id">
<span class="text-gray-600 block mb-1">Thread ID:</span>
<p class="font-medium">{{ worker.thread_id }}</p>
</div>
<div v-if="worker.latitude && worker.longitude">
<span class="text-gray-600 block mb-1">Ubicación:</span>
<p class="font-medium">{{ worker.latitude }}, {{ worker.longitude }}</p>
</div>
<div v-if="worker.max_distance">
<span class="text-gray-600 block mb-1">Distancia Máx:</span>
<p class="font-medium">{{ worker.max_distance }} km</p>
</div>
<div v-if="worker.check_every">
<span class="text-gray-600 block mb-1">Check cada:</span>
<p class="font-medium">{{ worker.check_every }}s</p>
</div>
</div>
<!-- Filtros aplicados -->
<div v-if="hasFilters(worker)" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<details class="text-xs">
<summary class="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 font-medium">
Ver filtros ({{ countFilters(worker) }})
</summary>
<div class="mt-2 space-y-1 text-gray-600 dark:text-gray-400">
<div v-if="worker.title_exclude?.length" class="flex items-start">
<span class="font-medium mr-2">Excluir título:</span>
<span>{{ worker.title_exclude.join(', ') }}</span>
</div>
<div v-if="worker.description_exclude?.length" class="flex items-start">
<span class="font-medium mr-2">Excluir descripción:</span>
<span>{{ worker.description_exclude.join(', ') }}</span>
</div>
<div v-if="worker.title_must_include?.length" class="flex items-start">
<span class="font-medium mr-2">Requerir título:</span>
<span>{{ worker.title_must_include.join(', ') }}</span>
</div>
<div v-if="worker.description_must_include?.length" class="flex items-start">
<span class="font-medium mr-2">Requerir descripción:</span>
<span>{{ worker.description_must_include.join(', ') }}</span>
</div>
<div v-if="worker.title_first_word_exclude?.length" class="flex items-start">
<span class="font-medium mr-2">Excluir primera palabra:</span>
<span>{{ worker.title_first_word_exclude.join(', ') }}</span>
</div>
</div>
</details>
</div>
</div>
<div class="flex flex-wrap gap-2 sm:space-x-2 sm:ml-4 mt-3 sm:mt-0">
<button
@click="editWorker(worker, activeWorkersIndex(index))"
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
>
Editar
</button>
<button
@click="deleteWorker(worker.name)"
class="btn btn-danger text-xs sm:text-sm flex-1 sm:flex-none"
>
🗑 Eliminar
</button>
<button
@click="disableWorker(worker)"
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
>
Desactivar
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Workers desactivados -->
<div v-if="disabledWorkers.length > 0" class="mt-6 sm:mt-8">
<h2 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Workers Desactivados ({{ disabledWorkers.length }})</h2>
<div class="grid grid-cols-1 gap-4">
<div
v-for="(worker, index) in disabledWorkers"
:key="index"
class="card opacity-60 hover:opacity-80 transition-opacity"
>
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div class="flex-1">
<div class="flex flex-wrap items-center gap-2 mb-2">
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100">{{ worker.name }}</h3>
<span class="px-2 py-1 text-xs font-semibold rounded bg-red-100 text-red-800">
Desactivado
</span>
<span class="px-2 py-1 text-xs font-semibold rounded" :class="worker.platform === 'wallapop' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'">
{{ (worker.platform || 'wallapop').toUpperCase() }}
</span>
</div>
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">{{ worker.search_query }}</p>
</div>
<div class="flex flex-wrap gap-2 sm:space-x-2 sm:ml-4">
<button
@click="editWorker(worker, disabledWorkersIndex(index))"
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
>
Editar
</button>
<button
@click="enableWorker(worker)"
class="btn btn-primary text-xs sm:text-sm flex-1 sm:flex-none"
>
Activar
</button>
<button
@click="deleteWorker(worker.name)"
class="btn btn-danger text-xs sm:text-sm flex-1 sm:flex-none"
>
🗑 Eliminar
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="activeWorkers.length === 0 && disabledWorkers.length === 0" class="card text-center py-12">
<p class="text-gray-600 dark:text-gray-400 mb-2">No tienes workers configurados</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
Los workers son personales para cada usuario. Cada usuario gestiona sus propias búsquedas.
</p>
<button @click="showAddModal = true" class="btn btn-primary mt-4">
+ Crear primer worker
</button>
</div>
</div>
<!-- Modal para añadir/editar worker -->
<div
v-if="showAddModal || editingWorker"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-2 sm:p-4"
@click.self="closeModal"
>
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">
{{ editingWorker ? 'Editar Worker' : 'Añadir Worker' }}
</h2>
<form @submit.prevent="saveWorker" class="space-y-6">
<!-- Información básica -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-3 sm:pb-4">
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Información Básica</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nombre *</label>
<input v-model="workerForm.name" type="text" class="input" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Plataforma</label>
<select v-model="workerForm.platform" class="input">
<option value="wallapop">Wallapop</option>
<option value="vinted">Vinted</option>
</select>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Búsqueda *</label>
<input v-model="workerForm.search_query" type="text" class="input" required placeholder="ej: playstation 1" />
</div>
</div>
</div>
<!-- Precios y Thread -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-3 sm:pb-4">
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Precios y Notificaciones</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Precio Mínimo ()</label>
<input v-model.number="workerForm.min_price" type="number" class="input" min="0" step="0.01" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Precio Máximo ()</label>
<input v-model.number="workerForm.max_price" type="number" class="input" min="0" step="0.01" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Thread ID (Telegram)</label>
<div class="flex gap-2">
<input v-model.number="workerForm.thread_id" type="number" class="input flex-1" placeholder="Ej: 8" />
<button
type="button"
@click="loadTelegramThreads"
:disabled="loadingThreads"
class="btn btn-secondary text-sm whitespace-nowrap"
>
{{ loadingThreads ? 'Cargando...' : '📋 Obtener Threads' }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Opcional: ID del hilo donde enviar notificaciones</p>
<!-- Lista de threads disponibles -->
<div v-if="availableThreads.length > 0" class="mt-2 p-2 bg-gray-50 dark:bg-gray-700 rounded border border-gray-200 dark:border-gray-600 max-h-40 overflow-y-auto">
<p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Threads disponibles:</p>
<div
v-for="thread in availableThreads"
:key="thread.id"
@click="selectThread(thread.id)"
class="flex items-center justify-between p-2 mb-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div class="flex-1">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ thread.name }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">ID: {{ thread.id }}</span>
</div>
<button
type="button"
@click.stop="selectThread(thread.id)"
class="text-xs text-primary-600 hover:text-primary-700 font-medium"
>
Usar
</button>
</div>
</div>
<!-- Mensaje informativo si no hay threads -->
<div v-if="threadsMessage" class="mt-2 p-2 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded">
<p class="text-xs text-blue-800 dark:text-blue-300">{{ threadsMessage }}</p>
<p v-if="threadsInfo" class="text-xs text-blue-700 dark:text-blue-400 mt-1">{{ threadsInfo }}</p>
</div>
</div>
</div>
</div>
<!-- Ubicación -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Búsqueda Local (Opcional)</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Latitud</label>
<input v-model.number="workerForm.latitude" type="number" class="input" step="any" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Longitud</label>
<input v-model.number="workerForm.longitude" type="number" class="input" step="any" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Distancia Máxima (km)</label>
<input v-model.number="workerForm.max_distance" type="number" class="input" min="0" />
</div>
</div>
</div>
<!-- Filtros de exclusión -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Filtros de Exclusión</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Excluir palabras del título</label>
<textarea
v-model="workerForm.title_exclude_text"
class="input"
rows="3"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Si aparece alguna palabra, se excluye el artículo</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Excluir palabras de la descripción</label>
<textarea
v-model="workerForm.description_exclude_text"
class="input"
rows="3"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Excluir si primera palabra del título es</label>
<textarea
v-model="workerForm.title_first_word_exclude_text"
class="input"
rows="2"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Ej: "Reacondicionado", "Vendido", etc.</p>
</div>
</div>
</div>
<!-- Filtros de inclusión -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Filtros de Inclusión (Requeridos)</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Palabras requeridas en el título</label>
<textarea
v-model="workerForm.title_must_include_text"
class="input"
rows="3"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">TODAS las palabras deben aparecer</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Palabras requeridas en la descripción</label>
<textarea
v-model="workerForm.description_must_include_text"
class="input"
rows="3"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
</div>
</div>
</div>
<!-- Configuración avanzada -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Configuración Avanzada</h3>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Intervalo de verificación (segundos)</label>
<input v-model.number="workerForm.check_every" type="number" class="input" min="1" />
<p class="text-xs text-gray-500 mt-1">Cada cuántos segundos se actualiza la búsqueda (por defecto 30s)</p>
</div>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:space-x-2 pt-4">
<button type="button" @click="closeModal" class="btn btn-secondary text-sm sm:text-base">
Cancelar
</button>
<button type="submit" class="btn btn-primary text-sm sm:text-base">
Guardar
</button>
</div>
</form>
</div>
</div>
<!-- Modal para configuración general -->
<div
v-if="showGeneralModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-2 sm:p-4"
@click.self="closeGeneralModal"
>
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Configuración General</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Estas configuraciones se aplican a todos tus workers.
<span class="text-xs text-gray-500 dark:text-gray-400">Los filtros globales se combinan con los filtros específicos de cada worker.</span>
</p>
<form @submit.prevent="saveGeneralConfig" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras del título (global)</label>
<textarea
v-model="generalForm.title_exclude_text"
class="input"
rows="4"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras de la descripción (global)</label>
<textarea
v-model="generalForm.description_exclude_text"
class="input"
rows="4"
placeholder="Una palabra por línea o separadas por comas"
></textarea>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:space-x-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="button" @click="closeGeneralModal" class="btn btn-secondary text-sm sm:text-base">
Cancelar
</button>
<button type="submit" class="btn btn-primary text-sm sm:text-base">
Guardar
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import api from '../services/api';
import authService from '../services/auth';
const workers = ref({ items: [], disabled: [], general: {} });
const loading = ref(true);
const showAddModal = ref(false);
const showGeneralModal = ref(false);
const editingWorker = ref(null);
const currentUser = ref(authService.getUsername() || null);
const activeWorkers = computed(() => {
return workers.value.items?.filter(
w => {
const workerId = w.id || w.worker_id;
const workerName = w.name;
return !workers.value.disabled?.includes(workerId) && !workers.value.disabled?.includes(workerName);
}
) || [];
});
const disabledWorkers = computed(() => {
return workers.value.items?.filter(
w => {
const workerId = w.id || w.worker_id;
const workerName = w.name;
return workers.value.disabled?.includes(workerId) || workers.value.disabled?.includes(workerName);
}
) || [];
});
function activeWorkersIndex(index) {
return workers.value.items?.findIndex(w => w.name === activeWorkers.value[index]?.name) ?? -1;
}
function disabledWorkersIndex(index) {
return workers.value.items?.findIndex(w => w.name === disabledWorkers.value[index]?.name) ?? -1;
}
function hasFilters(worker) {
return !!(worker.title_exclude?.length || worker.description_exclude?.length ||
worker.title_must_include?.length || worker.description_must_include?.length ||
worker.title_first_word_exclude?.length);
}
function countFilters(worker) {
let count = 0;
if (worker.title_exclude?.length) count++;
if (worker.description_exclude?.length) count++;
if (worker.title_must_include?.length) count++;
if (worker.description_must_include?.length) count++;
if (worker.title_first_word_exclude?.length) count++;
return count;
}
function textToArray(text) {
if (!text || !text.trim()) return [];
return text.split(/\n|,/)
.map(s => s.trim())
.filter(s => s.length > 0);
}
function arrayToText(arr) {
if (!arr || !Array.isArray(arr) || arr.length === 0) return '';
return arr.join('\n');
}
const workerForm = ref({
name: '',
platform: 'wallapop',
search_query: '',
min_price: null,
max_price: null,
thread_id: null,
latitude: null,
longitude: null,
max_distance: null,
title_exclude_text: '',
description_exclude_text: '',
title_must_include_text: '',
description_must_include_text: '',
title_first_word_exclude_text: '',
check_every: null,
});
const generalForm = ref({
title_exclude_text: '',
description_exclude_text: '',
});
const availableThreads = ref([]);
const loadingThreads = ref(false);
const threadsMessage = ref('');
const threadsInfo = ref('');
async function loadWorkers() {
loading.value = true;
try {
workers.value = await api.getWorkers();
// Actualizar formulario general
generalForm.value = {
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
};
} catch (error) {
console.error('Error cargando workers:', error);
} finally {
loading.value = false;
}
}
async function loadTelegramThreads() {
loadingThreads.value = true;
availableThreads.value = [];
threadsMessage.value = '';
threadsInfo.value = '';
try {
const result = await api.getTelegramThreads();
if (result.success && result.threads && result.threads.length > 0) {
availableThreads.value = result.threads;
threadsMessage.value = '';
threadsInfo.value = '';
} else {
availableThreads.value = [];
threadsMessage.value = result.message || 'No se pudieron obtener los threads automáticamente';
threadsInfo.value = result.info || '';
}
} catch (error) {
console.error('Error cargando threads de Telegram:', error);
availableThreads.value = [];
threadsMessage.value = 'Error al obtener threads de Telegram. Verifica que el bot y el canal estén configurados correctamente.';
threadsInfo.value = 'Para obtener el Thread ID manualmente: 1. Haz clic derecho en el tema/hilo en Telegram 2. Selecciona "Copiar enlace del tema" 3. El número al final de la URL es el Thread ID (ej: t.me/c/1234567890/8 → Thread ID = 8)';
} finally {
loadingThreads.value = false;
}
}
function selectThread(threadId) {
workerForm.value.thread_id = threadId;
// Opcional: limpiar la lista después de seleccionar
// availableThreads.value = [];
}
function editWorker(worker, index) {
editingWorker.value = { worker, index };
workerForm.value = {
name: worker.name || '',
platform: worker.platform || 'wallapop',
search_query: worker.search_query || '',
min_price: worker.min_price || null,
max_price: worker.max_price || null,
thread_id: worker.thread_id || null,
latitude: worker.latitude || null,
longitude: worker.longitude || null,
max_distance: worker.max_distance || null,
title_exclude_text: arrayToText(worker.title_exclude || []),
description_exclude_text: arrayToText(worker.description_exclude || []),
title_must_include_text: arrayToText(worker.title_must_include || []),
description_must_include_text: arrayToText(worker.description_must_include || []),
title_first_word_exclude_text: arrayToText(worker.title_first_word_exclude || []),
check_every: worker.check_every || null,
};
showAddModal.value = true;
}
function closeModal() {
showAddModal.value = false;
editingWorker.value = null;
workerForm.value = {
name: '',
platform: 'wallapop',
search_query: '',
min_price: null,
max_price: null,
thread_id: null,
latitude: null,
longitude: null,
max_distance: null,
title_exclude_text: '',
description_exclude_text: '',
title_must_include_text: '',
description_must_include_text: '',
title_first_word_exclude_text: '',
check_every: null,
};
}
function closeGeneralModal() {
showGeneralModal.value = false;
}
async function saveWorker() {
try {
const updatedWorkers = {
...workers.value,
items: [...(workers.value.items || [])],
disabled: [...(workers.value.disabled || [])],
general: workers.value.general || {}
};
const workerData = {
id: editingWorker.value ? (workers.value.items[editingWorker.value.index]?.id || workers.value.items[editingWorker.value.index]?.worker_id || crypto.randomUUID()) : crypto.randomUUID(),
name: workerForm.value.name,
platform: workerForm.value.platform,
search_query: workerForm.value.search_query,
...(workerForm.value.min_price !== null && { min_price: workerForm.value.min_price }),
...(workerForm.value.max_price !== null && { max_price: workerForm.value.max_price }),
...(workerForm.value.thread_id !== null && { thread_id: workerForm.value.thread_id }),
...(workerForm.value.latitude !== null && { latitude: workerForm.value.latitude }),
...(workerForm.value.longitude !== null && { longitude: workerForm.value.longitude }),
...(workerForm.value.max_distance !== null && { max_distance: String(workerForm.value.max_distance) }),
...(workerForm.value.check_every !== null && { check_every: workerForm.value.check_every }),
...(textToArray(workerForm.value.title_exclude_text).length > 0 && { title_exclude: textToArray(workerForm.value.title_exclude_text) }),
...(textToArray(workerForm.value.description_exclude_text).length > 0 && { description_exclude: textToArray(workerForm.value.description_exclude_text) }),
...(textToArray(workerForm.value.title_must_include_text).length > 0 && { title_must_include: textToArray(workerForm.value.title_must_include_text) }),
...(textToArray(workerForm.value.description_must_include_text).length > 0 && { description_must_include: textToArray(workerForm.value.description_must_include_text) }),
...(textToArray(workerForm.value.title_first_word_exclude_text).length > 0 && { title_first_word_exclude: textToArray(workerForm.value.title_first_word_exclude_text) }),
};
if (editingWorker.value) {
// Editar worker existente - mantener el ID existente
const index = editingWorker.value.index;
const existingId = workers.value.items[index]?.id || workers.value.items[index]?.worker_id;
if (existingId) {
workerData.id = existingId;
}
updatedWorkers.items[index] = workerData;
} else {
// Añadir nuevo worker con ID único
updatedWorkers.items.push(workerData);
}
await api.updateWorkers(updatedWorkers);
await loadWorkers();
closeModal();
} catch (error) {
console.error('Error guardando worker:', error);
alert('Error al guardar el worker');
}
}
async function saveGeneralConfig() {
try {
const updatedWorkers = {
...workers.value,
items: workers.value.items || [],
disabled: workers.value.disabled || [],
general: {
...(textToArray(generalForm.value.title_exclude_text).length > 0 && { title_exclude: textToArray(generalForm.value.title_exclude_text) }),
...(textToArray(generalForm.value.description_exclude_text).length > 0 && { description_exclude: textToArray(generalForm.value.description_exclude_text) }),
}
};
await api.updateWorkers(updatedWorkers);
await loadWorkers();
closeGeneralModal();
} catch (error) {
console.error('Error guardando configuración general:', error);
alert('Error al guardar la configuración general');
}
}
async function disableWorker(worker) {
const workerId = worker.id || worker.worker_id;
const workerName = worker.name;
if (!confirm(`¿Desactivar el worker "${workerName}"?`)) {
return;
}
try {
const updatedWorkers = {
...workers.value,
items: workers.value.items || [],
disabled: [...(workers.value.disabled || [])]
};
// Usar ID si existe, sino usar nombre (para compatibilidad)
const identifier = workerId || workerName;
// Eliminar cualquier referencia antigua (por nombre o ID)
updatedWorkers.disabled = updatedWorkers.disabled.filter(d => d !== workerId && d !== workerName && d !== worker.worker_id);
if (!updatedWorkers.disabled.includes(identifier)) {
updatedWorkers.disabled.push(identifier);
}
await api.updateWorkers(updatedWorkers);
await loadWorkers();
} catch (error) {
console.error('Error desactivando worker:', error);
alert('Error al desactivar el worker');
}
}
async function enableWorker(worker) {
const workerId = worker.id || worker.worker_id;
const workerName = worker.name;
try {
const updatedWorkers = {
...workers.value,
items: workers.value.items || [],
disabled: [...(workers.value.disabled || [])].filter(d => d !== workerId && d !== workerName && d !== worker.worker_id)
};
await api.updateWorkers(updatedWorkers);
await loadWorkers();
} catch (error) {
console.error('Error activando worker:', error);
alert('Error al activar el worker');
}
}
async function deleteWorker(name) {
if (!confirm(`¿Eliminar permanentemente el worker "${name}"? Esta acción no se puede deshacer.`)) {
return;
}
try {
const updatedWorkers = {
...workers.value,
items: (workers.value.items || []).filter(w => w.name !== name),
disabled: (workers.value.disabled || []).filter(n => n !== name),
general: workers.value.general || {}
};
await api.updateWorkers(updatedWorkers);
await loadWorkers();
} catch (error) {
console.error('Error eliminando worker:', error);
alert('Error al eliminar el worker');
}
}
function handleWSMessage(event) {
const data = event.detail;
if (data.type === 'workers_updated') {
// Solo actualizar si es para el usuario actual (o si no especifica usuario)
if (!data.username || data.username === currentUser.value) {
workers.value = data.data;
// Actualizar formulario general
generalForm.value = {
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
};
}
}
}
// Escuchar cambios de autenticación
function handleAuthChange() {
currentUser.value = authService.getUsername() || null;
// Recargar workers si cambia el usuario
if (currentUser.value) {
loadWorkers();
}
}
onMounted(() => {
loadWorkers();
window.addEventListener('ws-message', handleWSMessage);
window.addEventListener('auth-logout', handleAuthChange);
window.addEventListener('auth-login', handleAuthChange);
});
onUnmounted(() => {
window.removeEventListener('ws-message', handleWSMessage);
window.removeEventListener('auth-logout', handleAuthChange);
window.removeEventListener('auth-login', handleAuthChange);
});
</script>

View File

@@ -0,0 +1,55 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
// Metronic-inspired color palette
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
boxShadow: {
'sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'DEFAULT': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
'md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
},
transitionDuration: {
'DEFAULT': '200ms',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'url';
export default defineConfig({
plugins: [vue()],
base: '/dashboard/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3001',
ws: true,
},
},
},
});