208 lines
8.5 KiB
JavaScript
208 lines
8.5 KiB
JavaScript
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;
|
|
};
|
|
}
|
|
}
|
|
|
|
// A page that was open across a deploy carries a Livewire snapshot and a CSRF
|
|
// token the new code no longer accepts. The user sees "419 Page Expired" and a
|
|
// dead interface; the session itself is still fine, so a reload fixes it.
|
|
//
|
|
// Hooked at the network layer rather than through Livewire's request hook: the
|
|
// hook's failure callback is not invoked for this case in every version, and a
|
|
// silent no-op here is exactly the bug we are trying to remove.
|
|
const originalFetch = window.fetch.bind(window);
|
|
window.fetch = async (...args) => {
|
|
const response = await originalFetch(...args);
|
|
|
|
if (response.status === 419) {
|
|
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
|
|
if (url.includes('/livewire/')) {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
return response;
|
|
};
|
|
|
|
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();
|
|
},
|
|
}));
|
|
});
|