Personal comment analytics dashboard — only activates on your own profile
// ==UserScript==
// @name Imgur Comment Analytics Dashboard
// @namespace http://tampermonkey.net/
// @version 2026-06-08
// @description Personal comment analytics dashboard — only activates on your own profile
// @author You
// @match https://imgur.com/user/*/comments
// @icon https://www.google.com/s2/favicons?sz=64&domain=imgur.com
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(async () => {
'use strict';
// ═══════════════════════════════════════════════════════════
// CONFIG — locks to one username, prompts on first run
// ═══════════════════════════════════════════════════════════
const USERNAME = (window.location.pathname.match(/\/user\/([^\/]+)/) || [])[1];
if (!USERNAME) return;
// Load stored username (GM storage survives reinstalls unlike localStorage)
let MY_USERNAME = GM_getValue('imgurDashUser', '');
if (!MY_USERNAME) {
// First run — ask which account to track
const input = prompt(
`Imgur Analytics Dashboard\n\nEnter your Imgur username to activate the dashboard.\n` +
`(Currently viewing: ${USERNAME})\n\nLeave blank to use the current page's username.`
);
if (input === null) return; // user cancelled
MY_USERNAME = (input.trim() || USERNAME).toLowerCase();
GM_setValue('imgurDashUser', MY_USERNAME);
}
// Only run on the configured user's page
if (USERNAME.toLowerCase() !== MY_USERNAME) return;
// Auth — uses client ID from Imgur's page state if available, otherwise prompts once
const _state = window.imgur?._sharedState?.apiClient
|| window.Imgur?._sharedState?.apiClient
|| {};
const CRED_KEY = 'imgurDashClientId';
const CLIENT_ID = _state.clientId
|| GM_getValue(CRED_KEY, '')
|| '546c25a59c58ad7';
const ACCESS_TOKEN = _state.accessToken || null;
// OAuth — needed for private/hidden posts and images
const OAUTH_CLIENT_ID = CLIENT_ID || '';
const OAUTH_REDIRECT_URI = "https://imgur.com/";
const OAUTH_TOKEN_KEY = "imgurOAuthToken";
const OAUTH_EXPIRY_KEY = "imgurOAuthExpiry";
const DB_NAME = "ImgurAnalyticsStorage";
const STORE_COMMENTS = "comments";
const STORE_POSTS = "posts";
const FETCH_DELAY = 600; // ms between comment/post page fetches
const BATCH_SIZE = 16; // media cards per scroll batch (DOM only, no requests)
const PARALLEL = 2; // concurrent score refresh requests (keep low — Imgur rate limits hard)
const PARALLEL_DELAY = 500; // ms between score refresh batches (2 req/500ms = 4 req/s, well under limit)
const URL_RE = /https?:\/\/[^\s$.?#].[^\s]*/gi;
// Imgur thumbnail suffix — insert before extension to get resized version
// 'm' = 320px, 'l' = 1024px. For video/gifv use jpg poster frame.
function imgurThumb(url, size='lq') {
if (!url) return url;
// Only transform i.imgur.com URLs — leave giphy, external links etc untouched
if (!url.includes('i.imgur.com/')) return url;
// Videos/gifv: use _lq.mp4 variant
if (/\.(gifv|mp4|webm)(\?|$)/i.test(url))
return url.replace(/\.(gifv|mp4|webm)(\?.*)?$/i, `_${size}.mp4`);
// Animated gif: use _lq.mp4
if (/\.gif(\?|$)/i.test(url))
return url.replace(/\.gif(\?.*)?$/i, `_${size}.mp4`);
// Static images: _d with maxwidth query param
return url.replace(/\.(jpeg|jpg|png|webp)(\?.*)?$/i, `_d.$1?maxwidth=520&shape=thumb&fidelity=high`);
}
const IMG_RE = /\.(jpeg|jpg|png|webp)(\?.*)?$/i;
const GIF_RE = /\.(gif|gifv|mp4|webm)(\?.*)?$/i;
const DAY_LABELS = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
const HOUR_LABELS = Array.from({length:24}, (_,i) => `${i}:00`);
const MONTH_LABELS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const URL_NOISE = new Set([
"http","https","www","com","net","org","io","co","imgur","reddit","gif","gifv",
"png","jpg","jpeg","webp","mp4","gif","html","php","the","you","that","and",
"gallery","image","images","post","thread","link","url","cdn","static","media",
"content","upload","uploads","file","files","user","users","account","profile",
]);
const STOPWORDS = new Set(["the","a","and","to","of","is","in","it","i","that","you","he",
"was","for","on","are","as","with","his","they","this","but","be","at","or","an","if",
"not","my","your","from","so","have","just","like","about","there","what","can","out",
"all","get","has","no","one","do","we","up","me","more","would","think"]);
// ═══════════════════════════════════════════════════════════
// INDEXEDDB
// ═══════════════════════════════════════════════════════════
function openDB() {
return new Promise((res, rej) => {
// v2 adds the posts store
const req = indexedDB.open(DB_NAME, 2);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(STORE_COMMENTS))
db.createObjectStore(STORE_COMMENTS, { keyPath: "id" });
if (!db.objectStoreNames.contains(STORE_POSTS))
db.createObjectStore(STORE_POSTS, { keyPath: "id" });
};
req.onsuccess = e => res(e.target.result);
req.onerror = e => rej(e.target.error);
req.onblocked = () => rej(new Error("DB blocked — close other Imgur tabs and reload"));
});
}
function dbGetAll(db, store = STORE_COMMENTS) {
return new Promise((res, rej) => {
const req = db.transaction(store, "readonly").objectStore(store).getAll();
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
});
}
function dbPutAll(db, items, store = STORE_COMMENTS) {
if (!items.length) return Promise.resolve();
return new Promise((res, rej) => {
const tx = db.transaction(store, "readwrite");
const s = tx.objectStore(store);
items.forEach(item => s.put(item));
tx.oncomplete = res;
tx.onerror = () => rej(tx.error);
});
}
function dbClear(db, store = STORE_COMMENTS) {
return new Promise((res, rej) => {
const tx = db.transaction(store, "readwrite");
tx.objectStore(store).clear();
tx.oncomplete = res;
tx.onerror = () => rej(tx.error);
});
}
// ═══════════════════════════════════════════════════════════
// ENRICHMENT
// ═══════════════════════════════════════════════════════════
function classifyMedia(text) {
const urls = text.match(URL_RE) || [];
let hasImg = false, hasGif = false, hasLink = false;
const parsed = urls.map(url => {
if (IMG_RE.test(url)) { hasImg = true; return {type:'img', url}; }
else if (GIF_RE.test(url)||/imgur\.com\/(a|gallery)\//.test(url)) { hasGif = true; return {type:'gif', url}; }
else { hasLink = true; return {type:'link', url}; }
});
const bucket = hasImg && hasGif ? "Mixed Media"
: hasGif ? "GIF/Video"
: hasImg ? "Static Image"
: hasLink ? "Generic URL"
: "Plain Text";
return { urls: parsed, bucket, hasMedia: parsed.length > 0 };
}
function toDate(raw) {
if (!raw) return null;
const d = typeof raw === 'number' ? new Date(raw * 1000) : new Date(raw);
return isNaN(d) ? null : d;
}
function temporalFields(d, fallback = {}) {
if (d) return {
_hour: d.getHours(),
_day: d.getDay(),
_month: d.getMonth(),
_year: d.getFullYear(),
_ymKey: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`,
};
return {
_hour: fallback._hour ?? null,
_day: fallback._day ?? null,
_month: fallback._month ?? null,
_year: fallback._year ?? null,
_ymKey: fallback._ymKey ?? null,
};
}
function enrichComment(c) {
const text = c.comment || c.caption || "";
const words = text.split(/\s+/).filter(Boolean);
const clean = text.toLowerCase().match(/[a-z']+/g) || [];
const pts = c.point_count ?? c.points ?? 0;
const ups = c.upvote_count ?? c.ups ?? 0;
const downs = c.downvote_count ?? c.downs ?? 0;
const d = toDate(c.created_at || c.datetime);
const postId = c.post_id || c.image_id || "";
const { urls: mediaUrls, bucket: mediaBucket, hasMedia } = classifyMedia(text);
const alphaWords = words.filter(w => /[a-zA-Z]/.test(w));
const isAllCaps = alphaWords.length > 0
&& alphaWords.filter(w => w === w.toUpperCase()).length / alphaWords.length >= 0.7;
return {
// DB-persisted fields (raw + stable computed)
id: c.id, post_id: postId, parent_id: c.parent_id ?? null,
text, points: pts, ups, downs,
created_at: c.created_at || c.datetime,
permalink: postId
? `https://imgur.com/gallery/${postId}/comment/${c.id}`
: `https://imgur.com/user/${USERNAME}/comments`,
// NOTE: _words/_cleanWords NOT stored — recomputed on load from text
_pts: pts, _ups: ups, _downs: downs,
_totalVotes: ups + downs,
_wordCount: words.length,
_charCount: text.length,
_uniqueWords: new Set(words.map(w => w.toLowerCase())).size,
_date: d ? d.toISOString() : null,
...temporalFields(d),
_isAllCaps: isAllCaps,
_isUnseen: (ups + downs) <= 1,
// Imgur: top-level comments have parent_id === their own id (or 0 or null/absent)
_isTopLevel: !c.parent_id || c.parent_id === c.id || +c.parent_id === 0,
_mediaUrls: mediaUrls,
_mediaBucket: mediaBucket,
_hasMedia: hasMedia,
};
}
// Rehydrate after loading from DB: restore Date objects + recompute word arrays
function hydrateComment(c) {
const words = (c.text || '').split(/\s+/).filter(Boolean);
const clean = (c.text || '').toLowerCase().match(/[a-z']+/g) || [];
const d = c._date ? new Date(c._date) : null;
// Always recompute media from text — stored bucket names changed across versions
const { urls: mediaUrls, bucket: mediaBucket, hasMedia } = classifyMedia(c.text || '');
return {
...c,
_date: d,
_words: words,
_cleanWords: clean,
...temporalFields(d, c),
_mediaBucket: mediaBucket,
_mediaUrls: mediaUrls,
_hasMedia: hasMedia,
// Recompute — Imgur: top-level = parent_id equals own id, or 0, or absent
_isTopLevel: !c.parent_id || c.parent_id === c.id || +c.parent_id === 0,
};
}
// ─────────────────────────────────────────────
// PRIVATE POST FETCH (requires OAuth)
// ─────────────────────────────────────────────
async function fetchPrivateImagePage(page) {
if (!getStoredToken()) return null;
const res = await fetch(
`https://api.imgur.com/3/account/me/images/${page}`,
{ headers: oauthHeaders() }
);
if (!res.ok) return null;
const { success, data } = await res.json();
return success && Array.isArray(data) && data.length ? data : null;
}
async function fetchPrivateAlbumPage(page) {
if (!getStoredToken()) return null;
const res = await fetch(
`https://api.imgur.com/3/account/me/albums/${page}`,
{ headers: oauthHeaders() }
);
if (!res.ok) return null;
const { success, data } = await res.json();
return success && Array.isArray(data) && data.length ? data : null;
}
// ═══════════════════════════════════════════════════════════
// POST ENRICHMENT
// ═══════════════════════════════════════════════════════════
async function fetchPostPage(page) {
const res = await fetch(
`https://api.imgur.com/3/account/${USERNAME}/submissions/${page}/newest`,
{ headers: apiHeaders() }
);
if (!res.ok) return null;
const { success, data } = await res.json();
return success && Array.isArray(data) && data.length ? data : null;
}
function enrichPost(p) {
const d = toDate(p.datetime);
const title = p.title || '';
const desc = p.description || '';
const pts = p.points ?? p.score ?? 0;
const ups = p.ups ?? 0;
const downs = p.downs ?? 0;
const views = p.views ?? 0;
const isAlbum = !!p.is_album;
const inGallery = !!p.in_gallery;
const privacy = p.privacy || (inGallery ? 'public' : 'hidden');
const nsfw = !!p.nsfw;
const commentCt = p.comment_count ?? 0;
const favoriteCt = p.favorite_count ?? 0;
const tags = (p.tags || []).map(t => t.name || t.display_name || '').filter(Boolean);
// Cover image link — first image if album, direct link otherwise
const images = p.images || (p.link ? [{id: p.id, link: p.link, type: p.type}] : []);
const coverImg = images[0] || null;
const imageCount = images.length;
return {
// Persisted
id: p.id, title, description: desc,
link: p.link || `https://imgur.com/gallery/${p.id}`,
cover_link: coverImg?.link || null,
image_count: imageCount,
is_album: isAlbum, in_gallery: inGallery,
privacy, nsfw,
points: pts, ups, downs, views,
comment_count: commentCt, favorite_count: favoriteCt,
datetime: p.datetime,
tags,
// Computed
_pts: pts,
_ups: ups,
_downs: downs,
_views: views,
_commentCt: commentCt,
_favCt: favoriteCt,
_inGallery: inGallery,
_privacy: privacy, // 'public' | 'hidden' | 'secret'
_nsfw: nsfw,
_isAlbum: isAlbum,
_imageCount: imageCount,
_tags: tags,
_date: d ? d.toISOString() : null,
...temporalFields(d),
_totalVotes: ups + downs,
_engRate: views > 0 ? parseFloat((pts / views * 1000).toFixed(2)) : 0, // pts per 1k views
};
}
function hydratePost(p) {
const d = p._date ? new Date(p._date) : null;
return {
...p,
_date: d, ...temporalFields(d, p),
};
}
// ═══════════════════════════════════════════════════════════
function apiHeaders() {
// Prefer bearer token (full access incl. private) over client-ID (public only)
if (ACCESS_TOKEN) return { Authorization: `Bearer ${ACCESS_TOKEN}` };
return { Authorization: `Client-ID ${CLIENT_ID}` };
}
// ─────────────────────────────────────────────
// OAUTH HELPERS
// ─────────────────────────────────────────────
function getStoredToken() {
const token = GM_getValue(OAUTH_TOKEN_KEY, '');
const expiry = GM_getValue(OAUTH_EXPIRY_KEY, 0);
if (!token || Date.now() > expiry) return null;
return token;
}
function storeToken(token, expiresInSecs) {
GM_setValue(OAUTH_TOKEN_KEY, token);
GM_setValue(OAUTH_EXPIRY_KEY, Date.now() + expiresInSecs * 1000);
}
function clearToken() {
GM_setValue(OAUTH_TOKEN_KEY, '');
GM_setValue(OAUTH_EXPIRY_KEY, 0);
}
function oauthHeaders() {
// Prefer stored OAuth token (from explicit OAuth flow),
// then page session token, then client-ID
const token = getStoredToken();
if (token) return { Authorization: `Bearer ${token}` };
return apiHeaders();
}
// Opens the OAuth popup, waits for redirect, extracts token
function doOAuthFlow() {
return new Promise((resolve, reject) => {
const url = `https://api.imgur.com/oauth2/authorize?client_id=${OAUTH_CLIENT_ID}&response_type=token`;
const popup = window.open(url, 'imgurOAuth', 'width=600,height=700');
if (!popup) { reject(new Error('Popup blocked — allow popups for imgur.com')); return; }
const interval = setInterval(() => {
try {
const hash = popup.location.hash;
if (!hash) return;
const params = new URLSearchParams(hash.slice(1));
const token = params.get('access_token');
const expiry = parseInt(params.get('expires_in') || '3600');
if (token) {
clearInterval(interval);
popup.close();
storeToken(token, expiry);
resolve(token);
}
} catch { /* cross-origin, popup still on imgur auth page */ }
}, 500);
// Timeout after 3 minutes
setTimeout(() => {
clearInterval(interval);
if (!popup.closed) popup.close();
reject(new Error('OAuth timed out'));
}, 180000);
});
}
async function fetchCommentPage(page) {
const res = await fetch(
`https://api.imgur.com/3/account/${USERNAME}/comments/newest/${page}`,
{ headers: apiHeaders() }
);
if (!res.ok) return null;
const { success, data } = await res.json();
return success && Array.isArray(data) && data.length ? data : null;
}
async function fetchCommentScore(id) {
try {
const res = await fetch(`https://api.imgur.com/3/comment/${id}`, { headers: apiHeaders() });
if (!res.ok) return null;
const { success, data } = await res.json();
if (!success || !data) return null;
// The single-comment endpoint uses: points, ups, downs
// The account/comments list uses: point_count, upvote_count, downvote_count
// Support both to be safe
const pts = data.point_count ?? data.points ?? null;
const ups = data.upvote_count ?? data.ups ?? null;
const downs = data.downvote_count ?? data.downs ?? null;
// If ups/downs not in response, we can only compare pts
return { id: data.id, pts: pts ?? 0, ups, downs };
} catch { return null; }
}
// ═══════════════════════════════════════════════════════════
// DOM HELPERS
// ═══════════════════════════════════════════════════════════
function el(tag, styles = {}, html = "") {
const n = document.createElement(tag);
Object.assign(n.style, styles);
if (html) n.innerHTML = html;
return n;
}
// ═══════════════════════════════════════════════════════════
// TOOLTIP FACTORY
// ═══════════════════════════════════════════════════════════
function makeTooltip(id) {
let t = document.getElementById(id);
if (!t) {
t = document.createElement('div');
t.id = id;
t.style.cssText = 'position:fixed;pointer-events:none;background:#18181b;color:#e4e4e7;' +
'border:1px solid #3f3f46;border-radius:5px;padding:5px 9px;font-size:12px;' +
'font-family:monospace;z-index:9999999;display:none;white-space:nowrap;' +
'box-shadow:0 4px 12px rgba(0,0,0,0.6);';
document.body.appendChild(t);
}
return t;
}
function wireTooltips(container, selector, tip) {
container.querySelectorAll(selector).forEach(el => {
el.addEventListener('mouseenter', () => { tip.textContent = el.dataset.tip; tip.style.display = 'block'; });
el.addEventListener('mousemove', e => {
tip.style.left = Math.min(e.clientX+14, window.innerWidth-tip.offsetWidth-8) + 'px';
tip.style.top = Math.max(e.clientY-10, 4) + 'px';
});
el.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
});
}
// ─────────────────────────────────────────────
// SHARED MEDIA RENDER HELPER
// ─────────────────────────────────────────────
// Replace a data-src placeholder with a real img or video using imgurThumb
function swapPlaceholder(ph, rawSrc, imgStyle, videoStyle, autoplay=true) {
const src = rawSrc || ph.dataset.src;
if (!src) return;
const thumbSrc = imgurThumb(src, 'lq');
const isThumbVideo = thumbSrc.endsWith('.mp4');
if (isThumbVideo) {
const v = document.createElement('video');
v.src = thumbSrc;
v.muted = true; v.playsInline = true; v.loop = true;
v.preload = 'metadata'; // fetch just enough to show first frame, not full stream
if (autoplay) v.autoplay = true;
v.style.cssText = videoStyle || imgStyle;
v.onerror = () => { v.style.display = 'none'; };
ph.replaceWith(v);
} else {
const img = Object.assign(document.createElement('img'), { src: thumbSrc });
img.style.cssText = imgStyle;
img.onerror = () => { img.style.display = 'none'; };
ph.replaceWith(img);
}
}
// ═══════════════════════════════════════════════════════════
// STATUS BANNER
// ═══════════════════════════════════════════════════════════
const banner = el('div', {
position:'fixed', top:'20px', right:'20px',
background:'#18181b', color:'#eab308', border:'1px solid #eab308',
padding:'12px 20px', borderRadius:'8px', fontFamily:'monospace',
fontSize:'13px', fontWeight:'bold', zIndex:'1000000',
boxShadow:'0 4px 20px rgba(0,0,0,0.6)'
});
const setBanner = (msg, color='#eab308') => {
banner.innerText = msg;
banner.style.color = banner.style.borderColor = color;
};
setBanner("Initializing…");
document.body.appendChild(banner);
// ═══════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════
let db;
try { db = await openDB(); }
catch(e) { setBanner(`DB error: ${e.message}`, "#ef4444"); return; }
const cachedRaw = await dbGetAll(db);
const localIdSet = new Set(cachedRaw.map(c => c.id));
const cachedPostsRaw = await dbGetAll(db, STORE_POSTS);
// Fetch new pages until cache hit
const freshRaw = [];
for (let page = 0; ; page++) {
setBanner(`Scanning page ${page}…`);
try {
const data = await fetchCommentPage(page);
if (!data) break;
let hitCache = false;
for (const c of data) {
if (localIdSet.has(c.id)) { hitCache = true; break; }
freshRaw.push(enrichComment(c));
}
if (hitCache) break;
await new Promise(r => setTimeout(r, FETCH_DELAY));
} catch { break; }
}
if (freshRaw.length) {
setBanner(`Saving ${freshRaw.length} new…`);
await dbPutAll(db, freshRaw);
}
// Hydrate all comments (Date objects + word arrays)
const processedData = [...cachedRaw, ...freshRaw].map(hydrateComment);
// Hydrate posts
const processedPosts = [...cachedPostsRaw].map(hydratePost);
// Build ID→index Maps for O(1) lookups
const idMap = new Map(processedData.map((c, i) => [c.id, i]));
const postIdMap = new Map(processedPosts.map((p, i) => [p.id, i]));
// Compute bounds
const bounds = processedData.reduce((acc, c) => ({
minScore: Math.min(acc.minScore, c._pts),
maxScore: Math.max(acc.maxScore, c._pts),
maxWords: Math.max(acc.maxWords, c._wordCount),
maxVotes: Math.max(acc.maxVotes, c._totalVotes),
}), { minScore:0, maxScore:0, maxWords:0, maxVotes:0 });
setBanner(`Ready — ${processedData.length} comments`, "#47cf73");
setTimeout(() => banner.remove(), 2500);
// ═══════════════════════════════════════════════════════════
// LAUNCHER
// ═══════════════════════════════════════════════════════════
const launcher = el('button', {
position:'fixed', bottom:'20px', right:'20px',
background:'#47cf73', color:'#09090b', border:'0',
padding:'10px 18px', borderRadius:'30px', fontFamily:'monospace',
fontWeight:'bold', fontSize:'13px', cursor:'pointer',
zIndex:'999999', boxShadow:'0 4px 15px rgba(0,0,0,0.5)',
transition:'transform 0.15s'
});
launcher.innerText = '[ Analytics ]';
launcher.onmouseenter = () => launcher.style.transform = "scale(1.05)";
launcher.onmouseleave = () => launcher.style.transform = "scale(1)";
document.body.appendChild(launcher);
// ═══════════════════════════════════════════════════════════
// DASHBOARD — built once on first click
// ═══════════════════════════════════════════════════════════
launcher.onclick = async () => {
const existing = document.getElementById('imgur-dash');
if (existing) {
const visible = existing.style.display !== 'none';
existing.style.display = visible ? 'none' : 'block';
document.body.style.overflow = visible ? '' : 'hidden';
return;
}
// Load Chart.js
if (!window.Chart) await new Promise(resolve => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/chart.js';
s.onload = resolve;
document.head.appendChild(s);
});
// ─── CHART HELPERS ───────────────────────────────────────
const C = '#a1a1aa', G = '#27272a';
const chartBase = {
responsive: true, maintainAspectRatio: false,
layout: { padding: 0 },
plugins: {
legend: {
labels: { color:'#e4e4e7', font:{size:10}, boxWidth:8, padding:4 },
padding: 0,
},
},
scales: {
x: { grid:{color:G}, ticks:{color:C, font:{size:10}, maxRotation:0}, border:{display:false} },
y: { grid:{color:G}, ticks:{color:C, font:{size:10}}, border:{display:false} }
}
};
function titled(text) {
return { ...chartBase, plugins:{ ...chartBase.plugins,
title:{
display:true, text, color:'#e4e4e7', font:{size:11},
padding:{ top:0, bottom:2 }
}
}};
}
function dualAxis(t) {
return { ...titled(t), scales:{
x: chartBase.scales.x,
y1:{ type:'linear', position:'left', grid:{color:G}, ticks:{color:C, font:{size:10}}, border:{display:false} },
y2:{ type:'linear', position:'right', grid:{drawOnChartArea:false}, ticks:{color:C, font:{size:10}}, border:{display:false} }
}};
}
const chartRegistry = {}; // id → Chart instance
function renderChart(id, type, data, options) {
if (chartRegistry[id]) {
Object.assign(chartRegistry[id], { data, options });
chartRegistry[id].update('none');
} else {
const ctx = document.getElementById(id)?.getContext('2d');
if (ctx) chartRegistry[id] = new Chart(ctx, { type, data, options });
}
}
// ─── HTML FACTORIES ──────────────────────────────────────
const S = { // style snippets
input: `background:#09090b;border:1px solid #444;color:#fff;border-radius:4px;`,
card: `background:#18181b;border:1px solid #27272a;border-radius:8px;padding:12px;box-sizing:border-box;`,
btn: `border-radius:6px;cursor:pointer;font-size:12px;font-weight:700;padding:8px 12px;`,
};
function fmtSelect(id, opts) {
return `<select id="${id}" style="width:100%;${S.input}padding:4px;">`
+ opts.map(([v,l]) => `<option value="${v}">${l}</option>`).join('')
+ `</select>`;
}
function fmtRange(id, min, max, val) {
return `<input type="range" id="${id}" min="${min}" max="${max}" value="${val}" style="width:100%;accent-color:#47cf73;">`;
}
function fmtNum(id, val) {
return `<input type="number" id="${id}" value="${val}" style="width:100%;${S.input}padding:3px 5px;">`;
}
function fmtDate(id) {
return `<input type="date" id="${id}" style="width:100%;${S.input}padding:3px 5px;font-size:11px;color:#fff;">`;
}
function canvasCard(id, extra='') {
return `<div style="${S.card}height:260px;padding:6px;${extra}"><canvas id="${id}"></canvas></div>`;
}
function sectionHeader(t) {
return `<div style="grid-column:1/-1;color:#a1a1aa;font-size:11px;font-weight:700;
letter-spacing:0.1em;text-transform:uppercase;padding:6px 0 2px;
border-bottom:1px solid #27272a;margin-top:8px;">${t}</div>`;
}
function statCard(id, label, color='#e4e4e7') {
return `<div style="background:#09090b;border-radius:6px;padding:8px;text-align:center;">
<div style="font-size:18px;font-weight:700;font-family:monospace;color:${color};" id="${id}">—</div>
<div style="font-size:11px;color:#a1a1aa;text-transform:uppercase;letter-spacing:0.05em;margin-top:2px;">${label}</div>
</div>`;
}
// ─── SHELL ───────────────────────────────────────────────
const dash = el('div', {
position:'fixed', top:0, left:0, width:'100%', height:'100%',
background:'#09090b', zIndex:'99999',
fontFamily:'system-ui,-apple-system,sans-serif',
});
dash.id = 'imgur-dash';
// Hide all scrollbars inside dashboard
const scrollStyle = document.createElement('style');
scrollStyle.textContent = `
#imgur-dash * { scrollbar-width: none; -ms-overflow-style: none; }
#imgur-dash *::-webkit-scrollbar { display: none; }
`;
document.head.appendChild(scrollStyle);
const inner = el('div', {
width:'100%', height:'100%', boxSizing:'border-box',
padding:'14px', display:'grid',
gridTemplateColumns:'290px 1fr', gap:'14px',
color:'#e4e4e7', transformOrigin:'top left',
});
dash.appendChild(inner);
inner.innerHTML = `
<!-- ══ SIDEBAR ══ -->
<div style="background:#18181b;border-radius:10px;padding:14px;display:flex;
flex-direction:column;gap:10px;overflow-y:auto;
max-height:calc(100vh - 28px);box-sizing:border-box;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h2 style="margin:0;color:#47cf73;font-size:15px;">Analytics</h2>
<span style="color:#a1a1aa;font-size:12px;font-family:monospace;">${USERNAME}</span>
</div>
<!-- Scale -->
<div style="background:#202024;padding:8px;border-radius:6px;display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Scale</span>
${fmtRange('uiScale', 70, 130, 100)}
<span id="uiScaleVal" style="font-size:12px;color:#47cf73;font-family:monospace;min-width:34px;text-align:right;">100%</span>
</div>
<!-- Filters -->
<div style="background:#202024;padding:10px;border-radius:6px;display:flex;flex-direction:column;gap:8px;font-size:12px;">
<div><label style="color:#a1a1aa;">Date Preset</label>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;margin-top:3px;">
${['7d','30d','90d','1yr','All'].map((l,i) => {
const days = [7,30,90,365,0][i];
return `<button class="date-preset-btn" data-days="${days}"
style="padding:4px 2px;background:#18181b;color:#a1a1aa;border:1px solid #3f3f46;
border-radius:4px;cursor:pointer;font-size:11px;font-family:monospace;">${l}</button>`;
}).join('')}
</div></div>
<div><label style="color:#a1a1aa;">Date Range</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:3px;">
${fmtDate('filterDateFrom')} ${fmtDate('filterDateTo')}
</div></div>
<div><label style="color:#a1a1aa;">Min Words: <b id="valMinWords" style="color:#fff;">0</b></label>
${fmtRange('filterMinWords', 0, bounds.maxWords, 0)}</div>
<div><label style="color:#a1a1aa;">Score Range</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:3px;">
${fmtNum('filterMinScore', bounds.minScore)} ${fmtNum('filterMaxScore', bounds.maxScore)}
</div></div>
<div><label style="color:#a1a1aa;">Min Votes: <b id="valMinVotes" style="color:#fff;">0</b></label>
${fmtRange('filterMinVotes', 0, bounds.maxVotes, 0)}</div>
<div><label style="color:#a1a1aa;">Hour: <b id="valHourRange" style="color:#fff;">0–23</b></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;">
${fmtRange('filterMinHour',0,23,0)} ${fmtRange('filterMaxHour',0,23,23)}
</div></div>
<div><label style="color:#a1a1aa;">Media</label>
${fmtSelect('filterMedia',[['all','All'],['media','Has Media'],['nomedia','Text Only']])}
</div>
<div><label style="color:#a1a1aa;">Search</label>
<input type="text" id="filterText" placeholder="keyword…" style="width:100%;${S.input}padding:4px 6px;margin-top:3px;box-sizing:border-box;">
</div>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="filterUnseen"> <span style="color:#fbbf24;">Unseen only (≤1 vote)</span>
</label>
</div>
<!-- Stats -->
<div id="statBlock" style="display:grid;grid-template-columns:1fr 1fr;gap:4px;"></div>
<!-- Personal Records -->
<div id="recordsBlock" style="background:#202024;padding:10px;border-radius:6px;display:none;flex-direction:column;gap:6px;">
<div style="font-size:11px;color:#a1a1aa;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;">Personal Records</div>
<div id="recordsList" style="font-size:12px;display:flex;flex-direction:column;gap:4px;"></div>
</div>
<!-- Export -->
<div style="display:flex;flex-direction:column;gap:5px;">
<button id="btnExportCSV" style="${S.btn}background:#27272a;color:#fff;border:1px solid #3f3f46;width:100%;">Export CSV</button>
<button id="btnExportJSON" style="${S.btn}background:#27272a;color:#fff;border:1px solid #3f3f46;width:100%;">Export JSON</button>
</div>
<button id="btnClose" style="${S.btn}background:#ef4444;color:#fff;border:0;margin-top:auto;">✕ Close</button>
</div>
<!-- ══ MAIN ══ -->
<div style="display:flex;flex-direction:column;gap:10px;max-height:calc(100vh - 28px);overflow:hidden;box-sizing:border-box;">
<!-- Tabs -->
<div style="display:flex;gap:4px;border-bottom:2px solid #27272a;padding-bottom:2px;flex-shrink:0;">
<button class="tab-btn" data-tab="charts" style="padding:7px 14px;background:#27272a;color:#fff;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid #47cf73;">Charts</button>
<button class="tab-btn" data-tab="heatmap" style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">Heatmap</button>
<button class="tab-btn" data-tab="top" style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">Top Comments</button>
<button class="tab-btn" data-tab="media" style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">Media</button>
<button class="tab-btn" data-tab="posts" style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">Posts</button>
<button class="tab-btn" data-tab="sync" style="padding:7px 14px;background:#18181b;color:#a1a1aa;border:0;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;border-bottom:3px solid transparent;">⟳ Sync</button>
</div>
<!-- Charts Tab -->
<div id="tabCharts" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:12px;overflow-y:auto;flex-grow:1;align-content:start;padding-right:4px;">
${sectionHeader('Performance by Format')}
${canvasCard('cvMediaPerf')} ${canvasCard('cvWordLen')}
${sectionHeader('Temporal')}
${canvasCard('cvHourly')} ${canvasCard('cvWeekday')} ${canvasCard('cvMonthly')}
${canvasCard('cvTrend')} ${canvasCard('cvVoteStack')} ${canvasCard('cvEngagement')}
${sectionHeader('Score Distribution')}
${canvasCard('cvScoreDist')}
<div style="${S.card}height:260px;" id="cvControversyWrap"></div>
${canvasCard('cvLenScatter')}
${sectionHeader('Writing')}
${canvasCard('cvVocab')} ${canvasCard('cvWordStats')}
${canvasCard('cvPunc')} ${canvasCard('cvSyntax')}
${sectionHeader('Activity')}
${canvasCard('cvVelocity')} ${canvasCard('cvThread')}
<div style="${S.card}grid-column:1/-1;min-height:160px;" id="cvStreakWrap"></div>
</div>
<!-- Heatmap Tab -->
<div id="tabHeatmap" style="display:none;flex-direction:column;flex-grow:1;overflow:hidden;gap:10px;">
<div style="${S.card}display:flex;flex-direction:column;gap:10px;flex-shrink:0;">
<div style="display:grid;grid-template-columns:auto 1fr auto 1fr auto 1fr;gap:8px;align-items:center;flex-wrap:wrap;">
<label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Row axis</label>
${fmtSelect('hmRow',[
['dow', 'Day of Week'],
['hour', 'Hour of Day'],
['month', 'Month (repeating)'],
['dom', 'Day of Month'],
['week', 'Week of Year (repeating)'],
['year', 'Year (unique)'],
['yearmonth','Year-Month (unique)'],
['yearweek', 'Year-Week (unique)'],
])}
<label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Col axis</label>
${fmtSelect('hmCol',[
['hour', 'Hour of Day'],
['dow', 'Day of Week'],
['month', 'Month (repeating)'],
['dom', 'Day of Month'],
['week', 'Week of Year (repeating)'],
['year', 'Year (unique)'],
['yearmonth','Year-Month (unique)'],
['yearweek', 'Year-Week (unique)'],
])}
<label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Metric</label>
${fmtSelect('hmMetric',[['count','Count'],['score','Total Score'],['avg','Avg Score']])}
</div>
<div style="font-size:11px;color:#a1a1aa;" id="hmDesc">Select row and column axes above.</div>
</div>
<div id="hmContainer" style="overflow:auto;flex-grow:1;"></div>
</div>
<!-- Top Comments Tab -->
<div id="tabTop" style="display:none;flex-direction:column;flex-grow:1;overflow:hidden;gap:8px;">
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0;padding-bottom:6px;border-bottom:1px solid #27272a;">
<label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Sort by</label>
${fmtSelect('topSort',[['pts','Score ↑'],['pts_asc','Score ↓'],['newest','Newest'],['oldest','Oldest'],['controversy','Most Controversial'],['words','Longest']])}
<label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Show</label>
${fmtSelect('topLimit',[['25','Top 25'],['50','Top 50'],['100','Top 100']])}
</div>
<div id="topList" style="display:flex;flex-direction:column;gap:8px;overflow-y:auto;flex-grow:1;"></div>
</div>
<!-- Media Tab -->
<div id="tabMedia" style="display:none;flex-direction:column;flex-grow:1;overflow-y:auto;position:relative;">
<div style="display:flex;align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid #27272a;flex-shrink:0;margin-bottom:10px;">
<label style="font-size:12px;color:#a1a1aa;white-space:nowrap;">Sort by</label>
${fmtSelect('mediaSort',[['newest','Newest'],['oldest','Oldest'],['highest','Score ↑'],['lowest','Score ↓'],['engagement','Most Votes']])}
</div>
<div style="display:flex;gap:10px;width:100%;align-items:flex-start;" id="mediaCols">
<div class="media-col" style="flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;"></div>
<div class="media-col" style="flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;"></div>
<div class="media-col" style="flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;"></div>
<div class="media-col" style="flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;"></div>
</div>
<div id="mediaScrollTrigger" style="height:1px;margin-top:20px;"></div>
</div>
<!-- Posts Tab -->
<div id="tabPosts" style="display:none;flex-direction:column;flex-grow:1;overflow:hidden;gap:8px;">
<!-- Filter bar -->
<div style="display:flex;gap:6px;align-items:center;flex-shrink:0;">
<label style="font-size:11px;color:#71717a;white-space:nowrap;">Show</label>
<select id="postFilterVis" style="${S.input}padding:3px 5px;font-size:11px;width:auto;">
<option value="all">All</option><option value="gallery">Gallery</option>
<option value="hidden">Hidden</option><option value="nsfw">NSFW</option>
</select>
<label style="font-size:11px;color:#71717a;white-space:nowrap;">Type</label>
<select id="postFilterType" style="${S.input}padding:3px 5px;font-size:11px;width:auto;">
<option value="all">All</option><option value="album">Albums</option><option value="image">Images</option>
</select>
<label style="font-size:11px;color:#71717a;white-space:nowrap;">Sort</label>
<select id="postSort" style="${S.input}padding:3px 5px;font-size:11px;width:auto;">
<option value="newest">Newest</option><option value="oldest">Oldest</option>
<option value="score">Score</option><option value="views">Views</option>
<option value="comments">Comments</option>
</select>
<div style="margin-left:auto;display:flex;gap:0;border:1px solid #3f3f46;border-radius:6px;overflow:hidden;">
<button class="post-sub-btn" data-sub="gallery" style="padding:5px 14px;background:#f97316;color:#09090b;border:0;cursor:pointer;font-size:11px;font-weight:700;">🖼 Gallery</button>
<button class="post-sub-btn" data-sub="charts" style="padding:5px 14px;background:#18181b;color:#a1a1aa;border:0;border-left:1px solid #3f3f46;cursor:pointer;font-size:11px;font-weight:600;">📊 Charts</button>
</div>
</div>
<!-- Stat pills -->
<div id="postStatBlock" style="display:flex;gap:4px;flex-wrap:nowrap;overflow-x:auto;flex-shrink:0;"></div>
<!-- Gallery sub-tab -->
<div id="postSubGallery" style="overflow-y:auto;flex-grow:1;">
<div id="postGalleryHeader" style="font-size:11px;color:#52525b;margin-bottom:6px;"></div>
<div id="postGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px;align-content:start;"></div>
</div>
<!-- Charts sub-tab — hidden by default -->
<div id="postSubCharts" style="display:none;overflow-y:auto;flex-grow:1;">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(400px,1fr));gap:10px;align-content:start;">
<div style="${S.card}height:220px;"><canvas id="cvPostTrend"></canvas></div>
<div style="${S.card}height:220px;"><canvas id="cvPostViews"></canvas></div>
<div style="${S.card}height:220px;"><canvas id="cvPostEngRate"></canvas></div>
<div style="${S.card}height:220px;"><canvas id="cvPostHour"></canvas></div>
<div style="${S.card}height:220px;"><canvas id="cvPostScoreDist"></canvas></div>
<div style="${S.card}height:220px;"><canvas id="cvPostTags"></canvas></div>
<div style="${S.card}height:220px;"><canvas id="cvPostVisibility"></canvas></div>
</div>
</div>
</div>
<!-- Sync Tab -->
<div id="tabSync" style="display:none;overflow-y:auto;flex-grow:1;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<!-- Scan -->
<div style="${S.card}display:flex;flex-direction:column;gap:10px;">
<div style="font-size:13px;font-weight:700;">🔍 Scan New Comments</div>
<div style="font-size:11px;color:#a1a1aa;">Pages newest→oldest, stops on first cached ID.</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
${statCard('syncCached','Cached','#47cf73')}
${statCard('syncNew','New Found','#38bdf8')}
${statCard('syncPage','API Page','#a1a1aa')}
</div>
<div style="background:#09090b;border-radius:4px;height:6px;overflow:hidden;">
<div id="scanBar" style="height:100%;width:0%;background:#38bdf8;transition:width 0.2s;border-radius:4px;"></div>
</div>
<div id="scanLog" style="background:#09090b;border-radius:4px;padding:8px;font-size:11px;font-family:monospace;color:#a1a1aa;min-height:60px;max-height:120px;overflow-y:auto;line-height:1.7;">Ready.</div>
<div style="display:flex;gap:6px;">
<button id="btnScanStart" style="${S.btn}flex:1;background:#1e3050;color:#38bdf8;border:1px solid #1d4ed8;">▶ Start</button>
<button id="btnScanCancel" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;" disabled>■ Cancel</button>
</div>
</div>
<!-- Score Refresh -->
<div style="${S.card}display:flex;flex-direction:column;gap:10px;">
<div style="font-size:13px;font-weight:700;">↻ Refresh Scores</div>
<div style="font-size:11px;color:#a1a1aa;">Re-fetches live scores. No pagination.</div>
<div style="background:#09090b;border-radius:6px;padding:8px;display:flex;flex-direction:column;gap:6px;">
<div style="font-size:11px;color:#a1a1aa;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Target Window</div>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:4px;" id="scopeBtns">
${[['7','7d'],['30','30d'],['90','90d'],['365','1yr'],['0','All']].map(([v,l],i)=>
`<button class="scope-btn" data-days="${v}" style="${S.btn}padding:5px 2px;background:${i===1?'#1e3a2f':'#18181b'};color:${i===1?'#47cf73':'#a1a1aa'};border:1px solid ${i===1?'#166534':'#3f3f46'};font-family:monospace;font-size:11px;">${l}</button>`
).join('')}
</div>
<div style="font-size:11px;color:#a1a1aa;text-align:center;">~<span id="scopeCount">?</span> comments <span id="scopeWarn" style="color:#eab308;display:none;">⚠️ large scope — runs slow to avoid rate limits</span></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
${statCard('refreshChanged','Changed','#eab308')}
${statCard('refreshGained','Gained','#47cf73')}
${statCard('refreshLost','Lost','#ef4444')}
</div>
<div style="background:#09090b;border-radius:4px;height:6px;overflow:hidden;">
<div id="refreshBar" style="height:100%;width:0%;background:#47cf73;transition:width 0.15s;border-radius:4px;"></div>
</div>
<div id="refreshLbl" style="font-size:11px;color:#a1a1aa;font-family:monospace;text-align:center;min-height:16px;"></div>
<div style="display:flex;gap:6px;">
<button id="btnRefreshStart" style="${S.btn}flex:1;background:#1e3a2f;color:#47cf73;border:1px solid #166534;">▶ Start</button>
<button id="btnRefreshCancel" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;" disabled>■ Cancel</button>
</div>
</div>
<!-- Diff Table -->
<div id="diffWrap" style="display:none;grid-column:1/-1;${S.card}flex-direction:column;gap:8px;">
<div style="font-size:13px;font-weight:700;color:#eab308;">Score Changes</div>
<div id="diffSummary" style="font-size:12px;color:#d4d4d8;font-family:monospace;"></div>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:monospace;">
<thead><tr style="color:#a1a1aa;border-bottom:1px solid #27272a;text-align:left;font-size:11px;">
<th style="padding:4px 6px;">Δ Score</th>
<th style="padding:4px 6px;">Δ ↑</th>
<th style="padding:4px 6px;">Δ ↓</th>
<th style="padding:4px 6px;">Date</th>
<th style="padding:4px 6px;">Comment</th>
<th style="padding:4px 6px;"></th>
</tr></thead>
<tbody id="diffBody"></tbody>
</table>
</div>
</div>
<!-- Post Stats Refresh -->
<div style="${S.card}grid-column:1/-1;display:flex;flex-direction:column;gap:10px;">
<div style="font-size:13px;font-weight:700;">📊 Refresh Post Stats</div>
<div style="font-size:11px;color:#a1a1aa;">Re-scans submissions (public + private if connected) to update views, score, ups/downs, comment count. Scope applies to each source separately.</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
<span style="font-size:11px;color:#a1a1aa;">Stop after:</span>
${['1 page','5 pages','20 pages','All'].map((l,i) => {
const pages=[1,5,20,9999][i];
return `<button class="post-scope-btn" data-pages="${pages}"
style="padding:4px 10px;background:#18181b;color:#a1a1aa;border:1px solid #3f3f46;border-radius:4px;cursor:pointer;font-size:11px;">${l}</button>`;
}).join('')}
<span style="font-size:11px;color:#52525b;">(50 posts/page)</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
${statCard('postRefreshScanned','Updated','#38bdf8')}
${statCard('postRefreshNew','New Found','#47cf73')}
</div>
<div style="background:#09090b;border-radius:4px;height:6px;overflow:hidden;">
<div id="postRefreshBar" style="height:100%;width:0%;background:#47cf73;transition:width 0.15s;border-radius:4px;"></div>
</div>
<div id="postRefreshLbl" style="font-size:11px;color:#a1a1aa;font-family:monospace;text-align:center;min-height:16px;"></div>
<div style="display:flex;gap:6px;">
<button id="btnPostRefreshStart" style="${S.btn}flex:1;background:#1e3050;color:#38bdf8;border:1px solid #1d4ed8;">▶ Refresh Post Stats</button>
<button id="btnPostRefreshCancel" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;" disabled>■ Cancel</button>
</div>
</div>
<!-- OAuth / Account Connection -->
<div style="${S.card}grid-column:1/-1;display:flex;flex-direction:column;gap:8px;" id="oauthPanel">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<div style="flex:1;">
<div style="font-size:13px;font-weight:700;">🔐 Imgur Account Connection</div>
<div style="font-size:11px;color:#a1a1aa;margin-top:2px;">If your page session token has full access, private posts may already work. Use this only if the private scan fails.</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;">
<button id="btnOAuthConnect" style="${S.btn}background:#1e3050;color:#38bdf8;border:1px solid #1d4ed8;white-space:nowrap;">🔑 Connect Account</button>
<button id="btnOAuthDisconnect" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;font-size:10px;display:none;">Disconnect</button>
</div>
</div>
<div id="oauthStatus" style="font-size:11px;font-family:monospace;color:#52525b;">Not connected — public posts only</div>
</div>
<!-- Scan Posts -->
<div style="${S.card}grid-column:1/-1;display:flex;flex-direction:column;gap:10px;">
<div style="font-size:13px;font-weight:700;">📸 Scan Posts / Submissions</div>
<div style="font-size:11px;color:#a1a1aa;">
<b style="color:#47cf73;">Public scan</b> — fetches gallery submissions (no auth needed).<br>
<b style="color:#38bdf8;">Private scan</b> — fetches all images & albums including hidden (requires account connection above).
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;">
${statCard('postSyncCached','Cached','#f97316')}
${statCard('postSyncNew','New Found','#38bdf8')}
${statCard('postSyncPage','API Page','#a1a1aa')}
</div>
<div style="background:#09090b;border-radius:4px;height:6px;overflow:hidden;">
<div id="postScanBar" style="height:100%;width:0%;background:#f97316;transition:width 0.2s;border-radius:4px;"></div>
</div>
<div id="postScanLog" style="background:#09090b;border-radius:4px;padding:8px;font-size:11px;font-family:monospace;color:#a1a1aa;min-height:50px;max-height:100px;overflow-y:auto;line-height:1.7;">Ready.</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button id="btnPostScanStart" style="${S.btn}flex:1;background:#2a1800;color:#f97316;border:1px solid #7c2d12;">▶ Scan Public</button>
<button id="btnPostScanPrivateStart" style="${S.btn}flex:1;background:#1e3050;color:#38bdf8;border:1px solid #1d4ed8;" disabled>🔐 Scan Private</button>
<button id="btnPostScanCancel" style="${S.btn}background:#1a1a1f;color:#52525b;border:1px solid #27272a;" disabled>■ Cancel</button>
</div>
</div>
<!-- DB Management -->
<div style="${S.card}grid-column:1/-1;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<span style="font-size:12px;color:#a1a1aa;flex:1;">
DB: <b style="color:#fff;" id="dbCount">${processedData.length}</b> comments | <b style="color:#f97316;" id="postDbCount">${processedPosts.length}</b> posts
</span>
<button id="btnResetUser" style="${S.btn}background:#1a1a2e;color:#a1a1aa;border:1px solid #3f3f46;">Reset Username</button>
<button id="btnClearPostsDB" style="${S.btn}background:#1a0a0a;color:#f97316;border:1px solid #7c2d12;">Clear Posts Cache</button>
<button id="btnClearDB" style="${S.btn}background:#1a0a0a;color:#ef4444;border:1px solid #7f1d1d;">Clear Comments Cache</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(dash);
// ─── SCALE ──────────────────────────────────────────────
const SCALE_KEY = 'imgurDashScale';
const scaleSlider = document.getElementById('uiScale');
const scaleVal = document.getElementById('uiScaleVal');
function applyScale(pct) {
inner.style.zoom = pct + '%';
scaleVal.textContent = pct + '%';
localStorage.setItem(SCALE_KEY, pct);
}
const savedScale = parseInt(localStorage.getItem(SCALE_KEY) || '100');
scaleSlider.value = savedScale;
applyScale(savedScale);
scaleSlider.addEventListener('input', () => applyScale(+scaleSlider.value));
// ─── SCROLL TRAP ─────────────────────────────────────────
const SCROLL_KEYS = new Set(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','PageUp','PageDown','Home','End',' ']);
dash.addEventListener('wheel', e => e.stopPropagation(), { passive:true, capture:true });
dash.addEventListener('touchmove', e => e.stopPropagation(), { passive:true, capture:true });
dash.addEventListener('keydown', e => { if (SCROLL_KEYS.has(e.key)) e.stopPropagation(); }, { capture:true });
const savedOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const closeDash = () => {
dash.style.display = 'none';
document.body.style.overflow = savedOverflow;
};
const openDash = () => {
dash.style.display = 'block';
document.body.style.overflow = 'hidden';
};
document.getElementById('btnClose').onclick = closeDash;
launcher.onclick = () => dash.style.display === 'none' ? openDash() : closeDash();
// ─── TAB SWITCHING ───────────────────────────────────────
const TAB_COLORS = { charts:'#47cf73', heatmap:'#38bdf8', top:'#eab308', media:'#a855f7', posts:'#f97316', sync:'#71717a' };
function switchTab(active) {
document.querySelectorAll('.tab-btn').forEach(btn => {
const id = btn.dataset.tab;
const on = id === active;
btn.style.background = on ? '#27272a' : '#18181b';
btn.style.color = on ? '#fff' : '#a1a1aa';
btn.style.borderBottomColor = on ? TAB_COLORS[id] : 'transparent';
});
['charts','heatmap','top','media','posts','sync'].forEach(id => {
const el = document.getElementById(`tab${id.charAt(0).toUpperCase()+id.slice(1)}`);
if (!el) return;
if (id !== active) { el.style.display = 'none'; return; }
if (id === 'charts') el.style.display = 'grid';
else el.style.display = 'flex';
});
if (active === 'media') initMediaScroll();
if (active === 'posts') renderPostsTab();
}
document.querySelectorAll('.tab-btn').forEach(btn =>
btn.addEventListener('click', () => switchTab(btn.dataset.tab))
);
// ─── MEDIA STREAM ─────────────────────────────────────────
// Design: mediaPool holds all items. Cards are DOM placeholders (<div class="lazy-ph">)
// with data-src. Two observers:
// scrollObs — watches the sentinel div at the bottom; appends more card placeholders
// lazyObs — watches each placeholder; swaps in real img/video when near viewport
// Both use root:null (viewport) so they work whether the tab is hidden or visible.
// Filter/sort changes only rebuild mediaPool and reset the sentinel — no network requests.
let mediaPool = [], mediaIdx = 0, scrollObs = null, lazyObs = null;
function setupLazy() {
if (lazyObs) lazyObs.disconnect();
const mediaRoot = document.getElementById('tabMedia');
lazyObs = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const ph = entry.target;
const src = ph.dataset.src;
const type = ph.dataset.type;
if (!src) return;
swapPlaceholder(ph, src, 'width:100%;height:auto;display:block;border-radius:4px;object-fit:contain;');
lazyObs.unobserve(ph);
});
}, { root: mediaRoot, rootMargin: '400px' });
}
function renderMediaBatch() {
const cols = [...document.querySelectorAll('.media-col')];
if (!cols.length) return;
const slice = mediaPool.slice(mediaIdx, mediaIdx + BATCH_SIZE);
if (!slice.length) return;
slice.forEach(({comment:c, media:m}, i) => {
const isVideo = /\.(mp4|gifv)/.test(m.url);
const src = m.url.replace('.gifv', '.mp4');
const pts = c._pts;
const ptsStr = (pts > 0 ? '+' : '') + pts;
const card = document.createElement('div');
card.style.cssText = 'background:#18181b;border:1px solid #27272a;border-radius:8px;' +
'padding:8px;display:flex;flex-direction:column;gap:6px;box-sizing:border-box;';
const ph = document.createElement('div');
ph.className = 'lazy-ph';
ph.dataset.src = src;
ph.dataset.type = isVideo ? 'video' : 'img';
ph.style.cssText = 'min-height:100px;background:#101012;border-radius:4px;display:flex;align-items:center;justify-content:center;color:#a1a1aa;font-size:12px;';
ph.textContent = 'Loading…';
card.appendChild(ph);
card.insertAdjacentHTML('beforeend', `
<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;">
<span style="color:${pts>=0?'#eab308':'#ef4444'};font-weight:600;">${ptsStr} pts</span>
<span style="color:#71717a;font-size:10px;font-family:monospace;">${c._date ? c._date.toLocaleDateString(undefined,{month:'short',day:'numeric',year:'2-digit'}) : ''}</span>
<a href="${c.permalink}" target="_blank" style="color:#38bdf8;text-decoration:none;">↗</a>
</div>
<div style="color:#d4d4d8;font-size:11px;line-height:1.4;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;font-style:italic;">"${c.text.replace(/"/g,'"').replace(/</g,'<')}"</div>
`);
// Round-robin — even distribution by count, not height
// (height-based balancing doesn't work because images load async)
cols[(mediaIdx + i) % cols.length].appendChild(card);
lazyObs.observe(ph);
});
mediaIdx += BATCH_SIZE;
// Keep loading until container is scrollable
const root = document.getElementById('tabMedia');
if (root && mediaIdx < mediaPool.length && root.scrollHeight <= root.clientHeight) {
renderMediaBatch();
}
}
function initMediaScroll() {
// Remove old scroll listener
if (scrollObs) {
const mediaRoot = document.getElementById('tabMedia');
if (mediaRoot) mediaRoot.removeEventListener('scroll', scrollObs);
scrollObs = null;
}
setupLazy();
document.querySelectorAll('.media-col').forEach(c => c.innerHTML = '');
mediaIdx = 0;
document.getElementById('mediaLoadPrompt')?.remove();
if (!mediaPool.length) {
document.querySelector('.media-col').innerHTML =
'<div style="color:#a1a1aa;font-size:13px;padding:40px 0;text-align:center;">No media in current filter.</div>';
return;
}
const mediaRoot = document.getElementById('tabMedia');
// Simple scroll listener — loads next batch when near bottom
scrollObs = () => {
if (mediaIdx >= mediaPool.length) return;
const mediaRoot = document.getElementById('tabMedia');
if (!mediaRoot) return;
const { scrollTop, clientHeight } = mediaRoot;
const cols = [...document.querySelectorAll('.media-col')];
// Fire when the shortest column's bottom is within 200px of the visible area
const shortestBottom = Math.min(...cols.map(c => c.offsetHeight));
if (scrollTop + clientHeight >= shortestBottom - 200) {
renderMediaBatch();
}
};
mediaRoot.addEventListener('scroll', scrollObs);
renderMediaBatch(); // load first batch
}
// ─── HEATMAP ─────────────────────────────────────────────
let hmComments = [];
['hmRow','hmCol','hmMetric'].forEach(id =>
document.getElementById(id)?.addEventListener('change', () => renderHeatmap(hmComments))
);
function renderHeatmap(comments) {
hmComments = comments;
const rowDim = document.getElementById('hmRow')?.value || 'dow';
const colDim = document.getElementById('hmCol')?.value || 'hour';
const metric = document.getElementById('hmMetric').value;
const target = document.getElementById('hmContainer');
const desc = document.getElementById('hmDesc');
// ── Axis definitions ──────────────────────────────────────────────────
// Each dim: { key(c)→string, allKeys(comments)→string[], label(k)→string, shortLabel(k)→string }
function isoWeek(d) {
// ISO week number
const tmp = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
return { w: Math.ceil((((tmp - yearStart) / 86400000) + 1) / 7), y: tmp.getUTCFullYear() };
}
const DIMS = {
hour: {
key: c => String(c._date.getHours()),
allKeys: () => Array.from({length:24}, (_,i) => String(i)),
label: k => `${k}:00`,
short: k => +k % 3 === 0 ? `${k}h` : '',
},
dow: {
key: c => String(c._date.getDay()),
allKeys: () => ['0','1','2','3','4','5','6'],
label: k => DAY_LABELS[+k],
short: k => DAY_LABELS[+k],
},
month: {
key: c => String(c._date.getMonth()),
allKeys: () => Array.from({length:12}, (_,i) => String(i)),
label: k => MONTH_LABELS[+k],
short: k => MONTH_LABELS[+k],
},
dom: {
key: c => String(c._date.getDate() - 1),
allKeys: () => Array.from({length:31}, (_,i) => String(i)),
label: k => String(+k + 1),
short: k => (+k + 1) % 5 === 1 ? String(+k + 1) : '',
},
week: {
key: c => String(isoWeek(c._date).w),
allKeys: () => Array.from({length:53}, (_,i) => String(i+1)),
label: k => `W${k}`,
short: k => +k % 4 === 1 ? `W${k}` : '',
},
year: {
key: c => String(c._date.getFullYear()),
allKeys: cmts => [...new Set(cmts.filter(c=>c._date).map(c=>String(c._date.getFullYear())))].sort(),
label: k => k,
short: k => k,
},
yearmonth: {
key: c => `${c._date.getFullYear()}-${String(c._date.getMonth()+1).padStart(2,'0')}`,
allKeys: cmts => [...new Set(cmts.filter(c=>c._date).map(c=>`${c._date.getFullYear()}-${String(c._date.getMonth()+1).padStart(2,'0')}`))].sort(),
label: k => k,
short: k => k,
},
yearweek: {
key: c => { const {y,w} = isoWeek(c._date); return `${y}-W${String(w).padStart(2,'0')}`; },
allKeys: cmts => [...new Set(cmts.filter(c=>c._date).map(c=>{ const {y,w}=isoWeek(c._date); return `${y}-W${String(w).padStart(2,'0')}`; }))].sort(),
label: k => k,
short: k => k,
},
};
const RD = DIMS[rowDim], CD = DIMS[colDim];
if (!RD || !CD) return;
if (desc) desc.textContent = `${document.getElementById('hmRow').options[document.getElementById('hmRow').selectedIndex].text} × ${document.getElementById('hmCol').options[document.getElementById('hmCol').selectedIndex].text}`;
// ── Build map ─────────────────────────────────────────────────────────
const map = {};
const dated = comments.filter(c => c._date);
dated.forEach(c => {
const rk = RD.key(c), ck = CD.key(c);
if (!map[rk]) map[rk] = {};
if (!map[rk][ck]) map[rk][ck] = {n:0, pts:0};
map[rk][ck].n++;
map[rk][ck].pts += c._pts;
});
const rowKeys = RD.allKeys(dated);
const colKeys = CD.allKeys(dated);
// Filter to only rows/cols that have any data (for unique-key dims this avoids empty rows)
const isDynamic = ['year','yearmonth','yearweek'].includes(rowDim);
const cIsDynamic = ['year','yearmonth','yearweek'].includes(colDim);
const activeRows = isDynamic ? rowKeys.filter(k => map[k]) : rowKeys;
const activeCols = cIsDynamic ? colKeys.filter(k => dated.some(c => CD.key(c)===k)) : colKeys;
function cellVal(rk, ck) {
const cell = map[rk]?.[ck];
if (!cell || cell.n === 0) return null;
return metric==='count' ? cell.n : metric==='score' ? cell.pts : cell.pts/cell.n;
}
// ── Global min/max ────────────────────────────────────────────────────
let gMax = -Infinity, gMin = Infinity;
activeRows.forEach(rk => activeCols.forEach(ck => {
const v = cellVal(rk, ck);
if (v !== null) { if (v > gMax) gMax = v; if (v < gMin) gMin = v; }
}));
if (gMax === -Infinity) {
target.innerHTML = `<div style="color:#a1a1aa;padding:20px;">No data — ${dated.length} dated comments in filter, but no overlap between the two axes chosen (e.g. same axis selected for both row and column).</div>`;
return;
}
const hasNeg = gMin < 0;
function cellBg(val) {
if (val === null) return '#101012';
if (hasNeg) {
if (val < 0) { const t = gMin!==0 ? val/gMin : 0; return `rgba(239,68,68,${Math.min(.95,t*.85+.1).toFixed(2)})`; }
const t = gMax!==0 ? val/gMax : 0; return `rgba(71,207,115,${Math.min(.95,t*.85+.1).toFixed(2)})`;
}
const n = gMax===gMin ? 1 : (val-gMin)/(gMax-gMin);
return `rgba(71,207,115,${(n*.88+.08).toFixed(2)})`;
}
// ── Cell sizing ───────────────────────────────────────────────────────
const wideCols = new Set(['year','yearmonth','yearweek']);
const narrowCols = new Set(['hour','dow']);
const CW = wideCols.has(colDim) ? 64
: narrowCols.has(colDim) ? 18
: 24;
const CH = 17;
// ── Render table ──────────────────────────────────────────────────────
let html = `<table style="border-collapse:collapse;font-size:11px;font-family:monospace;">`;
html += `<tr><td style="padding:2px 8px;min-width:60px;"></td>`;
activeCols.forEach(ck => {
html += `<td style="padding:1px 2px;color:#a1a1aa;text-align:center;min-width:${CW}px;font-size:11px;white-space:nowrap;">${CD.short(ck)}</td>`;
});
html += '</tr>';
activeRows.forEach(rk => {
html += `<tr><td style="padding:3px 8px;color:#d4d4d8;white-space:nowrap;font-size:11px;">${RD.label(rk)}</td>`;
activeCols.forEach(ck => {
const val = cellVal(rk, ck);
const disp = val===null ? '—' : metric==='avg' ? val.toFixed(1) : Math.round(val);
const tip = `${RD.label(rk)} / ${CD.label(ck)}: ${disp} ${metric==='count'?'comments':metric==='score'?'pts':'avg pts'}`;
html += `<td data-tip="${tip}" style="width:${CW}px;height:${CH}px;background:${cellBg(val)};border-radius:2px;border:1px solid #09090b;cursor:default;"></td>`;
});
html += '</tr>';
});
html += '</table>';
const fmt = v => metric==='avg' ? v.toFixed(1) : Math.round(v);
html += `<div style="margin-top:10px;display:flex;align-items:center;gap:8px;font-size:11px;color:#a1a1aa;flex-wrap:wrap;">
<span>${fmt(gMin)}</span>
<div style="width:120px;height:8px;border-radius:4px;background:linear-gradient(to right,${hasNeg?'rgba(239,68,68,.9),#101012,':''}rgba(71,207,115,.08),rgba(71,207,115,.95));"></div>
<span>${fmt(gMax)}</span>
<span style="margin-left:8px;color:#a1a1aa;">${metric==='count'?'comments':metric==='score'?'total pts':'avg pts'} · ${dated.length.toLocaleString()} dated comments · ${activeRows.length}×${activeCols.length} grid</span>
</div>`;
target.innerHTML = html;
wireTooltips(target, 'td[data-tip]', makeTooltip('hm-tip'));
}
// ─── TOP COMMENTS ─────────────────────────────────────────
let topComments = [];
function renderTopList() {
const sortKey = document.getElementById('topSort').value;
const limit = parseInt(document.getElementById('topLimit').value);
const list = document.getElementById('topList');
const sorted = [...topComments].sort((a,b) => {
if (sortKey === 'pts') return b._pts - a._pts;
if (sortKey === 'pts_asc') return a._pts - b._pts;
if (sortKey === 'newest') return (b._date?.getTime()||0) - (a._date?.getTime()||0);
if (sortKey === 'oldest') return (a._date?.getTime()||0) - (b._date?.getTime()||0);
if (sortKey === 'controversy') {
const scoreOf = c => {
if (!c._ups || !c._downs) return 0;
const total = c._ups + c._downs;
const bal = 1 - Math.abs(c._ups - c._downs) / total;
return total * bal * bal;
};
return scoreOf(b) - scoreOf(a);
}
if (sortKey === 'words') return b._wordCount - a._wordCount;
return 0;
}).slice(0, limit);
list.innerHTML = '';
sorted.forEach(c => {
const card = document.createElement('div');
card.style.cssText = `background:#18181b;border:1px solid #27272a;border-radius:8px;padding:10px 12px;display:flex;gap:12px;align-items:flex-start;`;
const pts = c._pts;
const ptsColor = pts > 0 ? '#47cf73' : pts < 0 ? '#ef4444' : '#a1a1aa';
const ptsStr = (pts > 0 ? '+' : '') + pts;
// media thumbnail
let mediaHTML = '';
if (c._hasMedia) {
const m = c._mediaUrls.find(u => u.type==='img'||u.type==='gif') || c._mediaUrls[0];
const rawSrc = m.url.replace('.gifv', '.mp4');
const thumbSrc = imgurThumb(rawSrc, 'lq');
const isTVid = thumbSrc.endsWith('.mp4');
mediaHTML = isTVid
? `<video src="${thumbSrc}" autoplay loop muted playsinline style="width:100px;min-width:100px;max-height:100px;object-fit:cover;border-radius:6px;" onerror="this.style.display='none'"></video>`
: `<img src="${thumbSrc}" loading="lazy" style="width:100px;min-width:100px;max-height:100px;object-fit:cover;border-radius:6px;" onerror="this.style.display='none'">`;
}
card.innerHTML = `
<div style="min-width:50px;text-align:center;">
<div style="font-size:20px;font-weight:700;font-family:monospace;color:${ptsColor};">${ptsStr}</div>
<div style="font-size:11px;color:#a1a1aa;font-family:monospace;margin-top:2px;">${c._ups}↑ ${c._downs}↓</div>
</div>
${mediaHTML ? `<div style="flex-shrink:0;">${mediaHTML}</div>` : ''}
<div style="flex:1;min-width:0;">
<div style="color:#d4d4d8;font-size:12px;line-height:1.5;overflow:hidden;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;">${c.text.replace(/</g,'<')}</div>
<div style="margin-top:5px;display:flex;gap:8px;font-size:11px;color:#a1a1aa;flex-wrap:wrap;">
<span>${c._date ? c._date.toLocaleDateString() : '?'}</span>
<span style="color:#52525b;">·</span>
<span>${c._wordCount} words</span>
${c._hasMedia ? `<span style="color:#a855f7;">📎 ${c._mediaUrls.length}</span>` : ''}
<a href="${c.permalink}" target="_blank" style="color:#38bdf8;text-decoration:none;">↗ view</a>
</div>
</div>
`;
list.appendChild(card);
});
}
['topSort','topLimit'].forEach(id =>
document.getElementById(id).addEventListener('change', renderTopList)
);
const mediaSortEl = document.getElementById('mediaSort');
if (mediaSortEl) {
function applyMediaSort() {
const sk = mediaSortEl.value;
mediaPool.sort((a,b) => {
if (sk==='newest') return (b.comment._date ? b.comment._date.getTime() : 0) - (a.comment._date ? a.comment._date.getTime() : 0);
if (sk==='oldest') return (a.comment._date ? a.comment._date.getTime() : 0) - (b.comment._date ? b.comment._date.getTime() : 0);
if (sk==='highest') return b.comment._pts - a.comment._pts;
if (sk==='lowest') return a.comment._pts - b.comment._pts;
if (sk==='engagement') return b.comment._totalVotes - a.comment._totalVotes;
return 0;
});
// Only re-render if media was already loaded (prompt is gone)
if (!document.getElementById('mediaLoadPrompt')) {
document.querySelectorAll('.media-col').forEach(c => c.innerHTML = '');
mediaIdx = 0;
renderMediaBatch();
}
// Update prompt count if prompt still showing
const prompt = document.getElementById('mediaLoadPrompt');
if (prompt) {
const countEl = prompt.querySelector('div');
if (countEl) countEl.textContent = `${mediaPool.length.toLocaleString()} media items in current filter`;
}
}
mediaSortEl.addEventListener('change', applyMediaSort);
mediaSortEl.addEventListener('input', applyMediaSort);
}
// ─── SYNC TAB ─────────────────────────────────────────────
document.getElementById('syncCached').textContent = processedData.length;
document.getElementById('dbCount').textContent = processedData.length;
// Scope buttons
let scopeDays = 30;
function updateScopeCount() {
const cutoff = scopeDays > 0 ? Date.now() - scopeDays * 86400000 : -Infinity;
const n = processedData.filter(c => c._date ? c._date.getTime() >= cutoff : scopeDays===0).length;
document.getElementById('scopeCount').textContent = n.toLocaleString();
const warn = document.getElementById('scopeWarn');
if (warn) warn.style.display = n > 500 ? 'inline' : 'none';
}
document.querySelectorAll('.scope-btn').forEach(btn => {
btn.addEventListener('click', () => {
scopeDays = +btn.dataset.days;
document.querySelectorAll('.scope-btn').forEach(b => {
const on = b === btn;
b.style.background = on ? '#1e3a2f' : '#18181b';
b.style.color = on ? '#47cf73' : '#a1a1aa';
b.style.borderColor = on ? '#166534' : '#3f3f46';
});
updateScopeCount();
});
});
updateScopeCount();
// Scan
let scanCancelled = false;
function scanLog(boxId, msg, color='#a1a1aa') {
const box = document.getElementById(boxId);
if (!box) return;
const d = document.createElement('div');
d.style.color = color;
d.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
box.appendChild(d);
box.scrollTop = box.scrollHeight;
}
function setScanButtons(startId, cancelId, running) {
const start = document.getElementById(startId);
const cancel = document.getElementById(cancelId);
if (start) { start.disabled = running; start.style.opacity = running ? '0.4' : '1'; }
if (cancel) {
cancel.disabled = !running;
cancel.style.color = running ? '#ef4444' : '#52525b';
cancel.style.borderColor = running ? '#7f1d1d' : '#27272a';
}
}
function setScanUI(r) { setScanButtons("btnScanStart","btnScanCancel",r); }
document.getElementById('btnScanCancel').onclick = () => { scanCancelled = true; };
document.getElementById('btnScanStart').onclick = async () => {
scanCancelled = false;
setScanUI(true);
document.getElementById('scanLog').innerHTML = '';
document.getElementById('syncNew').textContent = '0';
document.getElementById('syncPage').textContent = '0';
const bar = document.getElementById('scanBar');
bar.style.width = '5%'; bar.style.background = '#38bdf8';
scanLog('scanLog','Starting scan…', '#38bdf8');
const existingIds = new Set(processedData.map(c => c.id));
const newItems = [];
for (let page = 0; !scanCancelled; page++) {
document.getElementById('syncPage').textContent = page;
scanLog('scanLog',`Fetching page ${page}…`);
try {
const data = await fetchCommentPage(page);
if (!data) { log('No more pages.', '#47cf73'); break; }
let hit = false;
for (const c of data) {
if (existingIds.has(c.id)) { hit = true; break; }
newItems.push(enrichComment(c));
}
document.getElementById('syncNew').textContent = newItems.length;
bar.style.width = Math.min(95, 5 + newItems.length / 2) + '%';
if (hit) { log(`Reached cached data at page ${page}.`, '#47cf73'); break; }
await new Promise(r => setTimeout(r, FETCH_DELAY));
} catch(e) { log(`Error: ${e.message}`, '#ef4444'); break; }
}
if (scanCancelled) log('Cancelled.', '#eab308');
if (newItems.length) {
scanLog('scanLog',`Saving ${newItems.length} comments…`);
await dbPutAll(db, newItems);
newItems.map(hydrateComment).forEach(c => {
processedData.unshift(c);
idMap.set(c.id, 0); // rough — rebuilding below
});
// Rebuild idMap
processedData.forEach((c,i) => idMap.set(c.id, i));
document.getElementById('syncCached').textContent = processedData.length;
document.getElementById('dbCount').textContent = processedData.length;
log(`✓ Done. ${newItems.length} added.`, '#47cf73');
updateScopeCount();
updateDashboard();
} else {
scanLog('scanLog','No new comments found.', '#47cf73');
}
bar.style.width = '100%';
bar.style.background = newItems.length ? '#47cf73' : '#38bdf8';
setScanUI(false);
};
// Refresh scores
let refreshCancelled = false;
function setRefreshUI(r) { setScanButtons("btnRefreshStart","btnRefreshCancel",r); document.querySelectorAll('.scope-btn').forEach(b => b.disabled = r); }
document.getElementById('btnRefreshCancel').onclick = () => { refreshCancelled = true; };
document.getElementById('btnRefreshStart').onclick = async () => {
refreshCancelled = false;
setRefreshUI(true);
const bar = document.getElementById('refreshBar');
const lbl = document.getElementById('refreshLbl');
const dw = document.getElementById('diffWrap');
const db2 = document.getElementById('diffBody');
const ds = document.getElementById('diffSummary');
bar.style.width = '0%'; bar.style.background = '#47cf73';
lbl.textContent = '';
['refreshChanged','refreshGained','refreshLost'].forEach(id =>
document.getElementById(id).textContent = '…'
);
dw.style.display = 'none'; db2.innerHTML = '';
// Page-scan approach: one request = 50 comments, compare with stored
// Scope = how many pages back to scan (50 comments/page)
const maxPages = scopeDays === 0 ? 9999 :
scopeDays <= 7 ? 1 :
scopeDays <= 30 ? 5 :
scopeDays <= 90 ? 20 : 9999;
const diffs = [];
let scanned = 0;
for (let page = 0; page < maxPages && !refreshCancelled; page++) {
lbl.textContent = `Fetching page ${page}… (${scanned} scanned, ${diffs.length} changed)`;
bar.style.width = Math.min(95, page / Math.max(maxPages, 1) * 100) + '%';
try {
const data = await fetchCommentPage(page);
if (!data || !data.length) { lbl.textContent += ' — no more pages'; break; }
// Check if we've gone past the scope window
const cutoff = scopeDays > 0 ? Date.now() - scopeDays * 86400000 : -Infinity;
let pastWindow = false;
data.forEach(fresh => {
const orig = processedData[idMap.get(fresh.id)];
if (!orig) return;
const freshDate = fresh.datetime ? fresh.datetime * 1000 : 0;
if (scopeDays > 0 && freshDate < cutoff) { pastWindow = true; return; }
scanned++;
const newPts = fresh.point_count ?? fresh.points ?? orig._pts;
const newUps = fresh.upvote_count ?? fresh.ups ?? null;
const newDowns = fresh.downvote_count ?? fresh.downs ?? null;
// Coerce to numbers to avoid type mismatch false positives
const ptsDelta = +newPts - +orig._pts;
const upsDelta = newUps !== null ? +newUps - +orig._ups : null;
const downsDelta = newDowns !== null ? +newDowns - +orig._downs : null;
if (ptsDelta !== 0 || (upsDelta !== null && upsDelta !== 0) || (downsDelta !== null && downsDelta !== 0)) {
diffs.push({ orig, r: { pts: +newPts, ups: newUps !== null ? +newUps : null, downs: newDowns !== null ? +newDowns : null } });
}
});
document.getElementById('refreshChanged').textContent = diffs.length;
document.getElementById('refreshGained').textContent = diffs.filter(d=>d.r.pts>d.orig._pts).length;
document.getElementById('refreshLost').textContent = diffs.filter(d=>d.r.pts<d.orig._pts).length;
if (pastWindow) { lbl.textContent += ' — reached scope window'; break; }
await new Promise(r => setTimeout(r, FETCH_DELAY));
} catch(e) { lbl.textContent = `Error: ${e.message}`; break; }
}
if (diffs.length) {
const dbRows = diffs.map(({orig, r}) => {
const idx = idMap.get(orig.id);
if (idx !== undefined) {
const c = processedData[idx];
c._pts=r.pts; if(r.ups!==null){c._ups=r.ups; c._downs=r.downs;}
c._totalVotes=(r.ups??orig._ups)+(r.downs??orig._downs);
c.points=r.pts; if(r.ups!==null){c.ups=r.ups; c.downs=r.downs;}
}
return { ...orig, _pts:r.pts,
_ups:r.ups??orig._ups, _downs:r.downs??orig._downs,
_totalVotes:(r.ups??orig._ups)+(r.downs??orig._downs),
points:r.pts, ups:r.ups??orig._ups, downs:r.downs??orig._downs,
_date: orig._date instanceof Date ? orig._date.toISOString() : orig._date,
_words:undefined, _cleanWords:undefined
};
});
await dbPutAll(db, dbRows);
processedData.forEach(c => {
if (c._pts < bounds.minScore) bounds.minScore = c._pts;
if (c._pts > bounds.maxScore) bounds.maxScore = c._pts;
});
const netDelta = diffs.reduce((s,d) => s+(d.r.pts-d.orig._pts), 0);
try {
const histKey = `imgurScoreHistory_${USERNAME}`;
const hist = JSON.parse(localStorage.getItem(histKey) || '[]');
hist.push({ ts: Date.now(), pool: scanned, changed: diffs.length, net: netDelta });
if (hist.length > 50) hist.splice(0, hist.length - 50);
localStorage.setItem(histKey, JSON.stringify(hist));
} catch {}
updateDashboard();
ds.textContent = `Scanned ${scanned} • Changed ${diffs.length} • Net ${netDelta>=0?'+':''}${netDelta} • Gained ${diffs.filter(d=>d.r.pts>d.orig._pts).length} • Lost ${diffs.filter(d=>d.r.pts<d.orig._pts).length}`;
const sorted = [...diffs].sort((a,b)=>Math.abs(b.r.pts-b.orig._pts)-Math.abs(a.r.pts-a.orig._pts));
db2.innerHTML = sorted.map(({orig:o, r}) => {
const ptsDelta = r.pts - o._pts;
const upsDelta = r.ups !== null ? r.ups - o._ups : null;
const downsDelta = r.downs !== null ? r.downs - o._downs : null;
const sign = v => v > 0 ? `+${v}` : String(v);
const deltaCell = (v, oldV, newV) => {
if (v === null) return '<td style="padding:4px 6px;color:#52525b;font-family:monospace;font-size:11px;">—</td>';
const c = v > 0 ? '#47cf73' : v < 0 ? '#ef4444' : '#a1a1aa';
return `<td style="padding:4px 6px;font-family:monospace;font-size:11px;">
<span style="color:${c};font-weight:700;">${sign(v)}</span>
<span style="color:#52525b;font-size:10px;"> (${oldV}→${newV})</span>
</td>`;
};
const ds2 = o._date instanceof Date ? o._date.toLocaleDateString() : '?';
const snip = (o.text||'').slice(0,55).replace(/</g,'<').replace(/\n/g,' ');
return `<tr style="border-bottom:1px solid #1a1a1f;">
${deltaCell(ptsDelta, o._pts, r.pts)}
${deltaCell(upsDelta, o._ups, r.ups ?? '?')}
${deltaCell(downsDelta,o._downs, r.downs ?? '?')}
<td style="padding:4px 6px;color:#a1a1aa;white-space:nowrap;font-size:11px;">${ds2}</td>
<td style="padding:4px 6px;color:#d4d4d8;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;">${snip}…</td>
<td style="padding:4px 6px;"><a href="${o.permalink}" target="_blank" style="color:#38bdf8;text-decoration:none;font-size:11px;">↗</a></td>
</tr>`;
}).join('');
dw.style.display = 'flex';
}
bar.style.width = '100%';
bar.style.background = diffs.length ? '#eab308' : '#47cf73';
lbl.textContent = diffs.length ? `Done — ${diffs.length} updated` : `Done — all unchanged`;
setRefreshUI(false);
};
// ── Post Stats Refresh — re-scans submissions pages (50 posts/request) ──
let postRefreshCancelled = false;
let postRefreshMaxPages = 5;
function setPostRefreshUI(r) { setScanButtons('btnPostRefreshStart','btnPostRefreshCancel',r); document.querySelectorAll('.post-scope-btn').forEach(b => b.disabled = r); }
document.querySelectorAll('.post-scope-btn').forEach(btn => {
btn.addEventListener('click', () => {
postRefreshMaxPages = +btn.dataset.pages;
document.querySelectorAll('.post-scope-btn').forEach(b => {
const on = b === btn;
b.style.background = on ? '#1e3050' : '#18181b';
b.style.color = on ? '#38bdf8' : '#a1a1aa';
b.style.borderColor= on ? '#1d4ed8' : '#3f3f46';
});
});
});
document.getElementById('btnPostRefreshCancel').onclick = () => { postRefreshCancelled = true; };
document.getElementById('btnPostRefreshStart').onclick = async () => {
postRefreshCancelled = false;
setPostRefreshUI(true);
const lbl = document.getElementById('postRefreshLbl');
const bar = document.getElementById('postRefreshBar');
bar.style.width = '0%'; bar.style.background = '#47cf73';
document.getElementById('postRefreshScanned').textContent = '0';
document.getElementById('postRefreshNew').textContent = '0';
let updated = 0, newFound = 0;
async function runPageScan(fetchFn, label, maxPages) {
for (let page = 0; page < maxPages && !postRefreshCancelled; page++) {
lbl.textContent = `[${label}] Page ${page}… (${updated} updated, ${newFound} new)`;
bar.style.width = Math.min(90, Math.round(page / Math.max(maxPages, 1) * 100)) + '%';
try {
const data = await fetchFn(page);
if (!data) break;
const enriched = data.map(enrichPost);
const toStore = enriched.map(p => ({...p, _date: p._date instanceof Date ? p._date.toISOString() : p._date}));
await dbPutAll(db, toStore, STORE_POSTS);
const idxMap = new Map(processedPosts.map((p,i) => [p.id, i]));
enriched.map(hydratePost).forEach(p => {
if (idxMap.has(p.id)) { processedPosts[idxMap.get(p.id)] = p; updated++; }
else { processedPosts.unshift(p); newFound++; }
});
processedPosts.forEach((p,i) => postIdMap.set(p.id, i));
document.getElementById('postRefreshScanned').textContent = updated;
document.getElementById('postRefreshNew').textContent = newFound;
document.getElementById('postSyncCached').textContent = processedPosts.length;
document.getElementById('postDbCount').textContent = processedPosts.length;
await new Promise(r => setTimeout(r, FETCH_DELAY));
} catch(e) { lbl.textContent = `Error: ${e.message}`; return; }
}
}
await runPageScan(fetchPostPage, 'Public', postRefreshMaxPages);
if (!postRefreshCancelled && getStoredToken()) {
await runPageScan(fetchPrivateImagePage, 'Private Images', postRefreshMaxPages);
if (!postRefreshCancelled) await runPageScan(fetchPrivateAlbumPage, 'Private Albums', postRefreshMaxPages);
}
bar.style.width = '100%';
bar.style.background = '#47cf73';
lbl.textContent = postRefreshCancelled
? `Cancelled — ${updated} updated, ${newFound} new`
: `Done — ${updated} updated, ${newFound} new`;
setPostRefreshUI(false);
if (updated || newFound) renderPostsTab();
};
document.getElementById('btnResetUser').onclick = () => {
if (!confirm('Reset the tracked username?')) return;
GM_setValue('imgurDashUser', '');
alert('Username cleared. Reload to set a new one.');
};
document.getElementById('btnClearDB').onclick = async () => {
if (!confirm('Clear ALL cached comments? Cannot be undone.')) return;
await dbClear(db, STORE_COMMENTS);
alert('Comments cache cleared. Reload to re-fetch.');
};
document.getElementById('btnClearPostsDB').onclick = async () => {
if (!confirm('Clear ALL cached posts? Cannot be undone.')) return;
await dbClear(db, STORE_POSTS);
processedPosts.length = 0;
postIdMap.clear();
document.getElementById('postDbCount').textContent = '0';
document.getElementById('postSyncCached').textContent = '0';
alert('Posts cache cleared. Scan again to re-fetch.');
};
// ── OAuth UI ─────────────────────────────────────────────
function updateOAuthUI() {
const token = getStoredToken();
const status = document.getElementById('oauthStatus');
const connectBtn = document.getElementById('btnOAuthConnect');
const disconnectBtn = document.getElementById('btnOAuthDisconnect');
const privateBtn = document.getElementById('btnPostScanPrivateStart');
if (token) {
status.textContent = '✓ Connected — private posts and images accessible';
status.style.color = '#47cf73';
connectBtn.style.display = 'none';
disconnectBtn.style.display = 'block';
if (privateBtn) { privateBtn.disabled = false; privateBtn.style.opacity = '1'; }
} else {
status.textContent = 'Not connected — public posts only';
status.style.color = '#52525b';
connectBtn.style.display = 'block';
disconnectBtn.style.display = 'none';
if (privateBtn) { privateBtn.disabled = true; privateBtn.style.opacity = '0.4'; }
}
}
updateOAuthUI();
document.getElementById('btnOAuthConnect').onclick = async () => {
const btn = document.getElementById('btnOAuthConnect');
btn.textContent = '⏳ Waiting for auth…';
btn.disabled = true;
try {
await doOAuthFlow();
updateOAuthUI();
} catch(e) {
document.getElementById('oauthStatus').textContent = `Auth failed: ${e.message}`;
document.getElementById('oauthStatus').style.color = '#ef4444';
}
btn.textContent = '🔑 Connect Account';
btn.disabled = false;
};
document.getElementById('btnOAuthDisconnect').onclick = () => {
clearToken();
updateOAuthUI();
};
// ── Post Scan ────────────────────────────────────────────
document.getElementById('postSyncCached').textContent = processedPosts.length;
let postScanCancelled = false;
function postLog(msg, color='#a1a1aa') {
const box = document.getElementById('postScanLog');
const d = document.createElement('div');
d.style.color = color;
d.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
box.appendChild(d);
box.scrollTop = box.scrollHeight;
}
function setPostScanUI(r) { setScanButtons("btnPostScanStart","btnPostScanCancel",r); }
document.getElementById('btnPostScanCancel').onclick = () => { postScanCancelled = true; };
// Shared scan runner — used by both public and private scan
async function runPostScan(fetchPage, label, color, forceUpdate=false) {
postScanCancelled = false;
setPostScanUI(true);
document.getElementById('postScanLog').innerHTML = '';
document.getElementById('postSyncNew').textContent = '0';
document.getElementById('postSyncPage').textContent = '0';
const bar = document.getElementById('postScanBar');
const plog = (msg, col) => scanLog('postScanLog', msg, col);
bar.style.width = '5%'; bar.style.background = color;
plog(`Starting ${label} scan…`, color);
const existingIds = new Set(processedPosts.map(p => p.id));
const newPosts = [], updatedPosts = [];
for (let page = 0; !postScanCancelled; page++) {
document.getElementById('postSyncPage').textContent = page;
plog(`Fetching page ${page}…`);
try {
const data = await fetchPage(page);
if (!data) { plog('No more pages — done.', '#47cf73'); break; }
let hit = false;
for (const p of data) {
if (existingIds.has(p.id)) {
if (forceUpdate) updatedPosts.push(enrichPost(p));
else { hit = true; break; }
} else {
newPosts.push(enrichPost(p));
}
}
document.getElementById('postSyncNew').textContent = newPosts.length + updatedPosts.length;
bar.style.width = Math.min(95, 5 + (newPosts.length + updatedPosts.length) / 2) + '%';
if (hit) { plog(`Hit cached record at page ${page}.`, '#47cf73'); break; }
await new Promise(r => setTimeout(r, FETCH_DELAY));
} catch(e) { plog(`Error: ${e.message}`, '#ef4444'); break; }
}
if (postScanCancelled) plog('Cancelled.', '#eab308');
const allNew = [...newPosts, ...updatedPosts];
if (allNew.length) {
plog(`Saving ${newPosts.length} new, ${updatedPosts.length} updated…`);
const toStore = allNew.map(p => ({...p, _date: p._date instanceof Date ? p._date.toISOString() : p._date}));
await dbPutAll(db, toStore, STORE_POSTS);
// Update existing records in memory
const updateMap = new Map(updatedPosts.map(p => [p.id, hydratePost(p)]));
for (let i = 0; i < processedPosts.length; i++) {
if (updateMap.has(processedPosts[i].id)) processedPosts[i] = updateMap.get(processedPosts[i].id);
}
// Prepend new posts
newPosts.map(hydratePost).forEach(p => processedPosts.unshift(p));
processedPosts.forEach((p,i) => postIdMap.set(p.id, i));
document.getElementById('postSyncCached').textContent = processedPosts.length;
document.getElementById('postDbCount').textContent = processedPosts.length;
plog(`✓ ${newPosts.length} added, ${updatedPosts.length} updated.`, '#47cf73');
renderPostsTab();
} else {
plog('No new posts found.', '#47cf73');
}
bar.style.width = '100%';
bar.style.background = allNew.length ? '#47cf73' : color;
setPostScanUI(false);
}
document.getElementById('btnPostScanStart').onclick = () =>
runPostScan(fetchPostPage, 'public', '#f97316', true); // forceUpdate: overwrite private-only records
document.getElementById('btnPostScanPrivateStart').onclick = async () => {
if (!getStoredToken()) {
scanLog('postScanLog','Not connected — click "Connect Account" first.', '#ef4444');
return;
}
// Scan images and albums separately, merge
// Run images scan first, then albums
scanLog('postScanLog','Scanning private images…', '#38bdf8');
await runPostScan(fetchPrivateImagePage, 'private images', '#38bdf8');
scanLog('postScanLog','Scanning private albums…', '#38bdf8');
await runPostScan(fetchPrivateAlbumPage, 'private albums', '#38bdf8');
};
// ─── EXPORT ───────────────────────────────────────────────
function download(name, content, mime) {
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(new Blob([content], {type:mime})),
download: name
});
a.click(); URL.revokeObjectURL(a.href);
}
// ─── POSTS TAB ────────────────────────────────────────────
// Posts lazy loader — created lazily with correct root when tab first renders
let postLazyObs = null;
function setupPostLazy() {
if (postLazyObs) postLazyObs.disconnect();
// Use postSubGallery as root if visible, otherwise fall back to null (viewport)
const galleryEl = document.getElementById('postSubGallery');
const root = (galleryEl && galleryEl.style.display !== 'none') ? galleryEl : null;
postLazyObs = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const ph = entry.target;
swapPlaceholder(ph, ph.dataset.src,
'width:100%;height:180px;object-fit:cover;display:block;');
postLazyObs.unobserve(ph);
});
}, { root, rootMargin: '50px' });
}
function applyPostFilters() {
const vis = document.getElementById('postFilterVis')?.value || 'all';
const type = document.getElementById('postFilterType')?.value || 'all';
const dateFrom = document.getElementById('filterDateFrom')?.value;
const dateTo = document.getElementById('filterDateTo')?.value;
const fromMs = dateFrom ? new Date(dateFrom).getTime() : -Infinity;
const toMs = dateTo ? new Date(dateTo+'T23:59:59').getTime() : Infinity;
return processedPosts.filter(p => {
if (p._date) {
const t = p._date.getTime();
if (t < fromMs || t > toMs) return false;
}
if (vis === 'gallery' && !p._inGallery) return false;
if (vis === 'hidden' && p._inGallery) return false;
if (vis === 'nsfw' && !p._nsfw) return false;
if (type === 'album' && !p._isAlbum) return false;
if (type === 'image' && p._isAlbum) return false;
return true;
});
}
function renderPostsTab() {
const POST_BATCH = 20;
const grid = document.getElementById('postGrid');
console.log('[Posts] grid:', grid, 'processedPosts:', processedPosts.length);
if (!grid) return;
const galleryHeader = document.getElementById('postGalleryHeader');
const statBlock = document.getElementById('postStatBlock');
console.log('[Posts] galleryHeader:', !!galleryHeader, 'statBlock:', !!statBlock);
if (!galleryHeader || !statBlock) return;
// (Re)create lazy observer with correct scrolling root
setupPostLazy();
if (!processedPosts.length) {
grid.innerHTML = `
<div style="grid-column:1/-1;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:12px;padding:60px 20px;text-align:center;color:#a1a1aa;">
<div style="font-size:40px;">📸</div>
<div style="font-size:14px;color:#e4e4e7;">No posts cached yet</div>
<div style="font-size:12px;color:#52525b;">Go to the <b style="color:#f97316;">⟳ Sync</b> tab and click <b style="color:#f97316;">Scan Public</b> to fetch your submissions.</div>
</div>`;
statBlock.innerHTML = '';
return;
}
const posts = applyPostFilters();
const sortKey= document.getElementById('postSort')?.value || 'newest';
const sorted = [...posts].sort((a,b) => {
if (sortKey==='newest') return (b._date?.getTime()||0)-(a._date?.getTime()||0);
if (sortKey==='oldest') return (a._date?.getTime()||0)-(b._date?.getTime()||0);
if (sortKey==='score') return b._pts - a._pts;
if (sortKey==='views') return b._views - a._views;
if (sortKey==='comments') return b._commentCt - a._commentCt;
return 0;
});
// ── Stat pills ──────────────────────────────────────────
const totalViews = posts.reduce((s,p)=>s+p._views,0);
const totalPts = posts.reduce((s,p)=>s+p._pts,0);
const avgPts = posts.length ? (totalPts/posts.length).toFixed(1) : 0;
const galleryPct = posts.length ? Math.round(posts.filter(p=>p._inGallery).length/posts.length*100) : 0;
const peakViews = posts.length ? Math.max(...posts.map(p=>p._views)) : 0;
const peakScore = posts.length ? Math.max(...posts.map(p=>p._pts)) : 0;
statBlock.innerHTML = [
['Showing', `${Math.min(POST_BATCH, posts.length)} of ${posts.length}`],
['Total Cached', processedPosts.length],
['Views', totalViews >= 1000 ? (totalViews/1000).toFixed(1)+'k' : totalViews],
['Net Score', totalPts],
['Avg Score', avgPts],
['Peak Score', peakScore],
['Peak Views', peakViews >= 1000 ? (peakViews/1000).toFixed(1)+'k' : peakViews],
['Gallery', `${galleryPct}%`],
['Albums', posts.filter(p=>p._isAlbum).length],
].map(([k,v]) => `
<div style="background:#09090b;padding:4px 8px;border-radius:4px;border:1px solid #27272a;white-space:nowrap;flex-shrink:0;">
<span style="color:#71717a;font-size:10px;text-transform:uppercase;letter-spacing:0.04em;">${k} </span>
<span style="color:#e4e4e7;font-size:11px;font-weight:600;font-family:monospace;">${v}</span>
</div>`).join('');
// ── Charts ──────────────────────────────────────────────
const ym = [...new Set(posts.filter(p=>p._ymKey).map(p=>p._ymKey))].sort();
const ymMap = {};
posts.forEach(p => {
if (!p._ymKey) return;
if (!ymMap[p._ymKey]) ymMap[p._ymKey] = {n:0,pts:0,views:0,comments:0};
ymMap[p._ymKey].n++;
ymMap[p._ymKey].pts += p._pts;
ymMap[p._ymKey].views += p._views;
ymMap[p._ymKey].comments+= p._commentCt;
});
renderChart('cvPostTrend','line',{
labels:ym,
datasets:[
{label:'Posts',data:ym.map(k=>ymMap[k].n),borderColor:'#f97316',tension:.15,yAxisID:'y1'},
{label:'Avg Score',data:ym.map(k=>ymMap[k].n?+(ymMap[k].pts/ymMap[k].n).toFixed(1):0),borderColor:'#eab308',tension:.15,yAxisID:'y2'},
]
}, dualAxis('Post Volume & Avg Score Over Time'));
renderChart('cvPostViews','line',{
labels:ym,
datasets:[
{label:'Total Views',data:ym.map(k=>ymMap[k].views),borderColor:'#38bdf8',tension:.15,yAxisID:'y1'},
{label:'Total Comments',data:ym.map(k=>ymMap[k].comments),borderColor:'#a855f7',tension:.15,yAxisID:'y2'},
]
}, dualAxis('Views & Comments Over Time'));
// Visibility pie: gallery vs hidden
const galCount = posts.filter(p=>p._inGallery && !p._nsfw).length;
const hiddenCount = posts.filter(p=>!p._inGallery).length;
const nsfwCount = posts.filter(p=>p._nsfw).length;
renderChart('cvPostVisibility','doughnut',{
labels:['Gallery (public)','Hidden','NSFW'],
datasets:[{data:[galCount,hiddenCount,nsfwCount],backgroundColor:['#47cf73','#71717a','#ef4444'],borderWidth:0}]
},{...titled('Post Visibility Split'),scales:{}});
// Engagement rate (pts per 1k views) over time
renderChart('cvPostEngRate','bar',{
labels:ym,
datasets:[{
label:'Pts per 1k views',
data:ym.map(k=>{
const m=ymMap[k];
return m.views>0 ? +(m.pts/m.views*1000).toFixed(2) : 0;
}),
backgroundColor:'#10b981'
}]
}, titled('Engagement Rate (pts / 1k views)'));
// Hour of day distribution
const hourMap = Array.from({length:24},()=>({n:0,pts:0}));
posts.forEach(p=>{ if(p._hour!==null){hourMap[p._hour].n++;hourMap[p._hour].pts+=p._pts;}});
renderChart('cvPostHour','bar',{
labels:HOUR_LABELS,
datasets:[
{label:'Posts',data:hourMap.map(h=>h.n),backgroundColor:'#f97316',yAxisID:'y1'},
{label:'Avg Score',data:hourMap.map(h=>h.n?+(h.pts/h.n).toFixed(1):0),backgroundColor:'#eab308',yAxisID:'y2'},
]
}, dualAxis('Post Performance by Hour'));
// Top tags
const tagFreq={};
posts.forEach(p=>p._tags.forEach(t=>{ tagFreq[t]=(tagFreq[t]||0)+1; }));
const topTags = Object.entries(tagFreq).sort((a,b)=>b[1]-a[1]).slice(0,14);
renderChart('cvPostTags','bar',{
labels:topTags.map(([t])=>t),
datasets:[{label:'Posts with tag',data:topTags.map(([,n])=>n),backgroundColor:'#a855f7'}]
}, titled('Top Tags'));
// Score distribution
const sDist={'<0':0,'0-1':0,'2-10':0,'11-50':0,'51-200':0,'201+':0};
posts.forEach(p=>{
const k=p._pts<0?'<0':p._pts<=1?'0-1':p._pts<=10?'2-10':p._pts<=50?'11-50':p._pts<=200?'51-200':'201+';
sDist[k]++;
});
renderChart('cvPostScoreDist','bar',{
labels:Object.keys(sDist),
datasets:[{label:'Posts',data:Object.values(sDist),backgroundColor:'#14b8a6'}]
}, titled('Post Score Distribution'));
// ── Gallery grid ────────────────────────────────────────
galleryHeader.textContent = `${sorted.length} posts — ${sortKey}`;
grid.innerHTML = '';
// Render first batch, load more on scroll
let postRenderIdx = 0;
function renderPostBatch() {
const slice = sorted.slice(postRenderIdx, postRenderIdx + POST_BATCH);
if (!slice.length) return;
slice.forEach(p => {
const card = document.createElement('div');
card.style.cssText = `background:#18181b;border:1px solid #27272a;border-radius:10px;
overflow:hidden;display:flex;flex-direction:column;cursor:pointer;
transition:border-color 0.15s;`;
card.onmouseenter = () => card.style.borderColor = '#3f3f46';
card.onmouseleave = () => card.style.borderColor = '#27272a';
// Determine preview media — use data-src placeholder, load on scroll
let previewEl;
if (p.cover_link) {
const isGifV = /\.(gifv|mp4|webm)(\?|$)/i.test(p.cover_link);
const isGif = /\.gif(\?|$)/i.test(p.cover_link);
const src = p.cover_link.replace('.gifv', '.mp4');
previewEl = document.createElement('div');
previewEl.className = 'post-lazy-ph';
previewEl.dataset.src = src;
previewEl.dataset.type = isGifV ? 'video' : 'img'; // .gif uses img, .gifv uses video
previewEl.style.cssText = 'width:100%;height:180px;background:#101012;display:flex;align-items:center;justify-content:center;';
previewEl.innerHTML = `<div style="font-size:11px;color:#52525b;">${isGifV ? '🎬' : isGif ? '🎞️' : '🖼️'}</div>`;
} else {
previewEl = document.createElement('div');
previewEl.style.cssText = 'width:100%;height:180px;background:#101012;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;';
previewEl.innerHTML = `
<div style="font-size:36px;">${p._isAlbum ? '📁' : '🖼️'}</div>
<div style="font-size:11px;color:#52525b;">${p._isAlbum ? `${p._imageCount} images` : 'No preview'}</div>`;
}
// Badges
const visBadge = p._inGallery
? `<span style="background:rgba(71,207,115,0.15);color:#47cf73;padding:2px 6px;border-radius:3px;font-size:9px;font-weight:700;">GALLERY</span>`
: `<span style="background:rgba(113,113,122,0.15);color:#71717a;padding:2px 6px;border-radius:3px;font-size:9px;">HIDDEN</span>`;
const nsfwBadge = p._nsfw
? `<span style="background:rgba(239,68,68,0.15);color:#ef4444;padding:2px 6px;border-radius:3px;font-size:9px;font-weight:700;">NSFW</span>` : '';
const albumBadge = p._isAlbum
? `<span style="background:rgba(168,85,247,0.15);color:#a855f7;padding:2px 6px;border-radius:3px;font-size:9px;">📁 ${p._imageCount}</span>` : '';
const ptsColor = p._pts > 0 ? '#eab308' : p._pts < 0 ? '#ef4444' : '#71717a';
const dateStr = p._date ? p._date.toLocaleDateString(undefined, {year:'2-digit',month:'short',day:'numeric'}) : '';
card.innerHTML = `
<div class="post-ph-wrap" style="overflow:hidden;border-radius:10px 10px 0 0;"></div>
<div style="padding:8px 10px;display:flex;flex-direction:column;gap:5px;flex:1;">
<div style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;">
${visBadge}${nsfwBadge}${albumBadge}
<span style="color:#52525b;font-size:9px;margin-left:auto;">${dateStr}</span>
</div>
<a href="${p.link}" target="_blank" style="color:#e4e4e7;font-size:12px;font-weight:600;
text-decoration:none;overflow:hidden;display:-webkit-box;
-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.4;">
${(p.title || '(untitled)').replace(/</g,'<')}
</a>
<div style="display:flex;gap:8px;font-size:11px;font-family:monospace;flex-wrap:wrap;align-items:center;">
<span style="color:${ptsColor};font-weight:700;">${p._pts > 0 ? '+' : ''}${p._pts}</span>
<span style="color:#a1a1aa;">👁 ${p._views >= 1000 ? (p._views/1000).toFixed(1)+'k' : p._views}</span>
<span style="color:#a1a1aa;">↑${p._ups} ↓${p._downs}</span>
<span style="color:#a1a1aa;">💬${p._commentCt}</span>
<span style="color:#a1a1aa;">⭐${p._favCt}</span>
</div>
${p._tags.length ? `<div style="display:flex;gap:3px;flex-wrap:wrap;">
${p._tags.slice(0,4).map(t =>
`<span style="background:#1a1a2e;color:#71717a;padding:1px 5px;border-radius:3px;font-size:9px;">${t}</span>`
).join('')}
</div>` : ''}
</div>
`;
card.querySelector('.post-ph-wrap').appendChild(previewEl);
grid.appendChild(card);
// Observe the placeholder for lazy loading
if (previewEl.classList.contains('post-lazy-ph') && postLazyObs) {
postLazyObs.observe(previewEl);
}
});
postRenderIdx += POST_BATCH;
}
renderPostBatch();
// Load more on scroll
const postGalleryEl = document.getElementById('postSubGallery');
if (postGalleryEl) {
postGalleryEl.onscroll = () => {
if (postRenderIdx >= sorted.length) return;
const { scrollTop, scrollHeight, clientHeight } = postGalleryEl;
if (scrollTop + clientHeight >= scrollHeight - 400) renderPostBatch();
};
}
}
// Sub-tab switching within posts tab
document.querySelectorAll('.post-sub-btn').forEach(btn => {
btn.addEventListener('click', () => {
const sub = btn.dataset.sub;
document.querySelectorAll('.post-sub-btn').forEach(b => {
const isGallery = b.dataset.sub === 'gallery';
const on = b === btn;
b.style.background = on ? '#f97316' : '#18181b';
b.style.color = on ? '#09090b' : '#a1a1aa';
b.style.fontWeight = on ? '700' : '600';
});
document.getElementById('postSubGallery')?.style.setProperty('display', sub === 'gallery' ? 'block' : 'none');
document.getElementById('postSubCharts')?.style.setProperty('display', sub === 'charts' ? 'block' : 'none');
if (sub === 'charts') renderPostsTab();
});
});
// Wire post filters
['postFilterVis','postFilterType','postSort'].forEach(id => {
document.getElementById(id)?.addEventListener('change', renderPostsTab);
});
// ─── MAIN UPDATE ──────────────────────────────────────────
function getFilters() {
return {
minWords: +document.getElementById('filterMinWords').value || 0,
minScore: +document.getElementById('filterMinScore').value,
maxScore: +document.getElementById('filterMaxScore').value,
minVotes: +document.getElementById('filterMinVotes').value || 0,
minHour: +document.getElementById('filterMinHour').value,
maxHour: +document.getElementById('filterMaxHour').value,
mediaMode: document.getElementById('filterMedia').value,
unseenOnly:document.getElementById('filterUnseen').checked,
textQuery: document.getElementById('filterText').value.trim().toLowerCase(),
dateFrom: document.getElementById('filterDateFrom').value,
dateTo: document.getElementById('filterDateTo').value,
};
}
function applyFilters(f) {
const fromMs = f.dateFrom ? new Date(f.dateFrom).getTime() : -Infinity;
const toMs = f.dateTo ? new Date(f.dateTo+'T23:59:59').getTime() : Infinity;
return processedData.filter(c => {
if (c._wordCount < f.minWords) return false;
if (c._pts < f.minScore || c._pts > f.maxScore) return false;
if (c._totalVotes < f.minVotes) return false;
if (c._hour !== null) {
const ok = f.minHour <= f.maxHour
? c._hour >= f.minHour && c._hour <= f.maxHour
: c._hour >= f.minHour || c._hour <= f.maxHour;
if (!ok) return false;
}
if (c._date) {
const t = c._date.getTime();
if (t < fromMs || t > toMs) return false;
}
if (f.mediaMode==='media' && !c._hasMedia) return false;
if (f.mediaMode==='nomedia' && c._hasMedia) return false;
if (f.unseenOnly && !c._isUnseen) return false;
if (f.textQuery && !c.text.toLowerCase().includes(f.textQuery)) return false;
return true;
});
}
function aggregate(filtered) {
const acc = {
totalPts:0, totalUps:0, totalDowns:0, mediaCount:0,
timeMap:{}, hourMap:Array.from({length:24},()=>({n:0,pts:0})),
weekMap:Array.from({length:7},()=>({n:0,pts:0})),
monthMap:Array.from({length:12},()=>({n:0,pts:0})),
mediaDist:{"Plain Text":{n:0,pts:0},"Generic URL":{n:0,pts:0},
"Static Image":{n:0,pts:0},"GIF/Video":{n:0,pts:0},"Mixed Media":{n:0,pts:0}},
lenBuckets:{"1-5":{n:0,pts:0},"6-15":{n:0,pts:0},"16-30":{n:0,pts:0},"31-50":{n:0,pts:0},"51+":{n:0,pts:0}},
scoreDist:{"<0":0,"0-1":0,"2-5":0,"6-20":0,"21-100":0,"101+":0},
puncBkt:{'?':0,'!':0,'...':0,'ALL CAPS':0,'URL/Media':0,'Plain':0},
velBins:{"<1h":0,"1-12h":0,"12-24h":0,"1-7d":0,"7d+":0},
wordFreq:{}, controversy:[], lenScat:[],
nextMedia:[], commentDays:new Set(),
postMap:{},
// syntax score sums (computed in single pass)
synQ:{n:0,pts:0}, synEx:{n:0,pts:0}, synPlain:{n:0,pts:0}, synUrl:{n:0,pts:0},
};
// Sort filtered by date for velocity to be meaningful
const sorted = [...filtered].sort((a,b) => {
const ta = a._date ? a._date.getTime() : 0;
const tb = b._date ? b._date.getTime() : 0;
return ta - tb;
});
sorted.forEach((c, idx) => {
acc.totalPts += c._pts;
acc.totalUps += c._ups;
acc.totalDowns += c._downs;
// Media pool
if (c._hasMedia) {
acc.mediaCount++;
c._mediaUrls.forEach(m => {
if (m.type==='img'||m.type==='gif') acc.nextMedia.push({comment:c, media:m});
});
}
// Media bucket
if (acc.mediaDist[c._mediaBucket]) {
acc.mediaDist[c._mediaBucket].n++;
acc.mediaDist[c._mediaBucket].pts += c._pts;
}
// Length bucket
const lk = c._wordCount<=5?'1-5':c._wordCount<=15?'6-15':c._wordCount<=30?'16-30':c._wordCount<=50?'31-50':'51+';
acc.lenBuckets[lk].n++; acc.lenBuckets[lk].pts += c._pts;
// Length scatter (1-in-5 sample)
if (idx%5===0) acc.lenScat.push({x:c._wordCount, y:c._pts});
// Score dist
const sk = c._pts<0?'<0':c._pts<=1?'0-1':c._pts<=5?'2-5':c._pts<=20?'6-20':c._pts<=100?'21-100':'101+';
acc.scoreDist[sk]++;
// Punctuation (single pass for syntax chart too)
const txt = c.text;
const hasQ = txt.includes('?'), hasEx = txt.includes('!');
const hasDot = /[?!.]/.test(txt);
const hasUrl = c._hasMedia || /https?:\/\//.test(txt);
if (hasQ) { acc.puncBkt['?']++; acc.synQ.n++; acc.synQ.pts += c._pts; }
if (hasEx) { acc.puncBkt['!']++; acc.synEx.n++; acc.synEx.pts += c._pts; }
if (hasUrl) { acc.puncBkt['URL/Media']++; acc.synUrl.n++; acc.synUrl.pts += c._pts; }
if (txt.includes('...')) acc.puncBkt['...']++;
if (c._isAllCaps) acc.puncBkt['ALL CAPS']++;
if (!hasDot && !hasUrl) { acc.puncBkt['Plain']++; acc.synPlain.n++; acc.synPlain.pts += c._pts; }
// Word freq — skip URL fragments and file extensions
c._cleanWords.forEach(w => {
if (!STOPWORDS.has(w) && w.length > 2 && !URL_NOISE.has(w))
acc.wordFreq[w] = (acc.wordFreq[w]||0) + 1;
});
// Controversy + polar
if (c._ups > 0 && c._downs > 0) {
const total = c._ups + c._downs;
const balance = 1 - Math.abs(c._ups - c._downs) / total;
// controversy = total_votes × balance² (rewards both high engagement AND split)
acc.controversy.push({ x: total, y: parseFloat(balance.toFixed(3)), c });
}
// Post map
if (c.post_id) {
if (!acc.postMap[c.post_id]) acc.postMap[c.post_id]={n:0,pts:0,permalink:c.permalink};
acc.postMap[c.post_id].n++;
acc.postMap[c.post_id].pts += c._pts;
}
// Temporal
if (c._date) {
acc.commentDays.add(c._date.toDateString());
const ym = c._ymKey;
if (ym) {
if (!acc.timeMap[ym]) acc.timeMap[ym]={n:0,pts:0,ups:0,downs:0,uqSum:0,wSum:0};
const tm = acc.timeMap[ym];
tm.n++; tm.pts+=c._pts; tm.ups+=c._ups; tm.downs+=c._downs;
tm.uqSum+=c._uniqueWords; tm.wSum+=c._wordCount;
}
acc.hourMap[c._hour].n++; acc.hourMap[c._hour].pts+=c._pts;
acc.weekMap[c._day].n++; acc.weekMap[c._day].pts+=c._pts;
acc.monthMap[c._month].n++;acc.monthMap[c._month].pts+=c._pts;
// Velocity (against date-sorted neighbours)
if (idx>0 && sorted[idx-1]._date) {
const diffH = (c._date - sorted[idx-1]._date) / 3600000;
if (diffH>0) {
const vk = diffH<1?'<1h':diffH<=12?'1-12h':diffH<=24?'12-24h':diffH<=168?'1-7d':'7d+';
acc.velBins[vk]++;
}
}
}
});
return { acc, sorted };
}
function computeStreak(commentDays) {
const days = [...commentDays].map(d => new Date(d)).sort((a,b) => b-a);
let current=0, longest=0, streak=0;
const todayMs = new Date().setHours(0,0,0,0);
for (let i=0; i<days.length; i++) {
streak = i===0 ? 1
: (days[i-1]-days[i])/86400000===1 ? streak+1 : 1;
if (i===0 && (todayMs-days[0].getTime()) < 86400000*2) current=streak;
if (streak>longest) longest=streak;
}
return { current, longest };
}
async function updateDashboard() {
const f = getFilters();
// Update label displays
document.getElementById('valMinWords').textContent = f.minWords;
document.getElementById('valMinVotes').textContent = f.minVotes;
document.getElementById('valHourRange').textContent = `${f.minHour}–${f.maxHour}`;
const filtered = applyFilters(f);
const { acc } = aggregate(filtered);
const streak = computeStreak(acc.commentDays);
// ── Media pool ──
const mediaSortKey = document.getElementById('mediaSort')?.value || 'newest';
acc.nextMedia.sort((a,b) => {
if (mediaSortKey==='newest') return (b.comment._date ? b.comment._date.getTime() : 0) - (a.comment._date ? a.comment._date.getTime() : 0);
if (mediaSortKey==='oldest') return (a.comment._date ? a.comment._date.getTime() : 0) - (b.comment._date ? b.comment._date.getTime() : 0);
if (mediaSortKey==='highest') return b.comment._pts - a.comment._pts;
if (mediaSortKey==='lowest') return a.comment._pts - b.comment._pts;
if (mediaSortKey==='engagement') return b.comment._totalVotes - a.comment._totalVotes;
return 0;
});
mediaPool = acc.nextMedia;
topComments = filtered;
// Media grid renders on demand when tab is activated (switchTab → initMediaScroll)
// Also re-render if media tab is currently visible
if (document.getElementById('tabMedia')?.style.display !== 'none') {
initMediaScroll();
}
// ── Stat block ──
const avg = filtered.length ? (acc.totalPts/filtered.length).toFixed(2) : '0';
const ptsSorted = filtered.map(c=>c._pts).sort((a,b)=>a-b);
const mid = Math.floor(ptsSorted.length/2);
const median = ptsSorted.length ? (ptsSorted.length%2 ? ptsSorted[mid] : ((ptsSorted[mid-1]+ptsSorted[mid])/2).toFixed(1)) : 0;
const peak = filtered.length ? Math.max(...filtered.map(c=>c._pts)) : 0;
const posRate= filtered.length ? ((filtered.filter(c=>c._pts>0).length/filtered.length)*100).toFixed(1) : 0;
const avgLen = filtered.length ? (filtered.reduce((a,c)=>a+c._wordCount,0)/filtered.length).toFixed(1) : 0;
document.getElementById('statBlock').innerHTML = [
['Filtered', `${filtered.length}/${processedData.length}`],
['Net Score', acc.totalPts],
['Avg Score', avg],
['Median', median],
['Peak', peak],
['Positive', `${posRate}%`],
['Media', acc.mediaCount],
['Avg Words', `${avgLen}w`],
['Upvotes', acc.totalUps],
['Downvotes', acc.totalDowns],
['Streak', `${streak.current}d / ${streak.longest}d`],
['Active Days', acc.commentDays.size],
].map(([k,v]) => `
<div style="background:#09090b;padding:5px 6px;border-radius:4px;border:1px solid #27272a;">
<div style="color:#a1a1aa;font-size:11px;text-transform:uppercase;letter-spacing:0.05em;">${k}</div>
<div style="color:#e4e4e7;font-size:13px;font-weight:600;font-family:monospace;">${v}</div>
</div>`).join('');
// ── Personal Records ──
if (filtered.length) {
const byPts = [...filtered].sort((a,b)=>b._pts-a._pts);
const contrScore = c => {
if (!c._ups || !c._downs) return 0;
const total = c._ups + c._downs;
const bal = 1 - Math.abs(c._ups - c._downs) / total;
return total * bal * bal;
};
const byContr= [...filtered].sort((a,b)=>contrScore(b)-contrScore(a));
const byWords= [...filtered].sort((a,b)=>b._wordCount-a._wordCount);
const worst = [...filtered].sort((a,b)=>a._pts-b._pts)[0];
const records = [
['🏆 Best', byPts[0], c=>`+${c._pts}pts`],
['💀 Worst', worst, c=>`${c._pts}pts`],
['🔥 Most Controversial', byContr[0], c=>`${c._ups}↑ ${c._downs}↓`],
['📝 Longest', byWords[0], c=>`${c._wordCount}w`],
];
document.getElementById('recordsBlock').style.display = 'flex';
document.getElementById('recordsList').innerHTML = records.map(([label, c, fmt]) =>
c ? `<div style="border-left:2px solid #27272a;padding-left:6px;">
<div style="color:#a1a1aa;font-size:11px;">${label} <b style="color:#e4e4e7;">${fmt(c)}</b></div>
<div style="color:#d4d4d8;font-size:11px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;max-width:240px;">${c.text.slice(0,80).replace(/</g,'<')}…</div>
<a href="${c.permalink}" target="_blank" style="color:#38bdf8;font-size:10px;text-decoration:none;">↗ view</a>
</div>` : ''
).join('');
}
// ── Heatmap ──
renderHeatmap(filtered);
// ── Top comments ──
renderTopList();
// ── Charts ──
const ym = Object.keys(acc.timeMap).sort();
const topWds = Object.keys(acc.wordFreq).sort((a,b)=>acc.wordFreq[b]-acc.wordFreq[a]).slice(0,14);
// 1. Media performance
renderChart('cvMediaPerf','bar',{
labels:Object.keys(acc.mediaDist),
datasets:[
{label:'Count',data:Object.values(acc.mediaDist).map(v=>v.n),backgroundColor:'#a855f7',yAxisID:'y1'},
{label:'Avg Score',data:Object.values(acc.mediaDist).map(v=>v.n?+(v.pts/v.n).toFixed(2):0),backgroundColor:'#eab308',yAxisID:'y2'}
]
}, dualAxis('Media Format Performance'));
// 2. Word length vs performance
renderChart('cvWordLen','bar',{
labels:Object.keys(acc.lenBuckets),
datasets:[
{label:'Count',data:Object.values(acc.lenBuckets).map(v=>v.n),backgroundColor:'#4f46e5',yAxisID:'y1'},
{label:'Avg Score',data:Object.values(acc.lenBuckets).map(v=>v.n?+(v.pts/v.n).toFixed(2):0),backgroundColor:'#10b981',yAxisID:'y2'}
]
}, dualAxis('Comment Length vs Score'));
// 3. Hourly
renderChart('cvHourly','line',{
labels:HOUR_LABELS,
datasets:[
{label:'Total Score',data:acc.hourMap.map(h=>h.pts),borderColor:'#ff7c43',tension:.15,yAxisID:'y1'},
{label:'Avg Score',data:acc.hourMap.map(h=>h.n?+(h.pts/h.n).toFixed(2):0),borderColor:'#00f2fe',tension:.15,yAxisID:'y2'}
]
}, dualAxis('Score by Hour of Day'));
// 4. Weekday
renderChart('cvWeekday','bar',{
labels:DAY_LABELS,
datasets:[
{label:'Volume',data:acc.weekMap.map(d=>d.n),backgroundColor:'#fc5a8d',yAxisID:'y1'},
{label:'Total Score',data:acc.weekMap.map(d=>d.pts),backgroundColor:'#eab308',yAxisID:'y2'}
]
}, dualAxis('Activity by Day of Week'));
// 5. Monthly
renderChart('cvMonthly','bar',{
labels:MONTH_LABELS,
datasets:[
{label:'Volume',data:acc.monthMap.map(m=>m.n),backgroundColor:'#ec4899',yAxisID:'y1'},
{label:'Net Score',data:acc.monthMap.map(m=>m.pts),backgroundColor:'#3b82f6',yAxisID:'y2'}
]
}, dualAxis('Seasonal Activity'));
// 6. Timeline trend
renderChart('cvTrend','line',{
labels:ym,
datasets:[
{label:'Volume',data:ym.map(k=>acc.timeMap[k].n),borderColor:'#47cf73',tension:.1,yAxisID:'y1'},
{label:'Net Score',data:ym.map(k=>acc.timeMap[k].pts),borderColor:'#38bdf8',tension:.1,yAxisID:'y2'}
]
}, dualAxis('Monthly Volume & Score Trend'));
// 7. Vote stack
renderChart('cvVoteStack','bar',{
labels:ym,
datasets:[
{label:'Upvotes',data:ym.map(k=>acc.timeMap[k].ups),backgroundColor:'#10b981'},
{label:'Downvotes',data:ym.map(k=>-acc.timeMap[k].downs),backgroundColor:'#ef4444'}
]
}, {...titled('Vote Split Over Time'), scales:{x:{...chartBase.scales.x,stacked:true},y:{...chartBase.scales.y,stacked:true}}});
// 8. Score distribution
renderChart('cvScoreDist','bar',{
labels:Object.keys(acc.scoreDist),
datasets:[{label:'Comments',data:Object.values(acc.scoreDist),backgroundColor:'#14b8a6'}]
}, titled('Score Distribution'));
// 9. Controversy — 2D density heatmap (vote bins × split bins) + top controversial list
(() => {
const wrap = document.getElementById('cvControversyWrap');
if (!wrap) return;
// Build density grid: X = vote count buckets, Y = split index buckets
const VOTE_EDGES = [0, 2, 5, 10, 20, 50, 100, 250, Infinity];
const SPLIT_EDGES = [0, 0.2, 0.4, 0.6, 0.8, 1.0];
const VOTE_LABELS = ['1','2-4','5-9','10-19','20-49','50-99','100-249','250+'];
const SPLIT_LABELS= ['0-20%','20-40%','40-60%','60-80%','80-100%'];
const grid = Array.from({length: SPLIT_LABELS.length}, () => Array(VOTE_LABELS.length).fill(0));
acc.controversy.forEach(({x, y}) => {
const xi = VOTE_EDGES.findIndex((e,i) => x >= VOTE_EDGES[i] && x < VOTE_EDGES[i+1]);
const yi = SPLIT_EDGES.findIndex((e,i) => y >= SPLIT_EDGES[i] && y < SPLIT_EDGES[i+1]);
if (xi >= 0 && xi < VOTE_LABELS.length && yi >= 0 && yi < SPLIT_LABELS.length)
grid[yi][xi]++;
});
const maxCell = Math.max(1, ...grid.flat());
// Top 5 most controversial (ups*downs product)
const topContr = [...filtered]
.filter(c => c._ups > 0 && c._downs > 0)
.sort((a,b) => {
const scoreOf = c => {
const total = c._ups + c._downs;
const bal = 1 - Math.abs(c._ups - c._downs) / total;
return total * bal * bal;
};
return scoreOf(b) - scoreOf(a);
})
.slice(0, 5);
const CELL = 28, CH = 18;
let html = `<div style="font-size:11px;color:#e4e4e7;font-weight:700;margin-bottom:4px;">Controversy Map</div>`;
html += `<div style="display:flex;gap:12px;height:calc(100% - 22px);">`;
// Heatmap
html += `<div style="flex-shrink:0;">`;
html += `<table style="border-collapse:collapse;font-size:10px;font-family:monospace;">`;
html += `<tr><td style="padding:1px 4px;"></td>${VOTE_LABELS.map(l=>`<td style="padding:1px 3px;color:#a1a1aa;text-align:center;width:${CELL}px;">${l}</td>`).join('')}</tr>`;
[...SPLIT_LABELS].reverse().forEach((sl, rri) => {
const ri = SPLIT_LABELS.length - 1 - rri;
html += `<tr><td style="padding:1px 4px;color:#a1a1aa;white-space:nowrap;font-size:10px;">${sl}</td>`;
grid[ri].forEach((count, ci) => {
const intensity = count / maxCell;
const r = Math.round(239 * intensity + 71 * (1-intensity));
const g = Math.round(68 * intensity + 207 * (1-intensity));
const b2= Math.round(68 * intensity + 115 * (1-intensity));
const bg = count === 0 ? '#101012' : `rgb(${r},${g},${b2})`;
const tip = `${sl} split, ${VOTE_LABELS[ci]} votes: ${count} comments`;
html += `<td data-tip="${tip}" style="width:${CELL}px;height:${CH}px;background:${bg};border-radius:2px;border:1px solid #09090b;cursor:default;"></td>`;
});
html += '</tr>';
});
html += `</table>`;
html += `<div style="margin-top:4px;font-size:10px;color:#a1a1aa;">← consensus split → (split index)</div>`;
html += `</div>`;
// Top controversial comments
html += `<div style="flex:1;min-width:0;overflow-y:auto;display:flex;flex-direction:column;gap:4px;">`;
html += `<div style="font-size:10px;color:#a1a1aa;font-weight:600;margin-bottom:2px;">Most Controversial</div>`;
topContr.forEach(c => {
const snip = c.text.slice(0,80).replace(/</g,'<');
html += `<div style="background:#09090b;border-radius:4px;padding:4px 6px;font-size:10px;">
<div style="color:#f59e0b;font-family:monospace;margin-bottom:2px;">${c._ups}↑ ${c._downs}↓
<a href="${c.permalink}" target="_blank" style="color:#38bdf8;text-decoration:none;float:right;">↗</a>
</div>
<div style="color:#d4d4d8;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${snip}</div>
</div>`;
});
html += `</div></div>`;
wrap.innerHTML = html;
wireTooltips(wrap, 'td[data-tip]', makeTooltip('contr-tip'));
})();
// 10. Length scatter
renderChart('cvLenScatter','scatter',{
datasets:[{label:'Words vs Score',data:acc.lenScat,backgroundColor:'rgba(56,189,248,.5)'}]
}, {...titled('Comment Length vs Score'),scales:{
x:{...chartBase.scales.x,title:{display:true,text:'Word Count',color:C}},
y:{...chartBase.scales.y,title:{display:true,text:'Score',color:C}}
}});
// 11. Vocabulary
renderChart('cvVocab','bar',{
labels:topWds,
datasets:[{label:'Frequency',data:topWds.map(w=>acc.wordFreq[w]),backgroundColor:'#f43f5e'}]
}, titled('Top Words (stopwords removed)'));
// 12. Word stats — avg total words vs avg unique words per comment over time
renderChart('cvWordStats','line',{
labels:ym,
datasets:[
{
label:'Avg words/comment',
data:ym.map(k => acc.timeMap[k].n ? +(acc.timeMap[k].wSum/acc.timeMap[k].n).toFixed(1) : 0),
borderColor:'#a855f7', tension:.15, yAxisID:'y1', fill:false,
},
{
label:'Avg unique words/comment',
data:ym.map(k => acc.timeMap[k].n ? +(acc.timeMap[k].uqSum/acc.timeMap[k].n).toFixed(1) : 0),
borderColor:'#14b8a6', tension:.15, yAxisID:'y1', fill:false,
}
]
}, titled('Comment Vocabulary Over Time'));
// 13. Punctuation radar
renderChart('cvPunc','radar',{
labels:Object.keys(acc.puncBkt),
datasets:[{label:'Syntax Fingerprint',data:Object.values(acc.puncBkt),backgroundColor:'rgba(99,102,241,.15)',borderColor:'#6366f1'}]
}, {
responsive:true, maintainAspectRatio:false,
layout:{ padding:0 },
plugins:{
legend:{ labels:{ color:'#e4e4e7', font:{size:10}, boxWidth:8, padding:4 } },
title:{ display:true, text:'Syntax Fingerprint', color:'#e4e4e7', font:{size:11}, padding:{top:0,bottom:2} }
},
scales:{r:{grid:{color:G},angleLines:{color:G},pointLabels:{color:C,font:{size:10}},ticks:{display:false}}}
});
// 14. Syntax avg score — with URL/media as own category
renderChart('cvSyntax','bar',{
labels:['Questions (?)', 'Exclamations (!)', 'URL/Media', 'Plain Text'],
datasets:[{
label:'Avg Score',
data:[
acc.synQ.n ? +(acc.synQ.pts/acc.synQ.n).toFixed(2) : 0,
acc.synEx.n ? +(acc.synEx.pts/acc.synEx.n).toFixed(2) : 0,
acc.synUrl.n ? +(acc.synUrl.pts/acc.synUrl.n).toFixed(2) : 0,
acc.synPlain.n? +(acc.synPlain.pts/acc.synPlain.n).toFixed(2): 0,
],
backgroundColor:['#f59e0b','#f43f5e','#a855f7','#14b8a6']
}]
}, titled('Avg Score by Comment Type'));
// 15. Velocity
renderChart('cvVelocity','bar',{
labels:Object.keys(acc.velBins),
datasets:[{label:'Intervals',data:Object.values(acc.velBins),backgroundColor:'#06b6d4'}]
}, titled('Commenting Cadence'));
// 16. Comments-per-post distribution (how often you have back-and-forth vs one-and-done)
const postDist = {'1':{n:0,pts:0},'2':{n:0,pts:0},'3-5':{n:0,pts:0},'6-10':{n:0,pts:0},'11+':{n:0,pts:0}};
Object.values(acc.postMap).forEach(v => {
const k = v.n===1?'1':v.n===2?'2':v.n<=5?'3-5':v.n<=10?'6-10':'11+';
postDist[k].n++;
postDist[k].pts += v.pts;
});
renderChart('cvThread','bar',{
labels: Object.keys(postDist),
datasets:[
{label:'Posts', data:Object.values(postDist).map(v=>v.n), backgroundColor:'#f59e0b', yAxisID:'y1', minBarLength:4, barPercentage:0.8, categoryPercentage:0.7},
{label:'Avg Score', data:Object.values(postDist).map(v=>v.n?+(v.pts/v.n).toFixed(1):0), backgroundColor:'#10b981', yAxisID:'y2', minBarLength:4, barPercentage:0.8, categoryPercentage:0.7},
]
}, {...dualAxis('Comments per Post (1 = one-and-done, 11+ = deep thread)'),
interaction: { mode:'index', intersect:false },
scales:{...dualAxis('').scales,
x:{...chartBase.scales.x, title:{display:true,text:'Your comments on that post',color:C,font:{size:10}}}
}
});
// 17. (cvAvgLen merged into cvWordStats above)
// 18. Engagement rate over time (avg votes per comment per month)
renderChart('cvEngagement','line',{
labels: ym,
datasets:[
{
label:'Avg votes/comment',
data: ym.map(k => {
const m = acc.timeMap[k];
return m.n ? +((m.ups + m.downs) / m.n).toFixed(2) : 0;
}),
borderColor:'#f97316', tension:.15, yAxisID:'y1', fill:false
},
{
label:'Upvote ratio %',
data: ym.map(k => {
const m = acc.timeMap[k];
const total = m.ups + m.downs;
return total ? +(m.ups / total * 100).toFixed(1) : 0;
}),
borderColor:'#47cf73', tension:.15, yAxisID:'y2', fill:false
}
]
}, dualAxis('Engagement Rate Over Time'));
// 19. Activity calendar (GitHub-style SVG)
const dayCount={}, dayScore={};
filtered.forEach(c => {
if (!c._date) return;
const k = c._date.toISOString().slice(0,10);
dayCount[k] = (dayCount[k]||0)+1;
dayScore[k] = (dayScore[k]||0)+c._pts;
});
const wrap = document.getElementById('cvStreakWrap');
if (!wrap) return;
wrap.innerHTML='';
if (!Object.keys(dayCount).length) {
wrap.innerHTML='<div style="color:#a1a1aa;padding:12px;">No dated comments.</div>';
} else {
const today = new Date(); today.setHours(0,0,0,0);
const start = new Date(today); start.setDate(start.getDate()-363-start.getDay());
const CELL=11, GAP=2, STEP=CELL+GAP;
const maxN = Math.max(...Object.values(dayCount));
const weeks=[]; let wk=[];
const cur=new Date(start);
while(cur<=today){ wk.push(new Date(cur)); if(wk.length===7){weeks.push(wk);wk=[];} cur.setDate(cur.getDate()+1); }
if(wk.length) weeks.push(wk);
const W=weeks.length*STEP, H=7*STEP+24;
const mPos=[];
weeks.forEach((w,wi)=>{ if(w[0].getDate()<=7) mPos.push({x:wi*STEP,l:MONTH_LABELS[w[0].getMonth()]}); });
function dcol(k){ const n=dayCount[k]||0; if(!n) return '#1a1a1f';
const t=n/maxN, g=Math.round(80+t*127), a=(0.3+t*0.7).toFixed(2);
return `rgba(71,${g},115,${a})`; }
let svg=`<svg xmlns="http://www.w3.org/2000/svg" width="${W+30}" height="${H}" style="font-family:monospace;overflow:visible;">`;
['S','M','T','W','T','F','S'].forEach((l,i)=>{ if(i%2===1) svg+=`<text x="0" y="${18+i*STEP}" font-size="10" fill="#a1a1aa">${l}</text>`; });
mPos.forEach(({x,l})=>svg+=`<text x="${30+x}" y="8" font-size="10" fill="#a1a1aa">${l}</text>`);
weeks.forEach((wk,wi)=>wk.forEach((day,di)=>{
const k=day.toISOString().slice(0,10);
const n=dayCount[k]||0, pts=dayScore[k]||0;
const fut=day>today;
const tip=fut?'':`${k}: ${n} comment${n!==1?'s':''}, ${pts>0?'+':''}${pts}pts`;
svg+=`<rect x="${30+wi*STEP}" y="${12+di*STEP}" width="${CELL}" height="${CELL}" rx="2" fill="${fut?'transparent':dcol(k)}" stroke="#09090b" stroke-width="0.5" ${tip?`data-tip="${tip}"`:''} style="cursor:${tip?'pointer':'default'};"></rect>`;
}));
svg+='</svg>';
wrap.innerHTML=`
<div style="font-size:12px;color:#47cf73;font-weight:600;margin-bottom:6px;font-family:monospace;">
Streak: current ${streak.current}d | longest ${streak.longest}d | active ${acc.commentDays.size} days
</div>
<div style="overflow-x:auto;">${svg}</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:5px;font-size:11px;color:#a1a1aa;">
<span>Less</span>
${[.1,.3,.5,.75,1].map(t=>`<div style="width:10px;height:10px;border-radius:2px;background:rgba(71,${Math.round(80+t*127)},115,${(0.3+t*0.7).toFixed(2)});"></div>`).join('')}
<span>More</span>
</div>`;
}
const calWrap = document.getElementById('cvStreakWrap');
if (calWrap) wireTooltips(calWrap, 'rect[data-tip]', makeTooltip('cal-tip'));
}
// ─── WIRE CONTROLS ────────────────────────────────────────
// Export — reads current filters at click time, no stale closure
document.getElementById('btnExportCSV').onclick = () => {
const filtered = applyFilters(getFilters());
const cols = ['id','post_id','text','points','ups','downs','created_at','permalink'];
const csv = [cols.join(','), ...filtered.map(c=>cols.map(k=>JSON.stringify(c[k]??'')).join(','))].join('\n');
download(`imgur-${USERNAME}.csv`, csv, 'text/csv');
};
document.getElementById('btnExportJSON').onclick = () => {
const filtered = applyFilters(getFilters());
const clean = filtered.map(({_words,_cleanWords,...r})=>r);
download(`imgur-${USERNAME}.json`, JSON.stringify(clean,null,2), 'application/json');
};
const FILTER_IDS = [
'filterMinWords','filterMinScore','filterMaxScore','filterMinVotes',
'filterMinHour','filterMaxHour','filterMedia','filterUnseen',
'filterText','filterDateFrom','filterDateTo'
];
FILTER_IDS.forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', updateDashboard);
});
// Date preset buttons
document.querySelectorAll('.date-preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const days = +btn.dataset.days;
const from = document.getElementById('filterDateFrom');
const to = document.getElementById('filterDateTo');
if (days === 0) {
from.value = '';
to.value = '';
} else {
const now = new Date();
const start = new Date(now - days * 86400000);
from.value = start.toISOString().slice(0,10);
to.value = now.toISOString().slice(0,10);
}
// Highlight active preset
document.querySelectorAll('.date-preset-btn').forEach(b => {
b.style.background = b === btn ? '#1e3a2f' : '#18181b';
b.style.color = b === btn ? '#47cf73' : '#a1a1aa';
b.style.borderColor = b === btn ? '#166534' : '#3f3f46';
});
updateDashboard();
if (document.getElementById('tabPosts')?.style.display !== 'none') renderPostsTab();
});
});
// Initial render
updateDashboard();
};
})();