c44e7fe72e
Introduce components.js with barTrack, barRow, barRankList, metricPill, metricStrip, and chartHeader helpers. Migrate dashboard.js and usage.js to use these primitives, replacing 13 families of duplicated CSS (stat-list, fw-bar, token-bar, metric-pill, chart-insight, chart-header, usage-chart-total, etc.) with a unified .am-* namespace. Net: -256 lines. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
103 lines
5.3 KiB
JavaScript
103 lines
5.3 KiB
JavaScript
// ── components.js — shared UI primitives ─────────────────
|
|
//
|
|
// One-stop helpers for the repeating bar / pill / chart-header
|
|
// patterns across pages. Renderers return HTML strings — callers
|
|
// inject via innerHTML and wire events on the resulting DOM.
|
|
|
|
import { escapeHTML } from './utils.js';
|
|
|
|
// ── Bar primitives ───────────────────────────────────────
|
|
|
|
// barTrack — just the percentage bar (no label/count head).
|
|
// value: numeric value to display
|
|
// max: ceiling for percentage; 0/falsy means 0%
|
|
// fwClass: optional framework slug (openclaw, claude-code, hermes, …)
|
|
// — applied as fw-<slug> on the fill for color
|
|
// size: 'xs' | 'sm' | 'md' | 'lg' (default 'md')
|
|
// modifier: extra class on the fill (e.g. 'model', 'input', 'output')
|
|
export function barTrack({ value = 0, max = 0, fwClass = '', size = 'md', modifier = '' } = {}) {
|
|
const pct = max > 0 ? Math.min(100, (value / max) * 100).toFixed(1) : '0';
|
|
const fillClasses = ['am-bar-fill'];
|
|
if (fwClass) fillClasses.push('fw-' + fwClass);
|
|
if (modifier) fillClasses.push(modifier);
|
|
return `<div class="am-bar-track am-bar-track--${escapeHTML(size)}"><div class="${fillClasses.join(' ')}" style="width:${pct}%"></div></div>`;
|
|
}
|
|
|
|
// barRow — head (name + count) plus track.
|
|
// countDisplay overrides the rendered count text (e.g. "238" vs "238 events").
|
|
export function barRow({ name = '', count = 0, countDisplay, max = 0, fwClass = '', size = 'sm', modifier = '' } = {}) {
|
|
const display = countDisplay != null ? countDisplay : count;
|
|
return `
|
|
<div class="am-bar-row">
|
|
<div class="am-bar-row-head">
|
|
<span class="am-bar-row-name">${escapeHTML(String(name))}</span>
|
|
<span class="am-bar-row-count">${escapeHTML(String(display))}</span>
|
|
</div>
|
|
${barTrack({ value: count, max, fwClass, size, modifier })}
|
|
</div>`;
|
|
}
|
|
|
|
// barRankList — ranked list of barRows wrapped in <ul class="am-bar-list">.
|
|
// items: array of arbitrary objects
|
|
// mapItem: fn(item) → { name, count, countDisplay?, fwClass?, modifier? }
|
|
// maxOverride: ceiling override; default = max of items' counts
|
|
// size: bar height (default 'xs' for ranked lists)
|
|
// emptyText: shown when items is empty
|
|
export function barRankList(items, { mapItem, maxOverride, size = 'xs', emptyText = 'No data' } = {}) {
|
|
if (!items || items.length === 0) {
|
|
return `<p class="empty-state" style="padding:0.5rem 0;font-size:0.8rem">${escapeHTML(emptyText)}</p>`;
|
|
}
|
|
const mapped = items.map(item => (mapItem ? mapItem(item) : { name: item.name, count: item.count }));
|
|
const max = maxOverride != null ? maxOverride : Math.max(1, ...mapped.map(m => Number(m.count) || 0));
|
|
const rows = mapped.map(m => `<li>${barRow({ ...m, max, size })}</li>`).join('');
|
|
return `<ul class="am-bar-list">${rows}</ul>`;
|
|
}
|
|
|
|
// ── Pill primitives ──────────────────────────────────────
|
|
|
|
// metricPill — small label + bold value, optional meta line.
|
|
// value: pre-escaped HTML when valueHTML=true, else escaped as text
|
|
// valueId: id attribute (for animateCounter targets)
|
|
// variant: 'insight' | 'total' | 'range' | '' (default — standalone metric pill)
|
|
// alert: adds .alert to the pill (turns value red)
|
|
export function metricPill({ label, value = '-', valueId = '', valueHTML = false, meta = '', variant = '', alert = false } = {}) {
|
|
const classes = ['am-pill'];
|
|
if (variant) classes.push('am-pill--' + variant);
|
|
if (alert) classes.push('alert');
|
|
const idAttr = valueId ? ` id="${escapeHTML(valueId)}"` : '';
|
|
const rendered = valueHTML ? value : escapeHTML(String(value));
|
|
const metaLine = meta ? `<span class="am-pill-meta">${escapeHTML(String(meta))}</span>` : '';
|
|
return `
|
|
<div class="${classes.join(' ')}">
|
|
<span class="am-pill-label">${escapeHTML(String(label))}</span>
|
|
<strong class="am-pill-value"${idAttr}>${rendered}</strong>
|
|
${metaLine}
|
|
</div>`;
|
|
}
|
|
|
|
// metricStrip — flex/grid container around a list of pill specs.
|
|
// variant: '' | 'insights' (4-up grid) | 'totals' (tight inline pills)
|
|
export function metricStrip(pills, { variant = '', className = '' } = {}) {
|
|
const classes = ['am-pill-strip'];
|
|
if (variant) classes.push('am-pill-strip--' + variant);
|
|
if (className) classes.push(className);
|
|
return `<div class="${classes.join(' ')}">${pills.map(p => metricPill(p)).join('')}</div>`;
|
|
}
|
|
|
|
// ── Chart header ─────────────────────────────────────────
|
|
|
|
// chartHeader — title + optional subtitle on the left, raw controls HTML on the right.
|
|
// controls is passed through unchanged (legend/buttons/tabs vary by chart).
|
|
export function chartHeader({ title = '', subtitle = '', controls = '' } = {}) {
|
|
const subtitleHTML = subtitle ? `<span class="am-chart-subtitle">${escapeHTML(subtitle)}</span>` : '';
|
|
const controlsHTML = controls ? `<div class="am-chart-controls">${controls}</div>` : '';
|
|
return `
|
|
<div class="am-chart-header">
|
|
<div class="am-chart-title-group">
|
|
<span class="am-chart-title">${escapeHTML(title)}</span>
|
|
${subtitleHTML}
|
|
</div>
|
|
${controlsHTML}
|
|
</div>`;
|
|
}
|