Fist commit

This commit is contained in:
Angel Ivan
2026-10-02 12:08:21 -06:00
commit 2a9eabce14
54 changed files with 10737 additions and 0 deletions

397
ui/index.html Executable file
View File

@@ -0,0 +1,397 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#123047">
<meta name="color-scheme" content="light">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'">
<title>TimbreESP · Control local</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<a class="skip-link" href="#contenido">Saltar al contenido</a>
<div id="app" aria-busy="true">
<section id="screen-bootstrap" class="auth-screen" aria-labelledby="bootstrap-title">
<div class="auth-card auth-card--compact">
<div class="brand brand--centered" aria-label="TimbreESP">
<span class="brand-mark" aria-hidden="true">TE</span>
<span>TimbreESP</span>
</div>
<div class="state-block" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<div>
<h1 id="bootstrap-title">Preparando el equipo</h1>
<p id="bootstrap-message">Comprobando el estado de la configuración.</p>
</div>
</div>
<div id="bootstrap-error" class="notice notice--error" role="alert" hidden>
<p id="bootstrap-error-message"></p>
<button id="bootstrap-retry" class="button button--secondary" type="button">Reintentar</button>
</div>
</div>
</section>
<section id="screen-claim" class="auth-screen" aria-labelledby="claim-title" hidden>
<div class="auth-card">
<div class="brand brand--centered" aria-label="TimbreESP">
<span class="brand-mark" aria-hidden="true">TE</span>
<span>TimbreESP</span>
</div>
<div class="auth-heading">
<p class="eyebrow">Configuración inicial</p>
<h1 id="claim-title" tabindex="-1">Protege el equipo</h1>
<p>Usa el token de un solo uso y crea una contraseña para esta interfaz local.</p>
</div>
<form id="claim-form" class="form-stack" novalidate>
<div class="form-field">
<label for="claim-token">Token de configuración</label>
<input id="claim-token" name="token" type="text" required minlength="16" autocomplete="off" autocapitalize="none" spellcheck="false" maxlength="128" placeholder="Introduce el token entregado por el equipo">
<p class="field-help">El token se invalida después de Claim. No se conserva en este navegador.</p>
</div>
<div class="form-grid form-grid--two">
<div class="form-field">
<label for="claim-password">Nueva contraseña</label>
<input id="claim-password" name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password">
</div>
<div class="form-field">
<label for="claim-password-confirm">Repite la contraseña</label>
<input id="claim-password-confirm" name="passwordConfirm" type="password" required minlength="12" maxlength="128" autocomplete="new-password">
</div>
</div>
<p id="claim-error" class="form-error" role="alert" hidden></p>
<button id="claim-submit" class="button button--primary button--wide" type="submit">Claim y continuar</button>
</form>
</div>
</section>
<section id="screen-login" class="auth-screen" aria-labelledby="login-title" hidden>
<div class="auth-card auth-card--compact">
<div class="brand brand--centered" aria-label="TimbreESP">
<span class="brand-mark" aria-hidden="true">TE</span>
<span>TimbreESP</span>
</div>
<div class="auth-heading">
<p class="eyebrow">Acceso local</p>
<h1 id="login-title" tabindex="-1">Iniciar sesión</h1>
<p id="login-intro">Introduce la contraseña configurada para controlar el timbre.</p>
</div>
<form id="login-form" class="form-stack" novalidate>
<div class="form-field">
<label for="login-password">Contraseña</label>
<input id="login-password" name="password" type="password" required maxlength="128" autocomplete="current-password" autofocus>
</div>
<p id="login-error" class="form-error" role="alert" hidden></p>
<button id="login-submit" class="button button--primary button--wide" type="submit">Entrar</button>
</form>
<p class="privacy-note">La sesión y el token de protección se mantienen únicamente en memoria.</p>
</div>
</section>
<div id="dashboard" hidden>
<header class="topbar">
<div class="topbar__inner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">TE</span>
<span>
<strong>TimbreESP</strong>
<small>Control local</small>
</span>
</div>
<div class="topbar__actions">
<span id="connection-badge" class="connection-badge connection-badge--loading" role="status" aria-live="polite">
<span class="status-indicator" aria-hidden="true"></span>
<span id="connection-text">Conectando</span>
</span>
<button id="logout-button" class="button button--quiet" type="button">Cerrar sesión</button>
</div>
</div>
</header>
<main id="contenido" class="page-shell" tabindex="-1">
<div class="page-heading">
<div>
<p class="eyebrow">Panel operativo</p>
<h1>Estado del equipo</h1>
<p>Configuración, programación y comprobaciones del timbre principal.</p>
</div>
<button id="refresh-data" class="button button--secondary" type="button">Actualizar datos</button>
</div>
<div id="csrf-warning" class="notice notice--warning" role="alert" hidden>
La sesión está iniciada, pero el dispositivo no entregó un token de protección. Las acciones de configuración se bloquearán.
</div>
<section class="overview-grid" aria-label="Resumen del equipo">
<article class="card clock-card" aria-labelledby="clock-title">
<div class="card-heading">
<div>
<p class="eyebrow">Hora del dispositivo</p>
<h2 id="clock-title">Reloj</h2>
</div>
<span id="clock-indicator" class="state-pill state-pill--muted">Sin datos</span>
</div>
<div class="clock-readout" aria-live="off">
<time id="clock-value" datetime="">--:--:--</time>
<p id="clock-date">Esperando estado…</p>
</div>
<p id="clock-status" class="status-message status-message--muted">Consultando la hora.</p>
<div class="card-footer card-footer--actions">
<button id="sync-time-button" class="button button--secondary" type="button">Usar la hora de este dispositivo</button>
</div>
</article>
<article class="card" aria-labelledby="sequence-title">
<div class="card-heading">
<div>
<p class="eyebrow">Salida principal</p>
<h2 id="sequence-title">Ciclo del timbre</h2>
</div>
<span id="sequence-indicator" class="state-pill state-pill--muted">Inactivo</span>
</div>
<dl class="detail-list">
<div>
<dt>Patrón</dt>
<dd id="active-pattern-label">—</dd>
</div>
<div>
<dt>Salida</dt>
<dd>GPIO22</dd>
</div>
</dl>
<p id="state-message" class="status-message status-message--muted">Consultando el estado…</p>
<div class="card-footer">
<button id="stop-button" class="button button--danger" type="button" disabled>Detener secuencia</button>
</div>
</article>
<article class="card" aria-labelledby="hardware-title">
<div class="card-heading">
<div>
<p class="eyebrow">Salidas</p>
<h2 id="hardware-title">Hardware</h2>
</div>
</div>
<ul class="hardware-list">
<li>
<span class="hardware-icon hardware-icon--primary" aria-hidden="true">22</span>
<span><strong>GPIO22</strong><small>Timbre principal</small></span>
</li>
<li>
<span class="hardware-icon" aria-hidden="true">23</span>
<span><strong>GPIO23</strong><small>Reservado; no se controla</small></span>
</li>
</ul>
</article>
</section>
<section class="card section-card" aria-labelledby="profiles-title">
<div class="section-heading">
<div>
<p class="eyebrow">Modos de operación</p>
<h2 id="profiles-title">Perfiles</h2>
<p>Selecciona el perfil activo y organiza distintos modos de horario.</p>
</div>
<button id="add-profile-button" class="button button--primary" type="button">Añadir perfil</button>
</div>
<div class="active-profile-panel">
<form id="active-profile-form" class="active-profile-form">
<div class="form-field">
<label for="active-profile-select">Perfil activo</label>
<select id="active-profile-select" required disabled>
<option value="">Cargando perfiles…</option>
</select>
</div>
<button id="active-profile-submit" class="button button--secondary" type="submit" disabled>Guardar perfil activo</button>
</form>
</div>
<div id="profiles-content" class="resource-content" aria-live="polite" aria-busy="true"></div>
</section>
<section class="card section-card" aria-labelledby="schedules-title">
<div class="section-heading">
<div>
<p class="eyebrow">Automatización</p>
<h2 id="schedules-title">Horarios</h2>
<p>El timbre principal puede tener como máximo 100 horarios entre todos los perfiles.</p>
</div>
<button id="add-schedule-button" class="button button--primary" type="button">Añadir horario</button>
</div>
<div class="schedule-toolbar">
<div id="profile-tabs" class="segmented-control" role="tablist" aria-label="Filtrar horarios por perfil"></div>
<span id="schedule-count" class="count-badge">0 / 100 horarios</span>
</div>
<div id="schedules-content" class="resource-content" aria-live="polite" aria-busy="true"></div>
</section>
<section class="card section-card" aria-labelledby="patterns-title">
<div class="section-heading">
<div>
<p class="eyebrow">Secuencias electricas</p>
<h2 id="patterns-title">Patrones</h2>
<p>Cada patrón alterna seis fases: encendido, apagado, encendido, apagado, encendido y apagado.</p>
</div>
<button id="add-pattern-button" class="button button--primary" type="button">Añadir patrón</button>
</div>
<div class="hardware-note">
<strong>Destino:</strong> GPIO22 (principal). GPIO23 está reservado y no se puede seleccionar.
</div>
<div id="patterns-content" class="resource-content" aria-live="polite" aria-busy="true"></div>
</section>
<section class="card section-card" aria-labelledby="holidays-title">
<div class="section-heading">
<div>
<p class="eyebrow">Excepciones anuales</p>
<h2 id="holidays-title">Feriados</h2>
<p>Las fechas de silencio se repiten cada año, sin guardar un año concreto.</p>
</div>
</div>
<form id="holiday-form" class="holiday-form" novalidate>
<div class="form-field">
<label for="holiday-month">Mes</label>
<select id="holiday-month" name="month" required></select>
</div>
<div class="form-field">
<label for="holiday-day">Día</label>
<input id="holiday-day" name="day" type="number" min="1" max="31" step="1" value="1" required inputmode="numeric">
</div>
<button id="holiday-submit" class="button button--primary" type="submit">Añadir feriado</button>
</form>
<p id="holiday-error" class="form-error" role="alert" hidden></p>
<div id="holidays-content" class="resource-content" aria-live="polite" aria-busy="true"></div>
</section>
</main>
<footer class="page-footer">
<p>Interfaz local de TimbreESP. No se carga ningún recurso desde Internet.</p>
</footer>
</div>
</div>
<dialog id="profile-dialog" class="dialog" aria-labelledby="profile-dialog-title">
<form id="profile-form" class="dialog__body" novalidate>
<div class="dialog__header">
<div>
<p class="eyebrow">Perfil</p>
<h2 id="profile-dialog-title">Añadir perfil</h2>
</div>
<button class="icon-button" type="button" data-close-dialog aria-label="Cerrar">×</button>
</div>
<input id="profile-id" name="id" type="hidden">
<div class="form-field">
<label for="profile-name">Nombre del perfil</label>
<input id="profile-name" name="name" type="text" required maxlength="32" autocomplete="off" placeholder="Ej. horario de invierno">
</div>
<p id="profile-form-error" class="form-error" role="alert" hidden></p>
<div class="dialog__actions">
<button class="button button--quiet" type="button" data-close-dialog>Cancelar</button>
<button id="profile-submit" class="button button--primary" type="submit">Guardar perfil</button>
</div>
</form>
</dialog>
<dialog id="schedule-dialog" class="dialog dialog--wide" aria-labelledby="schedule-dialog-title">
<form id="schedule-form" class="dialog__body" novalidate>
<div class="dialog__header">
<div>
<p class="eyebrow">Programación</p>
<h2 id="schedule-dialog-title">Añadir horario</h2>
</div>
<button class="icon-button" type="button" data-close-dialog aria-label="Cerrar">×</button>
</div>
<input id="schedule-id" name="id" type="hidden">
<div class="form-grid form-grid--two">
<div class="form-field">
<label for="schedule-time">Hora</label>
<input id="schedule-time" name="time" type="time" required>
</div>
<div class="form-field">
<label for="schedule-profile">Perfil</label>
<select id="schedule-profile" name="profileId" required></select>
</div>
</div>
<div class="form-field">
<label for="schedule-pattern">Patrón del timbre</label>
<select id="schedule-pattern" name="patternId" required></select>
</div>
<fieldset class="days-fieldset">
<legend>Días de la semana</legend>
<p class="field-help">Se envía como bitmask: domingo es el bit 0 y sábado el bit 6.</p>
<div id="schedule-days" class="day-picker"></div>
</fieldset>
<p id="schedule-form-error" class="form-error" role="alert" hidden></p>
<div class="dialog__actions">
<button class="button button--quiet" type="button" data-close-dialog>Cancelar</button>
<button id="schedule-submit" class="button button--primary" type="submit">Guardar horario</button>
</div>
</form>
</dialog>
<dialog id="pattern-dialog" class="dialog dialog--wide" aria-labelledby="pattern-dialog-title">
<form id="pattern-form" class="dialog__body" novalidate>
<div class="dialog__header">
<div>
<p class="eyebrow">Ciclo eléctrico</p>
<h2 id="pattern-dialog-title">Añadir patrón</h2>
</div>
<button class="icon-button" type="button" data-close-dialog aria-label="Cerrar">×</button>
</div>
<input id="pattern-id" name="id" type="hidden">
<div class="form-grid form-grid--two">
<div class="form-field">
<label for="pattern-code">Código</label>
<select id="pattern-code" name="legacyCode" required>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
</select>
<p class="field-help">Código heredado que identifica el patrón.</p>
</div>
<div class="form-field">
<span class="field-label">Destino</span>
<div class="readonly-value">GPIO22 · principal</div>
<p class="field-help">GPIO23 está reservado.</p>
</div>
</div>
<fieldset class="phases-fieldset">
<legend>Fases del ciclo</legend>
<div class="phase-grid" id="pattern-phases"></div>
</fieldset>
<label class="checkbox-field checkbox-field--standalone" for="pattern-enabled">
<input id="pattern-enabled" name="enabled" type="checkbox">
<span>Patrón habilitado</span>
</label>
<p id="pattern-form-error" class="form-error" role="alert" hidden></p>
<div class="dialog__actions">
<button class="button button--quiet" type="button" data-close-dialog>Cancelar</button>
<button id="pattern-submit" class="button button--primary" type="submit">Guardar patrón</button>
</div>
</form>
</dialog>
<dialog id="confirm-dialog" class="dialog dialog--confirm" aria-labelledby="confirm-title" aria-describedby="confirm-message">
<div class="dialog__body">
<div class="dialog__header">
<div>
<p class="eyebrow">Confirmar acción</p>
<h2 id="confirm-title">Confirmar</h2>
</div>
<button class="button button--quiet" type="button" data-close-dialog aria-label="Cerrar">Cerrar</button>
</div>
<p id="confirm-message" class="confirm-message"></p>
<div class="dialog__actions">
<button class="button button--secondary" type="button" data-close-dialog>Cancelar</button>
<button id="confirm-action-button" class="button button--danger" type="button">Confirmar</button>
</div>
</div>
</dialog>
<div id="toast-region" class="toast-region" aria-live="polite" aria-atomic="false"></div>
<noscript>
<div class="noscript-notice">Esta interfaz necesita JavaScript para comunicarse con TimbreESP.</div>
</noscript>
</body>
</html>