Refactor: Migrar UI completa a Tailwind CSS v4 + shadcn-vue
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 58s

- Reemplazar CSS nativo con Tailwind CSS v4 y utilidades custom
- Crear librería de componentes UI basada en shadcn-vue (Radix Vue)
- Componentes UI: Button, Card, Input, Textarea, Badge, Dialog, Avatar, DropdownMenu
- Migrar todos los componentes existentes a Tailwind utilities
- Convertir EventCard.js (htm) a EventCard.vue (SFC)
- Implementar sistema de temas dark/light con clase .dark
- Mantener efectos glassmorphism via @utility custom
- Eliminar styles.css legacy
This commit is contained in:
2025-11-24 18:12:24 -06:00
parent 003fdf18a7
commit 96a8f95f9e
52 changed files with 2580 additions and 1418 deletions

View File

@@ -1,180 +1,17 @@
<template>
<header class="topbar">
<div class="title">RADIUS Nucleo</div>
<div class="actions">
<a class="icon-btn" href="https://inicio.nucleoriofrio.com">
🏠 Inicio
</a>
<button class="icon-btn" @click="toggleTheme">
<img class="icon" :src="theme === 'light' ? '/icons/moon.svg' : '/icons/sun.svg'" alt="theme">
</button>
<span class="chip"><span class="muted">Estado:</span> {{ statusText }}</span>
<button class="icon-btn" @click="openAddUser">
<img class="icon" src="/icons/user-plus.svg" alt="usuario"> Usuario
</button>
<button class="icon-btn" @click="openAddGuest">
<img class="icon" src="/icons/guest.svg" alt="invitado"> Invitado
</button>
<UserDropdown />
<div class="dropdown">
<button class="icon-btn" @click="toggleSettingsMenu">
<img class="icon" src="/icons/settings.svg" alt="config"> Configuración
</button>
<div v-if="showSettingsMenu" class="menu">
<button @click="openRawDb">ver rawDB</button>
<button @click="openVlanForm">crear VLAN</button>
<hr/>
<a class="icon-btn" href="/api/users.csv">Exportar usuarios CSV</a>
<a class="icon-btn" href="/api/devices.csv">Exportar dispositivos CSV</a>
<a class="icon-btn" href="/api/vlans.csv">Exportar VLANs CSV</a>
<button class="icon-btn" @click="openImport('users')">Importar usuarios CSV</button>
<button class="icon-btn" @click="openImport('devices')">Importar dispositivos CSV</button>
<button class="icon-btn" @click="openImport('vlans')">Importar VLANs CSV</button>
</div>
</div>
</div>
</header>
<section class="shell">
<!-- Sidebar: Eventos -->
<aside class="panel" :class="{ collapsed: sidebarCollapsed }">
<div class="panel-header">
<div class="panel-title">Eventos FreeRADIUS</div>
<div class="panel-actions">
<span class="chip">Página {{ reqPage+1 }} / {{ Math.max(1, Math.ceil(filteredRequestsAll.length / pageSize)) }}</span>
<button class="icon-btn" @click="reqPage=Math.max(0, reqPage-1)">Anterior</button>
<button class="icon-btn" @click="reqPage=Math.min(Math.ceil(filteredRequestsAll.length/pageSize)-1, reqPage+1)">Siguiente</button>
<button class="icon-btn" title="Filtrar" @click="showEventFilters = true"><img class="icon" src="/icons/filter.svg" alt="filtrar"></button>
<button class="icon-btn" title="Limpiar" @click="clearRequests"><img class="icon" src="/icons/clear.svg" alt="limpiar"></button>
<button class="icon-btn" title="Test" @click="selfTest"><img class="icon" src="/icons/test.svg" alt="test"></button>
<a class="icon-btn" title="Descargar" href="/api/requests.csv" target="_blank"><img class="icon" src="/icons/download.svg" alt="descargar"></a>
<button class="icon-btn" title="Copiar" @click="copyRequests"><img class="icon" src="/icons/copy.svg" alt="copiar"></button>
<button class="icon-btn collapse-btn" title="Colapsar" @click="sidebarCollapsed = !sidebarCollapsed">{{ sidebarCollapsed ? 'Expandir' : 'Colapsar' }}</button>
</div>
</div>
<div class="scroll">
<div v-if="loading.requests" class="muted">Cargando eventos</div>
<EventCard v-for="ev in pagedRequests" :key="ev.id" :ev="ev" />
</div>
</aside>
<!-- Main: Usuarios -->
<main class="panel" :class="{ collapsed: mainCollapsed }">
<div class="panel-header">
<div class="row">
<div class="panel-title">Usuarios y Dispositivos</div>
<span class="spacer"></span>
<span class="chip" v-if="layoutMode==='user'">Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}</span>
<span class="chip" v-else>Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</span>
<button class="icon-btn" @click="layoutMode==='user' ? (userPage=Math.max(0,userPage-1)) : (devicePage=Math.max(0,devicePage-1))">Anterior</button>
<button class="icon-btn" @click="layoutMode==='user' ? (userPage=Math.min(Math.ceil(filteredUsersAll.length/pageSize)-1,userPage+1)) : (devicePage=Math.min(Math.ceil(devicesAll.length/pageSize)-1,devicePage+1))">Siguiente</button>
<button class="icon-btn" title="Vista usuarios" @click="layoutMode='user'">
<img class="icon" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
</button>
<button class="icon-btn" title="Vista dispositivos" @click="layoutMode='device'">
<img class="icon" src="/icons/layout-devices.svg" alt="dispositivos"/> Dispositivos
</button>
<button class="icon-btn" title="Filtrar" @click="showUserFilters = true"><img class="icon" src="/icons/filter.svg" alt="filtro"/></button>
<button class="icon-btn collapse-btn" title="Colapsar" @click="mainCollapsed = !mainCollapsed">{{ mainCollapsed ? 'Expandir' : 'Colapsar' }}</button>
</div>
</div>
<div class="scroll">
<div v-if="loading.users" class="muted">Cargando usuarios</div>
<template v-else>
<div v-if="layoutMode==='user'" class="grid">
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :devicesById="devicesById"
:expanded="!!userExpanded[u.username]"
@toggle-expand="userExpanded[u.username] = !userExpanded[u.username]"
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
</div>
<div v-else class="grid">
<DispositivoCard v-for="d in pagedDevices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
:expanded="!!deviceExpanded[d.id]"
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
@edit="openDeviceForm" @disconnect="disconnectDevice" />
</div>
</template>
</div>
</main>
</section>
<!-- Modals -->
<Modal :open="showEventFilters" title="Filtros de eventos" @close="showEventFilters=false">
<div class="row" style="margin:8px 0;">
<input v-model="eventFilters.text" placeholder="Buscar texto" class="toggle" style="flex:1;"/>
<select v-model="eventFilters.type" class="toggle">
<option value="">Todos</option>
<option value="authorize">authorize</option>
<option value="accounting">accounting</option>
<option value="post-auth">post-auth</option>
<option value="selftest">selftest</option>
<option value="coa-disconnect">coa-disconnect</option>
</select>
</div>
</Modal>
<Modal :open="showUserFilters" title="Filtros de usuarios" @close="showUserFilters=false">
<div class="row" style="margin:8px 0;">
<input v-model="userFilters.text" placeholder="Buscar usuario" class="toggle" style="flex:1;"/>
<select v-model="userFilters.status" class="toggle">
<option value="">Todos</option>
<option value="active">Activos</option>
<option value="disabled">Deshabilitados</option>
</select>
</div>
</Modal>
<Modal :open="showUserForm" :title="userFormMode==='edit' ? 'Editar usuario' : (userFormMode==='guest' ? 'Agregar invitado' : 'Agregar usuario')"
@close="showUserForm=false">
<UserForm :model-value="userFormModel" :mode="userFormMode" @submit="handleUserFormSubmit" @cancel="showUserForm=false" />
</Modal>
<Modal :open="showRawDb" :fullscreen="rawDbFullscreen" title="Raw DB Viewer" @close="closeRawDb">
<RawDbViewer @toggle-fullscreen="rawDbFullscreen = !rawDbFullscreen" />
</Modal>
<Modal :open="showVlan" title="Crear VLAN" @close="closeVlan">
<VlanForm @success="onVlanCreated" @cancel="closeVlan" />
</Modal>
<Modal :open="showDevice" title="Editar dispositivo" @close="closeDevice">
<DeviceForm :model="deviceFormModel" @success="onDeviceSaved" @cancel="closeDevice" />
</Modal>
<Modal :open="showImport" :title="'Importar ' + importKind + ' CSV'" @close="closeImport">
<div class="column" style="gap:8px;">
<div class="row" style="gap:8px; align-items:center; flex-wrap:wrap;">
<input id="csvFile" ref="importFile" type="file" accept=".csv,text/csv" @change="onImportFile" style="display:none;" />
<button class="icon-btn" @click="$refs.importFile.click()">Seleccionar archivo CSV</button>
<span class="muted" v-if="importFilename">{{ importFilename }}</span>
</div>
<textarea v-model="importText" rows="10" placeholder="Pega CSV aquí o selecciona un archivo" style="width:100%; background:transparent; border: 1px solid rgba(255,255,255,.12); color: inherit; padding:8px;"></textarea>
<div v-if="importError" class="muted" style="color:#ff6b6b;">{{ importError }}</div>
<div class="modal-footer">
<button class="icon-btn" @click="closeImport">Cancelar</button>
<button class="icon-btn" @click="submitImport">Importar</button>
</div>
</div>
</Modal>
<!-- Toast System -->
<Toast position="top-center" />
</template>
<script setup>
import { onMounted, reactive, ref, computed, watch } from 'vue';
import EventCard from './components/EventCard.js';
import UserCard from './components/UserCard.vue';
import DispositivoCard from './components/DispositivoCard.vue';
import Modal from './components/Modal.vue';
import UserForm from './components/UserForm.vue';
import RawDbViewer from './components/RawDbViewer.vue';
import VlanForm from './components/VlanForm.vue';
import DeviceForm from './components/DeviceForm.vue';
import Toast from './components/Toast.vue';
import UserDropdown from './components/auth/UserDropdown.vue';
import { createToastSystem, useToast } from './composables/useToast.js';
import { Button, Badge, Input, Card, CardHeader, CardTitle, CardActions, CardContent, Textarea } from '@/components/ui';
import EventCard from '@/components/EventCard.vue';
import UserCard from '@/components/UserCard.vue';
import DispositivoCard from '@/components/DispositivoCard.vue';
import Modal from '@/components/Modal.vue';
import UserForm from '@/components/UserForm.vue';
import RawDbViewer from '@/components/RawDbViewer.vue';
import VlanForm from '@/components/VlanForm.vue';
import DeviceForm from '@/components/DeviceForm.vue';
import Toast from '@/components/Toast.vue';
import UserDropdown from '@/components/auth/UserDropdown.vue';
import { createToastSystem, useToast } from '@/composables/useToast.js';
// Initialize toast system
createToastSystem();
@@ -186,11 +23,9 @@ const loading = reactive({ users: false, requests: false });
const devices = ref([]);
const userExpanded = reactive({});
const deviceExpanded = reactive({});
// formulario inline removido: se usa modal con UserForm
// Helper para detectar errores de autenticación
function isAuthError(error) {
// Si es un TypeError de fetch, probablemente es CORS (redirección de Authentik)
return error instanceof TypeError && error.message.includes('fetch');
}
@@ -214,17 +49,12 @@ async function fetchUsers() {
loading.users = true;
try {
const res = await fetch('/api/users');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
users.value = data.items || [];
} catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching users:', error);
}
if (isAuthError(error)) handleAuthError();
else console.error('Error fetching users:', error);
} finally { loading.users = false; }
}
@@ -232,34 +62,24 @@ async function fetchRequests() {
loading.requests = true;
try {
const res = await fetch('/api/requests');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
requests.value = data.items || [];
} catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching requests:', error);
}
if (isAuthError(error)) handleAuthError();
else console.error('Error fetching requests:', error);
} finally { loading.requests = false; }
}
async function fetchDevices() {
try {
const res = await fetch('/api/devices');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const data = await res.json();
devices.value = data.items || [];
} catch (error) {
if (isAuthError(error)) {
handleAuthError();
} else {
console.error('Error fetching devices:', error);
}
if (isAuthError(error)) handleAuthError();
else console.error('Error fetching devices:', error);
}
}
@@ -278,8 +98,6 @@ async function removeUser(u) {
await fetchUsers();
}
async function refreshRequests() { await fetchRequests(); }
async function clearRequests() {
await fetch('/api/requests', { method: 'DELETE' });
await fetchRequests();
@@ -327,7 +145,6 @@ function setupSse() {
}
onMounted(async () => {
// Load persisted expand state
try {
const ue = JSON.parse(localStorage.getItem('ui_userExpanded') || '{}');
Object.assign(userExpanded, ue && typeof ue === 'object' ? ue : {});
@@ -344,30 +161,19 @@ onMounted(async () => {
checkPWAStatus();
});
// PWA Detection and Toast
function checkPWAStatus() {
// Don't show if already dismissed
if (localStorage.getItem('pwa_toast_dismissed')) return;
// Check if running in standalone mode (PWA installed and active)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
// If NOT in standalone mode, user is in browser
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
if (!isStandalone) {
// Check if PWA can be installed
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
// Show install prompt
setTimeout(() => {
toast.pwa('Instala RADIUS Nucleo como aplicación para una mejor experiencia', {
title: '📱 Instalar Aplicación',
position: 'top-center',
duration: 0, // Persistent
duration: 0,
persistent: true,
action: {
label: 'Instalar',
@@ -375,9 +181,7 @@ function checkPWAStatus() {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
localStorage.setItem('pwa_toast_dismissed', 'true');
}
if (outcome === 'accepted') localStorage.setItem('pwa_toast_dismissed', 'true');
deferredPrompt = null;
}
}
@@ -385,27 +189,6 @@ function checkPWAStatus() {
});
}, 2000);
});
// If already installed but not in standalone, prompt to open in app
if ('getInstalledRelatedApps' in navigator) {
navigator.getInstalledRelatedApps().then((apps) => {
if (apps.length > 0) {
setTimeout(() => {
toast.pwa('Abre RADIUS Nucleo en la aplicación para una mejor experiencia', {
title: '📱 Abrir en App',
position: 'top-center',
duration: 10000,
action: {
label: 'Entendido',
handler: () => {
localStorage.setItem('pwa_toast_dismissed', 'true');
}
}
});
}, 2000);
}
});
}
}
}
@@ -429,6 +212,7 @@ const filteredUsersAll = computed(() => {
return true;
});
});
const pageSize = 20;
const userPage = ref(0);
const filteredUsers = computed(() => filteredUsersAll.value.slice(userPage.value*pageSize, userPage.value*pageSize + pageSize));
@@ -444,19 +228,16 @@ function usersForDevice(id) {
return users.value.filter(u => Array.isArray(u.dispositivos_utilizados) && u.dispositivos_utilizados.includes(id));
}
// Devices pagination
const devicesAll = computed(() => devices.value);
const devicePage = ref(0);
const pagedDevices = computed(() => devicesAll.value.slice(devicePage.value*pageSize, devicePage.value*pageSize + pageSize));
watch([devicesAll, () => layoutMode.value], () => { devicePage.value = 0; });
// Requests pagination (sidebar)
const filteredRequestsAll = computed(() => filteredRequests.value);
const reqPage = ref(0);
const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize));
watch(filteredRequestsAll, () => { reqPage.value = 0; });
// Persist expansion state
watch(userExpanded, (v) => {
try { localStorage.setItem('ui_userExpanded', JSON.stringify(v)); } catch {}
}, { deep: true });
@@ -475,11 +256,11 @@ function toggleTheme() {
applyTheme();
}
function applyTheme() {
document.documentElement.classList.toggle('light', theme.value === 'light');
document.documentElement.classList.toggle('dark', theme.value === 'dark');
}
const showUserForm = ref(false);
const userFormMode = ref('create'); // 'create' | 'edit' | 'guest'
const userFormMode = ref('create');
const userFormModel = ref({ username:'', password:'', vlan:'', disabled:false });
function openAddUser() {
@@ -507,19 +288,19 @@ function openDeviceForm(d) { deviceFormModel.value = d; showDevice.value = true;
function closeDevice() { showDevice.value = false; }
async function onDeviceSaved() { showDevice.value = false; await fetchDevices(); }
// Import CSV modal
const showImport = ref(false);
const importKind = ref('users');
const importText = ref('');
const importFilename = ref('');
const importError = ref('');
const importFile = ref(null);
function openImport(kind){ importKind.value = kind; importText.value=''; showImport.value = true; }
function closeImport(){ showImport.value = false; }
async function submitImport(){
importError.value = '';
const txt = (importText.value || '').trim();
if (!txt) { importError.value = 'El CSV está vacío.'; return; }
// Basic header validation
const headers = getCsvHeaders(txt);
if (!headers.length) { importError.value = 'No se encontraron columnas en el CSV.'; return; }
const need = (k)=>headers.includes(k);
@@ -567,7 +348,7 @@ function getCsvHeaders(text){
out.push(cur.trim().toLowerCase());
return out;
}
function openSettings() { showSettingsMenu.value = !showSettingsMenu.value; }
function openEditUser(u) {
userFormMode.value = 'edit';
userFormModel.value = { username: u.username, password: u.password || '', vlan: u.vlan || '', disabled: !!u.disabled };
@@ -586,3 +367,203 @@ async function handleUserFormSubmit(data) {
showUserForm.value = false;
}
</script>
<template>
<!-- Top Bar -->
<header class="sticky top-0 z-10 flex flex-wrap items-center gap-2.5 p-3 glass backdrop-blur-[14px] border-b border-pink-600/50 dark:border-pink-600/50 bg-gradient-to-r from-pink-600/20 via-transparent to-transparent">
<div class="text-base font-bold tracking-wide flex-1">RADIUS Nucleo</div>
<div class="flex flex-wrap items-center gap-2">
<Button as="a" href="https://inicio.nucleoriofrio.com" variant="ghost">
🏠 Inicio
</Button>
<Button variant="ghost" size="icon" @click="toggleTheme">
<img class="size-4 opacity-90" :src="theme === 'light' ? '/icons/moon.svg' : '/icons/sun.svg'" alt="theme">
</Button>
<Badge><span class="text-muted">Estado:</span> {{ statusText }}</Badge>
<Button @click="openAddUser">
<img class="size-4 opacity-90" src="/icons/user-plus.svg" alt="usuario"> Usuario
</Button>
<Button @click="openAddGuest">
<img class="size-4 opacity-90" src="/icons/guest.svg" alt="invitado"> Invitado
</Button>
<UserDropdown />
<div class="relative">
<Button @click="toggleSettingsMenu">
<img class="size-4 opacity-90" src="/icons/settings.svg" alt="config"> Configuración
</Button>
<div v-if="showSettingsMenu" class="absolute right-0 top-full mt-1.5 glass-card p-1.5 min-w-[200px] shadow-lg border border-pink-200 dark:border-pink-600/50 z-50 space-y-1">
<Button variant="ghost" class="w-full justify-start" @click="openRawDb">ver rawDB</Button>
<Button variant="ghost" class="w-full justify-start" @click="openVlanForm">crear VLAN</Button>
<hr class="border-border my-1" />
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/users.csv">Exportar usuarios CSV</Button>
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/devices.csv">Exportar dispositivos CSV</Button>
<Button as="a" variant="ghost" class="w-full justify-start" href="/api/vlans.csv">Exportar VLANs CSV</Button>
<Button variant="ghost" class="w-full justify-start" @click="openImport('users')">Importar usuarios CSV</Button>
<Button variant="ghost" class="w-full justify-start" @click="openImport('devices')">Importar dispositivos CSV</Button>
<Button variant="ghost" class="w-full justify-start" @click="openImport('vlans')">Importar VLANs CSV</Button>
</div>
</div>
</div>
</header>
<!-- Main Layout -->
<section class="h-[calc(100vh-54px)] grid grid-cols-[360px_1fr] gap-3 p-3 max-md:grid-cols-1 max-md:grid-rows-[auto_1fr]"
:class="{ 'grid-cols-[52px_1fr]': sidebarCollapsed && !mainCollapsed, 'grid-cols-[1fr_52px]': mainCollapsed && !sidebarCollapsed }">
<!-- Sidebar: Eventos -->
<aside>
<Card variant="panel" class="h-full border-pink-200 dark:border-pink-600/50" :class="{ 'panel-collapsed': sidebarCollapsed }">
<CardHeader>
<CardTitle>Eventos FreeRADIUS</CardTitle>
<CardActions>
<Badge>Página {{ reqPage+1 }} / {{ Math.max(1, Math.ceil(filteredRequestsAll.length / pageSize)) }}</Badge>
<Button size="sm" @click="reqPage=Math.max(0, reqPage-1)">Anterior</Button>
<Button size="sm" @click="reqPage=Math.min(Math.ceil(filteredRequestsAll.length/pageSize)-1, reqPage+1)">Siguiente</Button>
<Button size="sm" variant="ghost" title="Filtrar" @click="showEventFilters = true">
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtrar">
</Button>
<Button size="sm" variant="ghost" title="Limpiar" @click="clearRequests">
<img class="size-4 opacity-90" src="/icons/clear.svg" alt="limpiar">
</Button>
<Button size="sm" variant="ghost" title="Test" @click="selfTest">
<img class="size-4 opacity-90" src="/icons/test.svg" alt="test">
</Button>
<Button as="a" size="sm" variant="ghost" title="Descargar" href="/api/requests.csv" target="_blank">
<img class="size-4 opacity-90" src="/icons/download.svg" alt="descargar">
</Button>
<Button size="sm" variant="ghost" title="Copiar" @click="copyRequests">
<img class="size-4 opacity-90" src="/icons/copy.svg" alt="copiar">
</Button>
<Button size="sm" variant="ghost" class="hidden max-md:inline-flex" title="Colapsar" @click="sidebarCollapsed = !sidebarCollapsed">
{{ sidebarCollapsed ? 'Expandir' : 'Colapsar' }}
</Button>
</CardActions>
</CardHeader>
<CardContent v-if="!sidebarCollapsed" class="flex-1 overflow-auto scroll-custom space-y-2">
<div v-if="loading.requests" class="text-muted">Cargando eventos</div>
<EventCard v-for="ev in pagedRequests" :key="ev.id" :ev="ev" />
</CardContent>
</Card>
</aside>
<!-- Main: Usuarios -->
<main>
<Card variant="panel" class="h-full border-pink-200 dark:border-pink-600/50" :class="{ 'panel-collapsed': mainCollapsed }">
<CardHeader>
<div class="flex flex-wrap items-center gap-2 w-full">
<CardTitle>Usuarios y Dispositivos</CardTitle>
<span class="flex-1"></span>
<Badge v-if="layoutMode==='user'">Página {{ userPage+1 }} / {{ Math.max(1, Math.ceil(filteredUsersAll.length / pageSize)) }}</Badge>
<Badge v-else>Página {{ devicePage+1 }} / {{ Math.max(1, Math.ceil(devicesAll.length / pageSize)) }}</Badge>
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.max(0,userPage-1)) : (devicePage=Math.max(0,devicePage-1))">Anterior</Button>
<Button size="sm" @click="layoutMode==='user' ? (userPage=Math.min(Math.ceil(filteredUsersAll.length/pageSize)-1,userPage+1)) : (devicePage=Math.min(Math.ceil(devicesAll.length/pageSize)-1,devicePage+1))">Siguiente</Button>
<Button size="sm" :variant="layoutMode==='user' ? 'default' : 'ghost'" title="Vista usuarios" @click="layoutMode='user'">
<img class="size-4 opacity-90" src="/icons/layout-users.svg" alt="usuarios"/> Usuarios
</Button>
<Button size="sm" :variant="layoutMode==='device' ? 'default' : 'ghost'" title="Vista dispositivos" @click="layoutMode='device'">
<img class="size-4 opacity-90" src="/icons/layout-devices.svg" alt="dispositivos"/> Dispositivos
</Button>
<Button size="sm" variant="ghost" title="Filtrar" @click="showUserFilters = true">
<img class="size-4 opacity-90" src="/icons/filter.svg" alt="filtro"/>
</Button>
<Button size="sm" variant="ghost" class="hidden max-md:inline-flex" title="Colapsar" @click="mainCollapsed = !mainCollapsed">
{{ mainCollapsed ? 'Expandir' : 'Colapsar' }}
</Button>
</div>
</CardHeader>
<CardContent v-if="!mainCollapsed" class="flex-1 overflow-auto scroll-custom">
<div v-if="loading.users" class="text-muted">Cargando usuarios</div>
<template v-else>
<div v-if="layoutMode==='user'" class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :devicesById="devicesById"
:expanded="!!userExpanded[u.username]"
@toggle-expand="userExpanded[u.username] = !userExpanded[u.username]"
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser"
@disconnect="disconnectUser" @edit-device="openDeviceForm" />
</div>
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
<DispositivoCard v-for="d in pagedDevices" :key="d.id" :device="d" :users="usersForDevice(d.id)" :devicesById="devicesById"
:expanded="!!deviceExpanded[d.id]"
@toggle-expand="deviceExpanded[d.id] = !deviceExpanded[d.id]"
@edit="openDeviceForm" @disconnect="disconnectDevice" />
</div>
</template>
</CardContent>
</Card>
</main>
</section>
<!-- Modals -->
<Modal :open="showEventFilters" title="Filtros de eventos" @close="showEventFilters=false">
<div class="flex flex-wrap gap-2 my-2">
<Input v-model="eventFilters.text" placeholder="Buscar texto" class="flex-1" />
<select v-model="eventFilters.type" class="glass glass-border rounded-md px-3 py-2">
<option value="">Todos</option>
<option value="authorize">authorize</option>
<option value="accounting">accounting</option>
<option value="post-auth">post-auth</option>
<option value="selftest">selftest</option>
<option value="coa-disconnect">coa-disconnect</option>
</select>
</div>
</Modal>
<Modal :open="showUserFilters" title="Filtros de usuarios" @close="showUserFilters=false">
<div class="flex flex-wrap gap-2 my-2">
<Input v-model="userFilters.text" placeholder="Buscar usuario" class="flex-1" />
<select v-model="userFilters.status" class="glass glass-border rounded-md px-3 py-2">
<option value="">Todos</option>
<option value="active">Activos</option>
<option value="disabled">Deshabilitados</option>
</select>
</div>
</Modal>
<Modal :open="showUserForm" :title="userFormMode==='edit' ? 'Editar usuario' : (userFormMode==='guest' ? 'Agregar invitado' : 'Agregar usuario')"
@close="showUserForm=false">
<UserForm :model-value="userFormModel" :mode="userFormMode" @submit="handleUserFormSubmit" @cancel="showUserForm=false" />
</Modal>
<Modal :open="showRawDb" :fullscreen="rawDbFullscreen" title="Raw DB Viewer" @close="closeRawDb">
<RawDbViewer @toggle-fullscreen="rawDbFullscreen = !rawDbFullscreen" />
</Modal>
<Modal :open="showVlan" title="Crear VLAN" @close="closeVlan">
<VlanForm @success="onVlanCreated" @cancel="closeVlan" />
</Modal>
<Modal :open="showDevice" title="Editar dispositivo" @close="closeDevice">
<DeviceForm :model="deviceFormModel" @success="onDeviceSaved" @cancel="closeDevice" />
</Modal>
<Modal :open="showImport" :title="'Importar ' + importKind + ' CSV'" @close="closeImport">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<input ref="importFile" type="file" accept=".csv,text/csv" @change="onImportFile" class="hidden" />
<Button @click="importFile?.click()">Seleccionar archivo CSV</Button>
<span v-if="importFilename" class="text-muted text-sm">{{ importFilename }}</span>
</div>
<Textarea
v-model="importText"
:rows="10"
placeholder="Pega CSV aquí o selecciona un archivo"
/>
<div v-if="importError" class="text-sm text-red-400">{{ importError }}</div>
<div class="flex justify-end gap-2 mt-2">
<Button variant="ghost" @click="closeImport">Cancelar</Button>
<Button @click="submitImport">Importar</Button>
</div>
</div>
</Modal>
<!-- Toast System -->
<Toast position="top-center" />
</template>
<style>
/* Panel collapsed state */
.panel-collapsed .scroll-custom,
.panel-collapsed [class*="CardContent"] {
display: none;
}
</style>

180
frontend/src/app.css Normal file
View File

@@ -0,0 +1,180 @@
@import "tailwindcss";
/* ========================================
VARIABLES CSS BASE (tema dark/light)
======================================== */
/* Light mode (default when no .dark class) */
:root {
--bg: 245 245 248;
--fg: 20 20 22;
--muted: 110 110 120;
--accent: 18 108 242;
--card: 255 255 255 / 0.6;
--border: 0 0 0 / 0.08;
--glass-blur: 14px;
/* Scrollbar */
--sb-size: 10px;
--sb-thumb: rgba(255, 127, 187, 0.65);
--sb-thumb-hover: rgba(255, 127, 187, 0.82);
--sb-thumb-active: rgba(255, 110, 178, 0.95);
--sb-track: rgba(0,0,0,0.06);
}
/* Dark mode */
:root.dark {
--bg: 15 15 18;
--fg: 235 235 240;
--muted: 180 180 190;
--accent: 80 160 255;
--card: 28 28 34 / 0.55;
--border: 255 255 255 / 0.12;
--sb-thumb: rgba(255, 159, 203, 0.55);
--sb-thumb-hover: rgba(255, 159, 203, 0.75);
--sb-thumb-active: rgba(255, 127, 187, 0.9);
--sb-track: rgba(255,255,255,0.05);
}
/* ========================================
TAILWIND THEME (colores custom)
======================================== */
@theme {
/* Colores base usando CSS variables */
--color-background: rgb(var(--bg));
--color-foreground: rgb(var(--fg));
--color-muted: rgb(var(--muted));
--color-accent: rgb(var(--accent));
--color-card: rgba(var(--card));
--color-border: rgba(var(--border));
/* Colores pink/magenta (accent del diseño) */
--color-pink-50: #fff0f6;
--color-pink-100: #ffe0ed;
--color-pink-200: #ffcfe4;
--color-pink-300: #ff9fc7;
--color-pink-400: #ff7fbf;
--color-pink-500: #ff4da6;
--color-pink-600: #ff2e86;
--color-pink-700: #e01a6e;
--color-pink-800: #b8155a;
--color-pink-900: #99174d;
/* Border radius custom */
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 16px;
/* Animations */
--animate-fade-in: fade-in 0.15s ease;
--animate-slide-in: slide-in 0.2s ease;
--animate-slide-out: slide-out 0.2s ease;
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-in {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-10px); }
}
}
/* ========================================
BASE STYLES
======================================== */
* { box-sizing: border-box; }
html, body, #app { height: 100%; }
html, body {
margin: 0;
padding: 0;
background: rgb(var(--bg));
color: rgb(var(--fg));
}
body {
font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}
button { cursor: pointer; }
a { color: inherit; }
/* ========================================
GLASSMORPHISM UTILITIES
======================================== */
@utility glass {
backdrop-filter: blur(var(--glass-blur));
background: rgba(var(--card));
}
@utility glass-border {
border: 1px solid rgba(var(--border));
}
@utility glass-card {
backdrop-filter: blur(var(--glass-blur));
background: rgba(var(--card));
border: 1px solid rgba(var(--border));
border-radius: var(--radius-lg);
}
@utility glass-panel {
backdrop-filter: blur(var(--glass-blur));
background: linear-gradient(rgba(var(--card)), rgba(var(--card))) padding-box;
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ========================================
CUSTOM SCROLLBARS
======================================== */
/* Firefox */
html, body, .scroll-custom {
scrollbar-width: thin;
scrollbar-color: var(--sb-thumb) transparent;
}
/* WebKit */
html::-webkit-scrollbar,
body::-webkit-scrollbar,
.scroll-custom::-webkit-scrollbar {
width: var(--sb-size);
height: var(--sb-size);
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track,
.scroll-custom::-webkit-scrollbar-track {
background: transparent;
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb,
.scroll-custom::-webkit-scrollbar-thumb {
background: var(--sb-thumb);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
box-shadow: 0 0 10px rgba(255, 46, 134, 0.15);
}
html::-webkit-scrollbar-thumb:hover,
body::-webkit-scrollbar-thumb:hover,
.scroll-custom::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
background-clip: content-box;
}
html::-webkit-scrollbar-thumb:active,
body::-webkit-scrollbar-thumb:active,
.scroll-custom::-webkit-scrollbar-thumb:active {
background: var(--sb-thumb-active);
background-clip: content-box;
}

View File

@@ -1,35 +1,6 @@
<template>
<form @submit.prevent="submit" class="column" style="gap:10px;">
<div class="row">
<label class="toggle" style="flex:0 0 180px;">
<div class="muted" style="font-size:12px;">ID (DB)</div>
<input :value="model.id" readonly style="width:100%; background:transparent; border:none; outline:none; color:inherit;" />
</label>
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">MAC</div>
<input :value="model.mac" readonly style="width:100%; background:transparent; border:none; outline:none; color:inherit;" />
</label>
</div>
<div class="row">
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">Nombre</div>
<input v-model="state.nombre" placeholder="Nombre del dispositivo" style="width:100%; background:transparent; border:none; outline:none; color:inherit;" />
</label>
</div>
<label class="toggle" style="width:100%;">
<div class="muted" style="font-size:12px;">Descripción</div>
<textarea v-model="state.descripcion" rows="3" placeholder="Descripción opcional" style="width:100%; background:transparent; border:none; outline:none; color:inherit; resize: vertical;"></textarea>
</label>
<div v-if="error" class="muted" style="color:#ff6b6b;">{{ error }}</div>
<div class="modal-footer">
<button type="button" class="icon-btn" @click="$emit('cancel')">Cancelar</button>
<button type="submit" class="icon-btn">Guardar</button>
</div>
</form>
</template>
<script setup>
import { reactive, watch, ref } from 'vue';
import { Input, Textarea, Label, Button } from '@/components/ui';
const props = defineProps({ model: { type: Object, required: true } });
const emit = defineEmits(['success', 'cancel']);
@@ -50,7 +21,10 @@ async function submit() {
body: JSON.stringify({ nombre: state.nombre, descripcion: state.descripcion })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) { error.value = data.error || 'Error al guardar'; return; }
if (!res.ok || data.ok === false) {
error.value = data.error || 'Error al guardar';
return;
}
emit('success');
} catch (e) {
error.value = String(e?.message || e) || 'Error';
@@ -58,3 +32,41 @@ async function submit() {
}
</script>
<template>
<form @submit.prevent="submit" class="flex flex-col gap-3">
<div class="flex flex-wrap gap-3">
<div class="w-[180px] space-y-1">
<Label class="text-xs text-muted">ID (DB)</Label>
<Input :model-value="model.id" readonly />
</div>
<div class="flex-1 min-w-[140px] space-y-1">
<Label class="text-xs text-muted">MAC</Label>
<Input :model-value="model.mac" readonly />
</div>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted">Nombre</Label>
<Input
v-model="state.nombre"
placeholder="Nombre del dispositivo"
/>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted">Descripción</Label>
<Textarea
v-model="state.descripcion"
:rows="3"
placeholder="Descripción opcional"
/>
</div>
<div v-if="error" class="text-sm text-red-400">{{ error }}</div>
<div class="flex justify-end gap-2 mt-2">
<Button type="button" variant="ghost" @click="emit('cancel')">Cancelar</Button>
<Button type="submit">Guardar</Button>
</div>
</form>
</template>

View File

@@ -1,29 +1,6 @@
<template>
<div class="card">
<div class="row">
<b>{{ device.nombre || device.mac }}</b>
<span class="chip">MAC: {{ device.mac }}</span>
<span class="chip">ID: {{ device.id }}</span>
<span v-if="connectedCount>0 || connected" class="chip" style="background: rgba(255,127,187,.2); border-color: rgba(255,127,187,.5);">Conectado</span>
<span class="spacer"></span>
<button class="icon-btn" @click="$emit('edit', device)">Editar</button>
<button class="icon-btn" @click="$emit('disconnect', device)">Desconectar</button>
<button v-if="!simple" class="icon-btn" @click="$emit('toggleExpand')">{{ expanded ? 'Contraer' : 'Expandir' }}</button>
</div>
<div class="muted" style="font-size:12px; margin-top:6px;" v-if="device.nombre || device.descripcion">
<div v-if="device.nombre">Nombre: {{ device.nombre }}</div>
<div v-if="device.descripcion">Descripción: {{ device.descripcion }}</div>
</div>
<div v-if="expanded && users && users.length" style="margin-top:8px;">
<div class="grid">
<UserCard v-for="u in users" :key="u.username" :item="u" :devicesById="devicesById" :expandable="false" />
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Card, Badge, Button } from '@/components/ui';
import UserCard from './UserCard.vue';
const props = defineProps({
@@ -34,9 +11,48 @@ const props = defineProps({
connected: { type: Boolean, default: false },
expanded: { type: Boolean, default: false }
});
const emit = defineEmits(['edit', 'disconnect', 'toggleExpand']);
const connectedCount = computed(() => {
if (!props.users || !props.users.length) return props.connected ? 1 : 0;
const id = props.device.id;
return props.users.filter(u => Array.isArray(u.dispositivos_conectados) && u.dispositivos_conectados.includes(id)).length;
});
</script>
<template>
<Card class="p-3">
<div class="flex flex-wrap items-center gap-2">
<b>{{ device.nombre || device.mac }}</b>
<Badge>MAC: {{ device.mac }}</Badge>
<Badge variant="secondary">ID: {{ device.id }}</Badge>
<Badge v-if="connectedCount > 0 || connected" variant="pink">
Conectado
</Badge>
<span class="flex-1"></span>
<Button size="sm" @click="emit('edit', device)">Editar</Button>
<Button size="sm" variant="danger" @click="emit('disconnect', device)">Desconectar</Button>
<Button v-if="!simple" size="sm" variant="ghost" @click="emit('toggleExpand')">
{{ expanded ? 'Contraer' : 'Expandir' }}
</Button>
</div>
<div v-if="device.nombre || device.descripcion" class="text-muted text-xs mt-1.5">
<div v-if="device.nombre">Nombre: {{ device.nombre }}</div>
<div v-if="device.descripcion">Descripción: {{ device.descripcion }}</div>
</div>
<div v-if="expanded && users && users.length" class="mt-2">
<div class="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-2.5">
<UserCard
v-for="u in users"
:key="u.username"
:item="u"
:devicesById="devicesById"
:expandable="false"
/>
</div>
</div>
</Card>
</template>

View File

@@ -1,27 +0,0 @@
import { defineComponent, h } from 'vue';
import htm from 'htm';
const html = htm.bind(h);
export default defineComponent({
name: 'EventCard',
props: { ev: { type: Object, required: true } },
setup(props) {
return () => {
const a = props.ev.attrs || {};
return html`<div class="card">
<div class="row">
<b>${props.ev.type}</b>
<span class="chip muted">${props.ev.ts}</span>
${props.ev.decision ? html`<span class="chip">Decision: ${props.ev.decision}</span>` : ''}
${props.ev.error ? html`<span class="chip" style="color:#b33">Error</span>` : ''}
</div>
<div class="muted" style="margin-top:6px; font-size:12px;">
${a['User-Name'] || a['User-Name*0'] ? html`<span>User: ${a['User-Name'] || a['User-Name*0']}</span>` : ''}
${a['NAS-IP-Address'] ? html`<span> — NAS: ${a['NAS-IP-Address']}</span>` : ''}
${a['Calling-Station-Id'] ? html`<span> — STA: ${a['Calling-Station-Id']}</span>` : ''}
</div>
</div>`;
};
}
});

View File

@@ -0,0 +1,28 @@
<script setup>
import { computed } from 'vue';
import { Card, Badge } from '@/components/ui';
const props = defineProps({
ev: { type: Object, required: true }
});
const attrs = computed(() => props.ev.attrs || {});
const userName = computed(() => attrs.value['User-Name'] || attrs.value['User-Name*0']);
</script>
<template>
<Card class="p-3">
<div class="flex flex-wrap items-center gap-2">
<b>{{ ev.type }}</b>
<Badge variant="secondary">{{ ev.ts }}</Badge>
<Badge v-if="ev.decision">Decision: {{ ev.decision }}</Badge>
<Badge v-if="ev.error" variant="danger">Error</Badge>
</div>
<div class="text-muted text-xs mt-1.5">
<span v-if="userName">User: {{ userName }}</span>
<span v-if="attrs['NAS-IP-Address']"> NAS: {{ attrs['NAS-IP-Address'] }}</span>
<span v-if="attrs['Calling-Station-Id']"> STA: {{ attrs['Calling-Station-Id'] }}</span>
</div>
</Card>
</template>

View File

@@ -1,23 +1,36 @@
<template>
<div v-if="open" class="modal-backdrop" @click.self="$emit('close')">
<div class="modal" :class="{ fullscreen }">
<div class="modal-header">
<strong>{{ title }}</strong>
<button class="icon-btn" @click="$emit('close')">Cerrar</button>
</div>
<div>
<slot />
</div>
<div class="modal-footer">
<slot name="footer">
<button class="icon-btn" @click="$emit('close')">OK</button>
</slot>
</div>
</div>
</div>
</template>
<script setup>
defineProps({ open: Boolean, title: String, fullscreen: { type: Boolean, default: false } });
defineEmits(['close']);
import { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui';
import { Button } from '@/components/ui';
const props = defineProps({
open: Boolean,
title: String,
fullscreen: { type: Boolean, default: false }
});
const emit = defineEmits(['close', 'update:open']);
const handleClose = () => {
emit('close');
emit('update:open', false);
};
</script>
<template>
<Dialog :open="open" :fullscreen="fullscreen" @update:open="handleClose">
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<Button variant="ghost" size="sm" @click="handleClose">Cerrar</Button>
</DialogHeader>
<div :class="fullscreen ? 'flex-1 overflow-auto' : ''">
<slot />
</div>
<DialogFooter>
<slot name="footer">
<Button @click="handleClose">OK</Button>
</slot>
</DialogFooter>
</Dialog>
</template>

View File

@@ -1,59 +1,9 @@
<template>
<div>
<div class="row" style="gap:6px; margin-bottom:10px; flex-wrap:wrap;">
<button v-for="t in tables" :key="t" class="icon-btn" :class="{ active: t===active }" @click="select(t)">{{ t }}</button>
</div>
<div class="row" style="gap:8px; margin-bottom:8px; flex-wrap:wrap;">
<input v-model="q" class="toggle" placeholder="Buscar en página (texto)" style="flex:1; min-width:240px;" />
<label class="row toggle" style="gap:6px;">
Tamaño de página
<select v-model.number="limit" @change="reload()" style="background:transparent; border:none; color:inherit;">
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="250">250</option>
<option :value="500">500</option>
</select>
</label>
<span class="chip">{{ offset + 1 }}{{ Math.min(offset + limit, total) }} / {{ total }}</span>
<div class="row" style="gap:6px;">
<button class="icon-btn" :disabled="offset<=0" @click="prev">Anterior</button>
<button class="icon-btn" :disabled="offset+limit>=total" @click="next">Siguiente</button>
</div>
<button class="icon-btn" @click="exportCsv">Exportar CSV</button>
<button class="icon-btn" @click="$emit('toggle-fullscreen')">Fullscreen</button>
</div>
<div class="panel" style="max-height: 60vh;">
<div class="scroll">
<div v-if="loading" class="muted">Cargando</div>
<div v-else>
<table v-if="columns.length" style="width:100%; border-collapse: collapse;">
<thead>
<tr>
<th v-for="c in columns" :key="c" @click="toggleSort(c)" style="user-select:none; cursor:pointer; text-align:left; padding:6px; border-bottom: 1px solid rgba(255,255,255,.08);">
{{ c }}
<span v-if="sortBy===c">{{ sortDir==='asc' ? '' : '' }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in sortedRows" :key="idx">
<td v-for="c in columns" :key="c" style="padding:6px; border-bottom: 1px solid rgba(255,255,255,.06); font-size:12px;">
<pre style="margin:0; white-space: pre-wrap;">{{ fmt(row[c]) }}</pre>
</td>
</tr>
</tbody>
</table>
<div v-else class="muted">Sin columnas</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue';
import { Button, Badge, Input, Card } from '@/components/ui';
import { cn } from '@/lib/utils';
const emit = defineEmits(['toggle-fullscreen']);
const tables = ref([]);
const active = ref('');
@@ -120,7 +70,6 @@ const sortedRows = computed(() => {
arr.sort((a, b) => {
const va = a[key];
const vb = b[key];
// numeric compare if both look numeric
const na = typeof va === 'number' || (/^-?\d+(\.\d+)?$/.test(String(va)) ? Number(va) : NaN);
const nb = typeof vb === 'number' || (/^-?\d+(\.\d+)?$/.test(String(vb)) ? Number(vb) : NaN);
if (!Number.isNaN(na) && !Number.isNaN(nb)) return (na - nb) * dir;
@@ -163,6 +112,85 @@ function exportCsv() {
}
</script>
<style scoped>
.icon-btn.active { outline: 2px solid rgba(255,127,187,.6); }
</style>
<template>
<div>
<!-- Table selector -->
<div class="flex flex-wrap gap-1.5 mb-3">
<Button
v-for="t in tables"
:key="t"
:variant="t === active ? 'default' : 'ghost'"
size="sm"
:class="t === active && 'ring-2 ring-pink-400/60'"
@click="select(t)"
>
{{ t }}
</Button>
</div>
<!-- Controls -->
<div class="flex flex-wrap items-center gap-2 mb-2">
<Input
v-model="q"
placeholder="Buscar en página (texto)"
class="flex-1 min-w-[240px]"
/>
<label class="flex items-center gap-2 glass glass-border rounded-md px-3 py-2">
<span class="text-sm">Tamaño de página</span>
<select
v-model.number="limit"
@change="reload()"
class="bg-transparent border-none text-inherit text-sm cursor-pointer"
>
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="250">250</option>
<option :value="500">500</option>
</select>
</label>
<Badge>{{ offset + 1 }}{{ Math.min(offset + limit, total) }} / {{ total }}</Badge>
<div class="flex gap-1.5">
<Button size="sm" :disabled="offset <= 0" @click="prev">Anterior</Button>
<Button size="sm" :disabled="offset + limit >= total" @click="next">Siguiente</Button>
</div>
<Button size="sm" @click="exportCsv">Exportar CSV</Button>
<Button size="sm" variant="ghost" @click="emit('toggle-fullscreen')">Fullscreen</Button>
</div>
<!-- Table -->
<Card variant="panel" class="max-h-[60vh] border-pink-200 dark:border-pink-600/50">
<div class="overflow-auto p-3 scroll-custom">
<div v-if="loading" class="text-muted">Cargando</div>
<div v-else>
<table v-if="columns.length" class="w-full border-collapse">
<thead>
<tr>
<th
v-for="c in columns"
:key="c"
@click="toggleSort(c)"
class="select-none cursor-pointer text-left p-1.5 border-b border-white/10 text-sm font-medium hover:bg-white/5"
>
{{ c }}
<span v-if="sortBy === c" class="ml-1">{{ sortDir === 'asc' ? '' : '' }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in sortedRows" :key="idx" class="hover:bg-white/5">
<td
v-for="c in columns"
:key="c"
class="p-1.5 border-b border-white/5 text-xs"
>
<pre class="m-0 whitespace-pre-wrap font-mono">{{ fmt(row[c]) }}</pre>
</td>
</tr>
</tbody>
</table>
<div v-else class="text-muted">Sin columnas</div>
</div>
</div>
</Card>
</div>
</template>

View File

@@ -1,32 +1,6 @@
<template>
<Teleport to="body">
<div :class="['toast-container', position]">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="['toast', toast.type]"
@click="removeToast(toast.id)"
>
<div class="toast-content">
<div class="toast-icon">{{ getIcon(toast.type) }}</div>
<div class="toast-text">
<div v-if="toast.title" class="toast-title">{{ toast.title }}</div>
<div class="toast-message">{{ toast.message }}</div>
</div>
<button v-if="toast.action" class="toast-action" @click.stop="toast.action.handler">
{{ toast.action.label }}
</button>
<button v-if="!toast.persistent" class="toast-close" @click.stop="removeToast(toast.id)"></button>
</div>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup>
import { inject } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
position: {
@@ -49,167 +23,78 @@ function getIcon(type) {
};
return icons[type] || '';
}
const positionClasses = {
'top-left': 'top-5 left-5',
'top-center': 'top-5 left-1/2 -translate-x-1/2',
'top-right': 'top-5 right-5',
'bottom-left': 'bottom-5 left-5',
'bottom-center': 'bottom-5 left-1/2 -translate-x-1/2',
'bottom-right': 'bottom-5 right-5'
};
const typeClasses = {
success: 'border-l-4 border-l-green-400',
error: 'border-l-4 border-l-red-400',
warning: 'border-l-4 border-l-yellow-400',
info: 'border-l-4 border-l-blue-400',
pwa: 'border-l-4 border-l-purple-400 bg-gradient-to-br from-purple-500/15 to-transparent'
};
</script>
<template>
<Teleport to="body">
<div :class="cn(
'fixed z-[9999] flex flex-col gap-3 pointer-events-none max-w-[420px]',
positionClasses[position]
)">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
:class="cn(
'pointer-events-auto glass backdrop-blur-[10px] rounded-xl p-4 shadow-lg cursor-pointer',
'border border-black/10 dark:border-white/10',
'min-w-[280px] transition-all duration-300 hover:-translate-y-0.5 hover:shadow-xl',
'bg-[rgba(255,255,255,0.95)] dark:bg-[rgba(30,30,30,0.95)]',
typeClasses[toast.type]
)"
@click="removeToast(toast.id)"
>
<div class="flex items-start gap-3">
<div class="text-xl flex-shrink-0 size-7 flex items-center justify-center rounded-lg bg-black/5 dark:bg-white/10">
{{ getIcon(toast.type) }}
</div>
<div class="flex-1 min-w-0">
<div v-if="toast.title" class="font-semibold text-sm mb-1 text-black/95 dark:text-white/95">
{{ toast.title }}
</div>
<div class="text-[13px] text-black/75 dark:text-white/75 leading-relaxed">
{{ toast.message }}
</div>
</div>
<button
v-if="toast.action"
class="px-3 py-1.5 bg-black/10 dark:bg-white/15 border border-black/15 dark:border-white/20 rounded-md text-black/90 dark:text-white text-xs font-semibold whitespace-nowrap transition-all hover:bg-black/15 dark:hover:bg-white/25 hover:scale-105"
@click.stop="toast.action.handler"
>
{{ toast.action.label }}
</button>
<button
v-if="!toast.persistent"
class="size-5 flex items-center justify-center rounded text-black/50 dark:text-white/50 hover:bg-black/10 dark:hover:bg-white/10 hover:text-black/90 dark:hover:text-white/90 transition-all"
@click.stop="removeToast(toast.id)"
>
</button>
</div>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-container {
position: fixed;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
max-width: 420px;
}
.toast-container.top-left {
top: 20px;
left: 20px;
}
.toast-container.top-center {
top: 20px;
left: 50%;
transform: translateX(-50%);
}
.toast-container.top-right {
top: 20px;
right: 20px;
}
.toast-container.bottom-left {
bottom: 20px;
left: 20px;
}
.toast-container.bottom-center {
bottom: 20px;
left: 50%;
transform: translateX(-50%);
}
.toast-container.bottom-right {
bottom: 20px;
right: 20px;
}
.toast {
pointer-events: auto;
background: rgba(30, 30, 30, 0.95);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.3s ease;
min-width: 280px;
}
.toast:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.toast.success {
border-left: 4px solid #4ade80;
}
.toast.error {
border-left: 4px solid #f87171;
}
.toast.warning {
border-left: 4px solid #fbbf24;
}
.toast.info {
border-left: 4px solid #60a5fa;
}
.toast.pwa {
border-left: 4px solid #a78bfa;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(30, 30, 30, 0.95) 100%);
}
.toast-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.toast-icon {
font-size: 20px;
line-height: 1;
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
}
.toast-text {
flex: 1;
min-width: 0;
}
.toast-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: rgba(255, 255, 255, 0.95);
}
.toast-message {
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.4;
}
.toast-action {
padding: 6px 12px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.toast-action:hover {
background: rgba(255, 255, 255, 0.25);
transform: scale(1.05);
}
.toast-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: 18px;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
flex-shrink: 0;
}
.toast-close:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}
/* Animations */
.toast-enter-active,
.toast-leave-active {
@@ -227,51 +112,14 @@ function getIcon(type) {
}
/* Top center animations */
.toast-container.top-center .toast-enter-from {
.top-center .toast-enter-from {
transform: translateY(-100px);
}
.toast-container.top-center .toast-leave-to {
.top-center .toast-leave-to {
transform: translateY(-100px) scale(0.8);
}
/* Light theme adjustments */
:global(.light) .toast {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.light) .toast-title {
color: rgba(0, 0, 0, 0.95);
}
:global(.light) .toast-message {
color: rgba(0, 0, 0, 0.75);
}
:global(.light) .toast-icon {
background: rgba(0, 0, 0, 0.05);
}
:global(.light) .toast-action {
background: rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.12);
color: rgba(0, 0, 0, 0.9);
}
:global(.light) .toast-action:hover {
background: rgba(0, 0, 0, 0.15);
}
:global(.light) .toast-close {
color: rgba(0, 0, 0, 0.5);
}
:global(.light) .toast-close:hover {
background: rgba(0, 0, 0, 0.08);
color: rgba(0, 0, 0, 0.9);
}
@media (max-width: 640px) {
.toast-container {
max-width: calc(100vw - 40px);
@@ -279,9 +127,5 @@ function getIcon(type) {
right: 20px !important;
transform: none !important;
}
.toast {
min-width: 0;
}
}
</style>

View File

@@ -1,79 +1,7 @@
<template>
<div class="user-card" :class="{ 'is-connected': hasConnected, 'is-disabled': item.disabled }">
<div class="card-inner">
<div class="card-header">
<div class="user-initial">{{ userInitial }}</div>
<h3 class="user-name">{{ item.username }}</h3>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">vlan</span>
<span class="info-value">{{ item.vlan }}</span>
</div>
<div class="info-row">
<span class="info-label">estado</span>
<span class="info-value">{{ item.disabled ? 'deshabilitado' : 'activo' }}</span>
</div>
<div v-if="hasConnected" class="info-row">
<span class="info-label">conexión</span>
<span class="info-value status-connected">conectado</span>
</div>
<div v-if="item.etiquetas && item.etiquetas.length" class="info-row">
<span class="info-label">etiquetas</span>
<span class="info-value tags">
<span v-for="tag in item.etiquetas" :key="tag" class="tag">{{ tag }}</span>
</span>
</div>
</div>
<div class="card-actions">
<button class="action-btn" @click="$emit('edit', item)" title="Editar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="action-btn" @click="$emit('disconnect', item)" title="Desconectar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path>
<line x1="12" y1="2" x2="12" y2="12"></line>
</svg>
</button>
<button class="action-btn" @click="$emit('toggleDisable', item)" :title="item.disabled ? 'Habilitar' : 'Deshabilitar'">
<svg v-if="item.disabled" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
</button>
<button class="action-btn action-btn--danger" @click="$emit('remove', item)" title="Eliminar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
<button v-if="expandable" class="action-btn" @click="$emit('toggleExpand')" :title="expanded ? 'Contraer' : 'Expandir'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :style="{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
<div v-if="expanded && deviceList.length" class="card-devices">
<div class="devices-grid">
<DispositivoCard v-for="d in deviceList" :key="d.id" :device="d" :connected="isConnected(d.id)" simple @edit="$emit('editDevice', d)" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Card, Badge, Button } from '@/components/ui';
import { cn } from '@/lib/utils';
import DispositivoCard from './DispositivoCard.vue';
const props = defineProps({
@@ -83,6 +11,8 @@ const props = defineProps({
expanded: { type: Boolean, default: false }
});
const emit = defineEmits(['edit', 'disconnect', 'toggleDisable', 'remove', 'toggleExpand', 'editDevice']);
const deviceList = computed(() => {
const ids = props.item.dispositivos_utilizados || [];
return ids.map(id => props.devicesById[id]).filter(Boolean);
@@ -100,191 +30,101 @@ const userInitial = computed(() => {
});
</script>
<style scoped>
.user-card {
position: relative;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
<template>
<div :class="cn(
'relative transition-all duration-300',
hasConnected && 'user-card-connected',
item.disabled && 'opacity-60'
)">
<Card :class="cn(
'p-5 transition-all duration-300 hover:shadow-lg',
hasConnected && 'border-pink-400/30'
)">
<!-- Header -->
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-border">
<div :class="cn(
'size-9 flex items-center justify-center rounded-lg text-sm font-semibold',
hasConnected ? 'bg-pink-400/12 text-pink-400' : 'bg-accent/10 text-accent'
)">
{{ userInitial }}
</div>
<h3 class="text-lg font-semibold tracking-tight">{{ item.username }}</h3>
</div>
.card-inner {
position: relative;
background: rgba(var(--card));
border: 1px solid rgba(var(--border));
border-radius: 16px;
padding: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.05),
0 2px 8px rgba(0, 0, 0, 0.03);
}
<!-- Body -->
<div class="flex flex-col gap-2.5 mb-4">
<div class="flex justify-between items-center text-sm">
<span class="text-muted font-medium lowercase tracking-wide">vlan</span>
<span class="font-medium tabular-nums">{{ item.vlan }}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-muted font-medium lowercase tracking-wide">estado</span>
<span class="font-medium">{{ item.disabled ? 'deshabilitado' : 'activo' }}</span>
</div>
<div v-if="hasConnected" class="flex justify-between items-center text-sm">
<span class="text-muted font-medium lowercase tracking-wide">conexión</span>
<span class="font-medium text-pink-400">conectado</span>
</div>
<div v-if="item.etiquetas && item.etiquetas.length" class="flex justify-between items-center text-sm">
<span class="text-muted font-medium lowercase tracking-wide">etiquetas</span>
<div class="flex gap-1.5 flex-wrap">
<Badge v-for="tag in item.etiquetas" :key="tag" variant="secondary" class="text-[11px] py-0.5">
{{ tag }}
</Badge>
</div>
</div>
</div>
.user-card:hover .card-inner {
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.08),
0 8px 32px rgba(0, 0, 0, 0.06),
0 16px 48px rgba(0, 0, 0, 0.04);
}
<!-- Actions -->
<div class="flex gap-1.5 pt-4 border-t border-border">
<Button variant="ghost" size="icon" @click="emit('edit', item)" title="Editar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</Button>
<Button variant="ghost" size="icon" @click="emit('disconnect', item)" title="Desconectar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path>
<line x1="12" y1="2" x2="12" y2="12"></line>
</svg>
</Button>
<Button variant="ghost" size="icon" @click="emit('toggleDisable', item)" :title="item.disabled ? 'Habilitar' : 'Deshabilitar'">
<svg v-if="item.disabled" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
</Button>
<Button variant="danger" size="icon" @click="emit('remove', item)" title="Eliminar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</Button>
<Button v-if="expandable" variant="ghost" size="icon" @click="emit('toggleExpand')" :title="expanded ? 'Contraer' : 'Expandir'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="expanded ? 'rotate-180' : ''" class="transition-transform">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</Button>
</div>
/* Estados */
.user-card.is-connected .card-inner {
border-color: rgba(255, 127, 187, 0.3);
}
.user-card.is-disabled .card-inner {
opacity: 0.6;
}
/* Header */
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(var(--border));
}
.user-initial {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(var(--accent), 0.08);
color: rgb(var(--accent));
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
}
.user-card.is-connected .user-initial {
background: rgba(255, 127, 187, 0.12);
color: rgb(255, 127, 187);
}
.user-name {
margin: 0;
font-size: 18px;
font-weight: 600;
color: rgb(var(--fg));
letter-spacing: -0.01em;
}
/* Body */
.card-body {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
line-height: 1.5;
}
.info-label {
color: rgb(var(--muted));
font-weight: 500;
text-transform: lowercase;
letter-spacing: 0.02em;
}
.info-value {
color: rgb(var(--fg));
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.status-connected {
color: rgb(255, 127, 187);
}
.tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.tag {
padding: 2px 8px;
background: rgba(var(--accent), 0.08);
border-radius: 6px;
font-size: 11px;
font-weight: 500;
color: rgb(var(--accent));
}
/* Actions */
.card-actions {
display: flex;
gap: 6px;
padding-top: 16px;
border-top: 1px solid rgba(var(--border));
}
.action-btn {
flex: 1;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--border));
border-radius: 8px;
background: transparent;
color: rgb(var(--muted));
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.action-btn:hover {
background: rgba(var(--accent), 0.06);
border-color: rgba(var(--accent), 0.2);
color: rgb(var(--accent));
}
.action-btn:active {
opacity: 0.8;
}
.action-btn--danger:hover {
background: rgba(255, 107, 107, 0.06);
border-color: rgba(255, 107, 107, 0.2);
color: rgb(255, 107, 107);
}
.action-btn svg {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Devices */
.card-devices {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(var(--border));
}
.devices-grid {
display: grid;
gap: 12px;
}
/* Responsive */
@media (max-width: 640px) {
.card-inner {
padding: 16px;
}
.user-name {
font-size: 16px;
}
.action-btn {
height: 36px;
}
}
</style>
<!-- Devices -->
<div v-if="expanded && deviceList.length" class="mt-4 pt-4 border-t border-border">
<div class="grid gap-3">
<DispositivoCard
v-for="d in deviceList"
:key="d.id"
:device="d"
:connected="isConnected(d.id)"
simple
@edit="emit('editDevice', d)"
/>
</div>
</div>
</Card>
</div>
</template>

View File

@@ -1,40 +1,6 @@
<template>
<form @submit.prevent="submit" class="column" style="gap:10px;">
<div class="row">
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">Usuario</div>
<input v-model="state.username" :readonly="isEdit" placeholder="usuario" style="width:100%; background:transparent; border:none; outline:none; color:inherit;"/>
</label>
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">Contraseña</div>
<input v-model="state.password" placeholder="contraseña" style="width:100%; background:transparent; border:none; outline:none; color:inherit;"/>
</label>
</div>
<div class="row">
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">Etiquetas (separadas por coma)</div>
<input v-model="etiquetasText" placeholder="invitado, vip" style="width:100%; background:transparent; border:none; outline:none; color:inherit;" />
</label>
</div>
<div class="row">
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">VLAN</div>
<input v-model="state.vlan" placeholder="VLAN" style="width:100%; background:transparent; border:none; outline:none; color:inherit;"/>
</label>
<label class="row toggle" style="gap:6px;">
<input type="checkbox" v-model="state.disabled"/>
Deshabilitado
</label>
</div>
<div class="modal-footer">
<button type="button" class="icon-btn" @click="$emit('cancel')">Cancelar</button>
<button type="submit" class="icon-btn">Guardar</button>
</div>
</form>
</template>
<script setup>
import { reactive, watch, computed, ref } from 'vue';
import { Input, Label, Button } from '@/components/ui';
const props = defineProps({
modelValue: { type: Object, default: () => ({ username:'', password:'', vlan:'', disabled:false }) },
@@ -57,3 +23,52 @@ function submit() {
emit('submit', { ...state, etiquetas: tags });
}
</script>
<template>
<form @submit.prevent="submit" class="flex flex-col gap-3">
<div class="flex flex-wrap gap-3">
<div class="flex-1 min-w-[140px] space-y-1">
<Label class="text-xs text-muted">Usuario</Label>
<Input
v-model="state.username"
:readonly="isEdit"
placeholder="usuario"
/>
</div>
<div class="flex-1 min-w-[140px] space-y-1">
<Label class="text-xs text-muted">Contraseña</Label>
<Input
v-model="state.password"
placeholder="contraseña"
/>
</div>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted">Etiquetas (separadas por coma)</Label>
<Input
v-model="etiquetasText"
placeholder="invitado, vip"
/>
</div>
<div class="flex flex-wrap gap-3 items-end">
<div class="flex-1 min-w-[100px] space-y-1">
<Label class="text-xs text-muted">VLAN</Label>
<Input
v-model="state.vlan"
placeholder="VLAN"
/>
</div>
<label class="flex items-center gap-2 glass glass-border rounded-md px-3 py-2 cursor-pointer">
<input type="checkbox" v-model="state.disabled" class="accent-pink-500" />
<span class="text-sm">Deshabilitado</span>
</label>
</div>
<div class="flex justify-end gap-2 mt-2">
<Button type="button" variant="ghost" @click="emit('cancel')">Cancelar</Button>
<Button type="submit">Guardar</Button>
</div>
</form>
</template>

View File

@@ -1,29 +1,6 @@
<template>
<form @submit.prevent="submit" class="column" style="gap:10px;">
<div class="row">
<label class="toggle" style="flex:0 0 140px;">
<div class="muted" style="font-size:12px;">VLAN ID</div>
<input v-model.number="state.id" type="number" min="1" placeholder="e.g. 5" style="width:100%; background:transparent; border:none; outline:none; color:inherit;" required />
</label>
<label class="toggle" style="flex:1;">
<div class="muted" style="font-size:12px;">Nombre</div>
<input v-model="state.nombre" placeholder="Nombre descriptivo" style="width:100%; background:transparent; border:none; outline:none; color:inherit;" required />
</label>
</div>
<label class="toggle" style="width:100%;">
<div class="muted" style="font-size:12px;">Descripción</div>
<textarea v-model="state.descripcion" rows="3" placeholder="Opcional" style="width:100%; background:transparent; border:none; outline:none; color:inherit; resize: vertical;"></textarea>
</label>
<div v-if="error" class="muted" style="color:#ff6b6b;">{{ error }}</div>
<div class="modal-footer">
<button type="button" class="icon-btn" @click="$emit('cancel')">Cancelar</button>
<button type="submit" class="icon-btn">Crear</button>
</div>
</form>
</template>
<script setup>
import { reactive, ref } from 'vue';
import { Input, Textarea, Label, Button } from '@/components/ui';
const emit = defineEmits(['success', 'cancel']);
const state = reactive({ id: '', nombre: '', descripcion: '' });
@@ -49,3 +26,43 @@ async function submit() {
}
</script>
<template>
<form @submit.prevent="submit" class="flex flex-col gap-3">
<div class="flex flex-wrap gap-3">
<div class="w-[140px] space-y-1">
<Label class="text-xs text-muted">VLAN ID</Label>
<Input
v-model.number="state.id"
type="number"
min="1"
placeholder="e.g. 5"
required
/>
</div>
<div class="flex-1 min-w-[140px] space-y-1">
<Label class="text-xs text-muted">Nombre</Label>
<Input
v-model="state.nombre"
placeholder="Nombre descriptivo"
required
/>
</div>
</div>
<div class="space-y-1">
<Label class="text-xs text-muted">Descripción</Label>
<Textarea
v-model="state.descripcion"
:rows="3"
placeholder="Opcional"
/>
</div>
<div v-if="error" class="text-sm text-red-400">{{ error }}</div>
<div class="flex justify-end gap-2 mt-2">
<Button type="button" variant="ghost" @click="emit('cancel')">Cancelar</Button>
<Button type="submit">Crear</Button>
</div>
</form>
</template>

View File

@@ -1,19 +1,8 @@
<template>
<button
class="icon-btn group-check-btn"
:class="{ loading: loading }"
:disabled="loading"
@click="handleClick"
>
<img v-if="icon" class="icon" :src="icon" :alt="label" />
<slot>{{ label }}</slot>
</button>
</template>
<script setup>
import { ref, defineProps } from 'vue';
import { useAuthentik } from '../../composables/useAuthentik.js';
import { useToast } from '../../composables/useToast.js';
import { ref } from 'vue';
import { Button } from '@/components/ui';
import { useAuthentik } from '@/composables/useAuthentik.js';
import { useToast } from '@/composables/useToast.js';
const props = defineProps({
groupName: {
@@ -43,7 +32,6 @@ const handleClick = async () => {
try {
if (props.verifyBackend) {
// Verificación backend
const hasAccess = await checkGroupBackend(props.groupName);
if (hasAccess) {
@@ -56,7 +44,6 @@ const handleClick = async () => {
});
}
} else {
// Verificación frontend
const hasAccess = hasGroup(props.groupName);
if (hasAccess) {
@@ -80,21 +67,13 @@ const handleClick = async () => {
};
</script>
<style scoped>
.group-check-btn {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.group-check-btn:hover:not(:disabled) {
opacity: 0.9;
}
.group-check-btn:active:not(:disabled) {
opacity: 0.8;
}
.group-check-btn.loading {
opacity: 0.6;
cursor: wait;
}
</style>
<template>
<Button
:loading="loading"
:disabled="loading"
@click="handleClick"
>
<img v-if="icon" class="size-4 opacity-90" :src="icon" :alt="label" />
<slot>{{ label }}</slot>
</Button>
</template>

View File

@@ -1,12 +1,6 @@
<template>
<button class="icon-btn session-status-btn" @click="handleClick">
<img class="icon" src="/icons/settings.svg" alt="info" />
Estado de Sesión
</button>
</template>
<script setup>
import { useAuthentik } from '../../composables/useAuthentik.js';
import { Button } from '@/components/ui';
import { useAuthentik } from '@/composables/useAuthentik.js';
const { checkSessionStatus } = useAuthentik();
@@ -15,8 +9,9 @@ const handleClick = () => {
};
</script>
<style scoped>
.session-status-btn {
/* Heredar estilos de .icon-btn del CSS global */
}
</style>
<template>
<Button @click="handleClick">
<img class="size-4 opacity-90" src="/icons/settings.svg" alt="info" />
Estado de Sesión
</Button>
</template>

View File

@@ -1,15 +1,5 @@
<template>
<div v-if="user" class="user-avatar-container">
<img
:src="user.avatar"
:alt="user.name || user.username"
class="user-avatar"
/>
</div>
</template>
<script setup>
import { defineProps } from 'vue';
import { Avatar } from '@/components/ui';
defineProps({
user: {
@@ -19,23 +9,13 @@ defineProps({
});
</script>
<style scoped>
.user-avatar-container {
display: inline-flex;
align-items: center;
justify-content: center;
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(var(--border));
transition: transform 0.2s ease;
}
.user-avatar:hover {
transform: scale(1.05);
}
</style>
<template>
<Avatar
v-if="user"
:src="user.avatar"
:alt="user.name || user.username"
:fallback="user.name || user.username"
size="sm"
class="border-2 border-border hover:scale-105 transition-transform"
/>
</template>

View File

@@ -1,81 +1,7 @@
<template>
<div class="user-dropdown-container">
<!-- Loading state -->
<div v-if="isLoading" class="chip">
<span class="muted">Cargando...</span>
</div>
<!-- Not authenticated -->
<div v-else-if="!isAuthenticated" class="chip">
<span class="muted">No autenticado</span>
</div>
<!-- Authenticated - Show dropdown -->
<div v-else class="dropdown">
<button class="user-dropdown-trigger icon-btn" @click="toggleMenu">
<UserAvatar :user="user" />
<span class="user-name">{{ user.name || user.username }}</span>
</button>
<div v-if="isMenuOpen" class="menu user-menu">
<!-- User Info -->
<div class="menu-section user-info">
<UserAvatar :user="user" />
<div class="user-details">
<div class="user-full-name">{{ user.name || user.username }}</div>
<div class="user-email muted">{{ user.email }}</div>
</div>
</div>
<hr class="menu-divider" />
<!-- User Metadata -->
<div class="menu-section metadata-section">
<div class="metadata-item" v-if="user.uid">
<span class="muted">ID:</span>
<span class="metadata-value">{{ user.uid }}</span>
</div>
<div class="metadata-item" v-if="user.groups && user.groups.length > 0">
<span class="muted">Grupos:</span>
<div class="groups-list">
<span
v-for="group in user.groups"
:key="group"
class="chip group-chip"
>
{{ group }}
</span>
</div>
</div>
</div>
<hr class="menu-divider" />
<!-- Actions -->
<div class="menu-section">
<button class="menu-item-btn" @click="handleGoToProfile">
<img class="icon" src="/icons/user-plus.svg" alt="perfil" />
Ver Perfil en Authentik
</button>
<button class="menu-item-btn" @click="handleCheckSession">
<img class="icon" src="/icons/settings.svg" alt="info" />
Verificar Sesión
</button>
<button class="menu-item-btn logout-btn" @click="handleLogout">
<span class="icon"></span>
Cerrar Sesión
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useAuthentik } from '../../composables/useAuthentik.js';
import { ref, onMounted, onUnmounted } from 'vue';
import { useAuthentik } from '@/composables/useAuthentik.js';
import { Badge, Button, DropdownMenu, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel } from '@/components/ui';
import UserAvatar from './UserAvatar.vue';
const {
@@ -109,7 +35,6 @@ const handleCheckSession = () => {
checkSessionStatus();
};
// Cerrar el menú al hacer click fuera
const handleClickOutside = (event) => {
const dropdown = event.target.closest('.user-dropdown-container');
if (!dropdown) {
@@ -118,137 +43,102 @@ const handleClickOutside = (event) => {
};
onMounted(() => {
// Cargar datos del usuario al montar
fetchUserData();
// Agregar listener para cerrar menú al hacer click fuera
document.addEventListener('click', handleClickOutside);
});
// Cleanup
import { onUnmounted } from 'vue';
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.user-dropdown-container {
position: relative;
}
<template>
<div class="user-dropdown-container relative">
<!-- Loading state -->
<Badge v-if="isLoading" variant="secondary">
<span class="text-muted">Cargando...</span>
</Badge>
.user-dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
<!-- Not authenticated -->
<Badge v-else-if="!isAuthenticated" variant="secondary">
<span class="text-muted">No autenticado</span>
</Badge>
.user-name {
font-size: 14px;
font-weight: 500;
}
<!-- Authenticated - Show dropdown -->
<div v-else class="relative">
<Button variant="ghost" class="gap-2" @click="toggleMenu">
<UserAvatar :user="user" />
<span class="text-sm font-medium">{{ user.name || user.username }}</span>
</Button>
.user-menu {
min-width: 280px;
max-width: 320px;
}
<div
v-if="isMenuOpen"
class="absolute right-0 top-full mt-1.5 min-w-[280px] max-w-[320px] glass-card p-1.5 shadow-lg border border-pink-200 dark:border-pink-600/50 z-50 animate-slide-in"
>
<!-- User Info -->
<div class="flex items-center gap-3 p-3">
<UserAvatar :user="user" />
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm truncate">{{ user.name || user.username }}</div>
<div class="text-xs text-muted truncate">{{ user.email }}</div>
</div>
</div>
.menu-section {
padding: 8px;
}
<hr class="border-border my-1" />
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
}
<!-- User Metadata -->
<div class="p-2 space-y-2">
<div v-if="user.uid" class="text-xs space-y-1">
<span class="text-muted">ID:</span>
<span class="font-mono text-[11px] break-all ml-1">{{ user.uid }}</span>
</div>
<div v-if="user.groups && user.groups.length > 0" class="text-xs space-y-1">
<span class="text-muted">Grupos:</span>
<div class="flex flex-wrap gap-1 mt-1">
<Badge
v-for="group in user.groups"
:key="group"
variant="secondary"
class="text-[11px] py-0.5 px-1.5"
>
{{ group }}
</Badge>
</div>
</div>
</div>
.user-details {
flex: 1;
min-width: 0;
}
<hr class="border-border my-1" />
.user-full-name {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<!-- Actions -->
<div class="p-1 space-y-1">
<Button
variant="ghost"
class="w-full justify-start gap-2 text-sm"
@click="handleGoToProfile"
>
<img class="size-4 opacity-90" src="/icons/user-plus.svg" alt="perfil" />
Ver Perfil en Authentik
</Button>
.user-email {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
<Button
variant="ghost"
class="w-full justify-start gap-2 text-sm"
@click="handleCheckSession"
>
<img class="size-4 opacity-90" src="/icons/settings.svg" alt="info" />
Verificar Sesión
</Button>
.menu-divider {
border: none;
border-top: 1px solid rgba(var(--border));
margin: 4px 0;
}
.metadata-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.metadata-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.metadata-value {
font-family: monospace;
font-size: 11px;
word-break: break-all;
}
.groups-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.group-chip {
font-size: 11px;
padding: 2px 6px;
background: rgba(var(--accent), 0.1);
border-color: rgba(var(--accent), 0.3);
}
.menu-item-btn {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(var(--border));
background: rgba(var(--card));
color: inherit;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
font-size: 13px;
}
.menu-item-btn:hover {
background: rgba(255, 255, 255, 0.06);
transform: translateX(2px);
}
.logout-btn {
border-color: rgba(255, 100, 100, 0.3);
color: rgb(255, 120, 120);
}
.logout-btn:hover {
background: rgba(255, 100, 100, 0.1);
}
</style>
<Button
variant="danger"
class="w-full justify-start gap-2 text-sm"
@click="handleLogout"
>
<span class="text-base"></span>
Cerrar Sesión
</Button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
src: String,
alt: String,
fallback: String,
size: {
type: String,
default: 'default',
validator: (v) => ['sm', 'default', 'lg'].includes(v)
},
class: String
});
const sizeClasses = {
sm: 'size-8 text-xs',
default: 'size-10 text-sm',
lg: 'size-12 text-base'
};
const classes = computed(() => cn(
'relative flex shrink-0 overflow-hidden rounded-full',
sizeClasses[props.size],
props.class
));
// Generate initials from fallback text
const initials = computed(() => {
if (!props.fallback) return '?';
return props.fallback
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
});
</script>
<template>
<span :class="classes">
<img
v-if="src"
:src="src"
:alt="alt"
class="aspect-square size-full object-cover"
/>
<span
v-else
class="flex size-full items-center justify-center rounded-full bg-pink-400/20 text-pink-400 font-semibold border border-pink-400/30"
>
{{ initials }}
</span>
</span>
</template>

View File

@@ -0,0 +1 @@
export { default as Avatar } from './Avatar.vue';

View File

@@ -0,0 +1,35 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
variant: {
type: String,
default: 'default',
validator: (v) => ['default', 'secondary', 'success', 'warning', 'danger', 'pink', 'outline'].includes(v)
},
class: String
});
const variantClasses = {
default: 'glass glass-border',
secondary: 'bg-muted/20 text-muted',
success: 'bg-green-400/20 text-green-400 border border-green-400/30',
warning: 'bg-yellow-400/20 text-yellow-400 border border-yellow-400/30',
danger: 'bg-red-400/20 text-red-400 border border-red-400/30',
pink: 'bg-pink-400/20 text-pink-400 border border-pink-400/30',
outline: 'border border-border bg-transparent'
};
const classes = computed(() => cn(
'inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium',
variantClasses[props.variant],
props.class
));
</script>
<template>
<span :class="classes">
<slot />
</span>
</template>

View File

@@ -0,0 +1 @@
export { default as Badge } from './Badge.vue';

View File

@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
variant: {
type: String,
default: 'default',
validator: (v) => ['default', 'ghost', 'outline', 'danger', 'success', 'link'].includes(v)
},
size: {
type: String,
default: 'default',
validator: (v) => ['default', 'sm', 'lg', 'icon'].includes(v)
},
disabled: Boolean,
loading: Boolean,
class: String
});
const variantClasses = {
default: 'glass glass-border hover:bg-white/5',
ghost: 'hover:bg-white/5',
outline: 'border border-border bg-transparent hover:bg-white/5',
danger: 'glass glass-border hover:border-red-400/30 hover:text-red-400',
success: 'glass glass-border hover:border-green-400/30 hover:text-green-400',
link: 'underline-offset-4 hover:underline text-accent'
};
const sizeClasses = {
default: 'h-9 px-3 py-2',
sm: 'h-8 px-2.5 text-xs',
lg: 'h-10 px-4',
icon: 'h-9 w-9 p-0'
};
const classes = computed(() => cn(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50',
variantClasses[props.variant],
sizeClasses[props.size],
props.class
));
</script>
<template>
<button
:class="classes"
:disabled="disabled || loading"
>
<svg v-if="loading" class="size-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" class="opacity-25" />
<path 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" class="opacity-75" />
</svg>
<slot />
</button>
</template>

View File

@@ -0,0 +1 @@
export { default as Button } from './Button.vue';

View File

@@ -0,0 +1,27 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String,
variant: {
type: String,
default: 'default',
validator: (v) => ['default', 'glass', 'panel'].includes(v)
}
});
const variantClasses = {
default: 'glass-card shadow-md hover:shadow-lg transition-shadow',
glass: 'glass glass-border rounded-lg',
panel: 'glass-panel border border-pink-200 dark:border-pink-600/50'
};
const classes = computed(() => cn(variantClasses[props.variant], props.class));
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String
});
const classes = computed(() => cn('inline-flex flex-wrap gap-1.5', props.class));
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String
});
const classes = computed(() => cn('p-3', props.class));
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String
});
const classes = computed(() => cn(
'flex flex-wrap items-center justify-between p-3 border-b border-border',
props.class
));
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String
});
const classes = computed(() => cn('font-semibold', props.class));
</script>
<template>
<h3 :class="classes">
<slot />
</h3>
</template>

View File

@@ -0,0 +1,5 @@
export { default as Card } from './Card.vue';
export { default as CardHeader } from './CardHeader.vue';
export { default as CardTitle } from './CardTitle.vue';
export { default as CardContent } from './CardContent.vue';
export { default as CardActions } from './CardActions.vue';

View File

@@ -0,0 +1,41 @@
<script setup>
import { computed } from 'vue';
import {
DialogRoot,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose
} from 'radix-vue';
import { cn } from '@/lib/utils';
const props = defineProps({
open: Boolean,
fullscreen: Boolean
});
const emit = defineEmits(['update:open']);
</script>
<template>
<DialogRoot :open="open" @update:open="emit('update:open', $event)">
<slot name="trigger" />
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-black/35 backdrop-blur-sm animate-fade-in" />
<DialogContent
:class="cn(
'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 glass-card p-4 shadow-xl animate-fade-in',
fullscreen
? 'w-[96vw] max-w-[96vw] h-[92vh] max-h-[92vh] flex flex-col'
: 'w-[min(680px,92vw)]'
)"
>
<slot />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -0,0 +1,9 @@
<script setup>
import { DialogClose } from 'radix-vue';
</script>
<template>
<DialogClose as-child>
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String
});
const classes = computed(() => cn(
'flex justify-end gap-2 mt-3',
props.class
));
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String
});
const classes = computed(() => cn(
'flex justify-between items-center mb-3',
props.class
));
</script>
<template>
<div :class="classes">
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup>
import { DialogTitle } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String
});
const classes = computed(() => cn('text-lg font-semibold', props.class));
</script>
<template>
<DialogTitle :class="classes">
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,6 @@
export { default as Dialog } from './Dialog.vue';
export { default as DialogHeader } from './DialogHeader.vue';
export { default as DialogTitle } from './DialogTitle.vue';
export { default as DialogFooter } from './DialogFooter.vue';
export { default as DialogClose } from './DialogClose.vue';
export { DialogTrigger, DialogDescription } from 'radix-vue';

View File

@@ -0,0 +1,38 @@
<script setup>
import {
DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent
} from 'radix-vue';
const props = defineProps({
align: {
type: String,
default: 'end'
},
side: {
type: String,
default: 'bottom'
}
});
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<slot name="trigger" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:align="align"
:side="side"
:side-offset="6"
class="z-50 min-w-[180px] glass-card p-1.5 shadow-lg border border-pink-200 dark:border-pink-600/50 animate-slide-in"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { DropdownMenuItem } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
disabled: Boolean,
danger: Boolean,
class: String
});
const classes = computed(() => cn(
'relative flex cursor-pointer select-none items-center rounded-md px-2.5 py-2 text-sm outline-none transition-colors',
'glass glass-border',
'hover:bg-white/5 focus:bg-white/5',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.danger && 'hover:border-red-400/30 hover:text-red-400',
props.class
));
</script>
<template>
<DropdownMenuItem :disabled="disabled" :class="classes">
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,17 @@
<script setup>
import { DropdownMenuLabel } from 'radix-vue';
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
class: String
});
const classes = computed(() => cn('px-2 py-1.5 text-sm font-semibold', props.class));
</script>
<template>
<DropdownMenuLabel :class="classes">
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -0,0 +1,7 @@
<script setup>
import { DropdownMenuSeparator } from 'radix-vue';
</script>
<template>
<DropdownMenuSeparator class="-mx-1 my-1 h-px bg-border" />
</template>

