esp/layouts/layout.php

434 lines
20 KiB
PHP

<?php
// Layout principal (protegido): incluye head común, navbar, footer y estructura del sitio.
// Variables esperadas: $title (string), $content (HTML string)
$title = isset($title) ? $title : 'ESP Admin';
$content = isset($content) ? $content : '';
$CSS_VERSION = '20260522-tailwind';
$JS_VERSION = '20251023-1000';
?>
<?php
// Nocache en entorno local
if (getenv('ENTORNO') === 'local') {
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
$JS_VERSION = date('YmdHis');
} else {
$JS_VERSION = '20260505-ports-fix';
}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo htmlspecialchars($title); ?></title>
<link rel="stylesheet" href="/assets/css/tailwind.css?v=<?php echo $CSS_VERSION; ?>">
<link rel="stylesheet" href="/assets/css/layout.css?v=<?php echo $CSS_VERSION; ?>">
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<script>
// Pre-auth: validar sesión antes de cargar el resto
(function(){
try {
fetch('/api/me', { credentials: 'same-origin', cache: 'no-store' })
.then(function(res){ if (res.status === 401) window.location.replace('/'); })
.catch(function(){});
} catch(e) {}
})();
</script>
<!-- Scripts Vanilla JS (sin jQuery) -->
<script src="/assets/js/dom-helpers.js?v=<?php echo $JS_VERSION; ?>"></script>
<script src="/assets/js/toast.js?v=20250915-1005"></script>
<script src="/assets/js/apiClient.js?v=<?php echo $JS_VERSION; ?>"></script>
<script src="/assets/js/mqttRealtime.js?v=<?php echo $JS_VERSION; ?>"></script>
<script src="/assets/js/layout.js?v=<?php echo $JS_VERSION; ?>"></script>
<script>
// Utilidades globales de íconos (Heroicons via /assets/iconos.json)
(function(){
window.espIcons = window.espIcons || {};
window.loadIcons = function() {
try {
return fetch('/assets/iconos.json', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(function(data){ window.espIcons = data || {}; })
.catch(function(){});
} catch(_) { return Promise.resolve(); }
};
window.applyIcons = function(context) {
try {
var root = context ? (typeof context === 'string' ? document.querySelector(context) : context) : document;
if (!root) root = document;
var map = {
'icon-dashboard': 'dashboard',
'icon-edit': 'edit',
'icon-puzzle': 'puzzle',
'icon-reboot': 'reboot',
'icon-back': 'back',
'icon-add': 'add',
'icon-assign': 'assign',
'icon-chart': 'chart',
'icon-bolt': 'bolt'
};
Object.keys(map).forEach(function(cls){
var key = map[cls];
root.querySelectorAll('.' + cls).forEach(function(el){
if (window.espIcons && window.espIcons[key]) {
el.innerHTML = window.espIcons[key];
}
});
});
} catch(_){ }
};
document.addEventListener('DOMContentLoaded', function(){
try { window.loadIcons().finally(function(){ window.applyIcons(document); }); } catch(_){ }
});
})();
</script>
</head>
<style>
:root {
--color-primary: #10b981;
--color-primary-dark: #059669;
--color-primary-light: #34d399;
--color-secondary: #6366f1;
--color-accent: #f59e0b;
--color-danger: #ef4444;
--color-success: #10b981;
--color-bg-main: #f8fafc;
--color-bg-card: #ffffff;
--color-text-primary: #0f172a;
--color-text-secondary: #64748b;
--color-border: #e2e8f0;
}
.bg-primary { background-color: var(--color-primary); }
.text-primary { color: var(--color-primary); }
.border-primary { border-color: var(--color-primary); }
.hover\:bg-primary:hover { background-color: var(--color-primary); }
/* Gradientes personalizados */
.gradient-primary {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.gradient-card {
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
}
</style>
<body class="min-h-screen bg-slate-50 text-slate-900" data-layout="php">
<div id="wsStatusIndicator" class="ws-indicator inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-full bg-slate-100 text-slate-700 border border-slate-200 disconnected" title="Estado de conexion MQTT WebSocket">
<span class="dot w-2.5 h-2.5 rounded-full bg-red-500"></span>
<span class="label">WS: Desconectado</span>
</div>
<!-- Navbar moderno y responsive -->
<nav class="sticky top-0 z-50 bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6">
<div class="flex h-16 items-center justify-between">
<!-- Logo/Marca -->
<a class="flex items-center gap-2 text-xl font-bold text-gray-900 hover:text-emerald-600 transition-colors" href="/">
<svg class="w-8 h-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<span class="hidden sm:inline">ESP Admin</span>
</a>
<!-- Botones de acción (desktop) -->
<div class="hidden md:flex items-center gap-3">
<a href="/" class="nav-link px-4 py-2 text-sm font-medium text-gray-700 hover:text-emerald-600 hover:bg-emerald-50 rounded-lg transition-all">
Dispositivos
</a>
<a href="/flash" class="px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-all shadow-sm">
Flashear ESP
</a>
<!-- Usuario dropdown -->
<div class="relative">
<button id="userMenuToggle" class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-lg transition-all" aria-expanded="false">
<div class="w-8 h-8 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full flex items-center justify-center text-white font-semibold text-sm">
<span id="navbarInitial">U</span>
</div>
<span id="navbarUsername" class="hidden lg:inline">Usuario</span>
<svg class="w-4 h-4 text-gray-500 transition-transform duration-200" id="userChevron" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 10.94l3.71-3.71a.75.75 0 111.06 1.06l-4.24 4.24a.75.75 0 01-1.06 0L5.21 8.29a.75.75 0 01.02-1.08z" clip-rule="evenodd" />
</svg>
</button>
<div id="userDropdown" class="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-lg shadow-xl py-1 hidden origin-top-right transform transition-all duration-200 scale-95 opacity-0">
<div class="px-4 py-3 border-b border-gray-100">
<p class="text-sm font-medium text-gray-900" id="dropdownUsername">Usuario</p>
<p class="text-xs text-gray-500">Administrador</p>
</div>
<a href="#" id="menuPerfil" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Perfil
</a>
<div class="my-1 border-t border-gray-100"></div>
<button id="logoutDropdownBtn" class="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Cerrar sesión
</button>
</div>
</div>
</div>
<!-- Botón hamburguesa (móvil) -->
<button id="navToggle" class="md:hidden relative w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors" type="button" aria-label="Menú">
<div class="w-5 flex flex-col gap-1.5">
<span class="hamburger-line block h-0.5 w-full bg-gray-700 transition-all duration-300 ease-out"></span>
<span class="hamburger-line block h-0.5 w-full bg-gray-700 transition-all duration-300 ease-out"></span>
<span class="hamburger-line block h-0.5 w-full bg-gray-700 transition-all duration-300 ease-out"></span>
</div>
</button>
</div>
</div>
<!-- Menú móvil (slide-in desde la derecha) -->
<div id="mobileMenu" class="fixed inset-y-0 right-0 w-64 bg-white shadow-2xl transform translate-x-full transition-transform duration-300 ease-out md:hidden z-50">
<div class="flex flex-col h-full">
<!-- Header del menú móvil -->
<div class="flex items-center justify-between p-4 border-b border-gray-200">
<span class="text-lg font-semibold text-gray-900">Menú</span>
<button id="mobileMenuClose" class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors">
<svg class="w-6 h-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Usuario info -->
<div class="p-4 bg-gradient-to-br from-emerald-50 to-emerald-100 border-b border-emerald-200">
<div class="flex items-center gap-3">
<div class="w-12 h-12 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-full flex items-center justify-center text-white font-semibold text-lg">
<span id="mobileNavbarInitial">U</span>
</div>
<div>
<p class="text-sm font-semibold text-gray-900" id="mobileNavbarUsername">Usuario</p>
<p class="text-xs text-gray-600">Administrador</p>
</div>
</div>
</div>
<!-- Links del menú -->
<nav class="flex-1 overflow-y-auto p-4 space-y-2">
<a href="/" class="mobile-nav-link flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-emerald-50 hover:text-emerald-700 rounded-lg transition-all">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
<span class="font-medium">Dispositivos</span>
</a>
<a href="/programacion" class="mobile-nav-link flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-emerald-50 hover:text-emerald-700 rounded-lg transition-all">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
<span class="font-medium">Programación</span>
</a>
<a href="/flash" class="mobile-nav-link flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-emerald-50 hover:text-emerald-700 rounded-lg transition-all">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="font-medium">Flashear ESP</span>
</a>
<a href="#" id="mobileMenuPerfil" class="mobile-nav-link flex items-center gap-3 px-4 py-3 text-gray-700 hover:bg-emerald-50 hover:text-emerald-700 rounded-lg transition-all">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="font-medium">Perfil</span>
</a>
</nav>
<!-- Botón de logout -->
<div class="p-4 border-t border-gray-200">
<button id="mobileLogoutBtn" class="w-full flex items-center justify-center gap-2 px-4 py-3 text-white bg-red-600 hover:bg-red-700 rounded-lg transition-all font-medium">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Cerrar sesión
</button>
</div>
</div>
</div>
<!-- Overlay para menú móvil -->
<div id="mobileOverlay" class="fixed inset-0 bg-black/50 backdrop-blur-sm hidden md:hidden z-40 transition-opacity duration-300"></div>
</nav>
<script>
// Sistema de navegación moderno y responsive
document.addEventListener('DOMContentLoaded', function(){
// Elementos del menú móvil
var navToggle = document.getElementById('navToggle');
var mobileMenu = document.getElementById('mobileMenu');
var mobileOverlay = document.getElementById('mobileOverlay');
var mobileMenuClose = document.getElementById('mobileMenuClose');
var hamburgerLines = navToggle ? navToggle.querySelectorAll('.hamburger-line') : [];
// Elementos del dropdown de usuario (desktop)
var userBtn = document.getElementById('userMenuToggle');
var userDrop = document.getElementById('userDropdown');
var userChevron = document.getElementById('userChevron');
// Botones de logout
var logoutBtns = document.querySelectorAll('#logoutDropdownBtn, #mobileLogoutBtn');
// Funciones del menú móvil
function openMobileMenu() {
if (!mobileMenu || !mobileOverlay) return;
mobileMenu.classList.remove('translate-x-full');
mobileMenu.classList.add('translate-x-0');
mobileOverlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
// Animar hamburguesa a X
if (hamburgerLines.length === 3) {
hamburgerLines[0].style.transform = 'rotate(45deg) translateY(6px)';
hamburgerLines[1].style.opacity = '0';
hamburgerLines[2].style.transform = 'rotate(-45deg) translateY(-6px)';
}
}
function closeMobileMenu() {
if (!mobileMenu || !mobileOverlay) return;
mobileMenu.classList.add('translate-x-full');
mobileMenu.classList.remove('translate-x-0');
mobileOverlay.classList.add('hidden');
document.body.style.overflow = '';
// Restaurar hamburguesa
if (hamburgerLines.length === 3) {
hamburgerLines[0].style.transform = '';
hamburgerLines[1].style.opacity = '';
hamburgerLines[2].style.transform = '';
}
}
// Event listeners menú móvil
if (navToggle) navToggle.addEventListener('click', openMobileMenu);
if (mobileMenuClose) mobileMenuClose.addEventListener('click', closeMobileMenu);
if (mobileOverlay) mobileOverlay.addEventListener('click', closeMobileMenu);
// Cerrar menú móvil al hacer click en links
if (mobileMenu) {
mobileMenu.querySelectorAll('a, button').forEach(function(el){
el.addEventListener('click', function(){
if (el.id !== 'mobileMenuClose') {
setTimeout(closeMobileMenu, 150);
}
});
});
}
// Dropdown de usuario (desktop)
function toggleUserDropdown() {
if (!userDrop) return;
var isHidden = userDrop.classList.contains('hidden');
if (isHidden) {
userDrop.classList.remove('hidden', 'scale-95', 'opacity-0');
userDrop.classList.add('scale-100', 'opacity-100');
if (userChevron) userChevron.style.transform = 'rotate(180deg)';
userBtn.setAttribute('aria-expanded', 'true');
} else {
userDrop.classList.add('scale-95', 'opacity-0');
setTimeout(function(){ userDrop.classList.add('hidden'); }, 200);
if (userChevron) userChevron.style.transform = '';
userBtn.setAttribute('aria-expanded', 'false');
}
}
if (userBtn && userDrop) {
userBtn.addEventListener('click', function(e){
e.stopPropagation();
toggleUserDropdown();
});
document.addEventListener('click', function(){
if (!userDrop.classList.contains('hidden')) {
userDrop.classList.add('scale-95', 'opacity-0');
setTimeout(function(){ userDrop.classList.add('hidden'); }, 200);
if (userChevron) userChevron.style.transform = '';
}
});
}
// Logout handlers
logoutBtns.forEach(function(btn){
btn.addEventListener('click', function(){
fetch('/api/logout', { method: 'POST', credentials: 'same-origin' })
.catch(function(){})
.finally(function(){ window.location.href = '/'; });
});
});
// Cargar nombre de usuario desde API
fetch('/api/me', { credentials: 'same-origin' })
.then(function(res){ return res.json(); })
.then(function(data){
var username = (data && data.data && data.data.user) || 'Usuario';
var initial = username.charAt(0).toUpperCase();
// Desktop
var navbarUsername = document.getElementById('navbarUsername');
var navbarInitial = document.getElementById('navbarInitial');
var dropdownUsername = document.getElementById('dropdownUsername');
if (navbarUsername) navbarUsername.textContent = username;
if (navbarInitial) navbarInitial.textContent = initial;
if (dropdownUsername) dropdownUsername.textContent = username;
// Mobile
var mobileUsername = document.getElementById('mobileNavbarUsername');
var mobileInitial = document.getElementById('mobileNavbarInitial');
if (mobileUsername) mobileUsername.textContent = username;
if (mobileInitial) mobileInitial.textContent = initial;
})
.catch(function(){});
// Resaltar enlace activo
try {
var path = location.pathname.replace(/\/$/,'') || '/';
// Desktop links
document.querySelectorAll('.nav-link').forEach(function(link){
var href = link.getAttribute('href');
if (!href) return;
var linkPath = href.replace(/\/$/,'');
// Comparar rutas
var isActive = (linkPath === path) || (linkPath !== '/' && linkPath !== '' && path.startsWith(linkPath));
if (isActive) {
link.classList.add('bg-emerald-50', 'text-emerald-700', 'font-semibold');
link.classList.remove('text-gray-700');
}
});
// Mobile links
document.querySelectorAll('.mobile-nav-link').forEach(function(link){
var href = link.getAttribute('href');
if (!href) return;
var linkPath = href.replace(/\/$/,'');
var isActive = (linkPath === path) || (linkPath !== '/' && linkPath !== '' && path.startsWith(linkPath));
if (isActive) {
link.classList.add('bg-emerald-100', 'text-emerald-700', 'font-semibold');
link.classList.remove('text-gray-700');
}
});
} catch(_) {}
});
</script>
<!-- Contenido principal -->
<div class="mx-auto px-4 py-6 w-full">
<?php echo $content; ?>
</div>
<!-- Footer -->
<footer class="border-t border-gray-200">
<div class="max-w-7xl mx-auto px-4 py-4 text-center text-sm text-gray-500">ESP Project • v1 • <?php echo date('Y'); ?></div>
</footer>
<!-- Toasts container -->
<div class="fixed bottom-0 right-0 p-3 z-50" id="toast-container"></div>
</body>
</html>