refactor: replace Jinja templates with static frontend pages

This commit is contained in:
2026-06-16 03:35:41 +00:00
parent 2874680ffa
commit 593dece92b
39 changed files with 3532 additions and 2801 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* Hoover — components/modal.js
*
* Modal overlay system: openModal, closeModal, closeAllModals, formModal.
* Renders directly into #modal-root using DOM manipulation (not vdom) to
* avoid fighting with the main render cycle.
*/
import { esc } from '../helpers.js';
import { att_esc } from '../helpers.js';
const _modalQueue = [];
function _renderModals() {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = '';
_modalQueue.forEach((m, idx) => {
const wrap = document.createElement('div');
wrap.className = 'modal-overlay active';
wrap.onclick = (e) => { if (e.target === wrap) closeModal(idx); };
const content = document.createElement('div');
content.className = 'modal';
content.onclick = (e) => e.stopPropagation();
if (m.renderFn) {
try { m.renderFn(content, idx); }
catch (err) { content.textContent = err.message; }
}
wrap.appendChild(content);
root.appendChild(wrap);
});
}
/**
* Open a modal dialog.
*
* @param {function} renderFn (contentEl, idx) => void, renders into contentEl
*/
export function openModal(renderFn) {
_modalQueue.push({ renderFn, id: _modalQueue.length });
_renderModals();
}
/**
* Close a modal by index. Closes the topmost modal if index is omitted.
*
* @param {number} [idx]
*/
export function closeModal(idx) {
if (idx === undefined) idx = _modalQueue.length - 1;
if (idx >= 0 && idx < _modalQueue.length) _modalQueue.splice(idx, 1);
_renderModals();
}
/**
* Close all open modals.
*/
export function closeAllModals() {
_modalQueue.length = 0;
_renderModals();
}
/**
* Render a standard modal layout: title, form fields, action buttons.
*
* @param {HTMLElement} inner Modal content element to fill
* @param {string} title Modal title
* @param {object[]} fields Form field descriptors
* @param {object[]} actions Action button descriptors
*
* Field shape:
* { label, id, [tag: 'input'|'select'|'textarea'], [type], [value], [placeholder], [options] }
*
* Action shape:
* { label, cls, action, handler }
*/
export function formModal(inner, title, fields, actions) {
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
+ fields.map(f => {
if (f.tag === 'select')
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '">'
+ (f.options || []).map(o =>
typeof o === 'string'
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
: '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + esc(o[1]) + '</option>',
).join('') + '</select></div>';
const tag = f.tag || 'input';
return '<div class="form-group"><label>' + esc(f.label) + '</label><' + tag + ' id="' + att_esc(f.id) + '"'
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
+ '></' + tag + '></div>';
}).join('') + '</div><div class="modal-actions">'
+ actions.map(a =>
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
).join('') + '</div>';
actions.forEach(a => {
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
if (btn) btn.addEventListener('click', a.handler);
});
}