// ===================================================================== // Shell del portal: sesión, menú según permisos y ruteo por hash. // El menú se pinta con lo que el servidor autoriza, no con una lista // escrita a mano: así no puede quedar desfasado de la API. // ===================================================================== import { api, esc, icono, ficha, escalonar, activarCopiado, cargando } from './ui.mjs'; import * as V from './vistas.mjs'; const VISTAS = { inicio: V.inicio, oferta: V.oferta, requisitos: V.requisitos, becas: V.becas, clases: V.clases, preguntas: V.preguntas, mensajes: V.mensajes, contacto: V.contacto, equipo: V.equipo, bitacora: V.bitacora, }; const $ = (s) => document.querySelector(s); // El contenedor de la vista se reemplaza por uno nuevo en cada render. // Si se reusara el mismo nodo, cada montar() volvería a registrar sus // listeners sobre él y al recargar una sección se acumularían: un clic en // "Editar" abriría tantas ventanas como veces se hubiera recargado. let vista = $('#vista'); function lienzoNuevo() { const el = document.createElement('div'); el.className = 'lienzo__interno'; el.id = 'vista'; el.setAttribute('aria-live', 'polite'); vista.replaceWith(el); vista = el; return el; } // Qué bloques plegables dejó abiertos la persona. Sin esto, al guardar un // cambio la sección se repinta y el bloque en el que estaba trabajando se // vuelve a plegar, así que pierde su lugar. const plegablesAbiertos = new Map(); function recordarPlegables(caja) { caja.querySelectorAll('details.plegable[data-plegable]').forEach((d) => { const guardado = plegablesAbiertos.get(d.dataset.plegable); if (guardado !== undefined) d.open = guardado; }); // `toggle` no burbujea, pero un listener en captura sí lo recibe. caja.addEventListener('toggle', (ev) => { const d = ev.target.closest?.('details.plegable[data-plegable]'); if (d) plegablesAbiertos.set(d.dataset.plegable, d.open); }, true); } // ---- sesión ---------------------------------------------------------- let ctx; try { const yo = await api('/auth/yo'); ctx = { usuario: yo.usuario, permisos: new Set(yo.permisos), navegacion: yo.navegacion, grupos: yo.grupos, recargar: () => pintar(actual, { forzar: true }), }; } catch { location.href = '/'; throw new Error('sin sesión'); } // ---- menú ------------------------------------------------------------ function botonNav(s) { return ``; } function pintarMenu() { const sueltas = ctx.navegacion.filter((s) => !s.grupo); const bloques = ctx.grupos .map((g) => ({ ...g, items: ctx.navegacion.filter((s) => s.grupo === g.id) })) .filter((g) => g.items.length); $('#nav').innerHTML = [ sueltas.map(botonNav).join(''), ...bloques.map((g) => `
${esc(g.titulo)}
${g.items.map(botonNav).join('')}`), ].join(''); $('#pie-barra').innerHTML = `
${ficha(ctx.usuario.iniciales, ctx.usuario.rol_color)} ${esc(ctx.usuario.nombre)} ${esc(ctx.usuario.rol_nombre)}
`; $('#ficha-movil').innerHTML = ficha(ctx.usuario.iniciales, ctx.usuario.rol_color); $('#salir').addEventListener('click', async () => { await api('/auth/salir', { method: 'POST' }).catch(() => {}); location.href = '/'; }); } // ---- ruteo ----------------------------------------------------------- let actual = null; // Cada render lleva su número. Si la persona cambia de sección mientras una // carga, el render viejo se descarta al llegar en lugar de bloquear el menú: // antes, con una bandera de "ocupado", el clic se ignoraba y el menú parecía // muerto hasta ir a otra sección y volver. let renderActual = 0; function seccionValida(id) { return ctx.navegacion.some((s) => s.id === id) && VISTAS[id]; } async function pintar(id, { forzar = false } = {}) { if (!seccionValida(id)) id = ctx.navegacion[0]?.id ?? 'inicio'; if (id === actual && !forzar) return; const miRender = ++renderActual; actual = id; document.querySelectorAll('.nav-item[data-seccion]').forEach((b) => { if (b.dataset.seccion === id) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); }); cerrarCajon(); lienzoNuevo().innerHTML = cargando(3, 120); try { const v = await VISTAS[id](ctx); if (miRender !== renderActual) return; // llegó tarde: ya se pidió otra sección const caja = lienzoNuevo(); caja.innerHTML = `
${esc(v.ceja ?? '')}

${esc(v.titulo)}

${v.resumen ? `

${esc(v.resumen)}

` : ''} ${v.acciones ? `
${v.acciones}
` : ''}
${v.cuerpo}`; const contenido = caja.querySelector('.pila, .rejilla'); if (contenido) escalonar(contenido); recordarPlegables(caja); v.montar?.(caja); document.title = `${v.titulo} · Portal INSTA`; window.scrollTo({ top: 0, behavior: 'instant' }); } catch (err) { if (miRender !== renderActual) return; vista.innerHTML = `
Algo falló

No se pudo abrir esta sección

${esc(err.message)} ${err.estado === 403 ? '
Tu rol no tiene acceso a esto. Si lo necesitas, pídeselo a dirección.' : ''}
`; if (err.estado === 401) location.href = '/'; } } document.addEventListener('click', (ev) => { const nav = ev.target.closest('.nav-item[data-seccion]'); if (nav) { location.hash = `#/${nav.dataset.seccion}`; return; } const ir = ev.target.closest('[data-ir]'); if (ir) { location.hash = `#/${ir.dataset.ir}`; } }); addEventListener('hashchange', () => pintar(location.hash.replace(/^#\/?/, '') || 'inicio')); // ---- cajón móvil ----------------------------------------------------- function abrirCajon() { $('#barra').dataset.abierta = '1'; $('#abrir-menu').setAttribute('aria-expanded', 'true'); if (!document.querySelector('.velo')) { const velo = document.createElement('button'); velo.className = 'velo'; velo.type = 'button'; velo.setAttribute('aria-label', 'Cerrar el menú'); velo.addEventListener('click', cerrarCajon); document.body.appendChild(velo); } } function cerrarCajon() { $('#barra').dataset.abierta = '0'; $('#abrir-menu')?.setAttribute('aria-expanded', 'false'); document.querySelector('.velo')?.remove(); } $('#abrir-menu').innerHTML = icono('menu'); $('#abrir-menu').addEventListener('click', () => { ($('#barra').dataset.abierta === '1' ? cerrarCajon : abrirCajon)(); }); // Escape cierra el cajón, pero no si hay una ventana de edición abierta. addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && !document.querySelector('dialog.editor[open]')) cerrarCajon(); }); // ---- arranque -------------------------------------------------------- pintarMenu(); activarCopiado(); await pintar(location.hash.replace(/^#\/?/, '') || 'inicio');