aboutsummaryrefslogtreecommitdiffstats
path: root/assets/js/pages/admin.js
diff options
context:
space:
mode:
authorsillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
committersillylaird <sillyfanboy@gmail.com>2026-09-03 00:33:59 +0000
commit898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch)
tree85c6ee5ad58b860144551184d4cf86b560c62b91 /assets/js/pages/admin.js
downloadwww-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.tar.gz
www-898b52edcb47bcb3e9d6106e74ca73e74ea01e70.zip
import live www.sillylaird.ca webrootHEADmain
Diffstat (limited to '')
-rw-r--r--assets/js/pages/admin.js714
1 files changed, 714 insertions, 0 deletions
diff --git a/assets/js/pages/admin.js b/assets/js/pages/admin.js
new file mode 100644
index 0000000..076c5e9
--- /dev/null
+++ b/assets/js/pages/admin.js
@@ -0,0 +1,714 @@
+/* Unified admin panel client behaviour.
+ * Lives in an external file because the site's enforced CSP (nginx) uses
+ * script-src 'self' with no 'unsafe-inline' — inline <script> would be blocked.
+ */
+(function () {
+ // ---- Blog: markdown toolbar + live preview ----
+ var ed = document.getElementById('md-editor');
+ var pv = document.getElementById('md-preview');
+ if (ed) {
+ var esc = function (s) {
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ };
+ var render = function (t) {
+ return esc(t)
+ .replace(/```([\s\S]*?)```/g, function (_, c) { return '<pre><code>' + c + '</code></pre>'; })
+ .replace(/^### (.*)$/gm, '<h3>$1</h3>')
+ .replace(/^## (.*)$/gm, '<h2>$1</h2>')
+ .replace(/^# (.*)$/gm, '<h1>$1</h1>')
+ .replace(/^&gt; (.*)$/gm, '<blockquote>$1</blockquote>')
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
+ .replace(/\*([^*]+)\*/g, '<i>$1</i>')
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%">')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
+ .replace(/\n/g, '<br>');
+ };
+ var upd = function () {
+ if (pv) pv.innerHTML = ed.value.trim() ? render(ed.value) : '<em class="muted">Type to preview…</em>';
+ };
+ ed.addEventListener('input', upd);
+ upd();
+
+ document.querySelectorAll('.md-toolbar button[data-md]').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var mode = btn.dataset.md, a = btn.dataset.a || '', b = btn.dataset.b || '';
+ var s = ed.selectionStart, e = ed.selectionEnd, v = ed.value;
+ if (mode === 'wrap') {
+ ed.setRangeText(a + v.substring(s, e) + b, s, e, 'end');
+ } else if (mode === 'line') {
+ var ls = s; while (ls > 0 && v[ls - 1] !== '\n') ls--;
+ ed.setRangeText(a, ls, ls, 'end');
+ } else {
+ ed.setRangeText(a, s, e, 'end');
+ }
+ ed.dispatchEvent(new Event('input'));
+ ed.focus();
+ });
+ });
+ }
+
+ // ---- MOTD: live XMPP preview (emoji / heading / body / url) ----
+ var motdPreview = document.getElementById('motd-live-preview');
+ if (motdPreview) {
+ var motdVal = function (id) {
+ var el = document.getElementById(id);
+ return el ? String(el.value || '').trim() : '';
+ };
+ var motdPaint = function () {
+ var emoji = motdVal('motd-emoji');
+ var heading = motdVal('motd-heading');
+ var body = motdVal('motd-body');
+ var url = motdVal('motd-url');
+ var head = [emoji, heading].filter(Boolean).join(' ');
+ var lines = [];
+ if (head) lines.push(head);
+ if (body) lines.push(body);
+ if (url) lines.push(url);
+ motdPreview.textContent = lines.join('\n') || '// fill in a body';
+ };
+ ['motd-emoji', 'motd-heading', 'motd-body', 'motd-url'].forEach(function (id) {
+ var el = document.getElementById(id);
+ if (el) el.addEventListener('input', motdPaint);
+ });
+ motdPaint();
+ }
+
+ // ---- Vibe: live "detected type" badge ----
+ var url = document.getElementById('v-url');
+ var badge = document.getElementById('v-type');
+ if (url && badge) {
+ var detect = function (u) {
+ if (!u) return 'none';
+ if (/\.(jpe?g|png|gif|webp|bmp|svg|avif)(\?|$)/i.test(u)) return 'image';
+ if (/\.(mp4|webm|ogv|mov)(\?|$)/i.test(u)) return 'video';
+ if (/\.(mp3|flac|wav|ogg|aac|opus|m4a)(\?|$)/i.test(u)) return 'audio';
+ if (/(youtube\.com\/watch\?.*v=|youtu\.be\/|youtube\.com\/shorts\/)[\w-]{11}/.test(u)) return 'youtube';
+ if (/vimeo\.com\/(video\/)?\d+/.test(u)) return 'vimeo';
+ if (/(dailymotion\.com\/video|dai\.ly)\/[\w]+/i.test(u)) return 'dailymotion';
+ if (/bilibili\.com\/video\/(BV[\w]+|av\d+)/i.test(u)) return 'bilibili';
+ if (/(nicovideo\.jp\/watch|nico\.ms)\/(sm|nm|so)?\d+/i.test(u)) return 'niconico';
+ return 'link';
+ };
+ url.addEventListener('input', function () { badge.textContent = detect(url.value.trim()); });
+ }
+
+ // ---- Traffic analytics charts + live numbers (vanilla canvas, no CDN) ----
+ (function initTrafficLive() {
+ var liveRoot = document.getElementById('traffic-live-root');
+ var chartRoot = document.getElementById('traffic-analytics');
+ var dataEl = document.getElementById('traffic-analytics-data');
+ // Need at least KPI root or charts to run live updates
+ if (!liveRoot && !chartRoot) return;
+
+ var data = {};
+ if (dataEl) {
+ try {
+ data = JSON.parse(dataEl.textContent || '{}');
+ } catch (e) {
+ data = {};
+ }
+ }
+
+ var light = document.body.classList.contains('light-mode');
+ var colors = {
+ bg: light ? '#f4f7fa' : '#050000',
+ grid: light ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.08)',
+ axis: light ? 'rgba(0,0,0,0.45)' : 'rgba(200,200,210,0.55)',
+ hits: light ? '#1a7a12' : '#39ff14',
+ hitsFill: light ? 'rgba(26,122,18,0.15)' : 'rgba(57,255,20,0.12)',
+ uniq: light ? '#1a4f9c' : '#5b9dff',
+ uniqFill: light ? 'rgba(26,79,156,0.12)' : 'rgba(91,157,255,0.12)',
+ bar: light ? '#1a7a12' : '#39ff14',
+ barAlt: light ? '#1a4f9c' : '#5b9dff',
+ bot: light ? '#a65c00' : '#ff9f1a',
+ botFill: light ? 'rgba(166,92,0,0.12)' : 'rgba(255,159,26,0.14)',
+ botAlt: light ? '#8a3d00' : '#ff6b35',
+ text: light ? 'rgba(20,20,30,0.75)' : 'rgba(200,200,210,0.8)',
+ muted: light ? 'rgba(20,20,30,0.45)' : 'rgba(160,160,170,0.7)',
+ };
+
+ function setupCanvas(canvas) {
+ if (!canvas) return null;
+ var dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
+ var rect = canvas.getBoundingClientRect();
+ var cssW = Math.max(280, Math.floor(rect.width || canvas.width || 600));
+ var cssH = Math.max(160, Math.floor(canvas.height * (cssW / (canvas.width || cssW))));
+ // Prefer intrinsic aspect from attributes
+ var attrW = parseInt(canvas.getAttribute('width'), 10) || 600;
+ var attrH = parseInt(canvas.getAttribute('height'), 10) || 240;
+ cssH = Math.round(cssW * (attrH / attrW));
+ canvas.style.width = cssW + 'px';
+ canvas.style.height = cssH + 'px';
+ canvas.width = Math.floor(cssW * dpr);
+ canvas.height = Math.floor(cssH * dpr);
+ var ctx = canvas.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ return { ctx: ctx, w: cssW, h: cssH };
+ }
+
+ function maxOf(arr) {
+ var m = 0;
+ for (var i = 0; i < arr.length; i++) if (arr[i] > m) m = arr[i];
+ return m || 1;
+ }
+
+ function niceMax(n) {
+ if (n <= 1) return 1;
+ var p = Math.pow(10, Math.floor(Math.log10(n)));
+ var m = n / p;
+ var nice = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10;
+ return nice * p;
+ }
+
+ function drawEmpty(ctx, w, h, msg) {
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = colors.muted;
+ ctx.font = '12px ui-monospace, Menlo, Consolas, monospace';
+ ctx.textAlign = 'center';
+ ctx.fillText(msg || 'No data yet', w / 2, h / 2);
+ }
+
+ /** Multi-series line chart */
+ function drawLineChart(canvas, series, labels, opts) {
+ var s = setupCanvas(canvas);
+ if (!s) return;
+ var ctx = s.ctx, w = s.w, h = s.h;
+ var pad = { t: 16, r: 12, b: 36, l: 40 };
+ var plotW = w - pad.l - pad.r;
+ var plotH = h - pad.t - pad.b;
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, w, h);
+
+ var all = [];
+ series.forEach(function (ser) {
+ ser.values.forEach(function (v) { all.push(v); });
+ });
+ if (!labels.length || maxOf(all) === 0) {
+ drawEmpty(ctx, w, h, 'No hits in this range yet');
+ return;
+ }
+ var yMax = niceMax(maxOf(all));
+ var n = labels.length;
+ var xAt = function (i) {
+ return pad.l + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW);
+ };
+ var yAt = function (v) {
+ return pad.t + plotH - (v / yMax) * plotH;
+ };
+
+ // grid
+ ctx.strokeStyle = colors.grid;
+ ctx.lineWidth = 1;
+ ctx.fillStyle = colors.axis;
+ ctx.font = '10px ui-monospace, Menlo, Consolas, monospace';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ var ticks = 4;
+ for (var t = 0; t <= ticks; t++) {
+ var val = Math.round((yMax * t) / ticks);
+ var y = yAt(val);
+ ctx.beginPath();
+ ctx.moveTo(pad.l, y);
+ ctx.lineTo(pad.l + plotW, y);
+ ctx.stroke();
+ ctx.fillText(String(val), pad.l - 6, y);
+ }
+
+ // x labels (sparse)
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = colors.axis;
+ var step = Math.max(1, Math.ceil(n / 8));
+ for (var i = 0; i < n; i += step) {
+ var lab = labels[i];
+ if (opts && opts.labelFmt) lab = opts.labelFmt(lab);
+ ctx.fillText(lab, xAt(i), pad.t + plotH + 8);
+ }
+
+ series.forEach(function (ser) {
+ // fill
+ if (ser.fill) {
+ ctx.beginPath();
+ ctx.moveTo(xAt(0), yAt(0));
+ for (var j = 0; j < ser.values.length; j++) {
+ ctx.lineTo(xAt(j), yAt(ser.values[j]));
+ }
+ ctx.lineTo(xAt(ser.values.length - 1), yAt(0));
+ ctx.closePath();
+ ctx.fillStyle = ser.fill;
+ ctx.fill();
+ }
+ // line
+ ctx.beginPath();
+ for (var k = 0; k < ser.values.length; k++) {
+ var x = xAt(k), y = yAt(ser.values[k]);
+ if (k === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ }
+ ctx.strokeStyle = ser.color;
+ ctx.lineWidth = 2;
+ ctx.lineJoin = 'round';
+ ctx.stroke();
+ // dots on last points / sparse
+ ctx.fillStyle = ser.color;
+ for (var d = 0; d < ser.values.length; d++) {
+ if (ser.values[d] <= 0 && d !== ser.values.length - 1) continue;
+ if (d % Math.max(1, Math.floor(n / 12)) !== 0 && d !== n - 1) continue;
+ ctx.beginPath();
+ ctx.arc(xAt(d), yAt(ser.values[d]), 2.5, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ });
+ }
+
+ /**
+ * Vertical bar chart.
+ * values: number[] (single series) OR { human: number[], bots: number[] }
+ * Bots draw in orange beside human bars.
+ */
+ function drawBarChart(canvas, values, labels, opts) {
+ var s = setupCanvas(canvas);
+ if (!s) return;
+ var ctx = s.ctx, w = s.w, h = s.h;
+ var pad = { t: 16, r: 12, b: 36, l: 40 };
+ var plotW = w - pad.l - pad.r;
+ var plotH = h - pad.t - pad.b;
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, w, h);
+
+ var human = Array.isArray(values) ? values : (values.human || []);
+ var bots = Array.isArray(values) ? null : (values.bots || null);
+ var dual = !!(bots && bots.length === human.length);
+ var all = human.slice();
+ if (dual) bots.forEach(function (v) { all.push(v); });
+
+ if (!human.length || maxOf(all) === 0) {
+ drawEmpty(ctx, w, h, 'No hits in this range yet');
+ return;
+ }
+ var yMax = niceMax(maxOf(all));
+ var n = human.length;
+ var gap = 2;
+ var groupW = plotW / n;
+ var barW = dual
+ ? Math.max(2, (groupW - gap) / 2 - 1)
+ : Math.max(2, groupW - gap);
+
+ ctx.strokeStyle = colors.grid;
+ ctx.fillStyle = colors.axis;
+ ctx.font = '10px ui-monospace, Menlo, Consolas, monospace';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ for (var t = 0; t <= 4; t++) {
+ var val = Math.round((yMax * t) / 4);
+ var y = pad.t + plotH - (val / yMax) * plotH;
+ ctx.beginPath();
+ ctx.moveTo(pad.l, y);
+ ctx.lineTo(pad.l + plotW, y);
+ ctx.stroke();
+ ctx.fillText(String(val), pad.l - 6, y);
+ }
+
+ for (var i = 0; i < n; i++) {
+ var gx = pad.l + i * groupW + gap / 2;
+ // Human (green)
+ var bh = (human[i] / yMax) * plotH;
+ var hy = pad.t + plotH - bh;
+ ctx.fillStyle = colors.bar;
+ ctx.globalAlpha = human[i] > 0 ? 0.9 : 0.15;
+ ctx.fillRect(gx, hy, barW, Math.max(bh, human[i] > 0 ? 1 : 0));
+ // Bots (orange) — separate colour, not mixed into human bars
+ if (dual) {
+ var bb = (bots[i] / yMax) * plotH;
+ var by = pad.t + plotH - bb;
+ ctx.fillStyle = colors.bot;
+ ctx.globalAlpha = bots[i] > 0 ? 0.9 : 0.15;
+ ctx.fillRect(gx + barW + 1, by, barW, Math.max(bb, bots[i] > 0 ? 1 : 0));
+ }
+ ctx.globalAlpha = 1;
+ }
+
+ ctx.fillStyle = colors.axis;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ var step = Math.max(1, Math.ceil(n / 8));
+ for (var j = 0; j < n; j += step) {
+ var lab = labels[j] || '';
+ if (opts && opts.labelFmt) lab = opts.labelFmt(lab);
+ var lx = pad.l + j * groupW + groupW / 2;
+ ctx.fillText(lab, lx, pad.t + plotH + 8);
+ }
+ }
+
+ /** Horizontal bar chart. opts.palette: 'bot' uses orange bot colors. */
+ function drawHBarChart(canvas, items, opts) {
+ var s = setupCanvas(canvas);
+ if (!s) return;
+ var ctx = s.ctx, w = s.w, h = s.h;
+ ctx.fillStyle = colors.bg;
+ ctx.fillRect(0, 0, w, h);
+ if (!items || !items.length) {
+ drawEmpty(ctx, w, h, 'No data yet');
+ return;
+ }
+ var pad = { t: 8, r: 48, b: 8, l: 8 };
+ var rowH = Math.min(28, (h - pad.t - pad.b) / items.length);
+ var maxLabel = 0;
+ ctx.font = '11px ui-monospace, Menlo, Consolas, monospace';
+ items.forEach(function (it) {
+ var lab = truncate(it.label, 36);
+ maxLabel = Math.max(maxLabel, ctx.measureText(lab).width);
+ });
+ var labelW = Math.min(Math.max(80, maxLabel + 8), w * 0.45);
+ var barLeft = pad.l + labelW + 8;
+ var barMaxW = w - barLeft - pad.r;
+ var maxC = maxOf(items.map(function (it) { return it.count; }));
+ maxC = niceMax(maxC);
+ var cA = (opts && opts.palette === 'bot') ? colors.bot : colors.bar;
+ var cB = (opts && opts.palette === 'bot') ? colors.botAlt : colors.barAlt;
+
+ items.forEach(function (it, i) {
+ var y = pad.t + i * rowH + rowH * 0.15;
+ var bh = rowH * 0.7;
+ var lab = truncate(it.label, 36);
+ ctx.fillStyle = colors.text;
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ ctx.font = '11px ui-monospace, Menlo, Consolas, monospace';
+ ctx.fillText(lab, pad.l + labelW, y + bh / 2);
+
+ var bw = maxC ? (it.count / maxC) * barMaxW : 0;
+ ctx.fillStyle = i % 2 === 0 ? cA : cB;
+ ctx.globalAlpha = 0.85;
+ ctx.fillRect(barLeft, y, Math.max(bw, it.count > 0 ? 2 : 0), bh);
+ ctx.globalAlpha = 1;
+
+ ctx.fillStyle = colors.muted;
+ ctx.textAlign = 'left';
+ ctx.fillText(String(it.count), barLeft + bw + 6, y + bh / 2);
+ });
+ }
+
+ function truncate(s, n) {
+ s = String(s || '');
+ if (s.length <= n) return s;
+ return s.slice(0, n - 1) + '…';
+ }
+
+ function esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;');
+ }
+
+ function pad2(n) {
+ n = String(n);
+ return n.length < 2 ? '0' + n : n;
+ }
+
+ function fmtUtc(ts, withSec) {
+ if (!ts) return '—';
+ var d = new Date(ts * 1000);
+ var s =
+ d.getUTCFullYear() +
+ '-' + pad2(d.getUTCMonth() + 1) +
+ '-' + pad2(d.getUTCDate()) +
+ ' ' + pad2(d.getUTCHours()) +
+ ':' + pad2(d.getUTCMinutes());
+ if (withSec) s += ':' + pad2(d.getUTCSeconds());
+ return s + ' UTC';
+ }
+
+ function drawAllCharts(analytics) {
+ analytics = analytics || {};
+ var daily = analytics.daily || [];
+ var dailySeries = [
+ { values: daily.map(function (d) { return d.hits; }), color: colors.hits, fill: colors.hitsFill },
+ { values: daily.map(function (d) { return d.unique; }), color: colors.uniq, fill: colors.uniqFill },
+ { values: daily.map(function (d) { return d.bots || 0; }), color: colors.bot, fill: colors.botFill },
+ ];
+ drawLineChart(
+ document.getElementById('chart-daily'),
+ dailySeries,
+ daily.map(function (d) { return d.day; }),
+ { labelFmt: function (d) { return d.slice(5); } }
+ );
+
+ var hourly = analytics.hourly || [];
+ drawBarChart(
+ document.getElementById('chart-hourly'),
+ {
+ human: hourly.map(function (d) { return d.hits; }),
+ bots: hourly.map(function (d) { return d.bots || 0; }),
+ },
+ hourly.map(function (d) { return d.hour; }),
+ { labelFmt: function (h) { return h.slice(11, 13) + 'h'; } }
+ );
+
+ drawHBarChart(document.getElementById('chart-pages'), analytics.top_pages || []);
+ drawHBarChart(document.getElementById('chart-visitors'), analytics.visitors || []);
+ drawHBarChart(document.getElementById('chart-bots'), analytics.bots || [], { palette: 'bot' });
+ drawHBarChart(document.getElementById('chart-hosts'), analytics.hosts || []);
+ drawHBarChart(document.getElementById('chart-langs'), analytics.langs || []);
+ drawHBarChart(document.getElementById('chart-refs'), analytics.refs || []);
+ }
+
+ // Animated count-up for KPI big numbers
+ var kpiRaf = {};
+ function animateKpi(el, from, to) {
+ var key = el.getAttribute('data-kpi') || el.id || 'x';
+ if (kpiRaf[key]) cancelAnimationFrame(kpiRaf[key]);
+ from = Math.round(Number(from) || 0);
+ to = Math.round(Number(to) || 0);
+ if (from === to) {
+ el.textContent = String(to);
+ el.setAttribute('data-value', String(to));
+ return;
+ }
+ var start = performance.now();
+ var dur = Math.min(700, 180 + Math.abs(to - from) * 12);
+ function frame(now) {
+ var t = Math.min(1, (now - start) / dur);
+ // ease-out cubic
+ var e = 1 - Math.pow(1 - t, 3);
+ var v = Math.round(from + (to - from) * e);
+ el.textContent = String(v);
+ if (t < 1) {
+ kpiRaf[key] = requestAnimationFrame(frame);
+ } else {
+ el.textContent = String(to);
+ el.setAttribute('data-value', String(to));
+ delete kpiRaf[key];
+ }
+ }
+ kpiRaf[key] = requestAnimationFrame(frame);
+ }
+
+ function updateKpis(summary, opts) {
+ if (!summary) return;
+ var animate = !opts || opts.animate !== false;
+ var keys = [
+ 'total_hits', 'unique_sessions', 'today', 'week',
+ 'bot_hits', 'bot_today', 'bot_week', 'total_pages',
+ ];
+ keys.forEach(function (k) {
+ if (summary[k] == null) return;
+ var el = document.querySelector('[data-kpi="' + k + '"]');
+ if (!el) return;
+ var next = Math.round(Number(summary[k]) || 0);
+ var prev = parseInt(el.getAttribute('data-value') || el.textContent || '0', 10) || 0;
+ if (prev === next && el.textContent === String(next)) return;
+ el.classList.remove('traffic-kpi-flash');
+ void el.offsetWidth;
+ el.classList.add('traffic-kpi-flash');
+ if (animate && Math.abs(next - prev) < 5000) {
+ animateKpi(el, prev, next);
+ } else {
+ el.textContent = String(next);
+ el.setAttribute('data-value', String(next));
+ }
+ });
+ }
+
+ function updateTopTable(rows) {
+ var body = document.getElementById('traffic-top-body');
+ var table = document.getElementById('traffic-top-table');
+ var empty = document.getElementById('traffic-top-empty');
+ if (!body || !table) return;
+ rows = rows || [];
+ if (!rows.length) {
+ body.innerHTML = '';
+ table.hidden = true;
+ if (empty) empty.hidden = false;
+ return;
+ }
+ if (empty) empty.hidden = true;
+ table.hidden = false;
+ var html = '';
+ for (var i = 0; i < rows.length; i++) {
+ var p = rows[i];
+ html +=
+ '<tr>' +
+ '<td><code>' + esc((p.host || '') + (p.path || '')) + '</code></td>' +
+ '<td>' + (p.hits | 0) + '</td>' +
+ '<td>' + (p.unique_sessions | 0) + '</td>' +
+ '<td>' + (p.bot_hits | 0) + '</td>' +
+ '<td>' + (p.last_local_count | 0) + '</td>' +
+ '<td>' + esc(p.last_visitor || '—') + '</td>' +
+ '<td>' + esc(p.last_hit ? fmtUtc(p.last_hit, false) : '—') + '</td>' +
+ '</tr>';
+ }
+ body.innerHTML = html;
+ }
+
+ function updateRecentTable(rows) {
+ var body = document.getElementById('traffic-recent-body');
+ var table = document.getElementById('traffic-recent-table');
+ var empty = document.getElementById('traffic-recent-empty');
+ if (!body || !table) return;
+ rows = rows || [];
+ if (!rows.length) {
+ body.innerHTML = '';
+ table.hidden = true;
+ if (empty) empty.hidden = false;
+ return;
+ }
+ if (empty) empty.hidden = true;
+ table.hidden = false;
+ var html = '';
+ for (var i = 0; i < rows.length; i++) {
+ var r = rows[i];
+ var isBot = !!(r.is_bot | 0);
+ html +=
+ '<tr class="' + (isBot ? 'traffic-row--bot' : '') + '">' +
+ '<td>' + esc(fmtUtc(r.ts, true)) + '</td>' +
+ '<td>' + esc(r.visitor || 'Unknown visitor') + '</td>' +
+ '<td>' +
+ (isBot
+ ? '<span class="type-badge traffic-badge--bot">bot</span>'
+ : '<span class="type-badge">human</span>') +
+ '</td>' +
+ '<td><code>' + esc((r.host || '') + (r.path || '')) + '</code></td>' +
+ '<td>' + (r.local_count | 0) + '</td>' +
+ '<td>' + esc(r.lang || '—') + '</td>' +
+ '<td>' + esc(r.ref_host || '—') + '</td>' +
+ '<td>' + (!isBot && (r.is_new | 0) ? 'yes' : '') + '</td>' +
+ '</tr>';
+ }
+ body.innerHTML = html;
+ }
+
+ function setLiveStatus(kind, msg) {
+ var bar = document.getElementById('traffic-live-bar');
+ var status = document.getElementById('traffic-live-status');
+ var ago = document.getElementById('traffic-live-ago');
+ if (bar) {
+ bar.classList.toggle('traffic-live-bar--err', kind === 'err');
+ bar.classList.toggle('traffic-live-bar--ok', kind === 'ok');
+ }
+ if (status) status.textContent = kind === 'err' ? 'Offline' : 'Live';
+ if (ago) ago.textContent = msg ? '· ' + msg : '';
+ }
+
+ // Initial paint from server-rendered payload
+ if (chartRoot && dataEl) {
+ drawAllCharts(data);
+ chartRoot.setAttribute('data-ready', '1');
+ }
+
+ // Redraw on resize (debounced)
+ var resizeT = null;
+ var lastAnalytics = data;
+ window.addEventListener('resize', function () {
+ clearTimeout(resizeT);
+ resizeT = setTimeout(function () {
+ if (chartRoot) drawAllCharts(lastAnalytics);
+ }, 150);
+ });
+
+ // ---- Live polling: numbers + charts + tables ----
+ var pollHost = liveRoot || chartRoot;
+ var pollUrl = (pollHost && pollHost.getAttribute('data-poll')) || '/api/traffic-stats.php';
+ var pollMs = parseInt((pollHost && pollHost.getAttribute('data-poll-ms')) || '2000', 10) || 2000;
+ pollMs = Math.max(1500, Math.min(30000, pollMs));
+ var lastRev = '';
+ var lastOkAt = 0;
+ var pollTimer = null;
+ var inFlight = false;
+ var aborted = false;
+
+ function agoLabel(ts) {
+ if (!ts) return 'waiting…';
+ var sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
+ if (sec < 2) return 'just now';
+ if (sec < 60) return sec + 's ago';
+ return Math.floor(sec / 60) + 'm ago';
+ }
+
+ function tickAgo() {
+ if (lastOkAt) setLiveStatus('ok', 'updated ' + agoLabel(lastOkAt));
+ }
+
+ function applyPayload(payload, force) {
+ if (!payload || !payload.ok) return;
+ lastOkAt = Date.now();
+ setLiveStatus('ok', 'updated ' + agoLabel(lastOkAt));
+
+ var changed = force || !payload.rev || payload.rev !== lastRev;
+ if (payload.rev) lastRev = payload.rev;
+ if (!changed) return;
+
+ // Numbers first so KPIs feel instant
+ if (payload.summary) updateKpis(payload.summary, { animate: true });
+ if (payload.analytics) {
+ lastAnalytics = payload.analytics;
+ if (dataEl) dataEl.textContent = JSON.stringify(payload.analytics);
+ if (chartRoot) drawAllCharts(payload.analytics);
+ }
+ if (payload.top) updateTopTable(payload.top);
+ if (payload.recent) updateRecentTable(payload.recent);
+ }
+
+ function pollOnce() {
+ if (aborted || inFlight || document.hidden) return;
+ inFlight = true;
+ var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
+ var to = setTimeout(function () {
+ if (ctrl) ctrl.abort();
+ }, 12000);
+ fetch(pollUrl + (pollUrl.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now(), {
+ method: 'GET',
+ credentials: 'same-origin',
+ cache: 'no-store',
+ headers: { Accept: 'application/json' },
+ signal: ctrl ? ctrl.signal : undefined,
+ })
+ .then(function (res) {
+ if (res.status === 401) {
+ setLiveStatus('err', 'session expired — reload');
+ aborted = true;
+ return null;
+ }
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.json();
+ })
+ .then(function (json) {
+ if (!json) return;
+ if (!json.ok) throw new Error(json.err || 'bad response');
+ applyPayload(json, false);
+ })
+ .catch(function () {
+ if (!aborted) setLiveStatus('err', 'retrying…');
+ })
+ .finally(function () {
+ clearTimeout(to);
+ inFlight = false;
+ });
+ }
+
+ function schedulePoll() {
+ clearTimeout(pollTimer);
+ if (aborted) return;
+ pollTimer = setTimeout(function () {
+ pollOnce();
+ schedulePoll();
+ }, pollMs);
+ }
+
+ // Immediate first poll so numbers move as soon as hits land
+ setTimeout(pollOnce, 400);
+ schedulePoll();
+ setInterval(tickAgo, 1000);
+
+ document.addEventListener('visibilitychange', function () {
+ if (!document.hidden && !aborted) {
+ pollOnce();
+ }
+ });
+ })();
+
+})();