diff options
| author | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
|---|---|---|
| committer | sillylaird <sillyfanboy@gmail.com> | 2026-09-03 00:33:59 +0000 |
| commit | 898b52edcb47bcb3e9d6106e74ca73e74ea01e70 (patch) | |
| tree | 85c6ee5ad58b860144551184d4cf86b560c62b91 /assets/js/pages/admin.js | |
| download | www-main.tar.gz www-main.zip | |
Diffstat (limited to 'assets/js/pages/admin.js')
| -rw-r--r-- | assets/js/pages/admin.js | 714 |
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, '&').replace(/</g, '<').replace(/>/g, '>'); + }; + 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(/^> (.*)$/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, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); + } + + 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(); + } + }); + })(); + +})(); |
