refactor: replace Jinja templates with static frontend pages
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Hoover — components/data.js
|
||||
*
|
||||
* Data display components: Badge, StatusDot, Empty, Card.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
|
||||
/**
|
||||
* Colored badge/span.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.text – Badge text
|
||||
* @param {string} [props.variant] – 'info' | 'success' | 'warning' | 'danger'
|
||||
*/
|
||||
export function Badge(props = {}) {
|
||||
return h('span', { class: `badge badge-${props.variant || 'info'}` }, String(props.text || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator dot.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.status – 'success' | 'up' | 'danger' | 'down' | 'pending'
|
||||
*/
|
||||
export function StatusDot(props = {}) {
|
||||
const v = ['success', 'up'].includes(props.status) ? 'up' :
|
||||
['danger', 'down'].includes(props.status) ? 'down' : 'pending';
|
||||
return h('span', { class: `status-dot status-${v}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state placeholder card.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} [props.text]
|
||||
*/
|
||||
export function Empty(props = {}) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'text-muted text-sm' }, props.text || 'No data available'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card wrapper with optional header and body content.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} [props.header]
|
||||
* @param {VNode[]} [props.children]
|
||||
*/
|
||||
export function Card(props = {}) {
|
||||
if (props.header) {
|
||||
return h('div', { class: 'card' },
|
||||
h('div', { class: 'card-header' }, props.header),
|
||||
h('div', { class: 'card-body' }, props.children || []),
|
||||
);
|
||||
}
|
||||
return h('div', { class: 'card' }, props.children || []);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Hoover — components/layout.js
|
||||
*
|
||||
* Layout components: PageHeader for page titles with optional subtitles
|
||||
* and action buttons.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
|
||||
/**
|
||||
* Page header with title, optional subtitle, and action buttons.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title
|
||||
* @param {string} [props.subtitle]
|
||||
* @param {VNode} [props.actions]
|
||||
*/
|
||||
export function PageHeader(props = {}) {
|
||||
return h('div', { class: 'page-header' },
|
||||
h('div', null,
|
||||
h('h1', null, props.title || ''),
|
||||
props.subtitle ? h('div', { class: 'subtitle' }, props.subtitle) : null,
|
||||
),
|
||||
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Hoover — components/toast.js
|
||||
*
|
||||
* ToastContainer component that renders queued toast notifications.
|
||||
* Uses the toast/dismissToast state from api.js.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
import { _toasts, dismissToast } from '../api.js';
|
||||
|
||||
/**
|
||||
* Render all pending toast notifications.
|
||||
*
|
||||
* @returns {VNode}
|
||||
*/
|
||||
export function ToastContainer() {
|
||||
if (!_toasts.length) return h('#text', '');
|
||||
|
||||
const clsMap = {
|
||||
info: 'toast-info',
|
||||
success: 'toast-success',
|
||||
error: 'toast-error',
|
||||
warning: 'toast-warning',
|
||||
};
|
||||
|
||||
return h('div', { class: 'toast-container' },
|
||||
..._toasts.map(t =>
|
||||
h('div', {
|
||||
class: `toast ${clsMap[t.type] || clsMap.info}`,
|
||||
'on:click': () => dismissToast(t.id),
|
||||
},
|
||||
h('span', null, t.message),
|
||||
h('button', {
|
||||
class: 'toast-close',
|
||||
'on:click': (e) => { e.stopPropagation(); dismissToast(t.id); },
|
||||
}, '\u00d7'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user