mejoras de UI

This commit is contained in:
2025-09-26 17:08:33 -06:00
parent 974fe0b9e1
commit 6e6523c0c8
4 changed files with 154 additions and 28 deletions

View File

@@ -55,17 +55,10 @@
</div>
</div>
<div class="scroll">
<form @submit.prevent="createUser" class="row" style="margin-bottom:10px;">
<input v-model="form.username" placeholder="usuario" required class="toggle"/>
<input v-model="form.password" placeholder="contraseña" required class="toggle"/>
<input v-model="form.vlan" placeholder="VLAN" class="toggle"/>
<label class="row toggle" style="gap:6px;"><input type="checkbox" v-model="form.disabled"/> deshabilitado</label>
<button type="submit" class="icon-btn">Crear / Actualizar</button>
</form>
<div v-if="loading.users" class="muted">Cargando usuarios</div>
<div v-else class="grid">
<UserCard v-for="u in filteredUsers" :key="u.username" :item="u" :mode="layoutMode"
@toggleDisable="toggleDisable" @remove="removeUser" />
@toggleDisable="toggleDisable" @remove="removeUser" @edit="openEditUser" />
</div>
</div>
</main>
@@ -96,6 +89,11 @@
</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>
</template>
<script setup>
@@ -103,11 +101,12 @@ import { onMounted, reactive, ref, computed } from 'vue';
import EventCard from './components/EventCard.js';
import UserCard from './components/UserCard.js';
import Modal from './components/Modal.vue';
import UserForm from './components/UserForm.vue';
const users = ref([]);
const requests = ref([]);
const loading = reactive({ users: false, requests: false });
const form = reactive({ username: '', password: '', vlan: '', disabled: false });
// formulario inline removido: se usa modal con UserForm
const showEventFilters = ref(false);
const showUserFilters = ref(false);
@@ -137,17 +136,6 @@ async function fetchRequests() {
} finally { loading.requests = false; }
}
async function createUser() {
const payload = { ...form };
if (!payload.vlan) delete payload.vlan;
await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
form.username = '';
form.password = '';
form.vlan = '';
form.disabled = false;
await fetchUsers();
}
async function toggleDisable(u) {
await fetch(`/api/users/${encodeURIComponent(u.username)}`, {
method: 'PATCH',
@@ -227,7 +215,36 @@ function applyTheme() {
document.documentElement.classList.toggle('light', theme.value === 'light');
}
function openAddUser() { /* placeholder for advanced modal */ }
function openAddGuest() { /* placeholder for advanced modal */ }
function openSettings() { /* placeholder for advanced modal */ }
const showUserForm = ref(false);
const userFormMode = ref('create'); // 'create' | 'edit' | 'guest'
const userFormModel = ref({ username:'', password:'', vlan:'', disabled:false });
function openAddUser() {
userFormMode.value = 'create';
userFormModel.value = { username:'', password:'', vlan:'', disabled:false };
showUserForm.value = true;
}
function openAddGuest() {
userFormMode.value = 'guest';
userFormModel.value = { username:'', password:'', vlan:'5', disabled:false };
showUserForm.value = true;
}
function openSettings() { /* modal de configuración futura */ }
function openEditUser(u) {
userFormMode.value = 'edit';
userFormModel.value = { username: u.username, password: u.password || '', vlan: u.vlan || '', disabled: !!u.disabled };
showUserForm.value = true;
}
async function handleUserFormSubmit(data) {
if (userFormMode.value === 'edit') {
await fetch(`/api/users/${encodeURIComponent(userFormModel.value.username)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data)
});
} else {
await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
}
await fetchUsers();
showUserForm.value = false;
}
</script>

View File

@@ -5,16 +5,18 @@ const html = htm.bind(h);
export default defineComponent({
name: 'UserCard',
props: { item: { type: Object, required: true }, mode: { type: String, default: 'user' } },
emits: ['toggleDisable', 'remove'],
emits: ['toggleDisable', 'remove', 'edit'],
setup(props, { emit }) {
function toggle() { emit('toggleDisable', props.item); }
function remove() { emit('remove', props.item); }
function edit() { emit('edit', props.item); }
return () => html`<div class="card">
<div class="row">
<b>${props.mode === 'user' ? props.item.username : (props.item.device || props.item.username)}</b>
<span class="chip">VLAN ${props.item.vlan}</span>
${props.item.disabled ? html`<span class="chip" style="color:#b33">deshabilitado</span>` : html`<span class="chip">activo</span>`}
<span class="spacer"></span>
<button class="icon-btn" onClick=${edit}>Editar</button>
<button class="icon-btn" onClick=${toggle}>${props.item.disabled ? 'Habilitar' : 'Deshabilitar'}</button>
<button class="icon-btn" onClick=${remove}>Eliminar</button>
</div>
@@ -22,4 +24,3 @@ export default defineComponent({
</div>`;
}
});

View File

@@ -0,0 +1,50 @@
<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;">VLAN</div>
<input v-model="state.vlan" placeholder="VLAN" style="width:100%; background:transparent; border:none; outline:none; color:inherit;"/>
</label>
<label class="row toggle" style="gap:6px;">
<input type="checkbox" v-model="state.disabled"/>
Deshabilitado
</label>
</div>
<div class="modal-footer">
<button type="button" class="icon-btn" @click="$emit('cancel')">Cancelar</button>
<button type="submit" class="icon-btn">Guardar</button>
</div>
</form>
</template>
<script setup>
import { reactive, watch, computed } from 'vue';
const props = defineProps({
modelValue: { type: Object, default: () => ({ username:'', password:'', vlan:'', disabled:false }) },
mode: { type: String, default: 'create' } // 'create' | 'edit' | 'guest'
});
const emit = defineEmits(['update:modelValue', 'submit', 'cancel']);
const state = reactive({ username:'', password:'', vlan:'', disabled:false });
const isEdit = computed(() => props.mode === 'edit');
watch(() => props.modelValue, (v) => {
Object.assign(state, v || {});
}, { immediate: true, deep: true });
function submit() {
emit('submit', { ...state });
}
</script>

View File

@@ -7,6 +7,12 @@
--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;
@@ -15,6 +21,10 @@
--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%; }
@@ -28,8 +38,16 @@ a { color: inherit; }
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));
background: linear-gradient(180deg, rgba(var(--card)), rgba(var(--card)) 60%, rgba(0,0,0,0));
border-bottom: 1px solid rgba(var(--border));
border: 1px solid transparent;
background:
linear-gradient(rgba(var(--card)), rgba(var(--card))) padding-box,
linear-gradient(135deg, #ff9fcb, #ff7fbb) border-box;
}
:root:not(.light) .topbar {
background:
linear-gradient(rgba(var(--card)), rgba(var(--card))) padding-box,
linear-gradient(135deg, #ff2e86, #ff6bb0) border-box;
box-shadow: 0 0 18px rgba(255, 46, 134, 0.25), 0 0 6px rgba(255, 107, 176, 0.2);
}
.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; }
@@ -40,7 +58,17 @@ a { color: inherit; }
/* Layout */
.shell { height: calc(100vh - 54px); display: grid; grid-template-columns: 360px 1fr; gap: 12px; padding: 12px; }
.panel { border: 1px solid rgba(var(--border)); background: rgba(var(--card)); border-radius: var(--radius); backdrop-filter: blur(var(--glass-blur)); overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.panel { border: 1px solid transparent; background: rgba(var(--card)); border-radius: var(--radius); backdrop-filter: blur(var(--glass-blur)); overflow: hidden; display: flex; flex-direction: column; min-height: 0;
background:
linear-gradient(rgba(var(--card)), rgba(var(--card))) padding-box,
linear-gradient(135deg, #ff9fcb, #ff7fbb) border-box;
}
:root:not(.light) .panel {
background:
linear-gradient(rgba(var(--card)), rgba(var(--card))) padding-box,
linear-gradient(135deg, #ff2e86, #ff6bb0) border-box;
box-shadow: 0 0 22px rgba(255, 46, 134, 0.18), 0 0 8px rgba(255, 107, 176, 0.15);
}
.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; }
@@ -73,3 +101,33 @@ a { color: inherit; }
.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;
}