Files
boc/aamos-admin-upgrade/web/assets/components.js
T

963 lines
34 KiB
JavaScript
Raw Normal View History

// 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 = `<style>${BASE_CSS}${css}</style>${html}`;
return root;
}
// ─── KPICard ─────────────────────────────────────────────────────────────────
// Attrs: label, value, unit, delta, trend (up|down|flat), accent (hex/var)
// Example: <kpi-card label="Requests" value="14 302" unit="/s" delta="+4.2%" trend="up"></kpi-card>
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 = `
<div class="card">
<div class="label">${label}</div>
<div class="value-row">
<span class="value">${value}</span>
${unit ? `<span class="unit">${unit}</span>` : ''}
</div>
${delta ? `<div class="delta"><span class="trend-icon">${trendIcon}</span>${delta}</div>` : ''}
</div>
`;
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 = `<style>${BASE_CSS}${css}</style>${html}`;
}
}
}
// ─── HealthRing ───────────────────────────────────────────────────────────────
// Attrs: value (0-100), label, size (px), color, bg
// Example: <health-ring value="82" label="CPU" size="80"></health-ring>
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 = `
<div class="wrap">
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<circle class="track" cx="${size/2}" cy="${size/2}" r="${r}"/>
<circle class="arc" cx="${size/2}" cy="${size/2}" r="${r}"/>
</svg>
<div class="center">${Math.round(value)}%</div>
</div>
${label ? `<span class="label">${label}</span>` : ''}
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `<style>${BASE_CSS}${css}</style>${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: <module-card name="watchman" status="running" version="1.4.2" uptime="3d 14h"></module-card>
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 = `
<div class="card">
<div class="header">
<span class="name">${name}</span>
<span class="status-badge"><span class="dot"></span>${statusMeta.label}</span>
</div>
<div class="meta">
${version ? `<span>v${version}</span>` : ''}
${uptime ? `<span>↑ ${uptime}</span>` : ''}
${pid ? `<span>PID ${pid}</span>` : ''}
</div>
<div class="actions">
<button data-action="start">Start</button>
<button data-action="restart">Restart</button>
<button data-action="stop" class="danger">Stop</button>
</div>
</div>
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `<style>${BASE_CSS}${css}</style>${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: <audit-row ts="2026-05-10T12:34:56Z" actor="admin" action="login" result="ok" ip="10.0.0.1"></audit-row>
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 = `
<div class="row">
<div class="ts"><div>${time}</div><div class="date">${date}</div></div>
<div class="actor">${actor}</div>
<div class="action">${action}</div>
<div class="target">${target}</div>
<div class="result">${resultMeta.label}</div>
<div class="ip">${ip}</div>
</div>
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `<style>${BASE_CSS}${css}</style>${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 = `
<div class="row">
<div class="avatar">${initials}</div>
<div class="name-col">
<div class="name">${name}</div>
</div>
<div class="email-col">${email}</div>
<div class="role">${role}</div>
<div class="status">${statusMeta.label}</div>
<div class="last-seen">${lastSeen}</div>
<div class="actions">
<button data-action="edit">Edit</button>
<button data-action="lock" class="danger">Lock</button>
</div>
</div>
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `<style>${BASE_CSS}${css}</style>${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: <toast-msg message="Saved" type="success"></toast-msg>
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 = `
<div class="toast">
<span class="icon">${meta.icon}</span>
<span class="msg">${message}</span>
<button class="close" aria-label="Close">×</button>
</div>
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `<style>${BASE_CSS}${css}</style>${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: <app-modal title="Confirm" open><p>Are you sure?</p></app-modal>
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 = `
<div class="backdrop">
<div class="dialog" role="dialog" aria-modal="true" aria-label="${title}">
<div class="header">
<span class="title">${title}</span>
<button class="close-btn" aria-label="Close">×</button>
</div>
<div class="body"><slot></slot></div>
<div class="footer"><slot name="footer"></slot></div>
</div>
</div>
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `<style>${BASE_CSS}${css}</style>${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 <a> or <button data-icon="…" data-label="…">)
// Emits: sidebar-toggle (detail: {collapsed})
// Example: <app-sidebar logo-text="AAMOS"></app-sidebar>
class Sidebar extends HTMLElement {
static get observedAttributes() { return ['collapsed', 'logo-text', 'logo-icon']; }
connectedCallback() { this.#render(); }
attributeChangedCallback() { if (this.shadowRoot) this.#updateCollapsed(); }
#toggle() {
if (this.hasAttribute('collapsed')) this.removeAttribute('collapsed');
else this.setAttribute('collapsed', '');
this.dispatchEvent(new CustomEvent('sidebar-toggle', {
bubbles: true, composed: true,
detail: { collapsed: this.hasAttribute('collapsed') },
}));
}
#updateCollapsed() {
const el = this.shadowRoot?.querySelector('.sidebar');
if (!el) return;
el.classList.toggle('collapsed', this.hasAttribute('collapsed'));
}
#render() {
const logoText = this.getAttribute('logo-text') ?? 'AAMOS';
const logoIcon = this.getAttribute('logo-icon') ?? '⬡';
const css = `
:host { display: block; height: 100%; }
.sidebar {
display: flex; flex-direction: column;
width: 220px;
height: 100%;
background: var(--sidebar-bg, #0f172a);
border-right: 1px solid var(--border, #1e293b);
transition: width .25s ease;
overflow: hidden;
}
.sidebar.collapsed { width: 56px; }
.logo {
display: flex; align-items: center; gap: 10px;
padding: 20px 16px;
border-bottom: 1px solid var(--border, #1e293b);
flex-shrink: 0;
min-height: 60px;
}
.logo-icon {
font-size: 22px; flex-shrink: 0;
color: var(--accent, #6366f1);
width: 24px; text-align: center;
}
.logo-text {
font-size: 14px; font-weight: 800;
letter-spacing: .12em;
color: var(--text, #f1f5f9);
white-space: nowrap;
overflow: hidden;
transition: opacity .2s;
}
.collapsed .logo-text { opacity: 0; width: 0; }
.nav {
flex: 1; overflow-y: auto; overflow-x: hidden;
padding: 12px 8px;
display: flex; flex-direction: column; gap: 2px;
}
::slotted(a), ::slotted(button) {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px;
border-radius: 8px;
color: var(--text-muted, #94a3b8);
font-size: 13px; font-weight: 500;
text-decoration: none;
border: none; background: none; width: 100%;
cursor: pointer; text-align: left;
white-space: nowrap; overflow: hidden;
transition: background .15s, color .15s;
}
::slotted(a:hover), ::slotted(button:hover),
::slotted(a.active), ::slotted(button.active) {
background: var(--sidebar-hover, #1e293b);
color: var(--text, #f1f5f9);
}
.toggle {
padding: 12px 16px;
border-top: 1px solid var(--border, #1e293b);
flex-shrink: 0;
}
.toggle-btn {
width: 100%; padding: 6px;
border-radius: 7px;
border: 1px solid var(--border, #334155);
background: transparent;
color: var(--text-muted, #94a3b8);
font-size: 12px; cursor: pointer;
transition: background .15s;
}
.toggle-btn:hover { background: var(--sidebar-hover, #1e293b); color: var(--text, #f1f5f9); }
.toggle-label { white-space: nowrap; overflow: hidden; transition: opacity .2s; }
.collapsed .toggle-label { opacity: 0; width: 0; display: none; }
`;
const html = `
<nav class="sidebar${this.hasAttribute('collapsed') ? ' collapsed' : ''}">
<div class="logo">
<span class="logo-icon">${logoIcon}</span>
<span class="logo-text">${logoText}</span>
</div>
<div class="nav"><slot></slot></div>
<div class="toggle">
<button class="toggle-btn">
<span class="toggle-icon">◀</span>
<span class="toggle-label"> Collapse</span>
</button>
</div>
</nav>
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `<style>${BASE_CSS}${css}</style>${html}`;
this.shadowRoot.querySelector('.toggle-btn').addEventListener('click', () => this.#toggle());
}
}
// ─── TabBar ───────────────────────────────────────────────────────────────────
// Attrs: active (tab id/value), tabs (JSON array: [{id, label, icon?}])
// Emits: tab-change (detail: {id})
// Example: <tab-bar tabs='[{"id":"overview","label":"Overview"},{"id":"logs","label":"Logs"}]' active="overview"></tab-bar>
class TabBar extends HTMLElement {
static get observedAttributes() { return ['active', 'tabs']; }
connectedCallback() { this.#render(); }
attributeChangedCallback() { if (this.shadowRoot) this.#render(); }
#render() {
const active = this.getAttribute('active') ?? '';
let tabs = [];
try { tabs = JSON.parse(this.getAttribute('tabs') ?? '[]'); } catch {}
const css = `
:host { display: block; }
.tabbar {
display: flex;
gap: 2px;
padding: 4px;
background: var(--tabbar-bg, #0f172a);
border-radius: 10px;
border: 1px solid var(--border, #1e293b);
width: fit-content;
}
button {
display: flex; align-items: center; gap: 6px;
padding: 7px 16px;
border-radius: 7px;
border: none; background: none;
font-size: 13px; font-weight: 500;
color: var(--text-muted, #64748b);
cursor: pointer;
transition: background .15s, color .15s;
white-space: nowrap;
}
button:hover { color: var(--text, #f1f5f9); }
button.active {
background: var(--accent, #6366f1);
color: #fff;
font-weight: 600;
}
.icon { font-size: 15px; }
`;
const html = `
<div class="tabbar" role="tablist">
${tabs.map(t => `
<button
role="tab"
data-id="${t.id}"
aria-selected="${t.id === active}"
class="${t.id === active ? 'active' : ''}"
>
${t.icon ? `<span class="icon">${t.icon}</span>` : ''}
${t.label}
</button>
`).join('')}
</div>
`;
if (!this.shadowRoot) shadow(this, css, html);
else this.shadowRoot.innerHTML = `<style>${BASE_CSS}${css}</style>${html}`;
this.shadowRoot.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
this.setAttribute('active', btn.dataset.id);
this.dispatchEvent(new CustomEvent('tab-change', {
bubbles: true, composed: true,
detail: { id: btn.dataset.id },
}));
});
});
}
}
// ─── Registration ─────────────────────────────────────────────────────────────
customElements.define('kpi-card', KPICard);
customElements.define('health-ring', HealthRing);
customElements.define('module-card', ModuleCard);
customElements.define('audit-row', AuditRow);
customElements.define('user-row', UserRow);
customElements.define('toast-msg', Toast);
customElements.define('app-modal', Modal);
customElements.define('app-sidebar', Sidebar);
customElements.define('tab-bar', TabBar);
export { KPICard, HealthRing, ModuleCard, AuditRow, UserRow, Toast, Modal, Sidebar, TabBar };