// ==UserScript== // @name Discourse Trust-Level Floating Widget // @namespace https://github.com/yourname/discourse-tl-widget // @version 2025-06-07 // @description A sleek floating widget that shows your progress towards the next trust level on ANY Discourse forum (TL 0 → 3 supported). // @author Hua // @match *://*/* // @icon https://www.google.com/s2/favicons?sz=64&domain=discourse.org // @grant GM_addStyle // @run-at document-idle // ==/UserScript== /* ╔═══════════════════════════════════════════════════════════════╗ ║ CONFIGURABLE PART ║ ╚═══════════════════════════════════════════════════════════════╝ */ const TL_REQUIREMENTS = { 0: { topics_entered: 5, posts_read_count: 30, time_read: 600 }, 1: { days_visited: 15, likes_given: 1, likes_received: 1, posts_count: 3, topics_entered: 20, posts_read_count: 100, time_read: 3600, replies_to_different_topics: 3 }, 2: { days_visited: 50, likes_given: 30, likes_received: 20, posts_read_count: 0, topics_entered: 0, posts_count: 10 } }; const TL3_MAINTAIN_IDX = 2; /* ╔═══════════════════════════════════════════════════════════════╗ ║ CORE LOGIC START ║ ╚═══════════════════════════════════════════════════════════════╝ */ (async () => { /* Helpers */ const $ = (sel) => document.querySelector(sel); const html = (el, tpl) => el.insertAdjacentHTML('beforeend', tpl); /* Detect sub-folder installs (e.g. example.com/forum) */ const apiBase = (() => { const { origin, pathname } = location; const m = pathname.match(/^\/([^/]+)\/u\//); return origin + (m ? `/${m[1]}` : ''); })(); /* Current user */ async function getCurrentUsername() { const tag = $('meta[name="current-user-username"]'); if (tag?.content) return tag.content; const r = await fetch(`${apiBase}/session/current.json`, { credentials: 'same-origin', headers: { Accept: 'application/json' } }); if (r.ok) { const js = await r.json(); return js?.current_user?.username || null; } return null; } const username = await getCurrentUsername(); if (!username) return; /* Dark-mode */ const applyDark = (el) => matchMedia('(prefers-color-scheme: dark)').matches && el.classList.add('ld-dark'); /* ---------- UI ---------- */ function buildUI() { html(document.body, `
L?
0/0
TL → ?
`); applyDark($('#ld-container')); $('#ld-btn').addEventListener('mouseenter', () => $('#ld-popup').classList.add('show')); $('#ld-container').addEventListener('mouseleave', () => $('#ld-popup').classList.remove('show')); $('#ld-reload').onclick = refresh; } /* ---------- API ---------- */ const fetchSiteStats = () => fetch(`${apiBase}/about.json`, { headers: { Accept: 'application/json' } }) .then(r => r.json()).then(({ about }) => about.stats); const fetchUserSummary = (u) => fetch(`${apiBase}/u/${u}/summary.json`, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(r => r.json()); const fetchUserDirStats = (u) => fetch(`${apiBase}/directory_items?period=quarterly&order=days_visited`, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(r => r.json()) .then(({ directory_items }) => directory_items.find(i => i.user?.username === u) || null); /* ---------- Main ---------- */ async function refresh() { $('#ld-msg').innerText = 'Updating…'; try { const uname = await getCurrentUsername(); if (!uname) throw Error('Not logged in'); const [siteStats, sumRaw, dirItem] = await Promise.all([ fetchSiteStats(), fetchUserSummary(uname), fetchUserDirStats(uname) ]); if (!dirItem) throw Error('directory_items lookup failed'); const sumStats = sumRaw.user_summary; const tl = dirItem.user?.trust_level ?? sumRaw.users?.[0]?.trust_level ?? 0; if (tl >= 4) { $('#ld-msg').innerText = 'TL4+ widget hidden'; return; } const isMaintain = tl >= 3; const idx = isMaintain ? TL3_MAINTAIN_IDX : tl; if (idx === 2) { // TL2 → TL3 dynamic thresholds TL_REQUIREMENTS[2].posts_read_count = Math.min(Math.floor(siteStats.posts_30_days / 4), 20000); TL_REQUIREMENTS[2].topics_entered = Math.min(Math.floor(siteStats.topics_30_days / 4), 500); } /* merge fresher directory stats */ const stats = { ...sumStats }; [ ['days_visited', dirItem.days_visited], ['likes_given', dirItem.likes_given], ['likes_received', dirItem.likes_received], ['posts_count', dirItem.post_count], ['topics_entered', dirItem.topics_entered], ['posts_read_count', dirItem.posts_read] ].forEach(([k, v]) => { if (v != null) stats[k] = v; }); const req = TL_REQUIREMENTS[idx]; const items = Object.entries(req).map(([k, need]) => { const cur = stats[k] ?? 0; const fmt = v => (k === 'time_read' ? Math.round(v / 60) + 'm' : v); return { label: k.replace(/_/g, ' '), cur, need, ok: +cur >= +need, curShow: fmt(cur), needShow: fmt(need) }; }); const done = items.filter(i => i.ok).length, pct = Math.round(done / items.length * 100); /* render */ $('#ld-name').innerText = uname; $('#ld-badge').innerText = isMaintain ? 'Keep TL3' : `→ TL${tl + 1}`; $('#ld-lvl').innerText = `L${tl}`; $('#ld-fill').style.width = pct + '%'; $('#ld-stat').innerText = `${done}/${items.length}`; const list = $('#ld-list'); list.innerHTML = ''; items.forEach(i => { const color = i.ok ? 'var(--ld-good)' : 'var(--ld-bad)'; html(list, `
${i.label} ${i.curShow} / ${i.needShow}
`); }); $('#ld-msg').innerText = done === items.length ? (isMaintain ? 'You have secured TL3.' : `Congrats! You meet TL${tl + 1}.`) : (isMaintain ? `Need ${items.length - done} more to keep TL3.` : `Need ${items.length - done} more target(s).`); } catch (e) { $('#ld-msg').innerText = 'Error: ' + e.message; } } /* ---------- CSS & Init ---------- */ GM_addStyle(` :root{--ld-bg:#fff;--ld-fg:#1f2937;--ld-muted:#6b7280;--ld-good:#16a34a; --ld-bad:#dc2626;--ld-bar:#e5e7eb;--ld-accent:#fb923c;} .ld-dark{--ld-bg:#262626;--ld-fg:#e5e7eb;--ld-muted:#9ca3af;--ld-bar:#525252;} #ld-container{position:fixed;top:50%;right:0;transform:translateY(-50%); font-family:system-ui,sans-serif;z-index:9999;} #ld-btn{background:var(--ld-bg);border:1px solid var(--ld-bar); border-right:none;border-radius:8px 0 0 8px;padding:8px;width:56px; display:flex;flex-direction:column;align-items:center;gap:4px; cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.1);} #ld-btn:hover{width:72px} #ld-lvl{font-weight:700;color:var(--ld-accent)} #ld-bar{width:40px;height:4px;background:var(--ld-bar);border-radius:2px;overflow:hidden} #ld-fill{height:100%;background:linear-gradient(90deg,#fb923c,#f97316)} #ld-stat{font-size:10px;color:var(--ld-muted)} #ld-popup{position:absolute;right:100%;top:50%;transform:translateY(-50%); background:var(--ld-bg);border:1px solid var(--ld-bar);border-radius:12px;width:320px; opacity:0;pointer-events:none;transition:.2s;box-shadow:0 10px 20px rgba(0,0,0,.15);} #ld-popup.show{opacity:1;pointer-events:auto;transform:translate(-8px,-50%)} header{padding:12px;border-bottom:1px solid var(--ld-bar);font-size:14px; display:flex;justify-content:space-between;align-items:center} #ld-list{padding:12px;max-height:250px;overflow-y:auto;font-size:12px} .ld-row{display:flex;justify-content:space-between;padding:2px 0} footer{font-size:12px;color:var(--ld-muted);padding:8px 12px 12px; border-top:1px solid var(--ld-bar);text-align:center} #ld-reload{margin-top:6px;padding:4px 8px;border:0;border-radius:6px; background:var(--ld-bar);cursor:pointer;color:var(--ld-fg);} `); buildUI(); refresh(); })();