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]>
This commit is contained in:
Uriel Jareth
2026-07-29 23:20:34 -06:00
co-authored by Claude Opus 5
commit 0b9c9c1dc2
34 changed files with 8307 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+774
View File
@@ -0,0 +1,774 @@
/* =====================================================================
Portal Interno INSTA — hoja de estilo
---------------------------------------------------------------------
Tema claro, texto grande y contrastes altos: el portal lo usa gente
que lleva años en el instituto y lo consulta a media conversación,
muchas veces desde el teléfono. Se prefiere una línea de más antes que
una letra más chica.
La geometría sale del logo: una placa con radio grande en la esquina
superior izquierda e inferior derecha, y casi recto en las otras dos.
El amarillo se usa solo como marcador sobre el dato importante.
===================================================================== */
:root {
/* --- marca (muestreada del logo oficial) --- */
--azul: #4275b7;
--azul-profundo: #30517e;
--azul-panel: #9dc2e8;
--azul-niebla: #e7effa;
--azul-humo: #f3f7fc;
--marca: #efef07;
/* --- texto y superficies (contraste alto sobre papel) --- */
--carbon: #14181d; /* 15.6:1 sobre papel */
--tinta: #16283f; /* títulos */
--gris: #45535f; /* 8.4:1 · texto secundario */
--gris-claro: #66737f; /* 5.4:1 · etiquetas pequeñas */
--papel: #fafbfc;
--papel-hueco: #ffffff;
--linea: #cfdce9;
--linea-suave: #e5edf5;
/* --- estado --- */
--alerta: #b32222; /* 6.5:1 */
--atencion: #a95c00; /* 4.9:1 */
--bien: #0c7350; /* 5.1:1 */
/* --- tipografía --- */
--serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;
--sans: 'Segoe UI Variable Text', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', system-ui, sans-serif;
--mono: 'Cascadia Mono', Consolas, 'SF Mono', 'Roboto Mono', ui-monospace, monospace;
/* --- forma --- */
--radio-placa: 20px 4px 20px 4px;
--radio-chico: 10px 2px 10px 2px;
--radio-pill: 999px;
--sombra: 0 1px 2px rgba(22, 40, 63, .07), 0 8px 24px -14px rgba(22, 40, 63, .2);
--sombra-alta: 0 2px 6px rgba(22, 40, 63, .1), 0 22px 50px -20px rgba(22, 40, 63, .3);
--ancho: 1120px;
--barra: 282px;
--toque: 44px; /* alto mínimo de cualquier cosa que se pueda tocar */
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; color-scheme: light; }
body {
margin: 0;
background: var(--papel);
color: var(--carbon);
font-family: var(--sans);
font-size: 17px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 { font-family: var(--serif); font-weight: 600; line-height: 1.2; margin: 0; }
p { margin: 0; }
a { color: var(--azul-profundo); text-decoration-thickness: 1px; text-underline-offset: 3px; }
a:hover { color: var(--azul); }
:focus-visible { outline: 3px solid var(--azul); outline-offset: 2px; border-radius: 3px; }
::selection { background: var(--marca); color: #14181d; }
/* ---------------------------------------------------------------------
Rótulo
--------------------------------------------------------------------- */
.rotulo {
display: flex; align-items: center; gap: 12px;
text-decoration: none; color: inherit;
min-height: var(--toque); /* es un enlace: tiene que poder tocarse */
}
.rotulo img { width: 48px; height: auto; flex: none; border-radius: var(--radio-chico); }
.rotulo__texto { display: flex; flex-direction: column; line-height: 1.2; }
.rotulo__nombre { font-family: var(--serif); font-size: 17px; font-weight: 600; color: var(--tinta); }
.rotulo__sub { font-size: 13px; letter-spacing: .05em; text-transform: uppercase; color: var(--gris); }
/* ---------------------------------------------------------------------
Etiquetas y datos
--------------------------------------------------------------------- */
/* 14 px y en gris oscuro: en mayúsculas y con tracking, cualquier cosa más
chica se vuelve difícil de leer, y en teléfono esta etiqueta es lo único
que nombra el valor de cada celda. */
.ceja {
font-size: 14px; font-weight: 700;
text-transform: uppercase; letter-spacing: .07em; color: var(--gris);
}
.dato { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* Marcador amarillo: el único uso del amarillo sobre texto. La banda se
queda por debajo de la línea base para no comerse los descendentes de la
g, la j o la p en texto blanco sobre el azul. */
.marcado > * {
padding: 0 .1em;
background-image: linear-gradient(to top, rgba(239, 239, 7, .92) 12%, transparent 12%);
background-size: 100% .34em;
background-position: 0 100%;
background-repeat: no-repeat;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
/* ---------------------------------------------------------------------
Entrada · Magic Login
--------------------------------------------------------------------- */
.entrada { min-height: 100vh; min-height: 100dvh; display: grid; grid-template-columns: 1fr 1fr; }
.entrada__lienzo {
background: var(--azul-profundo); color: #eef4fb;
padding: 48px 44px;
display: flex; flex-direction: column; justify-content: space-between; gap: 30px;
position: relative; overflow: hidden;
}
.entrada__lienzo::before {
content: ''; position: absolute; inset: 0;
background:
radial-gradient(120% 90% at 78% -12%, rgba(157, 194, 232, .26), transparent 60%),
radial-gradient(90% 70% at 8% 108%, rgba(239, 239, 7, .06), transparent 62%);
pointer-events: none;
}
.entrada__lienzo > * { position: relative; }
.entrada__lienzo .rotulo__nombre { color: #fff; }
.entrada__lienzo .rotulo__sub { color: rgba(238, 244, 251, .88); }
.entrada__promesa { max-width: 33rem; }
.entrada__promesa h1 { font-size: clamp(30px, 4.2vw, 48px); color: #fff; }
.entrada__promesa h1 em { font-style: normal; color: var(--marca); }
.entrada__promesa p { margin-top: 18px; color: rgba(238, 244, 251, .85); font-size: 17px; max-width: 30rem; }
.entrada__hechos { display: flex; flex-wrap: wrap; gap: 14px 32px; }
.entrada__hecho { display: flex; flex-direction: column; }
.entrada__hecho b { font-family: var(--mono); font-size: 22px; color: #fff; font-variant-numeric: tabular-nums; }
.entrada__hecho span { font-size: 13px; text-transform: uppercase; letter-spacing: .07em; color: rgba(238, 244, 251, .88); }
.entrada__acceso { padding: 44px; display: flex; flex-direction: column; justify-content: center; background: var(--papel-hueco); }
.acceso { width: 100%; max-width: 460px; margin-inline: auto; }
.acceso > .ceja { display: block; margin-bottom: 8px; }
.acceso h2 { font-size: 29px; color: var(--tinta); }
.acceso__nota { margin-top: 10px; color: var(--gris); }
.acceso__roles { margin-top: 26px; display: flex; flex-direction: column; gap: 10px; }
.tarjeta-rol {
display: grid; grid-template-columns: 44px 1fr; align-items: center; gap: 14px;
width: 100%; text-align: left; cursor: pointer;
padding: 14px 16px; min-height: var(--toque);
background: var(--papel-hueco);
border: 2px solid var(--linea);
border-left: 5px solid var(--rol, var(--azul));
border-radius: var(--radio-chico);
font: inherit; color: inherit;
transition: border-color .16s, box-shadow .16s;
}
.tarjeta-rol:hover:not(:disabled) { border-color: var(--azul); box-shadow: var(--sombra); }
.tarjeta-rol:disabled { opacity: .6; cursor: progress; }
.tarjeta-rol__txt { min-width: 0; }
.tarjeta-rol__txt b { display: block; font-size: 17px; color: var(--tinta); font-weight: 600; }
.tarjeta-rol__txt span { display: block; font-size: 14.5px; color: var(--gris); line-height: 1.45; }
.ficha {
width: 44px; height: 44px; border-radius: var(--radio-chico); flex: none;
display: grid; place-items: center;
background: var(--azul-niebla); color: var(--azul-profundo);
border: 2px solid var(--azul-panel);
font-family: var(--mono); font-size: 15px; font-weight: 700;
}
.ficha--chica { width: 36px; height: 36px; font-size: 13px; }
.separador {
display: flex; align-items: center; gap: 12px; margin: 26px 0 18px;
font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .09em; color: var(--gris-claro);
}
.separador::before, .separador::after { content: ''; height: 1px; background: var(--linea); flex: 1; }
/* ---------------------------------------------------------------------
Formularios
--------------------------------------------------------------------- */
.campo { display: flex; flex-direction: column; gap: 7px; }
.campo__ayuda { font-size: 15.5px; color: var(--gris); }
label { font-size: 16px; font-weight: 600; color: var(--tinta); }
.campo-casilla {
display: flex; align-items: center; gap: 13px; cursor: pointer;
min-height: var(--toque); padding: 8px 12px;
border: 2px solid var(--linea); border-radius: var(--radio-chico);
}
.campo-casilla:hover { border-color: var(--azul); background: var(--azul-humo); }
.campo-casilla:has(input:checked) { border-color: var(--azul); background: var(--azul-niebla); }
.campo-casilla input {
width: 26px; height: 26px; min-height: auto; flex: none;
accent-color: var(--azul-profundo); margin: 0;
}
.campo-casilla span { font-weight: 600; }
input[type=email], input[type=text], input[type=search], input[type=url], input[type=number], select, textarea {
font: inherit; font-size: 17px;
padding: 11px 13px; min-height: var(--toque);
border: 2px solid var(--linea);
border-radius: var(--radio-chico);
background: var(--papel-hueco); color: var(--carbon);
width: 100%;
}
textarea { min-height: 120px; resize: vertical; line-height: 1.55; }
input::placeholder, textarea::placeholder { color: var(--gris-claro); }
input:focus, select:focus, textarea:focus {
outline: none; border-color: var(--azul); box-shadow: 0 0 0 3px rgba(66, 117, 183, .22);
}
input[aria-invalid=true], textarea[aria-invalid=true] { border-color: var(--alerta); }
.boton {
font: inherit; font-size: 16px; font-weight: 600;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 10px 20px; min-height: var(--toque); cursor: pointer;
border-radius: var(--radio-chico);
border: 2px solid var(--azul-profundo);
background: var(--azul-profundo); color: #fff;
text-decoration: none;
transition: background .15s, border-color .15s;
}
.boton:hover:not(:disabled) { background: var(--azul); border-color: var(--azul); color: #fff; }
.boton:disabled { opacity: .55; cursor: not-allowed; }
.boton svg { width: 19px; height: 19px; flex: none; }
.boton--fantasma { background: transparent; color: var(--azul-profundo); border-color: var(--linea); }
.boton--fantasma:hover:not(:disabled) { background: var(--azul-niebla); color: var(--azul-profundo); border-color: var(--azul); }
.boton--peligro { background: transparent; color: var(--alerta); border-color: color-mix(in srgb, var(--alerta) 40%, transparent); }
.boton--peligro:hover:not(:disabled) { background: color-mix(in srgb, var(--alerta) 10%, transparent); color: var(--alerta); border-color: var(--alerta); }
.boton--chico { font-size: 14.5px; padding: 7px 13px; min-height: 38px; }
.boton--bloque { width: 100%; }
.aviso {
padding: 13px 16px;
border-radius: var(--radio-chico);
border: 1px solid var(--linea);
border-left: 5px solid var(--gris-claro);
background: var(--azul-humo);
font-size: 16px; color: var(--carbon);
}
.aviso--error { border-left-color: var(--alerta); background: color-mix(in srgb, var(--alerta) 8%, transparent); }
.aviso--ok { border-left-color: var(--bien); background: color-mix(in srgb, var(--bien) 9%, transparent); }
.aviso--atencion { border-left-color: var(--atencion); background: color-mix(in srgb, var(--atencion) 9%, transparent); }
.aviso--nota { border-left-color: var(--azul-panel); }
.aviso + .aviso { margin-top: 10px; }
.enlace-magico code {
display: block; margin-top: 8px; padding: 10px 12px;
font-family: var(--mono); font-size: 13px; word-break: break-all;
background: var(--azul-niebla); border-radius: var(--radio-chico); color: var(--azul-profundo);
}
.pie-demo { margin-top: 26px; font-size: 14.5px; color: var(--gris); }
@media (max-width: 900px) {
.entrada { grid-template-columns: 1fr; }
.entrada__lienzo { padding: 28px 22px 32px; }
.entrada__acceso { padding: 30px 22px 46px; }
}
/* ---------------------------------------------------------------------
Estructura del portal
--------------------------------------------------------------------- */
.portal { display: grid; grid-template-columns: var(--barra) minmax(0, 1fr); min-height: 100vh; min-height: 100dvh; }
.barra {
position: sticky; top: 0; height: 100vh; height: 100dvh;
display: flex; flex-direction: column;
background: var(--papel-hueco);
border-right: 1px solid var(--linea);
padding: 18px 14px 14px;
overflow-y: auto;
}
.barra__cabeza { padding: 0 6px 16px; border-bottom: 1px solid var(--linea-suave); }
.barra__nav { flex: 1; padding-top: 12px; display: flex; flex-direction: column; gap: 3px; }
.barra__grupo { margin: 16px 0 5px; padding: 0 8px; }
.nav-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; min-height: var(--toque);
border-radius: var(--radio-chico);
border: 2px solid transparent;
background: none; font: inherit; font-size: 16px; color: var(--gris);
cursor: pointer; text-align: left; width: 100%;
transition: background .14s, color .14s;
}
.nav-item:hover { background: var(--azul-humo); color: var(--tinta); }
.nav-item[aria-current='page'] {
background: var(--azul-niebla); color: var(--azul-profundo); font-weight: 700;
border-color: var(--azul-panel);
}
.nav-item svg { width: 21px; height: 21px; flex: none; stroke-width: 1.7; }
.barra__pie { border-top: 1px solid var(--linea-suave); padding-top: 12px; }
.usuario { display: grid; grid-template-columns: 44px 1fr; gap: 11px; align-items: center; padding: 0 6px 10px; }
.usuario__txt { min-width: 0; }
.usuario__txt b { display: block; font-size: 15.5px; color: var(--tinta); font-weight: 600; }
/* El color del rol iría por debajo del contraste mínimo como texto, así que
va en un punto al lado y el nombre del rol en gris legible. */
.usuario__txt span { display: flex; align-items: center; gap: 7px; font-size: 14px; font-weight: 600; color: var(--gris); }
.usuario__txt span::before {
content: ''; width: 9px; height: 9px; border-radius: 50%; flex: none;
background: var(--rol, var(--azul-profundo));
}
.tope {
display: none;
position: sticky; top: 0; z-index: 40;
align-items: center; gap: 12px;
padding: 10px 16px;
background: var(--papel-hueco);
border-bottom: 1px solid var(--linea);
}
.tope__menu {
width: var(--toque); height: var(--toque); display: grid; place-items: center;
border: 2px solid var(--linea); border-radius: var(--radio-chico);
background: var(--papel-hueco); cursor: pointer; color: var(--tinta);
}
.tope__menu svg { width: 22px; height: 22px; }
.tope .ficha { margin-left: auto; }
.lienzo { min-width: 0; padding: 30px 34px 80px; }
.lienzo__interno { max-width: var(--ancho); margin-inline: auto; }
.cabecera-seccion { margin-bottom: 24px; }
.cabecera-seccion h1 { font-size: clamp(27px, 3vw, 36px); color: var(--tinta); margin-top: 5px; }
.cabecera-seccion p { color: var(--gris); margin-top: 8px; max-width: 62ch; }
.cabecera-seccion__acciones { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
@media (max-width: 1040px) {
.portal { grid-template-columns: 1fr; }
.tope { display: flex; }
.barra {
position: fixed; inset: 0 auto 0 0; z-index: 60; width: min(310px, 86vw);
transform: translateX(-102%); transition: transform .22s ease;
box-shadow: var(--sombra-alta);
}
.barra[data-abierta='1'] { transform: none; }
.velo { position: fixed; inset: 0; z-index: 50; background: rgba(22, 40, 63, .45); border: 0; }
.lienzo { padding: 20px 18px 70px; }
}
@media (max-width: 520px) { .lienzo { padding: 16px 14px 64px; } }
/* ---------------------------------------------------------------------
La placa
--------------------------------------------------------------------- */
.placa {
background: var(--papel-hueco);
border: 1px solid var(--linea);
border-radius: var(--radio-placa);
padding: 20px 22px;
min-width: 0;
}
.placa--acento { border-left: 5px solid var(--acento, var(--azul)); }
.placa__ceja { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.placa h3 { font-size: 21px; color: var(--tinta); }
.placa h4 { font-size: 18px; color: var(--tinta); }
/* La placa de respuesta: lo más importante de cada pantalla. */
.respuesta {
background: var(--azul-profundo); color: #eef4fb;
border-radius: var(--radio-placa);
padding: 24px 26px;
display: grid; gap: 16px;
}
.respuesta .ceja { color: rgba(238, 244, 251, .72); }
.respuesta h2 { color: #fff; font-size: clamp(21px, 2.4vw, 28px); max-width: 46ch; }
.respuesta p { color: rgba(238, 244, 251, .88); max-width: 66ch; }
.respuesta__fila { display: flex; align-items: flex-end; gap: 30px; flex-wrap: wrap; }
.respuesta__cifra { display: flex; flex-direction: column; gap: 3px; }
.respuesta__cifra b { font-family: var(--mono); font-size: clamp(26px, 3.4vw, 36px); color: #fff; font-variant-numeric: tabular-nums; line-height: 1; }
.respuesta__cifra span { font-size: 12.5px; text-transform: uppercase; letter-spacing: .09em; color: rgba(238, 244, 251, .7); }
.respuesta__acciones { display: flex; gap: 10px; flex-wrap: wrap; }
.respuesta .boton { border-color: rgba(238, 244, 251, .45); background: transparent; color: #fff; }
.respuesta .boton:hover:not(:disabled) { background: rgba(238, 244, 251, .16); border-color: #fff; color: #fff; }
.respuesta .boton--marca { background: var(--marca); border-color: var(--marca); color: #14181d; }
.respuesta .boton--marca:hover:not(:disabled) { background: #f7f735; border-color: #f7f735; color: #14181d; }
/* ---------------------------------------------------------------------
Rejillas
--------------------------------------------------------------------- */
.rejilla { display: grid; gap: 16px; }
.rejilla--2 { grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); }
.rejilla--3 { grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); }
/* Las fichas de programa llevan nombres largos y filas de horario con la hora
a la derecha: a menos de 430 px se parten en tres renglones y se leen mal. */
.rejilla--fichas { grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr)); }
.rejilla > *, .pila > * { min-width: 0; }
.rejilla--arriba { align-items: start; }
.pila { display: grid; gap: 16px; }
.pila--ancha { gap: 24px; }
/* ---------------------------------------------------------------------
Insignias
--------------------------------------------------------------------- */
/* Dos clases de insignia, y la diferencia es de contraste, no de gusto:
- Etiqueta (por omisión): el tono puede ser un color claro del catálogo
(el verde pálido de una maestría), así que el texto va en tinta y el
color solo tiñe el fondo y el borde. Así nunca queda ilegible.
- Estado (--fuerte): el tono es uno de los semánticos, que sí tienen
contraste suficiente, y ahí el color del texto es la información. */
.insignia {
display: inline-flex; align-items: center; gap: 7px;
font-size: 14px; font-weight: 700;
padding: 4px 12px; border-radius: var(--radio-pill);
background: color-mix(in srgb, var(--tono, var(--gris)) 16%, #fff);
color: var(--carbon);
border: 1px solid color-mix(in srgb, var(--tono, var(--gris)) 50%, #fff);
white-space: nowrap;
}
.insignia--fuerte {
color: var(--tono, var(--gris));
background: color-mix(in srgb, var(--tono, var(--gris)) 11%, #fff);
border-color: color-mix(in srgb, var(--tono, var(--gris)) 38%, #fff);
}
.insignia__punto {
width: 9px; height: 9px; border-radius: 50%; flex: none;
background: var(--tono, var(--gris));
}
.etiquetas { display: flex; flex-wrap: wrap; gap: 7px; }
/* ---------------------------------------------------------------------
Tablas: en escritorio son tabla, abajo de 780 px se apilan
--------------------------------------------------------------------- */
.envoltura-tabla { overflow-x: auto; -webkit-overflow-scrolling: touch; max-width: 100%; }
.tabla { width: 100%; border-collapse: collapse; font-size: 16px; }
.tabla th {
text-align: left; padding: 10px 14px;
font-size: 14px; font-weight: 700;
text-transform: uppercase; letter-spacing: .05em; color: var(--gris);
border-bottom: 2px solid var(--linea);
white-space: nowrap; background: var(--azul-humo);
}
.tabla td { padding: 13px 14px; border-bottom: 1px solid var(--linea-suave); vertical-align: top; }
.tabla tbody tr:last-child td { border-bottom: 0; }
.tabla tbody tr:hover { background: var(--azul-humo); }
.tabla__num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
.tabla__acciones { white-space: nowrap; text-align: right; }
@media (max-width: 780px) {
.tabla--apila thead { display: none; }
.tabla--apila tbody tr {
display: grid; gap: 4px; padding: 15px 0;
border-bottom: 1px solid var(--linea);
}
.tabla--apila tbody tr:hover { background: none; }
.tabla--apila td { display: grid; grid-template-columns: 38% 1fr; gap: 12px; padding: 3px 0; border: 0; }
/* En teléfono esta etiqueta es el único encabezado que ve la persona, así
que va al mismo tamaño y contraste que el resto del texto. */
.tabla--apila td::before {
content: attr(data-col);
font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
color: var(--gris);
}
.tabla--apila td:empty { display: none; }
.tabla__num, .tabla__acciones { text-align: left; }
}
/* ---------------------------------------------------------------------
Oferta educativa: la ficha de programa
--------------------------------------------------------------------- */
.vista-alterna { display: inline-flex; border: 2px solid var(--linea); border-radius: var(--radio-chico); overflow: hidden; }
.vista-alterna button {
font: inherit; font-size: 15.5px; font-weight: 600;
padding: 9px 18px; min-height: 42px; cursor: pointer;
border: 0; background: var(--papel-hueco); color: var(--gris);
display: inline-flex; align-items: center; gap: 8px;
}
.vista-alterna button + button { border-left: 2px solid var(--linea); }
.vista-alterna button:hover { background: var(--azul-humo); color: var(--tinta); }
.vista-alterna button[aria-pressed='true'] { background: var(--azul-profundo); color: #fff; }
.vista-alterna svg { width: 18px; height: 18px; }
.programa { border-left: 5px solid var(--acento); display: grid; gap: 14px; }
.programa__cabeza { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.programa h3 { font-size: 21px; max-width: 26ch; }
.precio { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.precio b { font-family: var(--mono); font-size: 30px; color: var(--tinta); font-variant-numeric: tabular-nums; line-height: 1.1; }
.precio s { font-family: var(--mono); font-size: 17px; color: var(--gris-claro); }
.precio small { font-size: 14.5px; color: var(--gris); }
.detalles { display: grid; gap: 0; }
.detalle { display: grid; grid-template-columns: 34% 1fr; gap: 14px; padding: 9px 0; align-items: baseline; }
.detalle:not(:last-child) { border-bottom: 1px solid var(--linea-suave); }
.detalle > dt { font-size: 15px; font-weight: 600; color: var(--gris); }
.detalle > dd { margin: 0; font-size: 16.5px; color: var(--carbon); }
@media (max-width: 560px) { .detalle { grid-template-columns: 1fr; gap: 2px; } }
.horario { display: flex; align-items: center; gap: 11px; padding: 9px 0; flex-wrap: wrap; }
.horario:not(:last-child) { border-bottom: 1px solid var(--linea-suave); }
.horario__punto { width: 12px; height: 12px; border-radius: 3px 0 3px 0; flex: none; }
.horario b { color: var(--tinta); font-weight: 600; }
.horario__hora { margin-left: auto; font-family: var(--mono); font-size: 16px; color: var(--carbon); white-space: nowrap; }
/* ---------------------------------------------------------------------
Links de clases
--------------------------------------------------------------------- */
.clase-enlace {
display: inline-flex; align-items: center; gap: 8px;
font-size: 15.5px; font-weight: 600; text-decoration: none;
padding: 8px 14px; min-height: 40px;
border-radius: var(--radio-chico);
background: var(--bien); color: #fff;
border: 2px solid var(--bien);
}
.clase-enlace:hover { background: #0a6144; border-color: #0a6144; color: #fff; }
.clase-enlace svg { width: 17px; height: 17px; }
.clase-inactiva { opacity: .55; }
/* La materia es lo que se busca con la vista, así que se lleva el ancho;
la hora nunca se parte en varias líneas. */
.tabla--clases { min-width: 880px; }
.tabla--clases th:nth-child(1), .tabla--clases td:nth-child(1) { width: 27%; }
.tabla--clases th:nth-child(2), .tabla--clases td:nth-child(2) { width: 14%; }
.tabla--clases th:nth-child(3), .tabla--clases td:nth-child(3) { width: 16%; }
.tabla--clases th:nth-child(4), .tabla--clases td:nth-child(4) { width: 15%; }
.tabla--clases td:nth-child(5) { white-space: nowrap; }
.tabla--clases td:nth-child(2) { white-space: nowrap; }
@media (max-width: 780px) {
.tabla--clases { min-width: 0; }
.tabla--clases th, .tabla--clases td { width: auto !important; }
.tabla--clases td:nth-child(5) { white-space: normal; }
}
/* ---------------------------------------------------------------------
Filtros y acordeón
--------------------------------------------------------------------- */
.filtros { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; }
.filtro {
font: inherit; font-size: 15px; font-weight: 600;
padding: 8px 16px; min-height: 40px; cursor: pointer;
border: 2px solid var(--linea); border-radius: var(--radio-pill);
background: var(--papel-hueco); color: var(--gris);
}
.filtro:hover { border-color: var(--azul); color: var(--tinta); }
.filtro[aria-pressed='true'] { background: var(--azul-profundo); border-color: var(--azul-profundo); color: #fff; }
details.acordeon { border-bottom: 1px solid var(--linea-suave); }
details.acordeon:last-child { border-bottom: 0; }
details.acordeon > summary {
cursor: pointer; padding: 15px 0; list-style: none;
display: flex; align-items: center; gap: 12px;
font-size: 18px; font-weight: 600; color: var(--tinta); min-height: var(--toque);
}
details.acordeon > summary::-webkit-details-marker { display: none; }
details.acordeon > summary::after {
content: '+'; margin-left: auto; flex: none;
font-family: var(--mono); font-size: 22px; color: var(--azul-profundo); line-height: 1;
}
details.acordeon[open] > summary::after { content: '–'; }
details.acordeon > summary:hover { color: var(--azul-profundo); }
.acordeon__cuerpo { padding: 0 0 18px; color: var(--carbon); max-width: 78ch; }
/* Lo que se escribe en una caja de texto se muestra con sus renglones. Sin
esto, una respuesta con viñetas en líneas separadas salía en un solo
párrafo corrido y se perdía el trabajo de quien la redactó. */
.respeta-renglones { white-space: pre-wrap; }
/* Plegable solo en teléfono: en escritorio el contenido va abierto y el
resumen desaparece, así que la tarjeta se ve completa. En pantalla chica
se cierra para que la sección no se convierta en un scroll infinito.
El atributo open lo pone el JS según el ancho al pintar. */
details.plegable > summary {
cursor: pointer; list-style: none;
display: flex; align-items: center; gap: 10px;
padding: 11px 0; min-height: var(--toque);
font-size: 16px; font-weight: 600; color: var(--azul-profundo);
border-top: 1px solid var(--linea-suave);
}
details.plegable > summary::-webkit-details-marker { display: none; }
details.plegable > summary::after { content: '+'; margin-left: auto; font-family: var(--mono); font-size: 22px; line-height: 1; }
details.plegable[open] > summary::after { content: '–'; }
details.plegable > summary:hover { color: var(--azul); }
details.plegable > .plegable__cuerpo { display: grid; gap: 14px; }
@media (min-width: 781px) { details.plegable > summary { display: none; } }
.mensaje-listo {
font-size: 16px; line-height: 1.65;
white-space: pre-wrap; word-break: break-word;
padding: 15px 17px; border-radius: var(--radio-chico);
background: var(--azul-humo); border: 1px solid var(--linea-suave);
color: var(--carbon);
}
/* ---------------------------------------------------------------------
Matriz de permisos
--------------------------------------------------------------------- */
.matriz { width: 100%; border-collapse: collapse; font-size: 16px; min-width: 520px; }
/* position:relative para que cualquier hijo absoluto quede recortado por
el contenedor con scroll y no estire la página. */
.matriz th, .matriz td { padding: 11px 12px; border-bottom: 1px solid var(--linea-suave); text-align: left; position: relative; }
.matriz th { font-size: 13px; text-transform: uppercase; letter-spacing: .07em; color: var(--gris); border-bottom: 2px solid var(--linea); background: var(--azul-humo); }
.matriz td:not(:first-child), .matriz th:not(:first-child) { text-align: center; width: 118px; }
.marca-si { color: var(--bien); font-size: 19px; font-weight: 700; }
.marca-no { color: var(--gris-claro); font-size: 19px; }
/* ---------------------------------------------------------------------
Ventana de edición
--------------------------------------------------------------------- */
dialog.editor {
padding: 0; border: 0; max-width: 640px; width: calc(100% - 28px);
border-radius: var(--radio-placa);
background: var(--papel-hueco); color: var(--carbon);
box-shadow: var(--sombra-alta);
}
dialog.editor::backdrop { background: rgba(22, 40, 63, .52); }
.editor__cabeza {
padding: 20px 24px 14px; border-bottom: 1px solid var(--linea);
display: flex; align-items: flex-start; gap: 14px;
}
.editor__cabeza h2 { font-size: 23px; color: var(--tinta); }
.editor__cabeza p { font-size: 15px; color: var(--gris); margin-top: 4px; }
.editor__cerrar {
margin-left: auto; flex: none;
width: 40px; height: 40px; display: grid; place-items: center;
border: 2px solid var(--linea); border-radius: var(--radio-chico);
background: var(--papel-hueco); color: var(--gris); cursor: pointer; font-size: 20px; line-height: 1;
}
.editor__cerrar:hover { border-color: var(--azul); color: var(--tinta); }
.editor__cuerpo { padding: 20px 24px; display: grid; gap: 18px; overflow-y: auto; }
.editor__pie {
padding: 16px 24px 20px; border-top: 1px solid var(--linea);
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
}
.editor__pie .boton--peligro { margin-right: auto; }
.editor__error { padding: 14px 24px 0; }
.editor__par { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
/* La ventana se estructura como una columna: cabeza y pie fijos, y el
formulario ocupa lo que queda y rueda. Con una altura máxima fija en el
cuerpo, en un teléfono se veía media forma y no había manera de saber que
faltaba lo demás. */
dialog.editor > form { display: flex; flex-direction: column; max-height: 100%; }
dialog.editor { max-height: calc(100dvh - 32px); }
dialog.editor .editor__cuerpo { flex: 1; min-height: 0; }
@media (max-width: 560px) {
.editor__par { grid-template-columns: 1fr; }
dialog.editor {
width: 100%; max-width: none;
max-height: 92dvh;
margin: auto auto 0; /* hoja pegada al borde inferior, como en el móvil */
border-radius: 20px 20px 0 0;
}
.editor__cabeza, .editor__cuerpo, .editor__pie, .editor__error { padding-inline: 18px; }
.editor__pie { position: sticky; bottom: 0; background: var(--papel-hueco); }
.editor__pie .boton { flex: 1 1 46%; }
.editor__pie .boton--peligro { flex-basis: 100%; margin-right: 0; order: 3; }
}
/* Un pie de tarjeta con las acciones de edición. */
.acciones-edicion {
display: flex; gap: 9px; flex-wrap: wrap; align-items: center;
padding-top: 14px; margin-top: 2px; border-top: 1px dashed var(--linea);
}
.acciones-edicion .ceja { margin-right: auto; }
/* ---------------------------------------------------------------------
Estados vacíos y de carga
--------------------------------------------------------------------- */
.vacio {
padding: 44px 24px; text-align: center;
border: 2px dashed var(--linea); border-radius: var(--radio-placa);
color: var(--gris);
}
.vacio h3 { font-size: 20px; color: var(--tinta); margin-bottom: 8px; }
.vacio p { max-width: 46ch; margin-inline: auto; }
.cargando { display: grid; gap: 14px; }
.hueso {
height: 86px; border-radius: var(--radio-placa);
background: linear-gradient(100deg, var(--linea-suave) 30%, var(--azul-humo) 50%, var(--linea-suave) 70%);
background-size: 220% 100%;
animation: barrido 1.3s linear infinite;
}
@keyframes barrido { from { background-position: 130% 0; } to { background-position: -30% 0; } }
/* ---------------------------------------------------------------------
Aviso flotante
--------------------------------------------------------------------- */
.brindis {
position: fixed; left: 50%; bottom: 26px; z-index: 90;
transform: translate(-50%, 14px);
display: flex; align-items: center; gap: 10px;
padding: 13px 20px; max-width: calc(100% - 32px);
background: var(--tinta); color: #fff;
border-radius: var(--radio-chico);
font-size: 16px; box-shadow: var(--sombra-alta);
opacity: 0; pointer-events: none;
transition: opacity .2s, transform .2s;
}
.brindis[data-visible='1'] { opacity: 1; transform: translate(-50%, 0); }
.brindis__marca { width: 18px; height: 4px; background: var(--marca); border-radius: 1px; flex: none; }
/* ---------------------------------------------------------------------
Entrada en escena
--------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
.aparece { animation: sube .32s cubic-bezier(.22, .7, .3, 1) backwards; }
@keyframes sube { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
}
/* ---------------------------------------------------------------------
Utilidades
--------------------------------------------------------------------- */
.prosa { max-width: 76ch; }
.nowrap { white-space: nowrap; }
.fila { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.fila--sep { justify-content: space-between; }
/* Botones que van juntos en una celda estrecha: no se parten en dos renglones. */
.fila--junta { flex-wrap: nowrap; gap: 7px; }
.crece { flex: 1; min-width: 0; }
.tenue { color: var(--gris); }
.mini { font-size: 15px; }
.recorte { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Texto solo para lectores de pantalla. Sin position:absolute a propósito:
dentro de una tabla con scroll horizontal, un absoluto sin ancestro
posicionado se cuelga del bloque inicial, se escapa del contenedor y
estira la página entera. Esta versión se queda en el flujo. */
.solo-lectores {
display: inline-block;
width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
overflow: hidden; white-space: nowrap;
clip-path: inset(50%);
}
/* Una pista que solo tiene sentido cuando la tabla se desliza. */
.solo-movil { display: none; }
@media (max-width: 780px) { .solo-movil { display: block; } }
.tira-demo {
display: inline-flex; align-items: center; gap: 7px;
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em;
padding: 4px 12px; border-radius: var(--radio-pill);
background: color-mix(in srgb, var(--marca) 32%, transparent);
color: #4a4300; border: 1px solid color-mix(in srgb, var(--marca) 60%, transparent);
}
@media print {
.barra, .tope, .cabecera-seccion__acciones, .brindis, .acciones-edicion, .vista-alterna { display: none !important; }
.portal { grid-template-columns: 1fr; }
.placa, .respuesta { break-inside: avoid; box-shadow: none; }
.respuesta { background: #fff; color: #000; border: 2px solid #000; }
.respuesta h2, .respuesta__cifra b, .respuesta p, .respuesta .ceja { color: #000; }
details.acordeon > summary::after { display: none; }
.acordeon__cuerpo { display: block !important; }
}
+86
View File
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="es-MX">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Entrar · Portal interno INSTA</title>
<meta name="description" content="Portal interno del Instituto Santiago Tapia. Acceso con enlace de un solo uso.">
<meta name="color-scheme" content="light">
<link rel="icon" href="/assets/insta-logo.webp">
<link rel="stylesheet" href="/css/insta.css">
</head>
<body>
<main class="entrada">
<!-- Lienzo izquierdo: la promesa del instituto, que es también la del portal -->
<section class="entrada__lienzo">
<a class="rotulo" href="https://institutosantiagotapia.com" rel="noreferrer">
<img src="/assets/insta-logo.webp" width="44" height="28" alt="">
<span class="rotulo__texto">
<span class="rotulo__nombre">Instituto Santiago Tapia</span>
<span class="rotulo__sub">Portal interno</span>
</span>
</a>
<div class="entrada__promesa">
<h1>Trabajar y estudiar<br><em>es posible</em>.</h1>
<p>
Informar bien también. Aquí está la oferta educativa, los costos, las becas, las
fechas y los links de clase en un solo lugar, para que nadie tenga que adivinar un
dato mientras está atendiendo a alguien.
</p>
</div>
<div class="entrada__hechos">
<span class="entrada__hecho"><b>2015</b><span>Desde</span></span>
<span class="entrada__hecho"><b>1,500+</b><span>Certificados</span></span>
<span class="entrada__hecho"><b>8</b><span>Programas</span></span>
<span class="entrada__hecho"><b>Morelia</b><span>Michoacán</span></span>
</div>
</section>
<!-- Acceso: Magic Login -->
<section class="entrada__acceso">
<div class="acceso">
<span class="ceja">Acceso al portal</span>
<h2>Entra con un enlace de un solo uso</h2>
<p class="acceso__nota">
No hay contraseñas que recordar ni compartir. Escribe tu correo institucional y
te llega un enlace que sirve una vez y caduca en <span id="vigencia">15</span> minutos.
</p>
<div id="error-entrada" hidden></div>
<span class="separador" id="titulo-roles">Acceso rápido de la demo</span>
<div class="acceso__roles" id="roles" role="group" aria-labelledby="titulo-roles">
<div class="cargando"><div class="hueso" style="height:62px"></div><div class="hueso" style="height:62px"></div><div class="hueso" style="height:62px"></div></div>
</div>
<span class="separador">o con tu correo</span>
<form id="forma" novalidate>
<div class="campo">
<label for="email">Correo institucional</label>
<input type="email" id="email" name="email" autocomplete="email"
placeholder="[email protected]" required>
</div>
<button class="boton boton--bloque" type="submit" id="enviar" style="margin-top:12px">
Enviar mi enlace de acceso
</button>
</form>
<div id="resultado"></div>
<p class="pie-demo">
<strong>Esto es una demo.</strong> Los tres accesos rápidos son usuarios de
demostración, no personal real del instituto. En producción el enlace llega por
correo; aquí se muestra en pantalla para que puedas verlo funcionar.
</p>
</div>
</section>
</main>
<div class="brindis" id="brindis" role="status" aria-live="polite"><span class="brindis__marca"></span><span id="brindis-txt"></span></div>
<script type="module" src="/js/entrada.mjs"></script>
</body>
</html>
+211
View File
@@ -0,0 +1,211 @@
// =====================================================================
// 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');
+89
View File
@@ -0,0 +1,89 @@
// =====================================================================
// Pantalla de entrada · Magic Login
// Dos caminos al mismo sitio: acceso rápido por rol (demo) o correo.
// =====================================================================
import { api, esc, ficha, brindis, activarCopiado } from './ui.mjs';
const $ = (s) => document.querySelector(s);
const MOTIVOS = {
invalido: 'Ese enlace no es válido. Genera uno nuevo.',
'ya-usado': 'Ese enlace ya se usó. Los enlaces de acceso sirven una sola vez.',
vencido: 'El enlace venció. Genera uno nuevo.',
'usuario-inactivo': 'Tu cuenta está desactivada. Habla con dirección.',
};
function mostrarError(texto) {
const caja = $('#error-entrada');
caja.hidden = false;
caja.className = 'aviso aviso--error';
caja.textContent = texto;
}
// Si venimos rebotados de /acceso/:token con un problema, decirlo aquí.
const motivo = new URLSearchParams(location.search).get('error');
if (motivo) mostrarError(MOTIVOS[motivo] ?? 'No se pudo validar el enlace.');
/** Genera el enlace y lo canjea de inmediato: es el flujo real, sin el correo. */
async function entrar(email, boton) {
const previo = boton?.textContent;
if (boton) { boton.disabled = true; boton.textContent = 'Generando enlace…'; }
$('#error-entrada').hidden = true;
$('#resultado').innerHTML = '';
try {
const r = await api('/auth/enlace', { method: 'POST', body: { email } });
$('#resultado').innerHTML = `
<div class="aviso aviso--ok enlace-magico">
<strong>Enlace listo para ${esc(r.usuario.nombre)}</strong> · ${esc(r.usuario.rol_nombre)}.
Vence en ${esc(r.expiraEnMinutos)} minutos y sirve una sola vez.
${r.enlace ? `<code>${esc(location.origin + r.enlace)}</code>` : ''}
</div>`;
if (!r.token) {
brindis('Enlace enviado a tu correo');
return;
}
// En producción esto lo haría la persona al abrir su correo.
await api('/auth/consumir', { method: 'POST', body: { token: r.token } });
location.href = '/portal';
} catch (err) {
mostrarError(err.message);
if (boton) { boton.disabled = false; boton.textContent = previo; }
}
}
// ---- accesos rápidos por rol ----------------------------------------
try {
const { usuarios, vigenciaMinutos } = await api('/auth/demo');
$('#vigencia').textContent = vigenciaMinutos;
$('#roles').innerHTML = usuarios.map((u) => `
<button class="tarjeta-rol" type="button" data-email="${esc(u.email)}" style="--rol:${esc(u.rol_color)}">
${ficha(u.iniciales, u.rol_color)}
<span class="tarjeta-rol__txt">
<b>${esc(u.rol_nombre)}</b>
<span>${esc(u.nombre)} · ${esc(u.puesto)}</span>
</span>
</button>`).join('');
$('#roles').addEventListener('click', (ev) => {
const btn = ev.target.closest('.tarjeta-rol');
if (!btn) return;
entrar(btn.dataset.email, btn);
});
} catch {
$('#roles').innerHTML = `<div class="aviso aviso--error">
No se pudo hablar con el servidor. Revisa que esté corriendo <code>npm start</code>
y que el contenedor de MySQL esté arriba.</div>`;
}
$('#forma').addEventListener('submit', (ev) => {
ev.preventDefault();
const email = $('#email').value.trim();
if (!email) return mostrarError('Escribe tu correo institucional.');
entrar(email, $('#enviar'));
});
activarCopiado();
+400
View File
@@ -0,0 +1,400 @@
// =====================================================================
// Utilidades compartidas: red, formato, iconos, avisos y el editor.
// Todo lo que se interpola en HTML pasa por esc().
// =====================================================================
export const FECHA_CORTE = '2026-07-29';
/** Escapa texto antes de meterlo en una plantilla. */
export function esc(v) {
if (v === null || v === undefined) return '';
return String(v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
// ---- red -------------------------------------------------------------
export async function api(ruta, opciones = {}) {
const res = await fetch(`/api${ruta}`, {
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
...opciones,
body: opciones.body ? JSON.stringify(opciones.body) : undefined,
});
let datos = null;
try { datos = await res.json(); } catch { /* sin cuerpo */ }
if (!res.ok) {
const err = new Error(datos?.mensaje ?? `Error ${res.status}`);
err.estado = res.status;
err.codigo = datos?.error;
err.campo = datos?.campo;
throw err;
}
return datos;
}
// ---- formato ---------------------------------------------------------
export const pesos = (n) => `$${Number(n ?? 0).toLocaleString('es-MX', { maximumFractionDigits: 0 })}`;
export const num = (n) => Number(n ?? 0).toLocaleString('es-MX');
const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
/** '2021-09-15' → '15 de septiembre de 2021' */
export function fecha(v) {
if (!v) return '—';
const [a, m, d] = String(v).slice(0, 10).split('-').map(Number);
if (!a || !m || !d) return esc(v);
return `${d} de ${MESES[m - 1]} de ${a}`;
}
/** Marca de tiempo corta para la bitácora. */
export function fechaHora(v) {
if (!v) return '—';
const d = new Date(v);
if (Number.isNaN(d.getTime())) return esc(v);
return `${d.getDate()} ${MESES[d.getMonth()].slice(0, 3)} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/** Una hora de 24 h a 12 h, o null si no es una hora. */
function aDoce(h24) {
const [h, m] = String(h24 ?? '').split(':').map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
return { txt: `${h % 12 === 0 ? 12 : h % 12}:${String(m).padStart(2, '0')}`, sufijo: h >= 12 ? 'pm' : 'am' };
}
/**
* Rango de horas legible: '20:00' y '21:30' → '8:00 a 9:30 pm'.
* Si falta o viene mal una de las dos, se muestra lo que sí se pueda leer en
* lugar de un "12:undefined am".
*/
export function rangoHora(inicio, fin) {
const a = aDoce(inicio);
const b = aDoce(fin);
if (!a && !b) return 'Sin horario';
if (!b) return `desde las ${a.txt} ${a.sufijo}`;
if (!a) return `hasta las ${b.txt} ${b.sufijo}`;
return a.sufijo === b.sufijo ? `${a.txt} a ${b.txt} ${b.sufijo}` : `${a.txt} ${a.sufijo} a ${b.txt} ${b.sufijo}`;
}
/** Traduce la acción registrada en la bitácora a algo que se entienda. */
const ACCIONES = {
'auth.enlace': 'Pidió un enlace de acceso',
'auth.entrada': 'Entró al portal',
'auth.salida': 'Cerró sesión',
'auth.canje': 'Intentó usar un enlace',
'acceso.denegado': 'Intentó abrir algo sin permiso',
'db.siembra': 'Se reconstruyó la base',
};
const ENTIDADES = {
oferta: 'la oferta educativa', requisitos: 'los requisitos', becas: 'las becas',
clases: 'los links de clases', kb: 'las preguntas frecuentes', guiones: 'los mensajes listos',
contacto: 'los datos del instituto', datos: 'los datos por confirmar',
};
const VERBOS = { editar: 'Editó', crear: 'Agregó algo en', borrar: 'Quitó algo de' };
export function accionLegible(accion) {
if (ACCIONES[accion]) return ACCIONES[accion];
const [seccion, verbo] = String(accion).split('.');
if (VERBOS[verbo] && ENTIDADES[seccion]) return `${VERBOS[verbo]} ${ENTIDADES[seccion]}`;
return accion;
}
export const TONO_RVOE = { confirmado: 'var(--bien)', revisar: 'var(--atencion)', 'por-confirmar': 'var(--alerta)' };
export const TONO_ESTADO = { abierto: 'var(--alerta)', 'en-proceso': 'var(--atencion)', aclarado: 'var(--bien)' };
export const TONO_PRIORIDAD = { alta: 'var(--alerta)', media: 'var(--atencion)', baja: 'var(--gris-claro)' };
export const ETQ_RVOE = {
confirmado: 'Validez confirmada',
revisar: 'Por revisar',
'por-confirmar': 'Sin confirmar',
};
// ---- piezas reutilizables -------------------------------------------
/** Solo se aceptan tonos que este archivo controla: una variable CSS o un
* hexadecimal. Cualquier otra cosa iría a parar al atributo style. */
function tonoSeguro(tono) {
const t = String(tono ?? '').trim();
return /^(var\(--[a-z-]+\)|#[0-9a-fA-F]{3,8})$/.test(t) ? t : 'var(--gris)';
}
/**
* Con punto es un ESTADO (el tono lleva la información y va también en el
* texto); sin punto es una ETIQUETA (el tono puede ser claro, así que el
* texto va en tinta para que siempre se lea).
*/
export function insignia(txt, tono = 'var(--gris)', { punto = false } = {}) {
const t = tonoSeguro(tono);
return `<span class="insignia${punto ? ' insignia--fuerte' : ''}" style="--tono:${t}">${punto ? '<span class="insignia__punto"></span>' : ''}${esc(txt)}</span>`;
}
/** El color del rol va en el borde, nunca en el texto: los tonos claros
* dejaban las iniciales por debajo del contraste mínimo. */
export function ficha(iniciales, tono, { chica = false } = {}) {
const estilo = tono ? ` style="border-color:${tonoSeguro(tono)}"` : '';
return `<span class="ficha${chica ? ' ficha--chica' : ''}"${estilo}>${esc(iniciales ?? '??')}</span>`;
}
export function vacio(titulo, texto) {
return `<div class="vacio"><h3>${esc(titulo)}</h3><p>${esc(texto)}</p></div>`;
}
export function cargando(n = 3, alto = 96) {
return `<div class="cargando">${Array.from({ length: n }, () => `<div class="hueso" style="height:${alto}px"></div>`).join('')}</div>`;
}
export function escalonar(contenedor, retardo = 45) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
[...contenedor.children].forEach((hijo, i) => {
hijo.classList.add('aparece');
hijo.style.animationDelay = `${Math.min(i * retardo, 300)}ms`;
});
}
// ---- avisos y portapapeles -------------------------------------------
let temporizador;
export function brindis(texto) {
const caja = document.getElementById('brindis');
const txt = document.getElementById('brindis-txt');
if (!caja || !txt) return;
txt.textContent = texto;
caja.dataset.visible = '1';
clearTimeout(temporizador);
temporizador = setTimeout(() => { caja.dataset.visible = '0'; }, 2600);
}
export async function copiar(texto, etiqueta = 'Copiado') {
try {
await navigator.clipboard.writeText(texto);
} catch {
const ta = document.createElement('textarea');
ta.value = texto;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* sin respaldo */ }
ta.remove();
}
brindis(etiqueta);
}
/** Delegación global: cualquier elemento con data-copiar copia su texto. */
export function activarCopiado(raiz = document) {
raiz.addEventListener('click', (ev) => {
const btn = ev.target.closest('[data-copiar], [data-copiar-de]');
if (!btn) return;
const de = btn.dataset.copiarDe;
const texto = de ? (document.getElementById(de)?.innerText ?? '') : btn.dataset.copiar;
if (!texto) return;
copiar(texto, btn.dataset.copiarAviso ?? 'Copiado');
});
}
// =====================================================================
// Editor: una ventana con un formulario armado desde una lista de campos
// =====================================================================
/**
* @param {object} cfg
* @param {string} cfg.titulo
* @param {string} [cfg.subtitulo]
* @param {Array} cfg.campos lista de campos, o {par:[a,b]} para dos en una fila
* @param {object} [cfg.valores] valores iniciales por nombre de campo
* @param {Function} cfg.guardar async (datos) => void · puede lanzar {campo, message}
* @param {Function} [cfg.borrar] async () => void · si viene, aparece "Eliminar"
* @param {string} [cfg.textoGuardar]
* @param {string} [cfg.confirmarBorrado]
* @returns {Promise<boolean>} true si se guardó o se eliminó
*/
export function abrirEditor(cfg) {
return new Promise((resolver) => {
const dlg = document.createElement('dialog');
dlg.className = 'editor';
const lista = cfg.campos.flatMap((c) => (c.par ? c.par : [c]));
dlg.innerHTML = `
<form method="dialog" novalidate>
<div class="editor__cabeza">
<div>
<h2>${esc(cfg.titulo)}</h2>
${cfg.subtitulo ? `<p>${esc(cfg.subtitulo)}</p>` : ''}
</div>
<button type="button" class="editor__cerrar" data-cerrar aria-label="Cerrar sin guardar">&times;</button>
</div>
<div class="editor__error" hidden><div class="aviso aviso--error" role="alert"></div></div>
<div class="editor__cuerpo">
${cfg.campos.map((c) => (c.par
? `<div class="editor__par">${c.par.map(pintarCampo).join('')}</div>`
: pintarCampo(c))).join('')}
</div>
<div class="editor__pie">
${cfg.borrar ? '<button type="button" class="boton boton--peligro" data-borrar>Eliminar</button>' : ''}
<button type="button" class="boton boton--fantasma" data-cerrar>Cancelar</button>
<button type="submit" class="boton" data-guardar>${esc(cfg.textoGuardar ?? 'Guardar cambios')}</button>
</div>
</form>`;
document.body.appendChild(dlg);
// Valores iniciales
for (const c of lista) {
const el = dlg.querySelector(`[name="${c.nombre}"]`);
if (!el) continue;
const v = cfg.valores?.[c.nombre];
if (c.tipo === 'casilla') el.checked = Boolean(Number(v ?? c.valorPorOmision ?? 0));
else el.value = v ?? c.valorPorOmision ?? '';
}
const cajaError = dlg.querySelector('.editor__error');
const avisoError = cajaError.querySelector('.aviso');
const btnGuardar = dlg.querySelector('[data-guardar]');
function mostrarError(mensaje, campo) {
avisoError.textContent = mensaje;
cajaError.hidden = false;
dlg.querySelectorAll('[aria-invalid]').forEach((el) => el.removeAttribute('aria-invalid'));
if (campo) {
const el = dlg.querySelector(`[name="${campo}"]`);
if (el) { el.setAttribute('aria-invalid', 'true'); el.focus(); }
}
}
function cerrar(resultado) {
dlg.close();
dlg.remove();
resolver(resultado);
}
dlg.querySelectorAll('[data-cerrar]').forEach((b) => b.addEventListener('click', () => cerrar(false)));
dlg.addEventListener('cancel', (ev) => { ev.preventDefault(); cerrar(false); });
dlg.querySelector('[data-borrar]')?.addEventListener('click', async () => {
const texto = cfg.confirmarBorrado ?? '¿Seguro que quieres eliminarlo? Esta acción no se puede deshacer.';
// eslint-disable-next-line no-alert
if (!window.confirm(texto)) return;
const btn = dlg.querySelector('[data-borrar]');
btn.disabled = true;
btn.textContent = 'Eliminando…';
try {
await cfg.borrar();
cerrar(true);
} catch (err) {
btn.disabled = false;
btn.textContent = 'Eliminar';
mostrarError(err.message);
}
});
dlg.querySelector('form').addEventListener('submit', async (ev) => {
ev.preventDefault();
const datos = {};
for (const c of lista) {
const el = dlg.querySelector(`[name="${c.nombre}"]`);
if (!el) continue;
datos[c.nombre] = c.tipo === 'casilla' ? (el.checked ? 1 : 0) : el.value;
}
// Validación mínima del lado del navegador; el servidor revalida todo.
const faltante = lista.find((c) => c.requerido && !String(datos[c.nombre] ?? '').trim());
if (faltante) return mostrarError(`Falta llenar: ${faltante.etiqueta}.`, faltante.nombre);
btnGuardar.disabled = true;
const previo = btnGuardar.textContent;
btnGuardar.textContent = 'Guardando…';
try {
await cfg.guardar(datos);
cerrar(true);
} catch (err) {
btnGuardar.disabled = false;
btnGuardar.textContent = previo;
mostrarError(err.message, err.campo);
}
});
dlg.showModal();
dlg.querySelector('.editor__cuerpo input, .editor__cuerpo select, .editor__cuerpo textarea')?.focus();
});
}
function pintarCampo(c) {
const id = `campo-${c.nombre}`;
const ayuda = c.ayuda ? `<span class="campo__ayuda" id="${id}-ayuda">${esc(c.ayuda)}</span>` : '';
const desc = c.ayuda ? ` aria-describedby="${id}-ayuda"` : '';
const req = c.requerido ? ' aria-required="true"' : '';
if (c.tipo === 'casilla') {
// La etiqueta envuelve la casilla y llega al alto mínimo de toque, así
// que se puede marcar tocando el texto y no solo el cuadrito.
return `<div class="campo">
<label class="campo-casilla" for="${id}">
<input type="checkbox" name="${esc(c.nombre)}" id="${id}"${desc}>
<span>${esc(c.etiqueta)}</span>
</label>${ayuda}
</div>`;
}
if (c.tipo === 'area') {
return `<div class="campo">
<label for="${id}">${esc(c.etiqueta)}</label>
${ayuda}
<textarea name="${esc(c.nombre)}" id="${id}"${req}${desc} rows="${c.filas ?? 6}"></textarea>
</div>`;
}
if (c.tipo === 'select') {
return `<div class="campo">
<label for="${id}">${esc(c.etiqueta)}</label>
${ayuda}
<select name="${esc(c.nombre)}" id="${id}"${req}${desc}>
${(c.opciones ?? []).map((o) => `<option value="${esc(o.valor)}">${esc(o.texto)}</option>`).join('')}
</select>
</div>`;
}
// Los campos de dinero son de texto con teclado numérico, no type=number:
// así "$1,500" llega entero al servidor, que sabe limpiarlo. Con
// type=number el navegador reinterpreta la coma como decimal y $1,500 se
// guardaba como $1.50, o se descartaba el valor y quedaba en cero.
const tipoHtml = { numero: 'text', url: 'url', fecha: 'date' }[c.tipo] ?? 'text';
const extra = c.tipo === 'numero' ? ' inputmode="decimal" autocomplete="off"' : '';
return `<div class="campo">
<label for="${id}">${esc(c.etiqueta)}</label>
${ayuda}
<input type="${tipoHtml}" name="${esc(c.nombre)}" id="${id}"
${c.marcador ? `placeholder="${esc(c.marcador)}"` : ''}${req}${desc}${extra}
${c.tipo === 'url' ? 'inputmode="url" autocapitalize="off" spellcheck="false"' : ''}>
</div>`;
}
// ---- iconos ----------------------------------------------------------
const TRAZOS = {
inicio: '<path d="M3 10.5 10 4l7 6.5"/><path d="M5 9.5V16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V9.5"/>',
birrete: '<path d="M10 4 2.5 7.5 10 11l7.5-3.5L10 4Z"/><path d="M5.5 9v4c0 1.4 2 2.5 4.5 2.5s4.5-1.1 4.5-2.5V9"/>',
documento: '<path d="M11.5 2.5H6a1.5 1.5 0 0 0-1.5 1.5v12A1.5 1.5 0 0 0 6 17.5h8a1.5 1.5 0 0 0 1.5-1.5V6.5l-4-4Z"/><path d="M11.5 2.5v4h4M7.5 11h5M7.5 14h3"/>',
etiqueta: '<path d="M10.5 3H16a1 1 0 0 1 1 1v5.5a1 1 0 0 1-.3.7l-6.5 6.5a1 1 0 0 1-1.4 0l-5.5-5.5a1 1 0 0 1 0-1.4l6.5-6.5a1 1 0 0 1 .7-.3Z"/><circle cx="13" cy="7" r="1.1"/>',
video: '<rect x="2.5" y="5" width="10.5" height="10" rx="2"/><path d="m13 9 4.5-2.5v7L13 11z"/>',
libro: '<path d="M3.5 4.5c2.5-1 4.5-1 6.5.5 2-1.5 4-1.5 6.5-.5v10c-2.5-1-4.5-1-6.5.5-2-1.5-4-1.5-6.5-.5v-10Z"/><path d="M10 5v10"/>',
mensaje: '<path d="M17 10.5c0 3.3-3.1 6-7 6a8 8 0 0 1-2.3-.3L4 17.5l1-2.7A5.7 5.7 0 0 1 3 10.5c0-3.3 3.1-6 7-6s7 2.7 7 6Z"/>',
edificio: '<path d="M4.5 17.5V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v13.5M12.5 8h2.5a1 1 0 0 1 1 1v8.5M3 17.5h14"/><path d="M7 6.5h2M7 9.5h2M7 12.5h2"/>',
personas: '<circle cx="7.5" cy="7" r="2.6"/><path d="M2.8 16.5c0-2.6 2.1-4.6 4.7-4.6s4.7 2 4.7 4.6"/><path d="M13.2 5.1a2.6 2.6 0 0 1 0 5M14 12.2c1.9.4 3.2 2.1 3.2 4.3"/>',
reloj: '<circle cx="10" cy="10" r="7"/><path d="M10 6v4.3l3 1.8"/>',
menu: '<path d="M3 6h14M3 10h14M3 14h14"/>',
salir: '<path d="M8 17H5a1.5 1.5 0 0 1-1.5-1.5v-11A1.5 1.5 0 0 1 5 3h3"/><path d="M12.5 13.5 16 10l-3.5-3.5M7.5 10H16"/>',
copiar: '<rect x="7" y="7" width="9.5" height="9.5" rx="1.5"/><path d="M13 4.5H5a1.5 1.5 0 0 0-1.5 1.5v8"/>',
buscar: '<circle cx="9" cy="9" r="5.3"/><path d="m13 13 4 4"/>',
lapiz: '<path d="M13.6 3.9a1.6 1.6 0 0 1 2.3 2.3L7 15l-3.5 1L4.5 12.5Z"/><path d="m12 5.5 2.5 2.5"/>',
mas: '<path d="M10 4v12M4 10h12"/>',
abrir: '<path d="M11 3h6v6"/><path d="M17 3 9.5 10.5"/><path d="M15 12v3.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 3 15.5v-9A1.5 1.5 0 0 1 4.5 5H8"/>',
tarjetas: '<rect x="3" y="3.5" width="6" height="6" rx="1.4"/><rect x="11" y="3.5" width="6" height="6" rx="1.4"/><rect x="3" y="10.5" width="6" height="6" rx="1.4"/><rect x="11" y="10.5" width="6" height="6" rx="1.4"/>',
tabla: '<rect x="3" y="4" width="14" height="12" rx="1.5"/><path d="M3 8h14M3 12h14M8 8v8"/>',
alerta: '<path d="M10 3.5 17 16H3l7-12.5Z"/><path d="M10 8v3.6M10 14h.01"/>',
check: '<circle cx="10" cy="10" r="7"/><path d="m6.8 10.2 2.2 2.2 4.2-4.6"/>',
};
export function icono(nombre) {
const trazo = TRAZOS[nombre] ?? TRAZOS.check;
return `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${trazo}</svg>`;
}
+1364
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="es-MX">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Portal interno · Instituto Santiago Tapia</title>
<meta name="color-scheme" content="light">
<link rel="icon" href="/assets/insta-logo.webp">
<link rel="stylesheet" href="/css/insta.css">
</head>
<body>
<div class="portal">
<!-- Barra lateral: en móvil se vuelve cajón deslizable -->
<aside class="barra" id="barra" aria-label="Secciones del portal">
<div class="barra__cabeza">
<a class="rotulo" href="/portal">
<img src="/assets/insta-logo.webp" width="44" height="28" alt="">
<span class="rotulo__texto">
<span class="rotulo__nombre">INSTA</span>
<span class="rotulo__sub">Portal interno</span>
</span>
</a>
</div>
<nav class="barra__nav" id="nav"></nav>
<div class="barra__pie" id="pie-barra"></div>
</aside>
<div>
<!-- Tope móvil -->
<header class="tope">
<button class="tope__menu" id="abrir-menu" type="button" aria-label="Abrir menú" aria-expanded="false" aria-controls="barra"></button>
<a class="rotulo" href="/portal">
<img src="/assets/insta-logo.webp" width="38" height="24" alt="">
<span class="rotulo__texto"><span class="rotulo__nombre">INSTA</span></span>
</a>
<span id="ficha-movil"></span>
</header>
<main class="lienzo">
<div class="lienzo__interno" id="vista" aria-live="polite">
<div class="cargando">
<div class="hueso" style="height:120px"></div>
<div class="hueso"></div>
<div class="hueso"></div>
</div>
</div>
</main>
</div>
</div>
<div class="brindis" id="brindis" role="status" aria-live="polite"><span class="brindis__marca"></span><span id="brindis-txt"></span></div>
<script type="module" src="/js/app.mjs"></script>
</body>
</html>