Refactor: Migrar UI completa a Tailwind CSS v4 + shadcn-vue
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 58s
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:
960
frontend/package-lock.json
generated
960
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,17 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.38",
|
||||
"htm": "^3.1.1"
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"htm": "^3.1.1",
|
||||
"radix-vue": "^1.9.17",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vue": "^3.4.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
180
frontend/src/app.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
28
frontend/src/components/EventCard.vue
Normal file
28
frontend/src/components/EventCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
56
frontend/src/components/ui/avatar/Avatar.vue
Normal file
56
frontend/src/components/ui/avatar/Avatar.vue
Normal 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>
|
||||
1
frontend/src/components/ui/avatar/index.js
Normal file
1
frontend/src/components/ui/avatar/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Avatar } from './Avatar.vue';
|
||||
35
frontend/src/components/ui/badge/Badge.vue
Normal file
35
frontend/src/components/ui/badge/Badge.vue
Normal 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>
|
||||
1
frontend/src/components/ui/badge/index.js
Normal file
1
frontend/src/components/ui/badge/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Badge } from './Badge.vue';
|
||||
56
frontend/src/components/ui/button/Button.vue
Normal file
56
frontend/src/components/ui/button/Button.vue
Normal 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>
|
||||
1
frontend/src/components/ui/button/index.js
Normal file
1
frontend/src/components/ui/button/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Button } from './Button.vue';
|
||||
27
frontend/src/components/ui/card/Card.vue
Normal file
27
frontend/src/components/ui/card/Card.vue
Normal 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>
|
||||
16
frontend/src/components/ui/card/CardActions.vue
Normal file
16
frontend/src/components/ui/card/CardActions.vue
Normal 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>
|
||||
16
frontend/src/components/ui/card/CardContent.vue
Normal file
16
frontend/src/components/ui/card/CardContent.vue
Normal 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>
|
||||
19
frontend/src/components/ui/card/CardHeader.vue
Normal file
19
frontend/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||
16
frontend/src/components/ui/card/CardTitle.vue
Normal file
16
frontend/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||
5
frontend/src/components/ui/card/index.js
Normal file
5
frontend/src/components/ui/card/index.js
Normal 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';
|
||||
41
frontend/src/components/ui/dialog/Dialog.vue
Normal file
41
frontend/src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
9
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
9
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import { DialogClose } from 'radix-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose as-child>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
19
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
19
frontend/src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
19
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
19
frontend/src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||
17
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
17
frontend/src/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||
6
frontend/src/components/ui/dialog/index.js
Normal file
6
frontend/src/components/ui/dialog/index.js
Normal 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';
|
||||
38
frontend/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
38
frontend/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup>
|
||||
import { DropdownMenuSeparator } from 'radix-vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator class="-mx-1 my-1 h-px bg-border" />
|
||||
</template>
|
||||
4
frontend/src/components/ui/dropdown-menu/index.js
Normal file
4
frontend/src/components/ui/dropdown-menu/index.js
Normal 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';
|
||||
26
frontend/src/components/ui/index.js
Normal file
26
frontend/src/components/ui/index.js
Normal 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';
|
||||
37
frontend/src/components/ui/input/Input.vue
Normal file
37
frontend/src/components/ui/input/Input.vue
Normal 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>
|
||||
1
frontend/src/components/ui/input/index.js
Normal file
1
frontend/src/components/ui/input/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.vue';
|
||||
20
frontend/src/components/ui/label/Label.vue
Normal file
20
frontend/src/components/ui/label/Label.vue
Normal 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>
|
||||
1
frontend/src/components/ui/label/index.js
Normal file
1
frontend/src/components/ui/label/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from './Label.vue';
|
||||
36
frontend/src/components/ui/textarea/Textarea.vue
Normal file
36
frontend/src/components/ui/textarea/Textarea.vue
Normal 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>
|
||||
1
frontend/src/components/ui/textarea/index.js
Normal file
1
frontend/src/components/ui/textarea/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Textarea } from './Textarea.vue';
|
||||
12
frontend/src/lib/utils.js
Normal file
12
frontend/src/lib/utils.js
Normal 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));
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user