refactor: modernize frontend with hoover framework components and docs
- Add quick modal, table, service status, and confirmation dialog components - Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns - Introduce refactor load utility and render guard for consistent UX - Add hoover documentation and update AGENTS.md, architecture, overview
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Hoover — components/layout.js
|
||||
*
|
||||
* Layout components: PageHeader for page titles with optional subtitles
|
||||
* and action buttons.
|
||||
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js';
|
||||
import { h } from '../vdom.js?v=6';
|
||||
import { Table } from './data.js?v=6';
|
||||
|
||||
/**
|
||||
* Page header with title, optional subtitle, and action buttons.
|
||||
@@ -24,3 +24,111 @@ export function PageHeader(props = {}) {
|
||||
props.actions ? h('div', { class: 'page-actions' }, props.actions) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle loading/error/no-data states and return early if applicable.
|
||||
* Returns null when data is ready for the page to render its content.
|
||||
*
|
||||
* @param {object} state - Page state with loading/error flags
|
||||
* @param {string} title - Page header title
|
||||
* @param {string} [subtitle] - Page header subtitle
|
||||
* @param {*} [data] - Data presence check for "no data" state
|
||||
* @returns {VNode[]|null}
|
||||
*/
|
||||
export function renderGuard(state, title, subtitle, data) {
|
||||
if (state.loading && !state.refreshing) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'loading' },
|
||||
h('div', { class: 'card-body loading' },
|
||||
state.refreshing ? 'Refreshing...' : 'Loading...',
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (state.error) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'error' },
|
||||
h('div', { class: 'card-body error-msg' }, state.error),
|
||||
),
|
||||
];
|
||||
}
|
||||
if ((data === undefined || data === null) && !state.loading) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'no-data' },
|
||||
h('div', { class: 'card-body loading' }, 'No data available'),
|
||||
),
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab bar component. Writes to state[prop] on tab click.
|
||||
* The caller is responsible for rendering tab body content.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {object} props.state - Reactive state object
|
||||
* @param {string[]} props.tabs - Array of tab keys (e.g. ['ranges', 'leases'])
|
||||
* @param {string} [props.prop] - State property name for active tab (default: 'activeTab')
|
||||
* @param {function} [props.formatLabel] - (key) => label string (default: capitalize)
|
||||
* @param {function} [props.onTabClick] - (key) => void, called after state update (for async side effects)
|
||||
*/
|
||||
export function Tabs(props = {}) {
|
||||
const tabKeys = props.tabs || [];
|
||||
const prop = props.prop || 'activeTab';
|
||||
const formatLabel = props.formatLabel || ((k) => k.charAt(0).toUpperCase() + k.slice(1));
|
||||
return h('div', { class: 'tabs' },
|
||||
tabKeys.map(t => h('span', {
|
||||
class: 'tab ' + (props.state[prop] === t ? 'active' : ''),
|
||||
'on:click': () => {
|
||||
props.state[prop] = t;
|
||||
if (props.onTabClick) props.onTabClick(t);
|
||||
},
|
||||
style: 'cursor:pointer;',
|
||||
}, formatLabel(t))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Section header.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title
|
||||
*/
|
||||
export function SectionTitle(props = {}) {
|
||||
return h('h3', { class: 'section-title' }, props.title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flex button container with 8px gap.
|
||||
*
|
||||
* @param {VNode[]} children
|
||||
*/
|
||||
export function ActionGroup(...children) {
|
||||
return h('div', { style: 'display:flex;gap:8px;' }, ...children);
|
||||
}
|
||||
|
||||
/**
|
||||
* DataTableSection — SectionTitle heading followed by a Table.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.title - Section heading
|
||||
* @param {string[]} props.columns
|
||||
* @param {VNode[]} props.rows
|
||||
* @param {string} [props.emptyText]
|
||||
* @param {string} [props.key]
|
||||
*/
|
||||
export function DataTableSection(props = {}) {
|
||||
const key = props.key !== undefined ? { key: props.key } : {};
|
||||
return h('div', { class: 'data-table-section', ...key },
|
||||
SectionTitle({ title: props.title }),
|
||||
Table({
|
||||
columns: props.columns,
|
||||
rows: props.rows,
|
||||
emptyText: props.emptyText,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user