// 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 = `
${Math.round(value)}%
${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 = `
${name} ${statusMeta.label}
${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 = `
${time}
${date}
${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}
${name}
${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 `; if (!this.shadowRoot) shadow(this, css, html); else this.shadowRoot.innerHTML = `${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: 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 = `
${tabs.map(t => ` `).join('')}
`; if (!this.shadowRoot) shadow(this, css, html); else this.shadowRoot.innerHTML = `${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 };