Files
soft_usite/resources/views/layouts/main.html
T
Lizandro GuarnizoandClaude Sonnet 5 5c931c9400 fix(ui): los modales largos ya no dejan los botones fuera de pantalla
En pantallas bajas (o con formularios largos como el de portal-usuarios)
el panel del modal se salía de la ventana y no había forma de llegar a
Guardar ni a Cancelar.

La regla va en el layout y no en cada vista: son ~214 modales repartidos
en las plantillas y arreglarlos uno por uno garantizaba olvidarse varios.
El selector toma solo los overlays centrados y deja afuera los 4 paneles
laterales, que ya manejan su propio scroll.

El alto se mide en % y no en vh/dvh: el overlay es inset-0, o sea que ya
es exactamente la caja visible. Medido en Edge headless a 600/900/700/420
de alto: el panel queda siempre en ventana-2rem, el tope nunca se recorta,
y Guardar se alcanza scrolleando. Con ventana alta el panel conserva su
alto natural y no scrollea.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:52:54 -05:00

314 lines
14 KiB
HTML
Executable File

<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>U-site</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Select2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/app.css">
<!-- Axios -->
<script src="https://cdn.jsdelivr.net/npm/axios@1.6.7/dist/axios.min.js"></script>
<!-- Alpine.js -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.5/dist/cdn.min.js" defer></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- jQuery -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Select2 JS -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
[x-cloak] { display: none !important; }
/* Modales: el panel nunca puede pasar del alto de la ventana. Sin
esto, un formulario largo (o una pantalla baja / un portátil) deja
los botones Guardar y Cancelar fuera de vista y sin forma de
llegar a ellos.
Va acá y no en cada vista a propósito: son ~214 modales repartidos
en las plantillas y arreglarlos uno por uno garantizaba olvidarse
varios. El selector toma los overlays centrados
(.fixed.inset-0 + .items-center) y deja afuera los paneles
laterales (justify-end), que ya manejan su propio scroll.
El alto se mide en % y no en vh/dvh a propósito: el overlay es
inset-0, o sea que YA es exactamente la caja visible, así que el
100% es el alto real. Con vh el panel quedaba más alto que la
ventana (medido: ventana 516px, panel 532px) porque vh no siempre
coincide con el viewport efectivo. */
.fixed.inset-0.items-center > div {
max-height: calc(100% - 2rem);
overflow-y: auto;
}
:root {
--color-primary: #8eb02f;
--color-primary-dark: #6d8c24;
--color-secondary: #1e293b;
--color-bg: #f1f5f9;
--color-border: #e2e8f0;
--radius: 0.5rem;
}
/* Sidebar scroll */
.sidebar-scroll { scrollbar-width: none; -ms-overflow-style: none; }
.sidebar-scroll::-webkit-scrollbar { display: none; }
/* Avatar circle */
.ui-avatar {
width: 2rem; height: 2rem; border-radius: 9999px;
display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; font-size: .7rem; color: #fff;
background-color: var(--color-primary);
text-transform: uppercase; flex-shrink: 0; user-select: none;
}
.ui-avatar-lg { width: 2.25rem; height: 2.25rem; font-size: .8rem; }
/* Pulse animation for activity dot */
@keyframes pulse-ring {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .75; transform: scale(1.15); }
}
.pulse-dot { animation: pulse-ring 2.5s ease-in-out infinite; }
/* Smooth focus inputs */
.ui-input {
width: 100%; padding: .5rem .75rem;
border: 1px solid var(--color-border); border-radius: var(--radius);
font-size: .875rem; outline: none; font-family: inherit;
transition: border-color .15s ease, box-shadow .15s ease;
}
.ui-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(142, 176, 47, .15);
}
.ui-input::placeholder { color: #94a3b8; }
/* Active nav item in sidebar */
.nav-active { background-color: var(--color-primary) !important; color: #fff !important; }
/* === Global content page improvements === */
/* Card wrapper (used in all content pages) */
.bg-white.rounded-lg.shadow,
.bg-white.rounded-lg {
border-radius: 0.75rem !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04) !important;
border: 1px solid #e2e8f0;
}
/* Page title */
h1.text-2xl.font-bold {
font-size: 1.125rem !important;
font-weight: 700 !important;
color: #1e293b !important;
letter-spacing: -0.01em;
}
/* Search input improvements */
input[placeholder="Buscar..."] {
border-radius: 0.5rem !important;
border: 1px solid #e2e8f0 !important;
font-size: 0.875rem !important;
transition: border-color .15s ease, box-shadow .15s ease !important;
outline: none !important;
padding: 0.5rem 0.75rem 0.5rem 2rem !important;
}
input[placeholder="Buscar..."]:focus {
border-color: var(--color-primary) !important;
box-shadow: 0 0 0 3px rgba(142,176,47,.12) !important;
}
/* Table - global improvements */
.table-auto, table.table-auto {
border-collapse: collapse;
font-size: 0.875rem;
}
.table-auto thead tr, table.table-auto thead tr {
background-color: #f8fafc !important;
}
.table-auto thead th, table.table-auto thead th {
font-size: 0.7rem !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
color: #475569 !important;
padding: 0.75rem 1rem !important;
white-space: nowrap;
}
.table-auto tbody td, table.table-auto tbody td {
padding: 0.875rem 1rem !important;
vertical-align: middle;
font-size: 0.8125rem !important;
}
.table-auto tbody tr, table.table-auto tbody tr {
transition: background-color 0.1s ease;
}
.table-auto tbody tr:hover, table.table-auto tbody tr:hover {
background-color: #f8fafc !important;
}
/* Action icons in rows */
.table-auto button svg, table.table-auto button svg {
transition: transform 0.1s ease;
}
.table-auto button:hover svg, table.table-auto button:hover svg {
transform: scale(1.15);
}
/* Loading overlay */
.fixed.inset-0.bg-gray-800 {
background-color: rgba(15, 23, 42, 0.55) !important;
backdrop-filter: blur(2px);
}
/* Page loading bar at top */
.page-content { animation: fadeIn .2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body class="bg-slate-100 w-full">
<main class="flex w-full">
<!-- Sidebar -->
{{template "partials/lateral" .}}
<!-- Main area -->
<div class="flex-1 flex flex-col min-w-0">
{{template "partials/header" .}}
<div class="flex-1 p-4 overflow-auto page-content" style="height:calc(100vh - 64px)">
{{embed}}
</div>
{{template "partials/footer" .}}
</div>
</main>
<!-- Session Timeout -->
<div x-data="sessionTimeout" x-init="init()" x-cloak>
<!-- Activity pill -->
<div x-show="!showModal"
x-transition
class="fixed top-4 right-4 z-50 hidden sm:flex items-center gap-2 bg-white border border-slate-200 shadow-sm rounded-full px-3 py-1.5 text-xs text-slate-500 select-none">
<div class="w-2 h-2 rounded-full bg-emerald-400 pulse-dot flex-shrink-0"></div>
<span>Sesión: <span class="font-semibold text-slate-700" x-text="formatTime(inactivityCountdown)"></span></span>
</div>
<!-- Modal -->
<div x-show="showModal"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
<div class="px-6 py-5" style="background: linear-gradient(135deg, #1e293b 0%, #334155 100%)">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
style="background:rgba(251,191,36,.15)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" style="color:#fbbf24" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
</div>
<div>
<h2 class="text-white font-semibold">Sesión por vencer</h2>
<p class="text-slate-400 text-xs mt-0.5">Tu sesión expirará por inactividad</p>
</div>
</div>
</div>
<div class="px-6 py-6">
<div class="flex items-center justify-center gap-2 bg-red-50 rounded-xl py-3 mb-5 border border-red-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm text-red-600">Cierre automático en <strong x-text="countdown"></strong> segundos</span>
</div>
<div class="flex gap-3">
<button @click="continueSession"
class="flex-1 py-2.5 rounded-xl text-sm font-semibold text-white transition-all"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
Continuar sesión
</button>
<form action="/do/logout" method="POST" class="flex-1">
<button type="submit"
class="w-full py-2.5 rounded-xl text-sm font-semibold bg-slate-100 text-slate-700 hover:bg-slate-200 transition-colors">
Cerrar sesión
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("alpine:init", () => {
Alpine.data("sessionTimeout", () => ({
showModal: false,
countdown: 60,
inactivityCountdown: 300,
inactivityTimer: null,
timer: null,
init() {
this.resetInactivityTimer();
window.addEventListener("mousemove", () => this.resetInactivityTimer());
window.addEventListener("keydown", () => this.resetInactivityTimer());
},
resetInactivityTimer() {
clearTimeout(this.inactivityTimer);
this.inactivityCountdown = 300;
this.startInactivityTimer();
},
startInactivityTimer() {
this.inactivityTimer = setInterval(() => {
this.inactivityCountdown--;
if (this.inactivityCountdown <= 0) {
clearInterval(this.inactivityTimer);
this.promptLogout();
}
}, 1000);
},
promptLogout() {
this.showModal = true;
this.startCountdown();
},
startCountdown() {
this.countdown = 60;
clearInterval(this.timer);
this.timer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) this.logout();
}, 1000);
},
continueSession() {
clearInterval(this.timer);
this.showModal = false;
this.resetInactivityTimer();
},
logout() {
clearInterval(this.timer);
document.querySelector('form[action="/do/logout"]').submit();
},
formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
},
}));
});
</script>
</body>
</html>