6de2455917
- 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
963 lines
34 KiB
JavaScript
963 lines
34 KiB
JavaScript
// 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 };
|