Files
Uriel JarethandClaude Opus 5 0b9c9c1dc2 Portal interno INSTA: base de conocimiento con Magic Login y permisos por rol
Base de conocimiento para el personal del Instituto Santiago Tapia: oferta
educativa (en tarjetas y en tabla), requisitos y titulacion, becas y fechas
de inscripcion, links de clases de Google Meet, preguntas frecuentes,
mensajes listos y datos del instituto.

Acceso con Magic Login: enlace de un solo uso, 15 minutos de vigencia, y en
la base solo se guarda el SHA-256 del token. El canje es atomico, asi que dos
canjes simultaneos del mismo enlace no pueden crear dos sesiones.

Tres roles con 19 permisos. El catalogo lo edita direccion; los links de
clases, direccion y administracion; el resto del equipo consulta. El menu se
pinta con lo que el servidor autoriza y cada endpoint exige la misma clave,
asi que no hay pantallas alcanzables escribiendo la direccion a mano.

Toda edicion pasa por una lista blanca de campos validados y queda en la
bitacora. Un precio vacio no se guarda como cero, un link que no es una
direccion se rechaza, y no se puede marcar una validez oficial como
confirmada sin numero de acuerdo.

Pensado para leerse comodo: texto de 17 px, contraste AA, areas de toque de
44 px, tema claro y bloques plegados en telefono.

La oferta, los costos, las becas, los requisitos y el contacto son los reales
del instituto, tomados del CSV de oferta educativa y de la base de
conocimiento. Los usuarios de acceso y los links de Meet son de demostracion.

Verificado: 103 comprobaciones de API y 51 pruebas en WebKit escritorio y
iPhone 13.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-29 23:20:34 -06:00

212 lines
7.6 KiB
JavaScript

// =====================================================================
// 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 `<button class="nav-item" type="button" data-seccion="${esc(s.id)}" title="${esc(s.resumen)}">
${icono(s.icono)}<span class="crece recorte">${esc(s.titulo)}</span>
</button>`;
}
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) => `<div class="barra__grupo"><span class="ceja">${esc(g.titulo)}</span></div>${g.items.map(botonNav).join('')}`),
].join('');
$('#pie-barra').innerHTML = `
<div class="usuario" style="--rol:${esc(ctx.usuario.rol_color)}">
${ficha(ctx.usuario.iniciales, ctx.usuario.rol_color)}
<span class="usuario__txt">
<b class="recorte">${esc(ctx.usuario.nombre)}</b>
<span>${esc(ctx.usuario.rol_nombre)}</span>
</span>
</div>
<button class="nav-item" id="salir" type="button">${icono('salir')}<span>Cerrar sesión</span></button>`;
$('#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 = `
<header class="cabecera-seccion">
<span class="ceja">${esc(v.ceja ?? '')}</span>
<h1>${esc(v.titulo)}</h1>
${v.resumen ? `<p>${esc(v.resumen)}</p>` : ''}
${v.acciones ? `<div class="cabecera-seccion__acciones">${v.acciones}</div>` : ''}
</header>
${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 = `
<header class="cabecera-seccion"><span class="ceja">Algo falló</span><h1>No se pudo abrir esta sección</h1></header>
<div class="aviso aviso--error">
${esc(err.message)}
${err.estado === 403 ? '<br>Tu rol no tiene acceso a esto. Si lo necesitas, pídeselo a dirección.' : ''}
</div>
<div style="margin-top:16px"><button class="boton boton--fantasma" type="button" data-ir="inicio">Volver al inicio</button></div>`;
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');