import './echo'; import { Chart, registerables } from 'chart.js'; Chart.register(...registerables); const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; // Resolve "token:name" / "token:name/0.12" strings in a chart config to real // colours pulled from the CSS design tokens (keeps charts on-palette, R3). function tokenColor(spec, css) { const [name, alpha] = spec.split('/'); const value = css.getPropertyValue('--' + name.trim()).trim(); if (!value) return spec; if (alpha && value.startsWith('#')) { let hex = value.slice(1); if (hex.length === 3) hex = hex.split('').map((c) => c + c).join(''); const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } return value; } function resolveTokens(node, css) { if (typeof node === 'string') return node.startsWith('token:') ? tokenColor(node.slice(6), css) : node; if (Array.isArray(node)) return node.map((n) => resolveTokens(n, css)); if (node && typeof node === 'object') { const out = {}; for (const key of Object.keys(node)) out[key] = resolveTokens(node[key], css); return out; } return node; } // Turn any resolved colour (#hex, rgb(), rgba()) into an rgba() with the given alpha. function toRgba(color, alpha) { if (typeof color !== 'string') return color; if (color.startsWith('#')) { let hex = color.slice(1); if (hex.length === 3) hex = hex.split('').map((c) => c + c).join(''); const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } const m = color.match(/rgba?\(([^)]+)\)/); if (m) { const [r, g, b] = m[1].split(',').map((n) => n.trim()); return `rgba(${r}, ${g}, ${b}, ${alpha})`; } return color; } // Every filled line dataset gets a vertical gradient from its line colour to // transparent — the "colour fades to nothing under the line" look (R3/token-driven). function applyLineGradients(cfg) { const isLine = (ds) => (ds.type || cfg.type) === 'line'; for (const ds of cfg.data?.datasets ?? []) { if (!isLine(ds) || !ds.fill) continue; const base = Array.isArray(ds.borderColor) ? ds.borderColor[0] : ds.borderColor; if (!base) continue; ds.backgroundColor = (ctx) => { const { chart } = ctx; const area = chart.chartArea; if (!area) return toRgba(base, 0.18); // first paint before layout const g = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom); g.addColorStop(0, toRgba(base, 0.32)); g.addColorStop(1, toRgba(base, 0)); return g; }; } } document.addEventListener('alpine:init', () => { // ── VPN config: copy / download ────────────────────────────────────── // navigator.clipboard only exists in a secure context, so over plain http // (the panel reached by IP) the copy button silently did nothing. Falls // back to the legacy selection copy, and says so when even that fails. window.Alpine.data('vpnConfigActions', (filename) => ({ copied: false, failed: false, text() { return this.$refs.config.textContent; }, async copy() { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(this.text()); } else { const area = document.createElement('textarea'); area.value = this.text(); area.setAttribute('readonly', ''); area.style.position = 'fixed'; area.style.opacity = '0'; document.body.appendChild(area); area.select(); const ok = document.execCommand('copy'); document.body.removeChild(area); if (!ok) throw new Error('copy rejected'); } this.failed = false; this.copied = true; setTimeout(() => (this.copied = false), 2000); } catch (e) { this.copied = false; this.failed = true; setTimeout(() => (this.failed = false), 6000); } }, download() { // Built in the browser from what is already on the page: the private // key never has to travel a second time, and no route has to exist. const url = URL.createObjectURL(new Blob([this.text()], { type: 'text/plain' })); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(url), 1000); }, })); // ── Segmented OTP input ────────────────────────────────────────────── window.Alpine.data('otpInput', ({ length = 6 } = {}) => ({ length, digits: Array(length).fill(''), get value() { return this.digits.join(''); }, cells() { return this.$root.querySelectorAll('input[inputmode="numeric"]'); }, focusCell(i) { const cell = this.cells()[i]; if (cell) cell.focus(); }, onInput(i, e) { const digit = e.target.value.replace(/\D/g, '').slice(-1); this.digits[i] = digit; if (digit && i < this.length - 1) this.focusCell(i + 1); this.maybeSubmit(); }, onBackspace(i) { if (!this.digits[i] && i > 0) this.focusCell(i - 1); }, onPaste(e) { const text = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, this.length); for (let i = 0; i < this.length; i++) this.digits[i] = text[i] || ''; this.focusCell(Math.min(text.length, this.length - 1)); this.maybeSubmit(); }, maybeSubmit() { if (this.value.length === this.length) { this.$nextTick(() => this.$root.closest('form')?.requestSubmit()); } }, })); // ── Chart.js island ────────────────────────────────────────────────── window.Alpine.data('chart', (config) => ({ instance: null, init() { // Read tokens from this element so charts honour a scoped theme // (e.g. .theme-admin dark tokens), not just :root. const css = getComputedStyle(this.$el); Chart.defaults.font.family = css.getPropertyValue('--font-sans').trim() || 'sans-serif'; Chart.defaults.color = css.getPropertyValue('--text-muted').trim() || '#667085'; Chart.defaults.borderColor = css.getPropertyValue('--border').trim() || '#e4e7ec'; const cfg = resolveTokens(config, css); applyLineGradients(cfg); cfg.options = { responsive: true, maintainAspectRatio: false, animation: prefersReducedMotion() ? false : cfg.options?.animation, ...cfg.options, }; this.instance = new Chart(this.$refs.canvas, cfg); }, destroy() { this.instance?.destroy(); }, })); });