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:
@@ -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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user