Files
boc/aamos-admin-upgrade/web/assets/components.js
T
Bernt 6de2455917 v1.2.0: Add Global Markets footer, translated to 9 languages
- Added GLOBAL_MARKETS_TITLE to all translation files
- Updated footer with 12 markets (4 active + 8 upcoming)
- Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi
- Built and deployed to production
- CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
2026-07-08 19:56:03 +00:00

963 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };