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:
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">×</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>`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
Reference in New Issue
Block a user