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"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.4.38",
|
"class-variance-authority": "^0.7.1",
|
||||||
"htm": "^3.1.1"
|
"clsx": "^2.1.1",
|
||||||
|
"htm": "^3.1.1",
|
||||||
|
"radix-vue": "^1.9.17",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"vue": "^3.4.38"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@vitejs/plugin-vue": "^5.0.5",
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
"vite": "^5.4.8"
|
"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>
|
<script setup>
|
||||||
import { onMounted, reactive, ref, computed, watch } from 'vue';
|
import { onMounted, reactive, ref, computed, watch } from 'vue';
|
||||||
import EventCard from './components/EventCard.js';
|
import { Button, Badge, Input, Card, CardHeader, CardTitle, CardActions, CardContent, Textarea } from '@/components/ui';
|
||||||
import UserCard from './components/UserCard.vue';
|
import EventCard from '@/components/EventCard.vue';
|
||||||
import DispositivoCard from './components/DispositivoCard.vue';
|
import UserCard from '@/components/UserCard.vue';
|
||||||
import Modal from './components/Modal.vue';
|
import DispositivoCard from '@/components/DispositivoCard.vue';
|
||||||
import UserForm from './components/UserForm.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
import RawDbViewer from './components/RawDbViewer.vue';
|
import UserForm from '@/components/UserForm.vue';
|
||||||
import VlanForm from './components/VlanForm.vue';
|
import RawDbViewer from '@/components/RawDbViewer.vue';
|
||||||
import DeviceForm from './components/DeviceForm.vue';
|
import VlanForm from '@/components/VlanForm.vue';
|
||||||
import Toast from './components/Toast.vue';
|
import DeviceForm from '@/components/DeviceForm.vue';
|
||||||
import UserDropdown from './components/auth/UserDropdown.vue';
|
import Toast from '@/components/Toast.vue';
|
||||||
import { createToastSystem, useToast } from './composables/useToast.js';
|
import UserDropdown from '@/components/auth/UserDropdown.vue';
|
||||||
|
import { createToastSystem, useToast } from '@/composables/useToast.js';
|
||||||
|
|
||||||
// Initialize toast system
|
// Initialize toast system
|
||||||
createToastSystem();
|
createToastSystem();
|
||||||
@@ -186,11 +23,9 @@ const loading = reactive({ users: false, requests: false });
|
|||||||
const devices = ref([]);
|
const devices = ref([]);
|
||||||
const userExpanded = reactive({});
|
const userExpanded = reactive({});
|
||||||
const deviceExpanded = reactive({});
|
const deviceExpanded = reactive({});
|
||||||
// formulario inline removido: se usa modal con UserForm
|
|
||||||
|
|
||||||
// Helper para detectar errores de autenticación
|
// Helper para detectar errores de autenticación
|
||||||
function isAuthError(error) {
|
function isAuthError(error) {
|
||||||
// Si es un TypeError de fetch, probablemente es CORS (redirección de Authentik)
|
|
||||||
return error instanceof TypeError && error.message.includes('fetch');
|
return error instanceof TypeError && error.message.includes('fetch');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,17 +49,12 @@ async function fetchUsers() {
|
|||||||
loading.users = true;
|
loading.users = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users');
|
const res = await fetch('/api/users');
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
users.value = data.items || [];
|
users.value = data.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAuthError(error)) {
|
if (isAuthError(error)) handleAuthError();
|
||||||
handleAuthError();
|
else console.error('Error fetching users:', error);
|
||||||
} else {
|
|
||||||
console.error('Error fetching users:', error);
|
|
||||||
}
|
|
||||||
} finally { loading.users = false; }
|
} finally { loading.users = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,34 +62,24 @@ async function fetchRequests() {
|
|||||||
loading.requests = true;
|
loading.requests = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/requests');
|
const res = await fetch('/api/requests');
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
requests.value = data.items || [];
|
requests.value = data.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAuthError(error)) {
|
if (isAuthError(error)) handleAuthError();
|
||||||
handleAuthError();
|
else console.error('Error fetching requests:', error);
|
||||||
} else {
|
|
||||||
console.error('Error fetching requests:', error);
|
|
||||||
}
|
|
||||||
} finally { loading.requests = false; }
|
} finally { loading.requests = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDevices() {
|
async function fetchDevices() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/devices');
|
const res = await fetch('/api/devices');
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
devices.value = data.items || [];
|
devices.value = data.items || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isAuthError(error)) {
|
if (isAuthError(error)) handleAuthError();
|
||||||
handleAuthError();
|
else console.error('Error fetching devices:', error);
|
||||||
} else {
|
|
||||||
console.error('Error fetching devices:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,8 +98,6 @@ async function removeUser(u) {
|
|||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRequests() { await fetchRequests(); }
|
|
||||||
|
|
||||||
async function clearRequests() {
|
async function clearRequests() {
|
||||||
await fetch('/api/requests', { method: 'DELETE' });
|
await fetch('/api/requests', { method: 'DELETE' });
|
||||||
await fetchRequests();
|
await fetchRequests();
|
||||||
@@ -327,7 +145,6 @@ function setupSse() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Load persisted expand state
|
|
||||||
try {
|
try {
|
||||||
const ue = JSON.parse(localStorage.getItem('ui_userExpanded') || '{}');
|
const ue = JSON.parse(localStorage.getItem('ui_userExpanded') || '{}');
|
||||||
Object.assign(userExpanded, ue && typeof ue === 'object' ? ue : {});
|
Object.assign(userExpanded, ue && typeof ue === 'object' ? ue : {});
|
||||||
@@ -344,30 +161,19 @@ onMounted(async () => {
|
|||||||
checkPWAStatus();
|
checkPWAStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// PWA Detection and Toast
|
|
||||||
function checkPWAStatus() {
|
function checkPWAStatus() {
|
||||||
// Don't show if already dismissed
|
|
||||||
if (localStorage.getItem('pwa_toast_dismissed')) return;
|
if (localStorage.getItem('pwa_toast_dismissed')) return;
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
|
||||||
// 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
|
|
||||||
if (!isStandalone) {
|
if (!isStandalone) {
|
||||||
// Check if PWA can be installed
|
|
||||||
let deferredPrompt = null;
|
let deferredPrompt = null;
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', (e) => {
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
deferredPrompt = e;
|
deferredPrompt = e;
|
||||||
|
|
||||||
// Show install prompt
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.pwa('Instala RADIUS Nucleo como aplicación para una mejor experiencia', {
|
toast.pwa('Instala RADIUS Nucleo como aplicación para una mejor experiencia', {
|
||||||
title: '📱 Instalar Aplicación',
|
title: '📱 Instalar Aplicación',
|
||||||
position: 'top-center',
|
position: 'top-center',
|
||||||
duration: 0, // Persistent
|
duration: 0,
|
||||||
persistent: true,
|
persistent: true,
|
||||||
action: {
|
action: {
|
||||||
label: 'Instalar',
|
label: 'Instalar',
|
||||||
@@ -375,9 +181,7 @@ function checkPWAStatus() {
|
|||||||
if (deferredPrompt) {
|
if (deferredPrompt) {
|
||||||
deferredPrompt.prompt();
|
deferredPrompt.prompt();
|
||||||
const { outcome } = await deferredPrompt.userChoice;
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
if (outcome === 'accepted') {
|
if (outcome === 'accepted') localStorage.setItem('pwa_toast_dismissed', 'true');
|
||||||
localStorage.setItem('pwa_toast_dismissed', 'true');
|
|
||||||
}
|
|
||||||
deferredPrompt = null;
|
deferredPrompt = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,27 +189,6 @@ function checkPWAStatus() {
|
|||||||
});
|
});
|
||||||
}, 2000);
|
}, 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;
|
return true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const userPage = ref(0);
|
const userPage = ref(0);
|
||||||
const filteredUsers = computed(() => filteredUsersAll.value.slice(userPage.value*pageSize, userPage.value*pageSize + pageSize));
|
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));
|
return users.value.filter(u => Array.isArray(u.dispositivos_utilizados) && u.dispositivos_utilizados.includes(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devices pagination
|
|
||||||
const devicesAll = computed(() => devices.value);
|
const devicesAll = computed(() => devices.value);
|
||||||
const devicePage = ref(0);
|
const devicePage = ref(0);
|
||||||
const pagedDevices = computed(() => devicesAll.value.slice(devicePage.value*pageSize, devicePage.value*pageSize + pageSize));
|
const pagedDevices = computed(() => devicesAll.value.slice(devicePage.value*pageSize, devicePage.value*pageSize + pageSize));
|
||||||
watch([devicesAll, () => layoutMode.value], () => { devicePage.value = 0; });
|
watch([devicesAll, () => layoutMode.value], () => { devicePage.value = 0; });
|
||||||
|
|
||||||
// Requests pagination (sidebar)
|
|
||||||
const filteredRequestsAll = computed(() => filteredRequests.value);
|
const filteredRequestsAll = computed(() => filteredRequests.value);
|
||||||
const reqPage = ref(0);
|
const reqPage = ref(0);
|
||||||
const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize));
|
const pagedRequests = computed(() => filteredRequestsAll.value.slice(reqPage.value*pageSize, reqPage.value*pageSize + pageSize));
|
||||||
watch(filteredRequestsAll, () => { reqPage.value = 0; });
|
watch(filteredRequestsAll, () => { reqPage.value = 0; });
|
||||||
|
|
||||||
// Persist expansion state
|
|
||||||
watch(userExpanded, (v) => {
|
watch(userExpanded, (v) => {
|
||||||
try { localStorage.setItem('ui_userExpanded', JSON.stringify(v)); } catch {}
|
try { localStorage.setItem('ui_userExpanded', JSON.stringify(v)); } catch {}
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
@@ -475,11 +256,11 @@ function toggleTheme() {
|
|||||||
applyTheme();
|
applyTheme();
|
||||||
}
|
}
|
||||||
function applyTheme() {
|
function applyTheme() {
|
||||||
document.documentElement.classList.toggle('light', theme.value === 'light');
|
document.documentElement.classList.toggle('dark', theme.value === 'dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
const showUserForm = ref(false);
|
const showUserForm = ref(false);
|
||||||
const userFormMode = ref('create'); // 'create' | 'edit' | 'guest'
|
const userFormMode = ref('create');
|
||||||
const userFormModel = ref({ username:'', password:'', vlan:'', disabled:false });
|
const userFormModel = ref({ username:'', password:'', vlan:'', disabled:false });
|
||||||
|
|
||||||
function openAddUser() {
|
function openAddUser() {
|
||||||
@@ -507,19 +288,19 @@ function openDeviceForm(d) { deviceFormModel.value = d; showDevice.value = true;
|
|||||||
function closeDevice() { showDevice.value = false; }
|
function closeDevice() { showDevice.value = false; }
|
||||||
async function onDeviceSaved() { showDevice.value = false; await fetchDevices(); }
|
async function onDeviceSaved() { showDevice.value = false; await fetchDevices(); }
|
||||||
|
|
||||||
// Import CSV modal
|
|
||||||
const showImport = ref(false);
|
const showImport = ref(false);
|
||||||
const importKind = ref('users');
|
const importKind = ref('users');
|
||||||
const importText = ref('');
|
const importText = ref('');
|
||||||
const importFilename = ref('');
|
const importFilename = ref('');
|
||||||
const importError = ref('');
|
const importError = ref('');
|
||||||
|
const importFile = ref(null);
|
||||||
function openImport(kind){ importKind.value = kind; importText.value=''; showImport.value = true; }
|
function openImport(kind){ importKind.value = kind; importText.value=''; showImport.value = true; }
|
||||||
function closeImport(){ showImport.value = false; }
|
function closeImport(){ showImport.value = false; }
|
||||||
|
|
||||||
async function submitImport(){
|
async function submitImport(){
|
||||||
importError.value = '';
|
importError.value = '';
|
||||||
const txt = (importText.value || '').trim();
|
const txt = (importText.value || '').trim();
|
||||||
if (!txt) { importError.value = 'El CSV está vacío.'; return; }
|
if (!txt) { importError.value = 'El CSV está vacío.'; return; }
|
||||||
// Basic header validation
|
|
||||||
const headers = getCsvHeaders(txt);
|
const headers = getCsvHeaders(txt);
|
||||||
if (!headers.length) { importError.value = 'No se encontraron columnas en el CSV.'; return; }
|
if (!headers.length) { importError.value = 'No se encontraron columnas en el CSV.'; return; }
|
||||||
const need = (k)=>headers.includes(k);
|
const need = (k)=>headers.includes(k);
|
||||||
@@ -567,7 +348,7 @@ function getCsvHeaders(text){
|
|||||||
out.push(cur.trim().toLowerCase());
|
out.push(cur.trim().toLowerCase());
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
function openSettings() { showSettingsMenu.value = !showSettingsMenu.value; }
|
|
||||||
function openEditUser(u) {
|
function openEditUser(u) {
|
||||||
userFormMode.value = 'edit';
|
userFormMode.value = 'edit';
|
||||||
userFormModel.value = { username: u.username, password: u.password || '', vlan: u.vlan || '', disabled: !!u.disabled };
|
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;
|
showUserForm.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
<script setup>
|
||||||
import { reactive, watch, ref } from 'vue';
|
import { reactive, watch, ref } from 'vue';
|
||||||
|
import { Input, Textarea, Label, Button } from '@/components/ui';
|
||||||
|
|
||||||
const props = defineProps({ model: { type: Object, required: true } });
|
const props = defineProps({ model: { type: Object, required: true } });
|
||||||
const emit = defineEmits(['success', 'cancel']);
|
const emit = defineEmits(['success', 'cancel']);
|
||||||
@@ -50,7 +21,10 @@ async function submit() {
|
|||||||
body: JSON.stringify({ nombre: state.nombre, descripcion: state.descripcion })
|
body: JSON.stringify({ nombre: state.nombre, descripcion: state.descripcion })
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
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');
|
emit('success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = String(e?.message || e) || 'Error';
|
error.value = String(e?.message || e) || 'Error';
|
||||||
@@ -58,3 +32,41 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
</script>
|
</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>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { Card, Badge, Button } from '@/components/ui';
|
||||||
import UserCard from './UserCard.vue';
|
import UserCard from './UserCard.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -34,9 +11,48 @@ const props = defineProps({
|
|||||||
connected: { type: Boolean, default: false },
|
connected: { type: Boolean, default: false },
|
||||||
expanded: { type: Boolean, default: false }
|
expanded: { type: Boolean, default: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'disconnect', 'toggleExpand']);
|
||||||
|
|
||||||
const connectedCount = computed(() => {
|
const connectedCount = computed(() => {
|
||||||
if (!props.users || !props.users.length) return props.connected ? 1 : 0;
|
if (!props.users || !props.users.length) return props.connected ? 1 : 0;
|
||||||
const id = props.device.id;
|
const id = props.device.id;
|
||||||
return props.users.filter(u => Array.isArray(u.dispositivos_conectados) && u.dispositivos_conectados.includes(id)).length;
|
return props.users.filter(u => Array.isArray(u.dispositivos_conectados) && u.dispositivos_conectados.includes(id)).length;
|
||||||
});
|
});
|
||||||
</script>
|
</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 @@
|
|||||||
|
<script setup>
|
||||||
|
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>
|
<template>
|
||||||
<div v-if="open" class="modal-backdrop" @click.self="$emit('close')">
|
<Dialog :open="open" :fullscreen="fullscreen" @update:open="handleClose">
|
||||||
<div class="modal" :class="{ fullscreen }">
|
<DialogHeader>
|
||||||
<div class="modal-header">
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
<strong>{{ title }}</strong>
|
<Button variant="ghost" size="sm" @click="handleClose">Cerrar</Button>
|
||||||
<button class="icon-btn" @click="$emit('close')">Cerrar</button>
|
</DialogHeader>
|
||||||
</div>
|
|
||||||
<div>
|
<div :class="fullscreen ? 'flex-1 overflow-auto' : ''">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
|
||||||
<slot name="footer">
|
|
||||||
<button class="icon-btn" @click="$emit('close')">OK</button>
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
<DialogFooter>
|
||||||
defineProps({ open: Boolean, title: String, fullscreen: { type: Boolean, default: false } });
|
<slot name="footer">
|
||||||
defineEmits(['close']);
|
<Button @click="handleClose">OK</Button>
|
||||||
</script>
|
</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>
|
<script setup>
|
||||||
import { onMounted, ref, computed } from 'vue';
|
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 tables = ref([]);
|
||||||
const active = ref('');
|
const active = ref('');
|
||||||
@@ -120,7 +70,6 @@ const sortedRows = computed(() => {
|
|||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
const va = a[key];
|
const va = a[key];
|
||||||
const vb = b[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 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);
|
const nb = typeof vb === 'number' || (/^-?\d+(\.\d+)?$/.test(String(vb)) ? Number(vb) : NaN);
|
||||||
if (!Number.isNaN(na) && !Number.isNaN(nb)) return (na - nb) * dir;
|
if (!Number.isNaN(na) && !Number.isNaN(nb)) return (na - nb) * dir;
|
||||||
@@ -163,6 +112,85 @@ function exportCsv() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
.icon-btn.active { outline: 2px solid rgba(255,127,187,.6); }
|
<div>
|
||||||
</style>
|
<!-- 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>
|
<script setup>
|
||||||
import { inject } from 'vue';
|
import { inject } from 'vue';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
position: {
|
position: {
|
||||||
@@ -49,167 +23,78 @@ function getIcon(type) {
|
|||||||
};
|
};
|
||||||
return icons[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>
|
</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>
|
<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 */
|
/* Animations */
|
||||||
.toast-enter-active,
|
.toast-enter-active,
|
||||||
.toast-leave-active {
|
.toast-leave-active {
|
||||||
@@ -227,51 +112,14 @@ function getIcon(type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Top center animations */
|
/* Top center animations */
|
||||||
.toast-container.top-center .toast-enter-from {
|
.top-center .toast-enter-from {
|
||||||
transform: translateY(-100px);
|
transform: translateY(-100px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-container.top-center .toast-leave-to {
|
.top-center .toast-leave-to {
|
||||||
transform: translateY(-100px) scale(0.8);
|
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) {
|
@media (max-width: 640px) {
|
||||||
.toast-container {
|
.toast-container {
|
||||||
max-width: calc(100vw - 40px);
|
max-width: calc(100vw - 40px);
|
||||||
@@ -279,9 +127,5 @@ function getIcon(type) {
|
|||||||
right: 20px !important;
|
right: 20px !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { Card, Badge, Button } from '@/components/ui';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import DispositivoCard from './DispositivoCard.vue';
|
import DispositivoCard from './DispositivoCard.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -83,6 +11,8 @@ const props = defineProps({
|
|||||||
expanded: { type: Boolean, default: false }
|
expanded: { type: Boolean, default: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['edit', 'disconnect', 'toggleDisable', 'remove', 'toggleExpand', 'editDevice']);
|
||||||
|
|
||||||
const deviceList = computed(() => {
|
const deviceList = computed(() => {
|
||||||
const ids = props.item.dispositivos_utilizados || [];
|
const ids = props.item.dispositivos_utilizados || [];
|
||||||
return ids.map(id => props.devicesById[id]).filter(Boolean);
|
return ids.map(id => props.devicesById[id]).filter(Boolean);
|
||||||
@@ -100,191 +30,101 @@ const userInitial = computed(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
.user-card {
|
<div :class="cn(
|
||||||
position: relative;
|
'relative transition-all duration-300',
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
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 {
|
<!-- Body -->
|
||||||
position: relative;
|
<div class="flex flex-col gap-2.5 mb-4">
|
||||||
background: rgba(var(--card));
|
<div class="flex justify-between items-center text-sm">
|
||||||
border: 1px solid rgba(var(--border));
|
<span class="text-muted font-medium lowercase tracking-wide">vlan</span>
|
||||||
border-radius: 16px;
|
<span class="font-medium tabular-nums">{{ item.vlan }}</span>
|
||||||
padding: 20px;
|
</div>
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
<div class="flex justify-between items-center text-sm">
|
||||||
box-shadow:
|
<span class="text-muted font-medium lowercase tracking-wide">estado</span>
|
||||||
0 1px 3px rgba(0, 0, 0, 0.05),
|
<span class="font-medium">{{ item.disabled ? 'deshabilitado' : 'activo' }}</span>
|
||||||
0 2px 8px rgba(0, 0, 0, 0.03);
|
</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 {
|
<!-- Actions -->
|
||||||
box-shadow:
|
<div class="flex gap-1.5 pt-4 border-t border-border">
|
||||||
0 4px 12px rgba(0, 0, 0, 0.08),
|
<Button variant="ghost" size="icon" @click="emit('edit', item)" title="Editar">
|
||||||
0 8px 32px rgba(0, 0, 0, 0.06),
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
0 16px 48px rgba(0, 0, 0, 0.04);
|
<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 */
|
<!-- Devices -->
|
||||||
.user-card.is-connected .card-inner {
|
<div v-if="expanded && deviceList.length" class="mt-4 pt-4 border-t border-border">
|
||||||
border-color: rgba(255, 127, 187, 0.3);
|
<div class="grid gap-3">
|
||||||
}
|
<DispositivoCard
|
||||||
|
v-for="d in deviceList"
|
||||||
.user-card.is-disabled .card-inner {
|
:key="d.id"
|
||||||
opacity: 0.6;
|
:device="d"
|
||||||
}
|
:connected="isConnected(d.id)"
|
||||||
|
simple
|
||||||
/* Header */
|
@edit="emit('editDevice', d)"
|
||||||
.card-header {
|
/>
|
||||||
display: flex;
|
</div>
|
||||||
align-items: center;
|
</div>
|
||||||
gap: 12px;
|
</Card>
|
||||||
margin-bottom: 16px;
|
</div>
|
||||||
padding-bottom: 16px;
|
</template>
|
||||||
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>
|
|
||||||
|
|||||||
@@ -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>
|
<script setup>
|
||||||
import { reactive, watch, computed, ref } from 'vue';
|
import { reactive, watch, computed, ref } from 'vue';
|
||||||
|
import { Input, Label, Button } from '@/components/ui';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Object, default: () => ({ username:'', password:'', vlan:'', disabled:false }) },
|
modelValue: { type: Object, default: () => ({ username:'', password:'', vlan:'', disabled:false }) },
|
||||||
@@ -57,3 +23,52 @@ function submit() {
|
|||||||
emit('submit', { ...state, etiquetas: tags });
|
emit('submit', { ...state, etiquetas: tags });
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
<script setup>
|
||||||
import { reactive, ref } from 'vue';
|
import { reactive, ref } from 'vue';
|
||||||
|
import { Input, Textarea, Label, Button } from '@/components/ui';
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'cancel']);
|
const emit = defineEmits(['success', 'cancel']);
|
||||||
const state = reactive({ id: '', nombre: '', descripcion: '' });
|
const state = reactive({ id: '', nombre: '', descripcion: '' });
|
||||||
@@ -49,3 +26,43 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
</script>
|
</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>
|
<script setup>
|
||||||
import { ref, defineProps } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useAuthentik } from '../../composables/useAuthentik.js';
|
import { Button } from '@/components/ui';
|
||||||
import { useToast } from '../../composables/useToast.js';
|
import { useAuthentik } from '@/composables/useAuthentik.js';
|
||||||
|
import { useToast } from '@/composables/useToast.js';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
groupName: {
|
groupName: {
|
||||||
@@ -43,7 +32,6 @@ const handleClick = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (props.verifyBackend) {
|
if (props.verifyBackend) {
|
||||||
// Verificación backend
|
|
||||||
const hasAccess = await checkGroupBackend(props.groupName);
|
const hasAccess = await checkGroupBackend(props.groupName);
|
||||||
|
|
||||||
if (hasAccess) {
|
if (hasAccess) {
|
||||||
@@ -56,7 +44,6 @@ const handleClick = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Verificación frontend
|
|
||||||
const hasAccess = hasGroup(props.groupName);
|
const hasAccess = hasGroup(props.groupName);
|
||||||
|
|
||||||
if (hasAccess) {
|
if (hasAccess) {
|
||||||
@@ -80,21 +67,13 @@ const handleClick = async () => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
.group-check-btn {
|
<Button
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
:loading="loading"
|
||||||
}
|
:disabled="loading"
|
||||||
|
@click="handleClick"
|
||||||
.group-check-btn:hover:not(:disabled) {
|
>
|
||||||
opacity: 0.9;
|
<img v-if="icon" class="size-4 opacity-90" :src="icon" :alt="label" />
|
||||||
}
|
<slot>{{ label }}</slot>
|
||||||
|
</Button>
|
||||||
.group-check-btn:active:not(:disabled) {
|
</template>
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-check-btn.loading {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -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>
|
<script setup>
|
||||||
import { useAuthentik } from '../../composables/useAuthentik.js';
|
import { Button } from '@/components/ui';
|
||||||
|
import { useAuthentik } from '@/composables/useAuthentik.js';
|
||||||
|
|
||||||
const { checkSessionStatus } = useAuthentik();
|
const { checkSessionStatus } = useAuthentik();
|
||||||
|
|
||||||
@@ -15,8 +9,9 @@ const handleClick = () => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
.session-status-btn {
|
<Button @click="handleClick">
|
||||||
/* Heredar estilos de .icon-btn del CSS global */
|
<img class="size-4 opacity-90" src="/icons/settings.svg" alt="info" />
|
||||||
}
|
Estado de Sesión
|
||||||
</style>
|
</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>
|
<script setup>
|
||||||
import { defineProps } from 'vue';
|
import { Avatar } from '@/components/ui';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
user: {
|
user: {
|
||||||
@@ -19,23 +9,13 @@ defineProps({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
.user-avatar-container {
|
<Avatar
|
||||||
display: inline-flex;
|
v-if="user"
|
||||||
align-items: center;
|
:src="user.avatar"
|
||||||
justify-content: center;
|
:alt="user.name || user.username"
|
||||||
}
|
:fallback="user.name || user.username"
|
||||||
|
size="sm"
|
||||||
.user-avatar {
|
class="border-2 border-border hover:scale-105 transition-transform"
|
||||||
width: 32px;
|
/>
|
||||||
height: 32px;
|
</template>
|
||||||
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>
|
|
||||||
|
|||||||
@@ -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>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
import { useAuthentik } from '../../composables/useAuthentik.js';
|
import { useAuthentik } from '@/composables/useAuthentik.js';
|
||||||
|
import { Badge, Button, DropdownMenu, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel } from '@/components/ui';
|
||||||
import UserAvatar from './UserAvatar.vue';
|
import UserAvatar from './UserAvatar.vue';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -109,7 +35,6 @@ const handleCheckSession = () => {
|
|||||||
checkSessionStatus();
|
checkSessionStatus();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cerrar el menú al hacer click fuera
|
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
const dropdown = event.target.closest('.user-dropdown-container');
|
const dropdown = event.target.closest('.user-dropdown-container');
|
||||||
if (!dropdown) {
|
if (!dropdown) {
|
||||||
@@ -118,137 +43,102 @@ const handleClickOutside = (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Cargar datos del usuario al montar
|
|
||||||
fetchUserData();
|
fetchUserData();
|
||||||
|
|
||||||
// Agregar listener para cerrar menú al hacer click fuera
|
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
import { onUnmounted } from 'vue';
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', handleClickOutside);
|
document.removeEventListener('click', handleClickOutside);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
.user-dropdown-container {
|
<div class="user-dropdown-container relative">
|
||||||
position: relative;
|
<!-- Loading state -->
|
||||||
}
|
<Badge v-if="isLoading" variant="secondary">
|
||||||
|
<span class="text-muted">Cargando...</span>
|
||||||
|
</Badge>
|
||||||
|
|
||||||
.user-dropdown-trigger {
|
<!-- Not authenticated -->
|
||||||
display: inline-flex;
|
<Badge v-else-if="!isAuthenticated" variant="secondary">
|
||||||
align-items: center;
|
<span class="text-muted">No autenticado</span>
|
||||||
gap: 8px;
|
</Badge>
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
<!-- Authenticated - Show dropdown -->
|
||||||
font-size: 14px;
|
<div v-else class="relative">
|
||||||
font-weight: 500;
|
<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 {
|
<div
|
||||||
min-width: 280px;
|
v-if="isMenuOpen"
|
||||||
max-width: 320px;
|
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 {
|
<hr class="border-border my-1" />
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
<!-- User Metadata -->
|
||||||
display: flex;
|
<div class="p-2 space-y-2">
|
||||||
align-items: center;
|
<div v-if="user.uid" class="text-xs space-y-1">
|
||||||
gap: 12px;
|
<span class="text-muted">ID:</span>
|
||||||
padding: 12px 8px;
|
<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 {
|
<hr class="border-border my-1" />
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-full-name {
|
<!-- Actions -->
|
||||||
font-weight: 600;
|
<div class="p-1 space-y-1">
|
||||||
font-size: 14px;
|
<Button
|
||||||
white-space: nowrap;
|
variant="ghost"
|
||||||
overflow: hidden;
|
class="w-full justify-start gap-2 text-sm"
|
||||||
text-overflow: ellipsis;
|
@click="handleGoToProfile"
|
||||||
}
|
>
|
||||||
|
<img class="size-4 opacity-90" src="/icons/user-plus.svg" alt="perfil" />
|
||||||
|
Ver Perfil en Authentik
|
||||||
|
</Button>
|
||||||
|
|
||||||
.user-email {
|
<Button
|
||||||
font-size: 12px;
|
variant="ghost"
|
||||||
white-space: nowrap;
|
class="w-full justify-start gap-2 text-sm"
|
||||||
overflow: hidden;
|
@click="handleCheckSession"
|
||||||
text-overflow: ellipsis;
|
>
|
||||||
}
|
<img class="size-4 opacity-90" src="/icons/settings.svg" alt="info" />
|
||||||
|
Verificar Sesión
|
||||||
|
</Button>
|
||||||
|
|
||||||
.menu-divider {
|
<Button
|
||||||
border: none;
|
variant="danger"
|
||||||
border-top: 1px solid rgba(var(--border));
|
class="w-full justify-start gap-2 text-sm"
|
||||||
margin: 4px 0;
|
@click="handleLogout"
|
||||||
}
|
>
|
||||||
|
<span class="text-base">⎋</span>
|
||||||
.metadata-section {
|
Cerrar Sesión
|
||||||
display: flex;
|
</Button>
|
||||||
flex-direction: column;
|
</div>
|
||||||
gap: 8px;
|
</div>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
.metadata-item {
|
</template>
|
||||||
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>
|
|
||||||
|
|||||||
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 { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
import './styles.css';
|
import './app.css';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
app.mount('#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 { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user