View File

@@ -0,0 +1,4 @@
export { default as DropdownMenu } from './DropdownMenu.vue';
export { default as DropdownMenuItem } from './DropdownMenuItem.vue';
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue';
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue';

View File

@@ -0,0 +1,26 @@
// Button
export { Button } from './button';
// Card
export { Card, CardHeader, CardTitle, CardContent, CardActions } from './card';
// Input
export { Input } from './input';
// Textarea
export { Textarea } from './textarea';
// Label
export { Label } from './label';
// Badge
export { Badge } from './badge';
// Dialog
export { Dialog, DialogHeader, DialogTitle, DialogFooter, DialogClose, DialogTrigger, DialogDescription } from './dialog';
// Avatar
export { Avatar } from './avatar';
// Dropdown Menu
export { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel } from './dropdown-menu';

View File

@@ -0,0 +1,37 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
modelValue: [String, Number],
type: { type: String, default: 'text' },
placeholder: String,
disabled: Boolean,
readonly: Boolean,
class: String
});
const emit = defineEmits(['update:modelValue']);
const classes = computed(() => cn(
'flex h-9 w-full rounded-md glass glass-border px-3 py-2 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50',
props.readonly && 'cursor-default opacity-70',
props.class
));
const handleInput = (e) => {
emit('update:modelValue', e.target.value);
};
</script>
<template>
<input
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:class="classes"
@input="handleInput"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue';

