// AAMOS Admin — Reusable Web Components
// Custom elements, Shadow DOM, attribute-based config.
// ─── Shared styles injected into every shadow root ───────────────────────────
const BASE_CSS = `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:host { font-family: 'Inter', system-ui, sans-serif; }
`;
function shadow(host, css, html) {
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = `${html}`;
return root;
}
// ─── KPICard ─────────────────────────────────────────────────────────────────
// Attrs: label, value, unit, delta, trend (up|down|flat), accent (hex/var)
// Example:
class KPICard extends HTMLElement {
static get observedAttributes() {
return ['label', 'value', 'unit', 'delta', 'trend', 'accent'];
}
connectedCallback() { this.#render(); }
attributeChangedCallback() { if (this.shadowRoot) this.#render(); }
#render() {
const label = this.getAttribute('label') ?? '';
const value = this.getAttribute('value') ?? '—';
const unit = this.getAttribute('unit') ?? '';
const delta = this.getAttribute('delta') ?? '';
const trend = this.getAttribute('trend') ?? 'flat';
const accent = this.getAttribute('accent') ?? 'var(--accent, #6366f1)';
const trendIcon = { up: '▲', down: '▼', flat: '▬' }[trend] ?? '▬';
const trendColor = { up: '#22c55e', down: '#ef4444', flat: '#94a3b8' }[trend] ?? '#94a3b8';
const css = `
:host { display: block; }
.card {
background: var(--card-bg, #1e293b);
border: 1px solid var(--border, #334155);
border-radius: 12px;
padding: 20px 24px;
position: relative;
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 4px; height: 100%;
background: ${accent};
border-radius: 12px 0 0 12px;
}
.label {
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--text-muted, #94a3b8);
margin-bottom: 10px;
}
.value-row {
display: flex;
align-items: baseline;
gap: 4px;
}
.value {
font-size: 28px;
font-weight: 700;
color: var(--text, #f1f5f9);
line-height: 1;
}
.unit {
font-size: 13px;
color: var(--text-muted, #94a3b8);
}
.delta {
margin-top: 8px;
font-size: 12px;
font-weight: 600;
color: ${trendColor};
display: flex;
align-items: center;
gap: 4px;
}
.trend-icon { font-size: 9px; }
`;
const html = `
${label}
${value}
${unit ? `${unit}` : ''}
${delta ? `
${trendIcon}${delta}
` : ''}
`;
if (!this.shadowRoot) {
shadow(this, css, html);
} else {
this.shadowRoot.querySelector('style').textContent = BASE_CSS + css;
this.shadowRoot.querySelector('.card').outerHTML; // trigger reflow
this.shadowRoot.innerHTML = `${html}`;
}
}
}
// ─── HealthRing ───────────────────────────────────────────────────────────────
// Attrs: value (0-100), label, size (px), color, bg
// Example:
class HealthRing extends HTMLElement {
static get observedAttributes() {
return ['value', 'label', 'size', 'color', 'bg'];
}
connectedCallback() { this.#render(); }
attributeChangedCallback() { if (this.shadowRoot) this.#render(); }
#render() {
const value = Math.min(100, Math.max(0, parseFloat(this.getAttribute('value') ?? 0)));
const label = this.getAttribute('label') ?? '';
const size = parseInt(this.getAttribute('size') ?? '80', 10);
const color = this.getAttribute('color') ?? this.#autoColor(value);
const bg = this.getAttribute('bg') ?? 'var(--ring-bg, #334155)';
const r = (size / 2) - 6;
const circ = 2 * Math.PI * r;
const offset = circ * (1 - value / 100);
const stroke = Math.max(4, size / 12);
const font = Math.max(10, size / 5);
const css = `
:host { display: inline-flex; flex-direction: column; align-items: center; gap: 6px; }
svg { transform: rotate(-90deg); }
.track { fill: none; stroke: ${bg}; stroke-width: ${stroke}; }
.arc {
fill: none;
stroke: ${color};
stroke-width: ${stroke};
stroke-linecap: round;
stroke-dasharray: ${circ};
stroke-dashoffset: ${offset};
transition: stroke-dashoffset .5s ease, stroke .3s;
}
.center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: ${font}px;
font-weight: 700;
color: var(--text, #f1f5f9);
}
.wrap { position: relative; width: ${size}px; height: ${size}px; }
.label {
font-size: 11px;
font-weight: 600;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-muted, #94a3b8);
}
`;
const html = `
${label ? `${label}` : ''}
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `${html}`;
}
#autoColor(v) {
if (v >= 80) return '#ef4444';
if (v >= 60) return '#f59e0b';
return '#22c55e';
}
}
// ─── ModuleCard ───────────────────────────────────────────────────────────────
// Attrs: name, status (running|stopped|error|pending), version, uptime, pid
// Emits: module-action (detail: {name, action})
// Example:
class ModuleCard extends HTMLElement {
static get observedAttributes() {
return ['name', 'status', 'version', 'uptime', 'pid'];
}
connectedCallback() { this.#render(); }
attributeChangedCallback() { if (this.shadowRoot) this.#render(); }
#render() {
const name = this.getAttribute('name') ?? 'module';
const status = this.getAttribute('status') ?? 'stopped';
const version = this.getAttribute('version') ?? '';
const uptime = this.getAttribute('uptime') ?? '';
const pid = this.getAttribute('pid') ?? '';
const statusMeta = {
running: { color: '#22c55e', dot: '#22c55e', label: 'Running' },
stopped: { color: '#94a3b8', dot: '#475569', label: 'Stopped' },
error: { color: '#ef4444', dot: '#ef4444', label: 'Error' },
pending: { color: '#f59e0b', dot: '#f59e0b', label: 'Pending' },
}[status] ?? { color: '#94a3b8', dot: '#475569', label: status };
const css = `
:host { display: block; }
.card {
background: var(--card-bg, #1e293b);
border: 1px solid var(--border, #334155);
border-radius: 12px;
padding: 16px 20px;
transition: border-color .2s;
}
.card:hover { border-color: var(--accent, #6366f1); }
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.name {
font-size: 14px;
font-weight: 700;
color: var(--text, #f1f5f9);
letter-spacing: .02em;
}
.status-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: ${statusMeta.color};
text-transform: uppercase;
letter-spacing: .06em;
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: ${statusMeta.dot};
${status === 'running' ? `box-shadow: 0 0 0 2px ${statusMeta.dot}33;` : ''}
}
.meta {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--text-muted, #94a3b8);
margin-bottom: 14px;
}
.meta span { display: flex; align-items: center; gap: 4px; }
.actions {
display: flex;
gap: 8px;
}
button {
flex: 1;
padding: 6px 0;
border-radius: 7px;
border: 1px solid var(--border, #334155);
background: transparent;
color: var(--text-muted, #94a3b8);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
button:hover {
background: var(--accent, #6366f1);
color: #fff;
border-color: var(--accent, #6366f1);
}
button.danger:hover { background: #ef4444; border-color: #ef4444; color: #fff; }
`;
const html = `
${version ? `v${version}` : ''}
${uptime ? `↑ ${uptime}` : ''}
${pid ? `PID ${pid}` : ''}
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `${html}`;
this.shadowRoot.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('module-action', {
bubbles: true, composed: true,
detail: { name, action: btn.dataset.action },
}));
});
});
}
}
// ─── AuditRow ─────────────────────────────────────────────────────────────────
// Attrs: ts, actor, action, target, result (ok|fail|warn), ip
// Example:
class AuditRow extends HTMLElement {
static get observedAttributes() {
return ['ts', 'actor', 'action', 'target', 'result', 'ip'];
}
connectedCallback() { this.#render(); }
attributeChangedCallback() { if (this.shadowRoot) this.#render(); }
#render() {
const ts = this.getAttribute('ts') ?? '';
const actor = this.getAttribute('actor') ?? '—';
const action = this.getAttribute('action') ?? '—';
const target = this.getAttribute('target') ?? '';
const result = this.getAttribute('result') ?? 'ok';
const ip = this.getAttribute('ip') ?? '';
const resultMeta = {
ok: { color: '#22c55e', label: 'OK' },
fail: { color: '#ef4444', label: 'FAIL' },
warn: { color: '#f59e0b', label: 'WARN' },
}[result] ?? { color: '#94a3b8', label: result.toUpperCase() };
const time = ts ? new Date(ts).toLocaleTimeString('sv-SE', { hour12: false }) : '';
const date = ts ? new Date(ts).toLocaleDateString('sv-SE') : '';
const css = `
:host { display: table-row; }
.row {
display: grid;
grid-template-columns: 110px 120px 1fr 1fr 60px 100px;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border, #1e293b);
font-size: 12px;
color: var(--text-muted, #94a3b8);
transition: background .15s;
}
.row:hover { background: var(--hover, #1e293b); }
.ts { font-variant-numeric: tabular-nums; }
.ts .date { font-size: 10px; opacity: .6; }
.actor { color: var(--text, #f1f5f9); font-weight: 600; }
.action { color: var(--text, #f1f5f9); }
.target { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result {
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
color: ${resultMeta.color};
text-align: center;
padding: 2px 6px;
border-radius: 4px;
background: ${resultMeta.color}18;
}
.ip { font-variant-numeric: tabular-nums; }
`;
const html = `
${actor}
${action}
${target}
${resultMeta.label}
${ip}
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `${html}`;
}
}
// ─── UserRow ──────────────────────────────────────────────────────────────────
// Attrs: uid, name, email, role, status (active|inactive|locked), last-seen
// Emits: user-action (detail: {uid, action})
class UserRow extends HTMLElement {
static get observedAttributes() {
return ['uid', 'name', 'email', 'role', 'status', 'last-seen'];
}
connectedCallback() { this.#render(); }
attributeChangedCallback() { if (this.shadowRoot) this.#render(); }
#render() {
const uid = this.getAttribute('uid') ?? '';
const name = this.getAttribute('name') ?? '—';
const email = this.getAttribute('email') ?? '';
const role = this.getAttribute('role') ?? 'user';
const status = this.getAttribute('status') ?? 'active';
const lastSeen = this.getAttribute('last-seen') ?? '';
const statusMeta = {
active: { color: '#22c55e', label: 'Active' },
inactive: { color: '#94a3b8', label: 'Inactive' },
locked: { color: '#ef4444', label: 'Locked' },
}[status] ?? { color: '#94a3b8', label: status };
const roleMeta = {
admin: { color: '#a78bfa' },
operator: { color: '#38bdf8' },
viewer: { color: '#94a3b8' },
}[role] ?? { color: '#94a3b8' };
const initials = name.split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase();
const css = `
:host { display: block; }
.row {
display: grid;
grid-template-columns: 44px 1fr 180px 80px 80px 100px auto;
align-items: center;
gap: 14px;
padding: 10px 16px;
border-bottom: 1px solid var(--border, #1e293b);
font-size: 13px;
transition: background .15s;
}
.row:hover { background: var(--hover, #1e293b); }
.avatar {
width: 36px; height: 36px;
border-radius: 50%;
background: var(--accent, #6366f1);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.name-col .name { color: var(--text, #f1f5f9); font-weight: 600; font-size: 13px; }
.name-col .email { color: var(--text-muted, #94a3b8); font-size: 11px; }
.email-col { color: var(--text-muted, #94a3b8); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }
.role {
font-size: 11px; font-weight: 600;
color: ${roleMeta.color};
text-transform: uppercase; letter-spacing: .06em;
}
.status {
font-size: 11px; font-weight: 600;
color: ${statusMeta.color};
text-transform: uppercase; letter-spacing: .06em;
}
.last-seen { font-size: 11px; color: var(--text-muted, #94a3b8); }
.actions { display: flex; gap: 6px; }
button {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border, #334155);
background: transparent;
color: var(--text-muted, #94a3b8);
font-size: 11px; font-weight: 600;
cursor: pointer;
transition: background .15s, color .15s;
}
button:hover { background: var(--accent, #6366f1); color: #fff; border-color: var(--accent, #6366f1); }
button.danger:hover { background: #ef4444; border-color: #ef4444; }
`;
const html = `
${initials}
${email}
${role}
${statusMeta.label}
${lastSeen}
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `${html}`;
this.shadowRoot.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('user-action', {
bubbles: true, composed: true,
detail: { uid, action: btn.dataset.action },
}));
});
});
}
}
// ─── Toast ────────────────────────────────────────────────────────────────────
// Attrs: message, type (info|success|warn|error), duration (ms, default 3500)
// Static helper: Toast.show({message, type, duration, target})
// Example:
class Toast extends HTMLElement {
static get observedAttributes() { return ['message', 'type']; }
connectedCallback() {
this.#render();
const duration = parseInt(this.getAttribute('duration') ?? '3500', 10);
setTimeout(() => this.#dismiss(), duration);
}
attributeChangedCallback() { if (this.shadowRoot) this.#render(); }
#dismiss() {
this.shadowRoot?.querySelector('.toast')?.classList.add('out');
setTimeout(() => this.remove(), 300);
}
#render() {
const message = this.getAttribute('message') ?? '';
const type = this.getAttribute('type') ?? 'info';
const meta = {
info: { color: '#38bdf8', icon: 'ℹ' },
success: { color: '#22c55e', icon: '✓' },
warn: { color: '#f59e0b', icon: '⚠' },
error: { color: '#ef4444', icon: '✕' },
}[type] ?? { color: '#94a3b8', icon: '·' };
const css = `
:host { display: block; pointer-events: none; }
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
background: var(--toast-bg, #1e293b);
border: 1px solid ${meta.color}55;
border-left: 4px solid ${meta.color};
border-radius: 10px;
box-shadow: 0 8px 30px #0009;
font-size: 13px;
color: var(--text, #f1f5f9);
pointer-events: all;
animation: slide-in .25s ease;
transition: opacity .3s, transform .3s;
min-width: 240px; max-width: 420px;
}
.toast.out { opacity: 0; transform: translateX(20px); }
.icon { color: ${meta.color}; font-size: 16px; flex-shrink: 0; }
.msg { flex: 1; }
.close {
cursor: pointer; color: var(--text-muted, #94a3b8);
font-size: 16px; line-height: 1;
border: none; background: none;
padding: 0; flex-shrink: 0;
}
.close:hover { color: var(--text, #f1f5f9); }
@keyframes slide-in {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
`;
const html = `
${meta.icon}
${message}
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `${html}`;
this.shadowRoot.querySelector('.close').addEventListener('click', () => this.#dismiss());
}
static show({ message, type = 'info', duration = 3500, target } = {}) {
let container = document.getElementById('toast-container');
if (!container) {
container = Object.assign(document.createElement('div'), { id: 'toast-container' });
Object.assign(container.style, {
position: 'fixed', bottom: '24px', right: '24px',
display: 'flex', flexDirection: 'column', gap: '10px', zIndex: '9999',
});
(target ?? document.body).appendChild(container);
}
const t = Object.assign(document.createElement('toast-msg'), {});
t.setAttribute('message', message);
t.setAttribute('type', type);
t.setAttribute('duration', String(duration));
container.appendChild(t);
}
}
// ─── Modal ────────────────────────────────────────────────────────────────────
// Attrs: open (presence toggles), title, size (sm|md|lg)
// Slots: default (body), [slot=footer]
// Emits: modal-close
// Example: Are you sure?
class Modal extends HTMLElement {
static get observedAttributes() { return ['open', 'title', 'size']; }
connectedCallback() { this.#render(); }
attributeChangedCallback(name) {
if (!this.shadowRoot) return;
if (name === 'open') this.#updateVisibility();
else this.#render();
}
#close() {
this.removeAttribute('open');
this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true, composed: true }));
}
#updateVisibility() {
const backdrop = this.shadowRoot?.querySelector('.backdrop');
if (!backdrop) return;
if (this.hasAttribute('open')) {
backdrop.style.display = 'flex';
requestAnimationFrame(() => backdrop.classList.add('visible'));
} else {
backdrop.classList.remove('visible');
setTimeout(() => { backdrop.style.display = 'none'; }, 250);
}
}
#render() {
const title = this.getAttribute('title') ?? '';
const size = this.getAttribute('size') ?? 'md';
const width = { sm: '380px', md: '560px', lg: '800px' }[size] ?? '560px';
const isOpen = this.hasAttribute('open');
const css = `
:host { display: contents; }
.backdrop {
display: ${isOpen ? 'flex' : 'none'};
position: fixed; inset: 0;
background: #000a;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
transition: opacity .25s;
}
.backdrop.visible { opacity: 1; }
.dialog {
background: var(--modal-bg, #1e293b);
border: 1px solid var(--border, #334155);
border-radius: 14px;
width: ${width};
max-width: calc(100vw - 32px);
max-height: 90vh;
display: flex; flex-direction: column;
box-shadow: 0 24px 80px #000a;
transform: scale(.95);
transition: transform .25s;
}
.backdrop.visible .dialog { transform: scale(1); }
.header {
display: flex; align-items: center;
padding: 18px 24px;
border-bottom: 1px solid var(--border, #334155);
flex-shrink: 0;
}
.title {
flex: 1;
font-size: 16px; font-weight: 700;
color: var(--text, #f1f5f9);
}
.close-btn {
width: 30px; height: 30px;
border-radius: 8px;
border: 1px solid var(--border, #334155);
background: transparent;
color: var(--text-muted, #94a3b8);
font-size: 18px; line-height: 1;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background .15s;
}
.close-btn:hover { background: var(--hover, #334155); color: var(--text, #f1f5f9); }
.body {
flex: 1; overflow-y: auto;
padding: 24px;
color: var(--text, #f1f5f9);
font-size: 14px; line-height: 1.6;
}
.footer {
padding: 14px 24px;
border-top: 1px solid var(--border, #334155);
display: flex; justify-content: flex-end; gap: 10px;
flex-shrink: 0;
}
`;
const html = `
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `${html}`;
this.shadowRoot.querySelector('.backdrop').addEventListener('click', e => {
if (e.target === e.currentTarget) this.#close();
});
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.#close());
if (isOpen) requestAnimationFrame(() => this.#updateVisibility());
}
}
// ─── Sidebar ──────────────────────────────────────────────────────────────────
// Attrs: collapsed (presence), logo-text, logo-icon
// Slot: default (nav items as or