NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Uniwersalny Licznik Akcji Moderatora dla Hyperreal
// @namespace http://tampermonkey.net/
// @version 7.1
// @description Dashboard moderatorski z wykresami Canvas, cache localStorage, heatmapą, timelineą, eksportem CSV i pełną responsywnością mobile.
// @author Merx
// @copyright 2026, Merx (https://openuserjs.org/users/Xrem786)
// @match https://hyperreal.info/talk/mcp.php?i=mcp_logs*
// @match https://hyperreal.info/talk/mcp.php?i=150*
// @grant none
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════════
// §1 KONFIGURACJA
// ═══════════════════════════════════════════════════════════════
const TOTAL_PAGES = 250;
const PER_PAGE = 15;
const DELAY_MS = 150;
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 h
const CACHE_PFX = 'mod-counter-';
const COLLAPSE_KEY = 'mod-counter-collapsed';
const COLORS = ['#4fc3f7','#81c784','#ffb74d','#e57373','#ba68c8',
'#4db6ac','#fff176','#f06292','#90a4ae','#aed581'];
const C_HI = '#4fc3f7';
const C_BAR = '#546e7a';
const MONTHS_PL = {
'stycznia':0,'lutego':1,'marca':2,'kwietnia':3,
'maja':4,'czerwca':5,'lipca':6,'sierpnia':7,
'września':8,'października':9,'listopada':10,'grudnia':11
};
const MONTH_NAMES = [
'Styczeń','Luty','Marzec','Kwiecień','Maj','Czerwiec',
'Lipiec','Sierpień','Wrzesień','Październik','Listopad','Grudzień'
];
const ACTION_GROUPS = [
{ name:'Akceptacja', re:/^zaakceptowano/i },
{ name:'Usuwanie', re:/^usunięto/i },
{ name:'Edycja', re:/^edytowano/i },
{ name:'Blokowanie', re:/^(za|od)blokowano/i },
{ name:'Przenoszenie', re:/^przeniesiono/i },
{ name:'Zarządzanie', re:/^(scalono|podzielono|zmieniono|przywrócono)/i },
];
// ═══════════════════════════════════════════════════════════════
// §2 SVG IKONY (Lucide-style 16×16)
// ═══════════════════════════════════════════════════════════════
const I = {
barChart: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="12" width="4" height="9" rx="1"/><rect x="10" y="7" width="4" height="14" rx="1"/><rect x="17" y="3" width="4" height="18" rx="1"/></svg>',
pieChart: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>',
list: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="4" cy="6" r="1" fill="currentColor"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/></svg>',
trash: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
copy: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
download: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
refresh: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
chevronR: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>',
chevronD: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg>',
code: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
trendUp: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>',
trendDn: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="23 18 13.5 8.5 8.5 13.5 1 6"/><polyline points="17 18 23 18 23 12"/></svg>',
timeline: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
grid: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
back: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>',
check: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>',
};
// ═══════════════════════════════════════════════════════════════
// §3 STYLE INJECTION (responsywność + animacje + motyw)
// ═══════════════════════════════════════════════════════════════
function injectStyles() {
if (document.getElementById('mc-styles')) return;
const s = document.createElement('style');
s.id = 'mc-styles';
s.textContent = `
/* --- Karty statystyk --- */
.mc-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin-bottom:16px}
.mc-card{background:var(--mc-card);border-radius:8px;padding:12px 10px;text-align:center;transition:transform .15s,box-shadow .15s}
.mc-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.3)}
.mc-val{font-size:22px;font-weight:bold;line-height:1.2}
.mc-lbl{font-size:11px;color:var(--mc-muted);margin-top:4px}
.mc-trend{font-size:11px;margin-top:2px;font-weight:600}
/* --- Bottom grid --- */
.mc-bottom{display:grid;grid-template-columns:280px 1fr;gap:16px;margin-bottom:16px}
.mc-section-title{font-size:13px;font-weight:bold;margin-bottom:8px;color:var(--mc-text);display:flex;align-items:center;gap:6px}
.mc-action-row{display:flex;justify-content:space-between;padding:6px 8px;border-radius:4px;margin-bottom:2px;font-size:12px;cursor:default}
.mc-action-row:nth-child(even){background:var(--mc-card)}
/* --- Tooltip --- */
.mc-tip{position:fixed;z-index:10000;background:var(--mc-bg);color:var(--mc-text);border:1px solid var(--mc-border);border-radius:6px;padding:6px 10px;font-size:12px;pointer-events:none;display:none;max-width:260px;box-shadow:0 4px 12px rgba(0,0,0,.4);white-space:nowrap}
/* --- Skeleton --- */
.mc-skel{border-radius:4px;background:linear-gradient(90deg,var(--mc-card) 25%,var(--mc-border) 50%,var(--mc-card) 75%);background-size:200% 100%;animation:mc-shimmer 1.5s infinite}
@keyframes mc-shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* --- Confetti overlay --- */
.mc-confetti{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100}
/* --- Heatmap --- */
.mc-hm-grid{display:grid;grid-template-columns:repeat(auto-fill,16px);gap:3px}
.mc-hm-cell{width:14px;height:14px;border-radius:3px;cursor:pointer;transition:transform .1s}
.mc-hm-cell:hover{transform:scale(1.3)}
/* --- Toggle/collapse chevron --- */
.mc-chev{display:inline-flex;transition:transform .2s;margin-right:6px}
.mc-chev.mc-open{transform:rotate(90deg)}
/* --- Badge --- */
.mc-badge{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 8px;border-radius:10px}
.mc-badge-ok{background:#1b5e20;color:#81c784}
.mc-badge-warn{background:#4e342e;color:#ffb74d}
.mc-badge-old{background:#4a1c1c;color:#e57373}
/* --- Group toggle --- */
.mc-group-toggle{font-size:11px;cursor:pointer;color:var(--mc-muted);text-decoration:underline;margin-left:8px}
.mc-group-toggle:hover{color:var(--mc-text)}
/* --- Detail back button --- */
.mc-back{display:inline-flex;align-items:center;gap:4px;font-size:12px;cursor:pointer;color:${C_HI};margin-bottom:8px;border:none;background:none;padding:0}
.mc-back:hover{text-decoration:underline}
/* --- General --- */
.mc-clickable{cursor:pointer}
#mod-counter-container .btn{transition:background .15s,border-color .15s}
#mod-counter-container .btn:focus-visible{outline:2px solid ${C_HI};outline-offset:2px}
#mod-counter-container canvas{cursor:pointer}
.mc-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
/* --- Dark theme vars (default) --- */
:root{--mc-bg:#212529;--mc-card:#2b3035;--mc-text:#cfd8dc;--mc-muted:#90a4ae;--mc-border:#37474f}
/* --- Light override --- */
body.mc-light{--mc-bg:#f5f5f5;--mc-card:#ffffff;--mc-text:#1a1a2e;--mc-muted:#546e7a;--mc-border:#e0e0e0}
/* --- Mobile ≤640px --- */
@media(max-width:640px){
.mc-cards{grid-template-columns:repeat(2,1fr)!important;gap:8px!important}
.mc-card{padding:10px 8px!important}
.mc-val{font-size:18px!important}
.mc-bottom{grid-template-columns:1fr!important}
.mc-controls{flex-direction:column!important;align-items:stretch!important;gap:8px!important}
.mc-controls>*{width:100%!important}
#mod-counter-container .btn{min-height:44px;font-size:14px!important}
#mod-counter-container select{min-height:44px;font-size:14px!important}
.mc-hm-grid{overflow-x:auto}
}
/* --- Reduced motion --- */
@media(prefers-reduced-motion:reduce){
.mc-card,.mc-hm-cell,.mc-chev{transition:none!important}
.mc-skel{animation:none!important}
.mc-confetti{display:none!important}
}
`;
(document.head || document.documentElement).appendChild(s);
}
// ═══════════════════════════════════════════════════════════════
// §4 THEME DETECTION
// ═══════════════════════════════════════════════════════════════
function detectTheme() {
const body = document.body;
if (!body) return;
const bg = getComputedStyle(body).backgroundColor;
if (!bg || bg === 'transparent') return;
const m = bg.match(/\d+/g);
if (!m) return;
const lum = (0.299 * +m[0] + 0.587 * +m[1] + 0.114 * +m[2]) / 255;
if (lum > 0.5) body.classList.add('mc-light');
}
// ═══════════════════════════════════════════════════════════════
// §5 CACHE (localStorage)
// ═══════════════════════════════════════════════════════════════
function cacheKey(mi) {
const now = new Date();
const y = mi > now.getMonth() ? now.getFullYear() - 1 : now.getFullYear();
return `${CACHE_PFX}${y}-${String(mi + 1).padStart(2, '0')}`;
}
function loadCache(mi) {
try { const r = localStorage.getItem(cacheKey(mi)); return r ? JSON.parse(r) : null; }
catch { return null; }
}
function saveCache(mi, stats, daily, monthName) {
try {
localStorage.setItem(cacheKey(mi), JSON.stringify({ stats, daily, monthName, timestamp: Date.now() }));
} catch (e) { console.warn('Cache save failed:', e); }
}
function clearAllCache() {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(CACHE_PFX)) keys.push(k);
}
keys.forEach(k => localStorage.removeItem(k));
}
function isStale(c) { return !c || !c.timestamp || Date.now() - c.timestamp > CACHE_TTL; }
function fmtTs(ts) {
const d = new Date(ts), p = n => String(n).padStart(2, '0');
return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()}, ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function cacheBadge(ts) {
if (!ts) return '';
const age = Date.now() - ts;
if (age < 3600000) return `<span class="mc-badge mc-badge-ok">${I.check} Aktualne</span>`;
if (age < CACHE_TTL) return `<span class="mc-badge mc-badge-warn">Dane z: ${fmtTs(ts)}</span>`;
return `<span class="mc-badge mc-badge-old">Nieaktualne — ${fmtTs(ts)}</span>`;
}
// ═══════════════════════════════════════════════════════════════
// §6 UTILS
// ═══════════════════════════════════════════════════════════════
function getUser() {
const el = document.querySelector('.offcanvas-title .username-coloured')
|| document.querySelector('nav .username-coloured');
return el ? el.textContent.trim() : null;
}
function parseDate(s) {
const t = s.trim().toLowerCase();
// phpBB relative dates like "40 min temu", "mniej niż minutę temu" etc. are all today
if (t === 'dzisiaj' || t.includes('temu') || t.includes('minut') || t.includes('godzin')) {
const d = new Date(); d.setHours(0,0,0,0); return d;
}
if (t === 'wczoraj') { const d = new Date(); d.setDate(d.getDate()-1); d.setHours(0,0,0,0); return d; }
const p = s.trim().split(' ');
if (p.length !== 3) return null;
const day = parseInt(p[0],10), mo = MONTHS_PL[p[1].toLowerCase()], yr = parseInt(p[2],10);
if (isNaN(day)||mo===undefined||isNaN(yr)) return null;
return new Date(yr, mo, day);
}
function median(arr) {
if (!arr.length) return 0;
const s = [...arr].sort((a,b)=>a-b);
const m = Math.floor(s.length/2);
return s.length%2 ? s[m] : (s[m-1]+s[m])/2;
}
function trunc(s, n) { return s.length > n ? s.slice(0,n-1)+'…' : s; }
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
function noMotion() { return window.matchMedia('(prefers-reduced-motion:reduce)').matches; }
function countUp(el, target, duration = 500) {
if (noMotion() || !target) { el.textContent = target; return; }
const start = performance.now();
(function tick(now) {
const p = Math.min((now - start) / duration, 1);
el.textContent = Math.round(target * easeOut(p));
if (p < 1) requestAnimationFrame(tick);
})(performance.now());
}
// ═══════════════════════════════════════════════════════════════
// §6b CONFETTI 🎉 (jeśli #1 w rankingu)
// ═══════════════════════════════════════════════════════════════
function launchConfetti(container) {
if (noMotion()) return;
const wrap = container.closest('.mc-cards') || container;
wrap.style.position = 'relative';
const cvs = document.createElement('canvas');
cvs.className = 'mc-confetti';
const rect = wrap.getBoundingClientRect();
cvs.width = rect.width; cvs.height = rect.height;
wrap.appendChild(cvs);
const ctx = cvs.getContext('2d');
const particles = Array.from({length:60}, () => ({
x: Math.random()*cvs.width, y: -10,
vx: (Math.random()-.5)*5, vy: Math.random()*3+2,
c: COLORS[Math.floor(Math.random()*COLORS.length)],
sz: Math.random()*6+3, r: Math.random()*360, rs: (Math.random()-.5)*10
}));
const t0 = performance.now();
(function draw(now) {
if (now-t0 > 2000) { cvs.remove(); return; }
ctx.clearRect(0,0,cvs.width,cvs.height);
for (const p of particles) {
p.x+=p.vx; p.y+=p.vy; p.vy+=.12; p.r+=p.rs;
ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.r*Math.PI/180);
ctx.fillStyle=p.c; ctx.globalAlpha=Math.max(0,1-(now-t0)/2000);
ctx.fillRect(-p.sz/2,-p.sz/2,p.sz,p.sz); ctx.restore();
}
requestAnimationFrame(draw);
})(t0);
}
// ═══════════════════════════════════════════════════════════════
// §7 ACTION GROUPING
// ═══════════════════════════════════════════════════════════════
function groupActions(actions) {
const grouped = {};
for (const [action, count] of Object.entries(actions)) {
const g = ACTION_GROUPS.find(g => g.re.test(action));
const name = g ? g.name : 'Inne';
grouped[name] = (grouped[name]||0) + count;
}
return grouped;
}
// ═══════════════════════════════════════════════════════════════
// §8 SCANNER (z danymi dziennymi)
// ═══════════════════════════════════════════════════════════════
let scanCtrl = null;
async function scanPages(monthIdx, onProgress) {
if (scanCtrl) scanCtrl.abort();
scanCtrl = new AbortController();
const sig = scanCtrl.signal;
const stats = {}, daily = {};
let targetYear = null;
// Zawsze używaj kanonicznego URL logów — unika problemu z i=150
const base = 'https://hyperreal.info/talk/mcp.php?i=mcp_logs&mode=front';
for (let pg = 1; pg <= TOTAL_PAGES; pg++) {
if (sig.aborted) throw new DOMException('Aborted','AbortError');
const furl = `${base}&start=${(pg-1)*PER_PAGE}`;
if (onProgress) onProgress(pg, TOTAL_PAGES);
const res = await fetch(furl, {signal:sig});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
// Wiersze logów: <div class="row m-0 mt-1 p-1 align-items-center bg1">
// Nie są bezpośrednimi dziećmi .card, więc szukamy bez >
const rows = doc.querySelectorAll('.card.mb-3.pb-1 .row.bg1');
if (!rows.length) break;
let stop = false;
for (const row of rows) {
// Struktura: [col-md-6 akcja] [col-md-2 moderator+IP] [col-md-2 czas]
const cols = row.querySelectorAll(':scope > div[class*="col-md-2"]');
const dEl = cols.length >= 2 ? cols[cols.length - 1] : null;
if (!dEl) continue;
const rawDate = dEl.textContent.replace(/Czas:/i,'').trim();
const d = parseDate(rawDate);
if (!d) continue;
const eM = d.getMonth(), eY = d.getFullYear();
if (eM === monthIdx) {
if (targetYear === null) targetYear = eY;
if (eY === targetYear) {
const mEl = row.querySelector('a.username-coloured');
const aEl = row.querySelector('div[class*="col-md-6"] strong');
if (!mEl||!aEl) continue;
const mod = mEl.textContent.trim();
const act = aEl.textContent.trim().split('\u201E')[0].split('„')[0].trim();
if (!stats[mod]) stats[mod] = {};
stats[mod][act] = (stats[mod][act]||0) + 1;
// daily
const dk = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
if (!daily[mod]) daily[mod] = {};
daily[mod][dk] = (daily[mod][dk]||0) + 1;
} else { stop=true; break; }
} else if (targetYear!==null && (eY<targetYear||(eY===targetYear&&eM<monthIdx))) {
stop=true; break;
}
}
if (stop) break;
await new Promise(r=>setTimeout(r,DELAY_MS));
}
scanCtrl = null;
return { stats, daily };
}
// ═══════════════════════════════════════════════════════════════
// §9 BBCode GENERATOR
// ═══════════════════════════════════════════════════════════════
function genBBCode(stats, monthName) {
const proc = Object.entries(stats).map(([n,a])=>({name:n,actions:a,total:Object.values(a).reduce((s,c)=>s+c,0)})).sort((a,b)=>b.total-a.total);
let bb = `[size=150][b]Statystyki działalności moderatorów — ${monthName}[/b][/size]\n\n`;
proc.forEach((m,i)=>{
if(i>0)bb+='\n[hr]\n\n';
bb+=`[mention]${m.name}[/mention] — łącznie: [b][size=150][color=red]${m.total}[/color][/size][/b]\n[list]\n`;
Object.entries(m.actions).sort((a,b)=>b[1]-a[1]).forEach(([a,c])=>{bb+=`[*]${a}: [b]${c}[/b]\n`;});
bb+='[/list]\n';
});
return bb;
}
// ═══════════════════════════════════════════════════════════════
// §10 CSV EXPORT
// ═══════════════════════════════════════════════════════════════
function exportCSV(stats, monthName) {
let csv = '\uFEFFModerator;Akcja;Ilość\n';
for (const [mod, actions] of Object.entries(stats)) {
for (const [act, cnt] of Object.entries(actions)) {
csv += `${mod};${act};${cnt}\n`;
}
}
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `moderacja_${monthName.toLowerCase()}.csv`;
a.click();
URL.revokeObjectURL(a.href);
}
// ═══════════════════════════════════════════════════════════════
// §11 TOOLTIP SYSTEM
// ═══════════════════════════════════════════════════════════════
let tipEl = null;
function showTip(x, y, html) {
if (!tipEl) { tipEl = document.createElement('div'); tipEl.className='mc-tip'; document.body.appendChild(tipEl); }
tipEl.innerHTML = html;
tipEl.style.display = 'block';
// Clamp to viewport
const vw = window.innerWidth, vh = window.innerHeight;
let left = x + 14, top = y - 10;
tipEl.style.left = left+'px'; tipEl.style.top = top+'px';
const r = tipEl.getBoundingClientRect();
if (r.right > vw) tipEl.style.left = (x - r.width - 8)+'px';
if (r.bottom > vh) tipEl.style.top = (y - r.height - 8)+'px';
}
function hideTip() { if (tipEl) tipEl.style.display = 'none'; }
// ═══════════════════════════════════════════════════════════════
// §12 CHARTS — Canvas API
// ═══════════════════════════════════════════════════════════════
// --- 12a: Bar chart (ranking) ---
function drawBarChart(canvas, processed, currentUser, onBarClick) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const mobile = (canvas.parentElement.clientWidth || 600) < 500;
const barH = mobile ? 24 : 28, gap = mobile ? 4 : 6;
const lblW = mobile ? 90 : 160;
const pad = { t:10, r:70, b:10, l:lblW+10 };
const W = canvas.parentElement.clientWidth || 600;
const H = pad.t+pad.b+processed.length*(barH+gap)-gap;
canvas.width=W*dpr; canvas.height=H*dpr;
canvas.style.width=W+'px'; canvas.style.height=H+'px';
ctx.scale(dpr,dpr);
const max = processed.length ? processed[0].total : 1;
const barW_area = W-pad.l-pad.r;
const rects = [];
processed.forEach((m,i)=>{
const y = pad.t+i*(barH+gap);
const w = Math.max(4, (m.total/max)*barW_area);
const isMe = currentUser && m.name.toLowerCase()===currentUser.toLowerCase();
ctx.fillStyle = isMe ? C_HI : C_BAR;
ctx.beginPath(); ctx.roundRect(pad.l,y,w,barH,4); ctx.fill();
// Label
ctx.fillStyle = isMe ? C_HI : '#cfd8dc';
ctx.font = (isMe?'bold ':'') + (mobile?'10':'12') + 'px system-ui,sans-serif';
ctx.textAlign='right'; ctx.textBaseline='middle';
const label = isMe ? `★ ${trunc(m.name,mobile?10:20)}` : trunc(m.name,mobile?10:20);
ctx.fillText(label, pad.l-8, y+barH/2);
// Count
ctx.fillStyle = isMe ? '#fff' : '#90a4ae';
ctx.font = (isMe?'bold ':'') + (mobile?'11':'13') + 'px system-ui,sans-serif';
ctx.textAlign='left';
ctx.fillText(m.total.toString(), pad.l+w+8, y+barH/2);
rects.push({x:pad.l,y,w,h:barH,mod:m});
});
// Hover + tooltip
let hoverIdx = -1;
canvas.onmousemove = (e) => {
const cr = canvas.getBoundingClientRect();
const mx=e.clientX-cr.left, my=e.clientY-cr.top;
let found = -1;
for (let i=0;i<rects.length;i++) {
const r=rects[i];
if (mx>=0 && mx<=W && my>=r.y && my<=r.y+r.h) { found=i; break; }
}
if (found !== hoverIdx) {
hoverIdx = found;
if (found >= 0) {
const m = rects[found].mod;
const total = processed.reduce((s,x)=>s+x.total,0);
showTip(e.clientX, e.clientY, `<b>${m.name}</b>: ${m.total} akcji (${((m.total/total)*100).toFixed(1)}%)`);
} else { hideTip(); }
} else if (found >= 0) {
showTip(e.clientX, e.clientY, tipEl.innerHTML);
}
};
canvas.onmouseleave = () => { hoverIdx=-1; hideTip(); };
// Click → detail
canvas.onclick = (e) => {
const cr = canvas.getBoundingClientRect();
const mx=e.clientX-cr.left, my=e.clientY-cr.top;
for (const r of rects) {
if (mx>=0 && mx<=W && my>=r.y && my<=r.y+r.h) {
hideTip();
if (onBarClick) onBarClick(r.mod);
break;
}
}
};
// Touch support
canvas.ontouchstart = (e) => {
const t = e.touches[0], cr = canvas.getBoundingClientRect();
const mx=t.clientX-cr.left, my=t.clientY-cr.top;
for (const r of rects) {
if (mx>=0 && mx<=W && my>=r.y && my<=r.y+r.h) {
if (onBarClick) onBarClick(r.mod);
break;
}
}
};
}
// --- 12b: Donut chart ---
function drawDonutChart(canvas, actions) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const sz = Math.min(canvas.parentElement.clientWidth || 260, 260);
canvas.width=sz*dpr; canvas.height=sz*dpr;
canvas.style.width=sz+'px'; canvas.style.height=sz+'px';
ctx.scale(dpr,dpr);
const sorted = Object.entries(actions).sort((a,b)=>b[1]-a[1]);
const total = sorted.reduce((s,[,c])=>s+c,0);
const slices=[]; let other=0;
sorted.forEach(([n,c],i)=>{
if(i<5) slices.push({name:n,count:c,pct:((c/total)*100).toFixed(1)});
else other+=c;
});
if(other>0) slices.push({name:'Inne',count:other,pct:((other/total)*100).toFixed(1)});
const cx=sz/2, cy=sz/2, oR=sz/2-10, iR=oR*.55;
let ang = -Math.PI/2;
const sliceAngles = [];
slices.forEach((s,i)=>{
const sa = (s.count/total)*2*Math.PI;
ctx.beginPath(); ctx.arc(cx,cy,oR,ang,ang+sa); ctx.arc(cx,cy,iR,ang+sa,ang,true); ctx.closePath();
ctx.fillStyle=COLORS[i%COLORS.length]; ctx.fill();
sliceAngles.push({start:ang, end:ang+sa, slice:s, color:COLORS[i%COLORS.length]});
ang+=sa;
});
ctx.fillStyle='var(--mc-text,#cfd8dc)'; ctx.font='bold 26px system-ui,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillStyle='#cfd8dc'; ctx.fillText(total.toString(),cx,cy-8);
ctx.font='11px system-ui,sans-serif'; ctx.fillStyle='#90a4ae';
ctx.fillText('akcji',cx,cy+14);
// Tooltip
canvas.onmousemove = (e) => {
const cr=canvas.getBoundingClientRect();
const mx=e.clientX-cr.left, my=e.clientY-cr.top;
const dx=mx-cx, dy=my-cy, dist=Math.sqrt(dx*dx+dy*dy);
if (dist<iR||dist>oR) { hideTip(); return; }
let angle = Math.atan2(dy,dx);
if (angle < -Math.PI/2) angle += 2*Math.PI;
for (const sa of sliceAngles) {
let s=sa.start, en=sa.end;
if (s<-Math.PI/2) s+=2*Math.PI;
if (en<-Math.PI/2) en+=2*Math.PI;
if (angle>=sa.start && angle<sa.end) {
showTip(e.clientX,e.clientY,`<span style="color:${sa.color}">●</span> ${sa.slice.name}: <b>${sa.slice.count}</b> (${sa.slice.pct}%)`);
return;
}
}
hideTip();
};
canvas.onmouseleave = () => hideTip();
return slices;
}
// --- 12c: Line chart (timeline multi-month) ---
function drawLineChart(canvas, currentUser) {
const months = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (!k || !k.startsWith(CACHE_PFX)) continue;
try {
const data = JSON.parse(localStorage.getItem(k));
if (!data || !data.stats) continue;
const ym = k.replace(CACHE_PFX,'');
const [yr,mo] = ym.split('-').map(Number);
const uStats = currentUser ? data.stats[currentUser] : null;
const total = uStats ? Object.values(uStats).reduce((s,c)=>s+c,0) : 0;
months.push({key:ym, yr, mo:mo-1, label:MONTH_NAMES[mo-1]?.slice(0,3)||'?', total});
} catch {}
}
if (months.length < 2) { canvas.style.display='none'; return false; }
months.sort((a,b)=>a.key.localeCompare(b.key));
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const W = canvas.parentElement.clientWidth || 400, H = 140;
canvas.width=W*dpr; canvas.height=H*dpr;
canvas.style.width=W+'px'; canvas.style.height=H+'px';
ctx.scale(dpr,dpr);
const pad = {t:20,r:20,b:30,l:40};
const aW = W-pad.l-pad.r, aH = H-pad.t-pad.b;
const maxV = Math.max(...months.map(m=>m.total),1);
// Grid lines
ctx.strokeStyle='#37474f'; ctx.lineWidth=1;
for (let i=0;i<=4;i++) {
const y = pad.t+aH*(1-i/4);
ctx.beginPath(); ctx.moveTo(pad.l,y); ctx.lineTo(W-pad.r,y); ctx.stroke();
ctx.fillStyle='#546e7a'; ctx.font='10px system-ui,sans-serif';
ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.fillText(Math.round(maxV*i/4).toString(), pad.l-6, y);
}
// Points + line
const points = months.map((m,i)=>{
const x = pad.l + (months.length===1?aW/2:i/(months.length-1)*aW);
const y = pad.t + aH*(1-m.total/maxV);
return {x,y,...m};
});
ctx.strokeStyle=C_HI; ctx.lineWidth=2.5; ctx.lineJoin='round';
ctx.beginPath();
points.forEach((p,i)=>i===0?ctx.moveTo(p.x,p.y):ctx.lineTo(p.x,p.y));
ctx.stroke();
// Fill under
ctx.globalAlpha=.1; ctx.fillStyle=C_HI;
ctx.beginPath();
ctx.moveTo(points[0].x,pad.t+aH);
points.forEach(p=>ctx.lineTo(p.x,p.y));
ctx.lineTo(points[points.length-1].x,pad.t+aH);
ctx.closePath(); ctx.fill(); ctx.globalAlpha=1;
// Dots + labels
points.forEach(p=>{
ctx.fillStyle=C_HI;
ctx.beginPath(); ctx.arc(p.x,p.y,4,0,Math.PI*2); ctx.fill();
ctx.fillStyle='#fff';
ctx.beginPath(); ctx.arc(p.x,p.y,2,0,Math.PI*2); ctx.fill();
// X label
ctx.fillStyle='#90a4ae'; ctx.font='10px system-ui,sans-serif';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(p.label, p.x, H-pad.b+6);
// Value above dot
ctx.fillStyle='#cfd8dc'; ctx.font='bold 10px system-ui,sans-serif';
ctx.textBaseline='bottom';
ctx.fillText(p.total.toString(), p.x, p.y-8);
});
return true;
}
// --- 12d: Heatmap (aktywność dzienna — styl GitHub) ---
function renderHeatmap(container, dailyData, monthIdx) {
container.innerHTML = '';
if (!dailyData || !Object.keys(dailyData).length) {
container.innerHTML='<div style="color:var(--mc-muted);font-size:12px">Brak danych dziennych.</div>';
return;
}
const now = new Date();
const year = monthIdx > now.getMonth() ? now.getFullYear()-1 : now.getFullYear();
const daysInMonth = new Date(year, monthIdx+1, 0).getDate();
const counts = [];
let maxC = 0;
for (let d=1;d<=daysInMonth;d++) {
const key = `${year}-${String(monthIdx+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const c = dailyData[key]||0;
counts.push({day:d,count:c,key});
if(c>maxC) maxC=c;
}
const grid = document.createElement('div');
grid.className='mc-hm-grid';
grid.style.gridTemplateColumns=`repeat(${Math.min(daysInMonth,Math.ceil(daysInMonth/1))},16px)`;
counts.forEach(c=>{
const cell = document.createElement('div');
cell.className='mc-hm-cell';
const intensity = maxC ? c.count/maxC : 0;
if (c.count===0) {
cell.style.background='var(--mc-card)';
} else {
const alpha = 0.2 + intensity * 0.8;
cell.style.background=`rgba(79,195,247,${alpha})`;
}
cell.title=`${c.day}. — ${c.count} akcji`;
cell.addEventListener('mouseenter',(e)=>showTip(e.clientX,e.clientY,`<b>Dzień ${c.day}</b>: ${c.count} akcji`));
cell.addEventListener('mouseleave',hideTip);
grid.appendChild(cell);
});
// Day numbers below
const labels = document.createElement('div');
labels.style.cssText='display:flex;gap:3px;margin-top:4px;font-size:9px;color:var(--mc-muted)';
for (let d=1;d<=daysInMonth;d++) {
const l = document.createElement('div');
l.style.cssText='width:14px;text-align:center';
l.textContent = d%5===0||d===1?d:'';
labels.appendChild(l);
}
container.appendChild(grid);
container.appendChild(labels);
}
// ═══════════════════════════════════════════════════════════════
// §13 SKELETON LOADING
// ═══════════════════════════════════════════════════════════════
function renderSkeleton(el) {
el.innerHTML = `
<div style="padding:5px 0">
<div class="mc-cards" style="margin-bottom:16px">
${[1,2,3,4].map(()=>'<div class="mc-card"><div class="mc-skel" style="height:50px"></div></div>').join('')}
</div>
<div class="mc-skel" style="height:120px;border-radius:6px;margin-bottom:12px"></div>
<div class="mc-bottom">
<div class="mc-skel" style="height:200px;border-radius:6px"></div>
<div class="mc-skel" style="height:200px;border-radius:6px"></div>
</div>
</div>`;
}
// ═══════════════════════════════════════════════════════════════
// §14 DASHBOARD RENDERER
// ═══════════════════════════════════════════════════════════════
let _dashState = {}; // przechowuje kontekst do re-renderowania detali
function renderDashboard(stats, daily, monthName, timestamp, monthIdx) {
const result = document.getElementById('mc-result');
if (!result) return;
result.innerHTML = '';
const user = getUser();
const proc = Object.entries(stats).map(([n,a])=>({
name:n, actions:a, total:Object.values(a).reduce((s,c)=>s+c,0)
})).sort((a,b)=>b.total-a.total);
if (!proc.length) {
result.innerHTML='<div style="padding:15px;color:var(--mc-muted)">Brak danych dla wybranego miesiąca.</div>';
return;
}
_dashState = { stats, daily, proc, user, monthName, monthIdx, timestamp };
const teamTotal = proc.reduce((s,m)=>s+m.total,0);
const teamAvg = (teamTotal/proc.length).toFixed(1);
const teamMed = median(proc.map(m=>m.total));
const my = user ? proc.find(m=>m.name.toLowerCase()===user.toLowerCase()) : null;
const myTotal = my?my.total:0;
const myRank = my?proc.indexOf(my)+1:'-';
const myPct = my?((myTotal/teamTotal)*100).toFixed(1):'0';
// Trend vs prev month
let trendHtml = '';
if (my) {
const prevMi = (monthIdx-1+12)%12;
const prevCache = loadCache(prevMi);
if (prevCache && prevCache.stats && prevCache.stats[my.name]) {
const prevTotal = Object.values(prevCache.stats[my.name]).reduce((s,c)=>s+c,0);
if (prevTotal > 0) {
const pct = ((myTotal-prevTotal)/prevTotal*100).toFixed(0);
const up = myTotal >= prevTotal;
trendHtml = `<div class="mc-trend" style="color:${up?'#81c784':'#e57373'}">${up?I.trendUp:I.trendDn} ${up?'+':''}${pct}% vs ${MONTH_NAMES[prevMi].slice(0,3)}</div>`;
}
}
}
const wrap = document.createElement('div');
wrap.style.padding='5px 0';
// --- Header z badge ---
const hdr = document.createElement('div');
hdr.style.cssText='display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:5px';
hdr.innerHTML=`<div style="font-size:15px;font-weight:bold;display:flex;align-items:center;gap:6px">${I.barChart} Statystyki — ${monthName}</div><div>${cacheBadge(timestamp)}</div>`;
wrap.appendChild(hdr);
// --- Stat cards ---
if (my) {
const cards = document.createElement('div');
cards.className='mc-cards';
const data = [
{lbl:'Twoje akcje',val:myTotal,color:C_HI,trend:trendHtml,animate:true},
{lbl:'Ranking',val:`#${myRank} / ${proc.length}`,color:'#81c784'},
{lbl:'% wkładu',val:`${myPct}%`,color:'#ffb74d'},
{lbl:'Śr. drużyny',val:teamAvg,color:'#ba68c8'},
{lbl:'Mediana',val:teamMed,color:'#4db6ac'},
];
data.forEach(cd=>{
const c = document.createElement('div');
c.className='mc-card';
const valEl = document.createElement('div');
valEl.className='mc-val';
valEl.style.color=cd.color;
if (cd.animate && typeof cd.val==='number') {
valEl.textContent='0';
setTimeout(()=>countUp(valEl,cd.val,600),100);
} else {
valEl.textContent=cd.val;
}
c.appendChild(valEl);
c.insertAdjacentHTML('beforeend',`<div class="mc-lbl">${cd.lbl}</div>`);
if(cd.trend) c.insertAdjacentHTML('beforeend',cd.trend);
cards.appendChild(c);
});
wrap.appendChild(cards);
// Confetti if #1 🎉
if (myRank === 1) setTimeout(()=>launchConfetti(cards), 300);
}
// --- Bar chart ---
const barSec = document.createElement('div');
barSec.style.marginBottom='16px';
barSec.innerHTML=`<div class="mc-section-title">${I.barChart} Ranking moderatorów <span style="font-weight:normal;font-size:11px;color:var(--mc-muted)">(kliknij słupek)</span></div>`;
const barCvs = document.createElement('canvas');
barSec.appendChild(barCvs);
wrap.appendChild(barSec);
// --- Bottom detail section ---
const bottomWrap = document.createElement('div');
bottomWrap.id='mc-bottom-detail';
wrap.appendChild(bottomWrap);
// Render bottom for current user (or first mod if no user)
function renderBottom(modData) {
bottomWrap.innerHTML='';
if (!modData) return;
const isMe = user && modData.name.toLowerCase()===user.toLowerCase();
// Back button if viewing someone else
if (!isMe) {
const back = document.createElement('button');
back.type='button';
back.className='mc-back';
back.innerHTML=`${I.back} Powrót do moich statystyk`;
back.onclick=()=>renderBottom(my||proc[0]);
bottomWrap.appendChild(back);
}
const viewLabel = isMe ? 'Twoje' : modData.name;
const row = document.createElement('div');
row.className='mc-bottom';
// Donut
const donutSec = document.createElement('div');
donutSec.style.textAlign='center';
// Group toggle
let showGrouped = false;
const titleRow = document.createElement('div');
titleRow.className='mc-section-title';
titleRow.style.justifyContent='center';
titleRow.innerHTML=`${I.pieChart} Podział akcji (${viewLabel})`;
const toggleGrp = document.createElement('span');
toggleGrp.className='mc-group-toggle';
toggleGrp.textContent='grupuj';
titleRow.appendChild(toggleGrp);
donutSec.appendChild(titleRow);
const donutCvs = document.createElement('canvas');
donutSec.appendChild(donutCvs);
const legendDiv = document.createElement('div');
legendDiv.style.cssText='margin-top:8px;text-align:left;padding-left:10px';
donutSec.appendChild(legendDiv);
function redrawDonut() {
legendDiv.innerHTML='';
const acts = showGrouped ? groupActions(modData.actions) : modData.actions;
const slices = drawDonutChart(donutCvs, acts);
slices.forEach((s,i)=>{
const item = document.createElement('div');
item.style.cssText='display:flex;align-items:center;gap:6px;margin-bottom:3px;font-size:11px';
item.innerHTML=`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${COLORS[i%COLORS.length]}"></span><span style="color:var(--mc-text)">${s.name}</span><span style="color:var(--mc-muted);margin-left:auto">${s.pct}%</span>`;
legendDiv.appendChild(item);
});
toggleGrp.textContent = showGrouped ? 'szczegóły' : 'grupuj';
}
toggleGrp.onclick = () => { showGrouped=!showGrouped; redrawDonut(); };
row.appendChild(donutSec);
// Top actions list
const topSec = document.createElement('div');
topSec.innerHTML=`<div class="mc-section-title">${I.list} Top akcje (${viewLabel})</div>`;
const topList = document.createElement('div');
Object.entries(modData.actions).sort((a,b)=>b[1]-a[1]).forEach(([act,cnt],i)=>{
const r = document.createElement('div');
r.className='mc-action-row';
r.innerHTML=`<span style="color:var(--mc-text)">${act}</span><span style="font-weight:bold;color:${COLORS[i%COLORS.length]}">${cnt}</span>`;
topList.appendChild(r);
});
topSec.appendChild(topList);
row.appendChild(topSec);
bottomWrap.appendChild(row);
// --- Heatmap (daily activity) ---
const modDaily = (daily && daily[modData.name]) ? daily[modData.name] : null;
if (modDaily) {
const hmSec = document.createElement('div');
hmSec.style.marginBottom='16px';
hmSec.innerHTML=`<div class="mc-section-title">${I.grid} Aktywność dzienna (${viewLabel})</div>`;
const hmContainer = document.createElement('div');
hmSec.appendChild(hmContainer);
bottomWrap.appendChild(hmSec);
renderHeatmap(hmContainer, modDaily, monthIdx);
}
setTimeout(redrawDonut, 0);
}
// --- Line chart (timeline — multi-month) ---
if (user) {
const lineSec = document.createElement('div');
lineSec.style.marginBottom='16px';
lineSec.innerHTML=`<div class="mc-section-title">${I.timeline} Twoja aktywność w czasie</div>`;
const lineCvs = document.createElement('canvas');
lineSec.appendChild(lineCvs);
wrap.appendChild(lineSec);
setTimeout(()=>{
const ok = drawLineChart(lineCvs, user);
if (!ok) lineSec.style.display='none';
},0);
}
// --- BBCode + CSV (ukryte) ---
const actionsSec = document.createElement('div');
actionsSec.style.marginTop='8px';
const actRow = document.createElement('div');
actRow.style.cssText='display:flex;gap:8px;align-items:center;flex-wrap:wrap';
const togBB = mkBtn('btn-outline-secondary', `${I.chevronR} Pokaż BBCode`);
const cpBB = mkBtn('btn-primary', `${I.copy} Kopiuj BBCode`);
cpBB.style.display='none';
const dlCSV = mkBtn('btn-outline-secondary', `${I.download} CSV`);
const bbTA = document.createElement('textarea');
bbTA.className='form-control';
bbTA.style.cssText='width:100%;height:260px;margin-top:8px;font-family:monospace;font-size:11px;display:none;resize:vertical';
bbTA.readOnly=true;
bbTA.value=genBBCode(stats,monthName);
togBB.onclick=()=>{
const show=bbTA.style.display==='none';
bbTA.style.display=show?'block':'none';
cpBB.style.display=show?'inline-flex':'none';
togBB.innerHTML=show?`${I.chevronD} Ukryj BBCode`:`${I.chevronR} Pokaż BBCode`;
};
cpBB.onclick=()=>{
navigator.clipboard.writeText(bbTA.value).then(()=>{
cpBB.innerHTML=`${I.check} Skopiowano!`;
setTimeout(()=>{cpBB.innerHTML=`${I.copy} Kopiuj BBCode`;},2000);
}).catch(()=>{bbTA.focus();bbTA.select();document.execCommand('copy');
cpBB.innerHTML=`${I.check} Skopiowano!`;
setTimeout(()=>{cpBB.innerHTML=`${I.copy} Kopiuj BBCode`;},2000);
});
};
dlCSV.onclick=()=>exportCSV(stats,monthName);
actRow.append(togBB,cpBB,dlCSV);
actionsSec.append(actRow,bbTA);
wrap.appendChild(actionsSec);
result.appendChild(wrap);
// Draw charts after DOM attach
setTimeout(()=>{
drawBarChart(barCvs, proc, user, (mod)=>renderBottom(mod));
renderBottom(my || proc[0]);
},0);
}
// ═══════════════════════════════════════════════════════════════
// §15 SCAN RUNNER
// ═══════════════════════════════════════════════════════════════
async function runScan(mi, mName) {
const prog = document.getElementById('mc-progress');
const pbar = document.getElementById('mc-pbar');
const refBtn = document.getElementById('mc-refresh');
const scanBtn = document.getElementById('mc-scan');
if(refBtn){refBtn.disabled=true;refBtn.innerHTML=`${I.refresh} Skanowanie...`;}
if(scanBtn)scanBtn.disabled=true;
if(prog)prog.style.display='block';
if(pbar){pbar.style.display='block';pbar.value=0;}
try {
const {stats,daily} = await scanPages(mi,(pg,tot)=>{
if(prog)prog.textContent=`Skanowanie strony ${pg} / ${tot}...`;
if(pbar){pbar.value=pg;pbar.max=tot;}
});
saveCache(mi,stats,daily,mName);
if(Object.keys(stats).length) {
renderDashboard(stats,daily,mName,Date.now(),mi);
} else {
const r=document.getElementById('mc-result');
if(r)r.innerHTML='<div style="padding:10px;color:var(--mc-muted)">Brak akcji w wybranym miesiącu.</div>';
}
} catch(e) {
if(e.name==='AbortError')return;
console.error(e);
if(prog){prog.textContent=`Błąd: ${e.message}`;prog.style.color='#ff6b6b';prog.style.display='block';}
} finally {
if(pbar)pbar.style.display='none';
if(prog&&prog.style.color!=='rgb(255, 107, 107)')prog.style.display='none';
if(refBtn){refBtn.disabled=false;refBtn.innerHTML=`${I.refresh} Odśwież`;}
if(scanBtn)scanBtn.disabled=false;
}
}
// ═══════════════════════════════════════════════════════════════
// §16 UI BUILDER
// ═══════════════════════════════════════════════════════════════
function mkBtn(cls, html) {
const b = document.createElement('div');
b.className=`btn btn-sm ${cls}`;
b.style.cssText='font-size:12px;display:inline-flex;align-items:center;gap:4px;cursor:pointer';
b.innerHTML=html;
return b;
}
function createUI() {
if (document.getElementById('mc-container')) return;
const target = document.querySelector('h4.cp-page-title');
if (!target) return;
injectStyles();
detectTheme();
const ctr = document.createElement('div');
ctr.id='mc-container'; ctr.className='card mb-3';
// --- Header (collapsible) ---
const hdr = document.createElement('div');
hdr.className='card-header mc-clickable';
hdr.style.cssText='display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;user-select:none';
const collapsed = localStorage.getItem(COLLAPSE_KEY)==='1';
const titleSpan = document.createElement('span');
titleSpan.style.cssText='font-weight:bold;display:flex;align-items:center;gap:6px';
titleSpan.innerHTML=`<span class="mc-chev${collapsed?'':' mc-open'}">${I.chevronR}</span>Licznik Akcji Moderatorów`;
const clearBtn = mkBtn('btn-outline-danger', `${I.trash} Wyczyść cache`);
clearBtn.style.fontSize='11px';
clearBtn.onclick=(e)=>{
e.stopPropagation();
clearAllCache();
const r=document.getElementById('mc-result'); if(r)r.innerHTML='';
clearBtn.innerHTML=`${I.check} Wyczyszczono`;
setTimeout(()=>{clearBtn.innerHTML=`${I.trash} Wyczyść cache`;},2000);
};
hdr.append(titleSpan, clearBtn);
ctr.appendChild(hdr);
// --- Body ---
const body = document.createElement('div');
body.className='card-body';
body.id='mc-body';
body.style.padding='12px 15px';
if (collapsed) body.style.display='none';
// Toggle collapse
hdr.onclick=()=>{
const isCollapsed=body.style.display==='none';
body.style.display=isCollapsed?'':'none';
titleSpan.querySelector('.mc-chev').classList.toggle('mc-open',isCollapsed);
localStorage.setItem(COLLAPSE_KEY,isCollapsed?'0':'1');
};
// Controls
const controls = document.createElement('div');
controls.className='mc-controls';
const label = document.createElement('label');
label.textContent='Miesiąc:'; label.style.fontWeight='500';
const sel = document.createElement('select');
sel.id='mc-month'; sel.className='form-select form-select-sm'; sel.style.width='auto';
const curMo = new Date().getMonth();
MONTH_NAMES.forEach((m,i)=>{
const o=document.createElement('option'); o.value=i; o.textContent=m;
if(i===curMo)o.selected=true; sel.appendChild(o);
});
const scanBtn = mkBtn('btn-primary','Skanuj');
scanBtn.id='mc-scan';
const refBtn = mkBtn('btn-outline-secondary',`${I.refresh} Odśwież`);
refBtn.id='mc-refresh'; refBtn.style.display='none';
function doScan(){runScan(parseInt(sel.value,10),MONTH_NAMES[parseInt(sel.value,10)]);}
scanBtn.onclick=doScan;
refBtn.onclick=doScan;
sel.onchange=()=>{
const mi=parseInt(sel.value,10);
const c=loadCache(mi);
const r=document.getElementById('mc-result');
if(c&&Object.keys(c.stats).length){
renderDashboard(c.stats,c.daily||{},c.monthName,c.timestamp,mi);
refBtn.style.display='inline-flex';
if(isStale(c))runScan(mi,MONTH_NAMES[mi]);
} else if(r){
r.innerHTML='<div style="padding:10px;color:var(--mc-muted);font-size:13px">Brak danych w cache. Kliknij "Skanuj".</div>';
refBtn.style.display='none';
}
};
const prog = document.createElement('div');
prog.id='mc-progress';
prog.style.cssText='display:none;margin-top:10px;font-weight:bold;font-size:13px;color:var(--mc-text)';
const pbar = document.createElement('progress');
pbar.id='mc-pbar'; pbar.max=TOTAL_PAGES; pbar.value=0;
pbar.style.cssText='width:100%;height:6px;margin-top:5px;display:none';
const result = document.createElement('div');
result.id='mc-result';
controls.append(label,sel,scanBtn,refBtn);
body.append(controls,prog,pbar,result);
ctr.appendChild(body);
// Wstawiamy przed FORM, żeby uniknąć przechwytywania zdarzeń (np. odświeżanie po kliknięciu buttona)
const formTarget = target.closest('form');
if (formTarget) {
formTarget.parentNode.insertBefore(ctr, formTarget);
} else {
target.parentNode.insertBefore(ctr, target.nextSibling);
}
// --- Auto-load cache ---
const mi = parseInt(sel.value,10);
const cached = loadCache(mi);
if(cached&&Object.keys(cached.stats).length){
renderDashboard(cached.stats,cached.daily||{},cached.monthName,cached.timestamp,mi);
refBtn.style.display='inline-flex';
if(isStale(cached))runScan(mi,MONTH_NAMES[mi]);
}
}
// ═══════════════════════════════════════════════════════════════
// §17 KEYBOARD SHORTCUTS
// ═══════════════════════════════════════════════════════════════
document.addEventListener('keydown',(e)=>{
if(e.ctrlKey&&e.shiftKey&&e.key==='S'){
e.preventDefault();
const sel=document.getElementById('mc-month');
if(sel)runScan(parseInt(sel.value,10),MONTH_NAMES[parseInt(sel.value,10)]);
}
if(e.ctrlKey&&e.shiftKey&&e.key==='M'){
e.preventDefault();
const body=document.getElementById('mc-body');
if(body){
const show=body.style.display==='none';
body.style.display=show?'':'none';
localStorage.setItem(COLLAPSE_KEY,show?'0':'1');
const chev=document.querySelector('.mc-chev');
if(chev)chev.classList.toggle('mc-open',show);
}
}
});
// ═══════════════════════════════════════════════════════════════
// §18 INITIALIZATION
// ═══════════════════════════════════════════════════════════════
function init() {
if (document.querySelector('h4.cp-page-title')) { createUI(); return; }
const t = document.body || document.documentElement;
if (!t) {
document.addEventListener('DOMContentLoaded', () => {
if (document.querySelector('h4.cp-page-title')) createUI();
else observe();
});
return;
}
observe();
}
function observe() {
const obs = new MutationObserver((_,o) => {
if (document.querySelector('h4.cp-page-title')) { o.disconnect(); createUI(); }
});
obs.observe(document.body||document.documentElement, {childList:true,subtree:true});
setTimeout(()=>obs.disconnect(), 30000);
}
init();
})();