View File

@@ -0,0 +1,20 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
for: String,
class: String
});
const classes = computed(() => cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class
));
</script>
<template>
<label :for="for" :class="classes">
<slot />
</label>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue';

View File

@@ -0,0 +1,36 @@
<script setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
const props = defineProps({
modelValue: String,
placeholder: String,
disabled: Boolean,
readonly: Boolean,
rows: { type: Number, default: 3 },
class: String
});
const emit = defineEmits(['update:modelValue']);
const classes = computed(() => cn(
'flex min-h-[80px] w-full rounded-md glass glass-border px-3 py-2 text-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50 resize-none',
props.class
));
const handleInput = (e) => {
emit('update:modelValue', e.target.value);
};
</script>
<template>
<textarea
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:rows="rows"
:class="classes"
@input="handleInput"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Textarea } from './Textarea.vue';

12
frontend/src/lib/utils.js Normal file
View File

@@ -0,0 +1,12 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Utility function to merge Tailwind CSS classes
* Combines clsx for conditional classes with tailwind-merge for deduplication
* @param {...any} inputs - Class names, arrays, or objects
* @returns {string} Merged class string
*/
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

View File

@@ -1,6 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
import './app.css';
const app = createApp(App);
app.mount('#app');

View File

@@ -1,182 +0,0 @@
:root {
--bg: 15 15 18;
--fg: 235 235 240;
--muted: 180 180 190;
--accent: 80 160 255;
--card: 28 28 34 / 0.55;
--border: 255 255 255 / 0.12;
--glass-blur: 14px;
--radius: 14px;
/* Scrollbar */
--sb-size: 10px;
--sb-thumb: rgba(255, 159, 203, 0.55);
--sb-thumb-hover: rgba(255, 159, 203, 0.75);
--sb-thumb-active: rgba(255, 127, 187, 0.9);
--sb-track: rgba(255,255,255,0.05);
}
:root.light {
--bg: 245 245 248;
--fg: 20 20 22;
--muted: 110 110 120;
--accent: 18 108 242;
--card: 255 255 255 / 0.6;
--border: 0 0 0 / 0.08;
--sb-thumb: rgba(255, 127, 187, 0.65);
--sb-thumb-hover: rgba(255, 127, 187, 0.82);
--sb-thumb-active: rgba(255, 110, 178, 0.95);
--sb-track: rgba(0,0,0,0.06);
}
* { box-sizing: border-box; }
html, body, #app { height: 100%; }
html, body { margin: 0; padding: 0; background: rgb(var(--bg)); color: rgb(var(--fg)); }
body { font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
button { cursor: pointer; }
a { color: inherit; }
/* Top bar */
.topbar {
position: sticky; top: 0; z-index: 10;
display: flex; flex-wrap: wrap; align-items: center;
gap: 10px; padding: 10px 14px; backdrop-filter: blur(var(--glass-blur));
border: 1px solid transparent;
background:
linear-gradient(rgba(var(--card)), rgba(var(--card))) padding-box,
linear-gradient(135deg,
rgba(255, 159, 203, 0.95) 0%,
rgba(255, 159, 203, 0.60) 10%,
rgba(255, 127, 187, 0.45) 18%,
rgba(0, 0, 0, 0.20) 28%,
rgba(0, 0, 0, 0.06) 50%,
rgba(0, 0, 0, 0.00) 70%
) border-box;
}
:root:not(.light) .topbar {
background:
linear-gradient(rgba(var(--card)), rgba(var(--card))) padding-box,
linear-gradient(135deg,
rgba(255, 46, 134, 0.95) 0%,
rgba(255, 46, 134, 0.60) 10%,
rgba(255, 107, 176, 0.45) 18%,
rgba(0, 0, 0, 0.28) 28%,
rgba(0, 0, 0, 0.10) 50%,
rgba(0, 0, 0, 0.00) 70%
) border-box;
border-width: 0.5px; /* borde más delgado en dark */
}
.title { font-size: 16px; font-weight: 700; letter-spacing: .2px; flex: 1 1 auto; }
.actions { display: inline-flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.icon-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 10px; border-radius: 10px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); color: inherit; transition: background .2s;
backdrop-filter: blur(var(--glass-blur)); }
.icon-btn:hover { background: rgba(var(--card)); }
.icon { width: 16px; height: 16px; opacity: .9; }
.dropdown { position: relative; }
.menu { position: absolute; right: 0; top: calc(100% + 6px); background: rgba(var(--card)); border: 1px solid #ffcfe4; border-radius: 10px; padding: 6px; backdrop-filter: blur(var(--glass-blur)); min-width: 180px; box-shadow: 0 10px 24px rgba(0,0,0,.18); }
.menu button { display: block; width: 100%; text-align: left; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); }
.menu button:hover { transform: none; background: rgba(255,255,255,0.06); }
/* Layout */
.shell { height: calc(100vh - 54px); display: grid; grid-template-columns: 360px 1fr; grid-template-areas: "sidebar main"; gap: 12px; padding: 12px; }
.shell > aside { grid-area: sidebar; }
.shell > main { grid-area: main; }
.panel {
border: 1px solid #ffcfe4; /* light más claro */
background: linear-gradient(rgba(var(--card)), rgba(var(--card))) padding-box;
border-radius: var(--radius);
backdrop-filter: blur(var(--glass-blur));
overflow: hidden; display: flex; flex-direction: column; min-height: 0;
}
:root:not(.light) .panel { border-color: #ff2e86; border-width: 0.5px; /* borde más delgado en dark */ }
.panel-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid rgba(var(--border)); }
.panel-title { font-weight: 600; }
.panel-actions { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.scroll { overflow: auto; padding: 10px; }
/* Cards */
.card { border: 1px solid rgba(var(--border)); background: rgba(var(--card)); border-radius: 12px; padding: 10px; transition: box-shadow .2s ease; box-shadow: 0 4px 14px rgba(0,0,0,.08); }
.card:hover { box-shadow: 0 8px 20px rgba(0,0,0,.12); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
/* Botones de colapsar: solo visibles en móvil */
.collapse-btn {
display: none;
}
/* Responsive */
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; grid-template-areas:
"main"
"sidebar"; }
/* Mostrar botones de colapsar solo en móvil */
.collapse-btn {
display: inline-flex;
}
/* En móvil, desactivar el sistema de grid de 52px para contenedores colapsados */
.shell:has(> aside.collapsed) { grid-template-columns: 1fr; }
.shell:has(> main.collapsed) { grid-template-columns: 1fr; }
/* En móvil, los paneles colapsados solo ocupan el espacio del header */
.panel.collapsed {
height: auto;
min-height: auto;
}
}
/* When a panel is collapsed, hide its scroll and shrink its grid track to show only header */
.panel.collapsed .scroll { display: none; }
/* Desktop-only: Using :has to adapt the grid columns when a side is collapsed (modern browsers) */
@media (min-width: 981px) {
.shell:has(> aside.collapsed) { grid-template-columns: 52px 1fr; }
.shell:has(> main.collapsed) { grid-template-columns: 1fr 52px; }
}
/* Modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.35); backdrop-filter: blur(4px); display: grid; place-items: center; z-index: 20; animation: fadeIn .15s ease;
}
.modal { width: min(680px, 92vw); border-radius: 14px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); padding: 14px; box-shadow: 0 10px 32px rgba(0,0,0,.2); }
.modal.fullscreen { width: 96vw; max-width: 96vw; height: 92vh; max-height: 92vh; display: flex; flex-direction: column; }
.modal.fullscreen > .modal-header { position: sticky; top: 0; background: rgba(var(--card)); z-index: 1; }
.modal.fullscreen > .modal-footer { position: sticky; bottom: 0; background: rgba(var(--card)); z-index: 1; }
.modal.fullscreen > div:nth-child(2) { flex: 1 1 auto; overflow: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* Small bits */
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid rgba(var(--border)); border-radius: 999px; background: rgba(var(--card)); font-size: 12px; }
.muted { color: rgb(var(--muted)); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.spacer { flex: 1; }
.toggle { padding: 6px 10px; border-radius: 8px; border: 1px solid rgba(var(--border)); background: rgba(var(--card)); }
/* Scrollbars */
/* Firefox */
html, body, .scroll {
scrollbar-width: thin;
scrollbar-color: var(--sb-thumb) transparent;
}
/* WebKit */
html::-webkit-scrollbar, body::-webkit-scrollbar, .scroll::-webkit-scrollbar {
width: var(--sb-size);
height: var(--sb-size);
}
html::-webkit-scrollbar-track, body::-webkit-scrollbar-track, .scroll::-webkit-scrollbar-track {
background: transparent;
}
html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb, .scroll::-webkit-scrollbar-thumb {
background: var(--sb-thumb);
border-radius: 999px;
border: 2px solid transparent; /* creates inset padding */
background-clip: content-box;
box-shadow: 0 0 10px rgba(255, 46, 134, 0.15);
}
html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover, .scroll::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
background-clip: content-box;
}
html::-webkit-scrollbar-thumb:active, body::-webkit-scrollbar-thumb:active, .scroll::-webkit-scrollbar-thumb:active {
background: var(--sb-thumb-active);
background-clip: content-box;
}