Attach shared notes and 👍/👎/🤡 reactions to Reddit usernames. Visible to all extension/script users.
// ==UserScript== // @name Reddit User Notes // @namespace [email protected] // @version 1.0.1 // @license MIT // @description Attach shared notes and 👍/👎/🤡 reactions to Reddit usernames. Visible to all extension/script users. // @author Literally 0 // @match *://*.reddit.com/* // @icon https://www.redditstatic.com/desktop2x/img/favicon/android-icon-192x192.png // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @connect reddit-rating-api.green-feather-2bb6.workers.dev // @connect localhost // @connect 127.0.0.1 // @run-at document-idle // ==/UserScript== (() => { // src/shared/backend.js function createBackend({ httpRequest, storage: storage2 }) { async function proxyFetch(path, init2) { const apiBaseUrl = await storage2.getApiBaseUrl(); const res = await httpRequest(`${apiBaseUrl}${path}`, init2 || {}); if (res.networkError) { return { ok: false, status: 0, error: "Network error reaching backend" }; } if (res.status === 204) { return { ok: true, status: 204, data: null }; } let data = null; try { data = res.text ? JSON.parse(res.text) : null; } catch { } if (res.status < 200 || res.status >= 300) { return { ok: false, status: res.status, error: data && data.error || `HTTP ${res.status}` }; } return { ok: true, status: res.status, data }; } async function adminAuthedFetch(path, init2) { const auth = await storage2.getAuth(); if (!auth) return { ok: false, status: 401, error: "Not logged in" }; return proxyFetch(path, { ...init2, headers: { ...init2 && init2.headers, Authorization: `Bearer ${auth.token}` } }); } return { getAuth: () => storage2.getAuth(), getApiBaseUrl: () => storage2.getApiBaseUrl(), setApiBaseUrl: (url) => storage2.setApiBaseUrl(url), async login(username, password, inviteCode, redditUsername) { const res = await proxyFetch("/api/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password, invite_code: inviteCode || void 0, reddit_username: redditUsername || void 0 }) }); if (!res.ok) return { ok: false, error: res.error || "Login failed" }; const data = res.data; if (!data || typeof data.token !== "string" || typeof data.username !== "string") { return { ok: false, error: "Backend returned an unexpected auth response" }; } const isAdmin = data.is_admin === true; await storage2.setAuth({ token: data.token, username: data.username, isAdmin, issuedAt: Date.now() }); return { ok: true, loggedIn: true, username: data.username, isAdmin }; }, async logout() { await storage2.clearAuth(); return { ok: true, loggedIn: false, username: null }; }, async getAuthState() { const auth = await storage2.getAuth(); return auth ? { ok: true, loggedIn: true, username: auth.username ?? null, isAdmin: auth.isAdmin === true } : { ok: true, loggedIn: false, username: null, isAdmin: false }; }, async fetchNotes(username) { const auth = await storage2.getAuth(); const headers = { Accept: "application/json" }; if (auth) headers.Authorization = `Bearer ${auth.token}`; return proxyFetch(`/api/notes/${encodeURIComponent(username)}`, { headers }); }, async saveNote(username, comment, reaction, sourceUrl, anonymous) { const auth = await storage2.getAuth(); if (!auth) return { ok: false, status: 401, error: "Not logged in" }; return proxyFetch(`/api/notes/${encodeURIComponent(username)}`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${auth.token}` }, body: JSON.stringify({ comment, reaction, source_url: sourceUrl || void 0, anonymous: Boolean(anonymous) }) }); }, fetchNotesSummary: (usernames) => proxyFetch("/api/notes/summary", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ usernames }) }), async deleteNote(username) { const auth = await storage2.getAuth(); if (!auth) return { ok: false, status: 401, error: "Not logged in" }; return proxyFetch(`/api/notes/${encodeURIComponent(username)}`, { method: "DELETE", headers: { Authorization: `Bearer ${auth.token}` } }); }, adminListNotes(limit, offset, target) { const params = new URLSearchParams(); if (limit) params.set("limit", String(limit)); if (offset) params.set("offset", String(offset)); if (target) params.set("target", target); const qs = params.toString(); return adminAuthedFetch(`/api/admin/notes${qs ? `?${qs}` : ""}`, { headers: { Accept: "application/json" } }); }, adminEditNote: (id, patch) => adminAuthedFetch(`/api/admin/notes/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch) }), adminDeleteNote: (id) => adminAuthedFetch(`/api/admin/notes/${encodeURIComponent(id)}`, { method: "DELETE" }), adminListInvites: () => adminAuthedFetch("/api/admin/invites", { headers: { Accept: "application/json" } }), adminCreateInvite: (label) => adminAuthedFetch("/api/admin/invites", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label }) }) }; } // src/shared/constants.js var DEFAULT_API_BASE_URL = "https://reddit-rating-api.green-feather-2bb6.workers.dev"; var MAX_COMMENT_LENGTH = 500; var REPORT_EMAIL = "[email protected]"; var REACTIONS = [ { value: "like", emoji: "\u{1F44D}", label: "Like" }, { value: "dislike", emoji: "\u{1F44E}", label: "Dislike" }, { value: "clown", emoji: "\u{1F921}", label: "Clown" } ]; var REACTION_EMOJI = Object.fromEntries(REACTIONS.map((r) => [r.value, r.emoji])); function esc(value) { return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } function relativeTime(iso) { const t = Date.parse(iso); if (Number.isNaN(t)) return String(iso ?? ""); const s = Math.max(0, Math.floor((Date.now() - t) / 1e3)); if (s < 60) return "just now"; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 7) return `${d}d ago`; const w = Math.floor(d / 7); if (w < 5) return `${w}w ago`; const mo = Math.floor(d / 30); if (mo < 12) return `${mo}mo ago`; return `${Math.floor(d / 365)}y ago`; } function errorMessage(err) { return err && err.message ? err.message : String(err); } // src/shared/dom.js function deepQueryAll(selector, root) { const out = []; const walk = (node) => { if (node.querySelectorAll) { out.push(...node.querySelectorAll(selector)); node.querySelectorAll("*").forEach((el) => { if (el.shadowRoot) walk(el.shadowRoot); }); } }; walk(root || document); return out; } var USER_LINK_RE = /^\/(?:user|u)\/([A-Za-z0-9_-]{1,25})\/?$/i; function extractUsernameFromHref(href) { if (!href) return null; let url; try { url = new URL(href, location.origin); } catch { return null; } if (url.hostname && !/(^|\.)reddit\.com$/i.test(url.hostname)) return null; const m = USER_LINK_RE.exec(url.pathname); return m ? m[1] : null; } // src/shared/ui/panel.js var PANEL_ID = "rrn-notes-panel"; function createPanel(api) { let panelUsername = null; let lastUrlUsername = null; let renderToken = 0; let panelCollapsed = false; let panelSourceUrl = null; function extractUsername() { const match = location.pathname.match(/^\/(?:user|u)\/([A-Za-z0-9_-]+)/i); return match ? match[1] : null; } function ensurePanel() { let panel = document.getElementById(PANEL_ID); if (!panel) { panel = document.createElement("div"); panel.id = PANEL_ID; panel.hidden = true; document.body.appendChild(panel); } return panel; } function headerHtml(username) { return `<div class="rrn-header"><span class="rrn-title">\u{1F4DD} Notes on <b>u/${esc(username)}</b></span><button type="button" class="rrn-close" title="Hide panel">\xD7</button></div>`; } function reportHref(targetUsername, note) { const subject = `Report note on u/${targetUsername}`; const body = `Target: u/${targetUsername} Author: ${note.author_username ? `u/${note.author_username}` : "(posted anonymously - check the admin panel for the real author)"} Reaction: ${note.reaction} Comment: ${note.comment || "(none)"} Updated: ${note.updated_at} Source: ${note.source_url || "(none)"} Page: ${location.href} `; return `mailto:${REPORT_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; } function listHtml(notes, targetUsername) { if (!notes.length) { return '<p class="rrn-empty">No notes yet. Be the first.</p>'; } const items = notes.map((note) => { const emoji = REACTION_EMOJI[note.reaction] || "\u2753"; const comment = note.comment ? `<div class="rrn-comment"></div>` : ""; const source = note.source_url ? ` <a class="rrn-source" href="${esc(note.source_url)}" target="_blank" rel="noopener noreferrer">source</a>` : ""; const report = ` <a class="rrn-report" href="${esc(reportHref(targetUsername, note))}" title="Report this note">report</a>`; const authorLabel = note.author_username ? `u/${esc(note.author_username)}` : `<span class="rrn-anon-label" title="This author chose to stay anonymous">anonymous</span>`; return `<li class="rrn-note"><div class="rrn-notemeta"><span class="rrn-reaction" title="${esc(note.reaction)}">${emoji}</span><span class="rrn-author">${authorLabel}</span><span class="rrn-time" title="${esc(note.updated_at)}">${esc(relativeTime(note.updated_at))}</span>` + source + report + `</div>` + comment + `</li>`; }).join(""); return `<ul class="rrn-list">${items}</ul>`; } function fillComments(container, notes) { const items = container.querySelectorAll(".rrn-note"); notes.forEach((note, i) => { const item = items[i]; if (!item || !note.comment) return; const el = item.querySelector(".rrn-comment"); if (el) el.textContent = note.comment; }); } function formHtml(myNote) { const comment = myNote ? String(myNote.comment ?? "") : ""; const selected = myNote ? myNote.reaction : ""; const reactionButtons = REACTIONS.map( (r) => `<button type="button" class="rrn-reaction-btn${r.value === selected ? " rrn-selected" : ""}" data-reaction="${r.value}" title="${r.label}">${r.emoji}</button>` ).join(""); return `<div class="rrn-form"><p class="rrn-rules">No insults. No doxxing. Calling someone a scammer or a jerk? Link the comments/facts that back it up.</p><label class="rrn-label">Your note:</label><textarea class="rrn-textarea" maxlength="${MAX_COMMENT_LENGTH}" placeholder="Add a note about this user\u2026"></textarea><div class="rrn-formrow"><div class="rrn-reactbtns">${reactionButtons}</div><span class="rrn-counter">${comment.length}/${MAX_COMMENT_LENGTH}</span></div><label class="rrn-anon-toggle"><input type="checkbox" class="rrn-anon-checkbox"${myNote && myNote.is_anonymous ? " checked" : ""} /> Post anonymously (hides your username from other users)</label><div class="rrn-actions"><button type="button" class="rrn-btn rrn-save">Save</button><button type="button" class="rrn-btn rrn-delete"${myNote ? "" : ' disabled title="You have no note to delete"'}>Delete</button></div><div class="rrn-error" hidden></div></div>`; } function detectLoggedInRedditUsername() { const candidates = deepQueryAll("a[href]").filter((a) => { const id = a.id || ""; const label = a.getAttribute("aria-label") || ""; return /user-drawer|account-menu/i.test(id) || /account menu|my account/i.test(label); }); for (const a of candidates) { const u = extractUsernameFromHref(a.getAttribute("href")); if (u) return u; } const oldHeaderLink = document.querySelector("#header-bottom-right .user a, span.user a"); if (oldHeaderLink) { const u = extractUsernameFromHref(oldHeaderLink.getAttribute("href")); if (u) return u; } return null; } function loggedOutHtml() { const detected = detectLoggedInRedditUsername(); return `<div class="rrn-loginbox"><p>Log in to post your own note on this user.</p><form class="rrn-loginform"><input type="text" class="rrn-login-username" placeholder="Username" autocomplete="username" required /><input type="password" class="rrn-login-password" placeholder="Password" autocomplete="current-password" required /><input type="text" class="rrn-login-invite" placeholder="Invite code (first time only)" autocomplete="off" /><input type="text" class="rrn-login-reddit" placeholder="Your Reddit username" autocomplete="off" value="${esc(detected ?? "")}" /><button type="submit" class="rrn-btn rrn-login">Log in / Register</button></form><div class="rrn-error" hidden></div></div>`; } function showError(scope, message) { const el = scope.querySelector(".rrn-error"); if (el) { el.textContent = message; el.hidden = false; } } function wireCloseButton(panel) { const close = panel.querySelector(".rrn-close"); if (close) { close.addEventListener("click", () => { panelCollapsed = true; panel.hidden = true; }); } } async function fetchNotes(username) { const res = await api.fetchNotes(username); if (!res || !res.ok) { throw new Error(res && res.error || "Failed to load notes"); } return Array.isArray(res.data && res.data.notes) ? res.data.notes : []; } async function renderPanel(username) { const panel = ensurePanel(); panel.hidden = panelCollapsed; panel.dataset.username = username; const token = ++renderToken; panel.innerHTML = headerHtml(username) + '<div class="rrn-body"><p class="rrn-empty">Loading\u2026</p></div>'; wireCloseButton(panel); const body = panel.querySelector(".rrn-body"); let auth; let notes; try { auth = await api.getAuth(); notes = await fetchNotes(username); } catch (err) { if (token !== renderToken || panelUsername !== username) return; body.innerHTML = '<p class="rrn-empty rrn-error-text"></p><button type="button" class="rrn-btn rrn-retry">Retry</button>'; body.querySelector(".rrn-error-text").textContent = `\u26A0 ${err && err.message ? err.message : err}`; body.querySelector(".rrn-retry").addEventListener("click", () => renderPanel(username)); return; } if (token !== renderToken || panelUsername !== username) return; const mine = auth ? notes.find((n) => n.is_mine) : null; body.innerHTML = listHtml(notes, username) + (auth ? formHtml(mine) : loggedOutHtml()); fillComments(body, notes); const textarea = body.querySelector(".rrn-textarea"); const counter = body.querySelector(".rrn-counter"); if (textarea) textarea.value = mine ? String(mine.comment ?? "") : ""; if (textarea && counter) { textarea.addEventListener("input", () => { counter.textContent = `${textarea.value.length}/${MAX_COMMENT_LENGTH}`; }); } body.querySelectorAll(".rrn-reaction-btn").forEach((btn) => { btn.addEventListener("click", () => { body.querySelectorAll(".rrn-reaction-btn.rrn-selected").forEach((b) => b.classList.remove("rrn-selected")); btn.classList.add("rrn-selected"); }); }); const saveBtn = body.querySelector(".rrn-save"); if (saveBtn && textarea) { saveBtn.addEventListener("click", async () => { const reaction = body.querySelector(".rrn-reaction-btn.rrn-selected"); if (!reaction) { showError(body, "Pick a reaction first (\u{1F44D} / \u{1F44E} / \u{1F921})."); return; } saveBtn.disabled = true; try { const anonCheckbox = body.querySelector(".rrn-anon-checkbox"); const res = await api.saveNote( username, textarea.value.slice(0, MAX_COMMENT_LENGTH), reaction.dataset.reaction, panelUsername === username ? panelSourceUrl : null, anonCheckbox ? anonCheckbox.checked : false ); if (res && res.status === 401) { showError(body, "Session expired \u2014 log in again."); return; } if (!res || !res.ok) { showError(body, `Save failed: ${res && res.error || "unknown error"}`); return; } renderPanel(username); } catch (err) { showError(body, `Save failed: ${err && err.message ? err.message : err}`); } finally { saveBtn.disabled = false; } }); } const deleteBtn = body.querySelector(".rrn-delete"); if (deleteBtn) { deleteBtn.addEventListener("click", async () => { if (!window.confirm(`Delete your note on u/${username}?`)) return; deleteBtn.disabled = true; try { const res = await api.deleteNote(username); if (res && res.status === 401) { showError(body, "Session expired \u2014 log in again."); return; } if (!res || !res.ok) { showError(body, `Delete failed: ${res && res.error || "unknown error"}`); return; } renderPanel(username); } catch (err) { showError(body, `Delete failed: ${err && err.message ? err.message : err}`); } finally { deleteBtn.disabled = false; } }); } const loginForm = body.querySelector(".rrn-loginform"); if (loginForm) { loginForm.addEventListener("submit", async (event) => { event.preventDefault(); const loginBtn = loginForm.querySelector(".rrn-login"); loginBtn.disabled = true; try { const res = await api.login( loginForm.querySelector(".rrn-login-username").value.trim(), loginForm.querySelector(".rrn-login-password").value, loginForm.querySelector(".rrn-login-invite").value.trim(), loginForm.querySelector(".rrn-login-reddit").value.trim() ); if (res && res.ok) { renderPanel(username); } else { showError(body, res && res.error || "Login failed."); loginBtn.disabled = false; } } catch (err) { showError(body, `Login failed: ${err && err.message ? err.message : err}`); loginBtn.disabled = false; } }); } } function onRouteChange() { const username = extractUsername(); if (username === lastUrlUsername) return; lastUrlUsername = username; panelUsername = username; panelCollapsed = false; panelSourceUrl = null; if (username) { renderPanel(username); } else { const panel = document.getElementById(PANEL_ID); if (panel) panel.hidden = true; } } function openPanelForUsername(username, sourceUrl) { panelUsername = username; panelCollapsed = false; panelSourceUrl = sourceUrl || null; renderPanel(username); } function watchNavigation() { for (const name of ["pushState", "replaceState"]) { const original = history[name]; history[name] = function(...args) { const result = original.apply(this, args); onRouteChange(); return result; }; } window.addEventListener("popstate", onRouteChange); setInterval(onRouteChange, 2e3); } return { onRouteChange, openPanelForUsername, watchNavigation }; } // src/shared/ui/badges.js function createBadges(api, { onOpenPanel }) { const badgeCache = /* @__PURE__ */ new Map(); const pendingUsernames = /* @__PURE__ */ new Set(); let flushTimer = null; let scanTimer = null; function findSourceUrl(a) { const root = a.getRootNode(); const host = root instanceof ShadowRoot ? root.host : a; const container = host.closest ? host.closest("shreddit-comment, shreddit-post") : null; const permalink = container ? container.getAttribute("permalink") : null; if (!permalink) return null; try { return new URL(permalink, location.origin).href; } catch { return null; } } function badgeStyle(hasReactions) { return "display:inline-block;margin-left:4px;padding:0 5px;border-radius:8px;font-size:11px;line-height:1.6;white-space:nowrap;vertical-align:middle;cursor:pointer;" + (hasReactions ? "background:rgba(120,120,120,0.28);" : "background:rgba(120,120,120,0.12);opacity:0.7;"); } function reactionParts(counts) { const parts = []; if (counts.like > 0) parts.push(`\u{1F44D}${counts.like}`); if (counts.dislike > 0) parts.push(`\u{1F44E}${counts.dislike}`); if (counts.clown > 0) parts.push(`\u{1F921}${counts.clown}`); return parts; } function renderBadgeForAnchor(a, counts) { if (!counts) return; const parts = reactionParts(counts); const hasReactions = parts.length > 0; const badge = document.createElement("span"); badge.className = "rrn-inline-badge"; badge.textContent = hasReactions ? parts.join(" ") : "\u{1F4DD}"; badge.title = hasReactions ? "Click to open notes for this user" : "No notes yet \u2014 click to add one"; badge.style.cssText = badgeStyle(hasReactions); const username = a.dataset.rrnUsername; badge.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); onOpenPanel(username, findSourceUrl(a)); }); a.insertAdjacentElement("afterend", badge); a.dataset.rrnRendered = "1"; } function processAnchor(a) { if (a.dataset.rrnBadged) return; const username = extractUsernameFromHref(a.getAttribute("href")); if (!username) return; if (!a.textContent || !a.textContent.trim()) { a.dataset.rrnBadged = "1"; return; } a.dataset.rrnBadged = "1"; const key = username.toLowerCase(); a.dataset.rrnUsername = key; if (badgeCache.has(key)) { renderBadgeForAnchor(a, badgeCache.get(key)); } else { pendingUsernames.add(key); scheduleFlush(); } } function scheduleFlush() { if (flushTimer) return; flushTimer = setTimeout(flushPending, 250); } async function flushPending() { flushTimer = null; const usernames = Array.from(pendingUsernames); pendingUsernames.clear(); if (!usernames.length) return; try { const res = await api.fetchNotesSummary(usernames); const counts = res && res.ok && res.data && res.data.counts || {}; usernames.forEach((u) => { badgeCache.set(u, counts[u] || { like: 0, dislike: 0, clown: 0 }); }); applyCachedBadges(usernames); } catch { } } function applyCachedBadges(usernames) { const wanted = new Set(usernames); deepQueryAll("a[data-rrn-username]").forEach((a) => { if (a.dataset.rrnRendered) return; const u = a.dataset.rrnUsername; if (u && wanted.has(u) && badgeCache.has(u)) { renderBadgeForAnchor(a, badgeCache.get(u)); } }); deepQueryAll("shreddit-post[data-rrn-username]").forEach((post) => { if (post.dataset.rrnRendered) return; const u = post.dataset.rrnUsername; if (u && wanted.has(u) && badgeCache.has(u)) { renderBadgeForPost(post, badgeCache.get(u)); } }); } function findSourceUrlForPost(post) { const permalink = post.getAttribute("permalink"); if (!permalink) return null; try { return new URL(permalink, location.origin).href; } catch { return null; } } function renderBadgeForPost(post, counts) { if (!counts) return; if (post.dataset.rrnRendered) return; const creditBar = post.querySelector('[id^="feed-post-credit-bar-"]'); if (!creditBar) return; const parts = reactionParts(counts); const hasReactions = parts.length > 0; const badge = document.createElement("span"); badge.className = "rrn-inline-badge"; badge.textContent = hasReactions ? parts.join(" ") : "\u{1F4DD}"; badge.title = hasReactions ? `Click to open notes for u/${post.getAttribute("author")}` : "No notes yet \u2014 click to add one"; badge.style.cssText = badgeStyle(hasReactions); const username = post.dataset.rrnUsername; badge.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); onOpenPanel(username, findSourceUrlForPost(post)); }); creditBar.insertAdjacentElement("beforeend", badge); post.dataset.rrnRendered = "1"; } function processPost(post) { if (post.dataset.rrnPostBadged) return; const username = post.getAttribute("author"); if (!username || !/^[A-Za-z0-9_-]{1,25}$/.test(username)) { post.dataset.rrnPostBadged = "1"; return; } post.dataset.rrnPostBadged = "1"; const key = username.toLowerCase(); post.dataset.rrnUsername = key; if (badgeCache.has(key)) { renderBadgeForPost(post, badgeCache.get(key)); } else { pendingUsernames.add(key); scheduleFlush(); } } function scanForUserLinks() { deepQueryAll("a[href]").forEach((a) => { if (!a.dataset.rrnBadged) processAnchor(a); }); deepQueryAll("shreddit-post[author]").forEach((post) => { if (!post.dataset.rrnPostBadged) processPost(post); }); } function scheduleScan() { if (scanTimer) return; scanTimer = setTimeout(() => { scanTimer = null; scanForUserLinks(); }, 300); } function observeShadowRoot(root) { try { new MutationObserver(scheduleScan).observe(root, { childList: true, subtree: true }); } catch { } } function patchAttachShadow() { const original = Element.prototype.attachShadow; if (!original || Element.prototype.__rrnPatched) return; Element.prototype.attachShadow = function(init2) { const root = original.call(this, init2); observeShadowRoot(root); scheduleScan(); return root; }; Element.prototype.__rrnPatched = true; } function initBadges() { patchAttachShadow(); new MutationObserver(scheduleScan).observe(document.documentElement, { childList: true, subtree: true }); scheduleScan(); setInterval(scanForUserLinks, 2e3); } return { initBadges }; } // src/shared/ui/app.js function startApp(api) { const panel = createPanel(api); const badges = createBadges(api, { onOpenPanel: panel.openPanelForUsername }); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", panel.onRouteChange, { once: true }); } else { panel.onRouteChange(); } panel.watchNavigation(); badges.initBadges(); } // src/shared/ui/admin.js var PAGE_SIZE = 50; var MARKUP = ` <p class="rrn-admin-status"></p> <div class="rrn-admin-body" hidden> <div class="rrn-admin-toolbar"> <input type="text" class="rrn-admin-filter-target" placeholder="Filter by target username\u2026" autocomplete="off" spellcheck="false" /> <button type="button" class="rrn-admin-filter-apply">Filter</button> <button type="button" class="rrn-admin-filter-clear">Clear</button> <span class="rrn-admin-spacer"></span> <span class="rrn-admin-total"></span> </div> <div class="rrn-admin-table-wrap"> <table> <thead> <tr><th>ID</th><th>Target</th><th>Author</th><th>Author's Reddit</th><th>Reaction</th><th>Comment</th><th>Source</th><th>Anon</th><th>Updated</th><th></th></tr> </thead> <tbody class="rrn-admin-notes-body"></tbody> </table> </div> <div class="rrn-admin-pager"> <button type="button" class="rrn-admin-prev">\u2190 Prev</button> <span class="rrn-admin-page-label"></span> <button type="button" class="rrn-admin-next">Next \u2192</button> </div> <h2>Invite codes</h2> <div class="rrn-admin-toolbar"> <input type="text" class="rrn-admin-invite-label" placeholder="Label (who this is for)\u2026" autocomplete="off" spellcheck="false" /> <button type="button" class="rrn-admin-invite-generate">Generate invite code</button> </div> <div class="rrn-admin-invite-result" hidden></div> <details class="rrn-admin-invites-list"> <summary>Show past invite codes</summary> <div class="rrn-admin-table-wrap"> <table> <thead><tr><th>Code</th><th>Label</th><th>Created</th><th>Used by</th></tr></thead> <tbody class="rrn-admin-invites-body"></tbody> </table> </div> </details> </div> `; function rowHtml(note) { const reactionOptions = REACTIONS.map( (r) => `<option value="${r.value}"${r.value === note.reaction ? " selected" : ""}>${r.value}</option>` ).join(""); const source = note.source_url ? `<a href="${esc(note.source_url)}" target="_blank" rel="noopener noreferrer">${esc(note.source_url)}</a>` : '<span class="rrn-admin-dim">\u2014</span>'; return `<tr data-id="${note.id}"><td>${note.id}</td><td>u/${esc(note.target_username)}</td><td>u/${esc(note.author_username)}</td><td>${note.author_reddit_username ? `u/${esc(note.author_reddit_username)}` : '<span class="rrn-admin-dim">unknown</span>'}</td><td><select class="rrn-admin-reaction-select">${reactionOptions}</select></td><td class="rrn-admin-comment-cell"><textarea class="rrn-admin-comment-input">${esc(note.comment)}</textarea></td><td class="rrn-admin-source-cell">${source}</td><td><input type="checkbox" class="rrn-admin-anon-checkbox"${note.is_anonymous ? " checked" : ""} title="Posted anonymously - hidden from other users, visible here since you're an admin" /></td><td>${esc(note.updated_at)}</td><td class="rrn-admin-actions-cell"><button type="button" class="rrn-admin-save-btn">Save</button><button type="button" class="rrn-admin-delete-btn">Delete</button></td></tr>`; } function inviteRowHtml(invite) { const used = invite.used_by_user_id ? `user #${invite.used_by_user_id} (${esc(invite.used_at)})` : "\u2014 unused \u2014"; return `<tr><td><code>${esc(invite.code)}</code></td><td>${esc(invite.label)}</td><td>${esc(invite.created_at)}</td><td>${used}</td></tr>`; } function mountAdminApp(container, api) { container.innerHTML = MARKUP; const statusEl = container.querySelector(".rrn-admin-status"); const bodyEl = container.querySelector(".rrn-admin-body"); const tbody = container.querySelector(".rrn-admin-notes-body"); const totalEl = container.querySelector(".rrn-admin-total"); const pageLabel = container.querySelector(".rrn-admin-page-label"); const prevBtn = container.querySelector(".rrn-admin-prev"); const nextBtn = container.querySelector(".rrn-admin-next"); const filterInput = container.querySelector(".rrn-admin-filter-target"); const invitesBody = container.querySelector(".rrn-admin-invites-body"); const inviteResultEl = container.querySelector(".rrn-admin-invite-result"); const invitesList = container.querySelector(".rrn-admin-invites-list"); let offset = 0; let target = ""; function setStatus(text, isError) { statusEl.textContent = text; statusEl.classList.toggle("error", Boolean(isError)); } function wireRow(tr, note) { const saveBtn = tr.querySelector(".rrn-admin-save-btn"); const deleteBtn = tr.querySelector(".rrn-admin-delete-btn"); const reactionSelect = tr.querySelector(".rrn-admin-reaction-select"); const commentInput = tr.querySelector(".rrn-admin-comment-input"); const anonCheckbox = tr.querySelector(".rrn-admin-anon-checkbox"); saveBtn.addEventListener("click", async () => { saveBtn.disabled = true; try { const res = await api.adminEditNote(note.id, { comment: commentInput.value, reaction: reactionSelect.value, is_anonymous: anonCheckbox.checked }); if (!res || !res.ok) { setStatus(`Save failed for #${note.id}: ${res && res.error || "unknown error"}`, true); return; } setStatus(`Saved note #${note.id}.`, false); } catch (err) { setStatus(`Save failed for #${note.id}: ${errorMessage(err)}`, true); } finally { saveBtn.disabled = false; } }); deleteBtn.addEventListener("click", async () => { if (!window.confirm(`Delete note #${note.id} (u/${note.author_username} on u/${note.target_username})?`)) return; deleteBtn.disabled = true; saveBtn.disabled = true; try { const res = await api.adminDeleteNote(note.id); if (!res || !res.ok) { setStatus(`Delete failed for #${note.id}: ${res && res.error || "unknown error"}`, true); deleteBtn.disabled = false; saveBtn.disabled = false; return; } tr.remove(); setStatus(`Deleted note #${note.id}.`, false); } catch (err) { setStatus(`Delete failed for #${note.id}: ${errorMessage(err)}`, true); deleteBtn.disabled = false; saveBtn.disabled = false; } }); } async function loadPage() { setStatus("Loading\u2026", false); tbody.innerHTML = ""; try { const res = await api.adminListNotes(PAGE_SIZE, offset, target || void 0); if (!res || !res.ok) { setStatus( res && res.status === 403 ? "Your account is not an admin." : `Failed to load notes: ${res && res.error || "unknown error"}`, true ); return; } const { notes, total } = res.data; totalEl.textContent = `${total} note${total === 1 ? "" : "s"} total`; if (!notes.length) { tbody.innerHTML = '<tr><td colspan="10">No notes.</td></tr>'; } else { notes.forEach((note) => { tbody.insertAdjacentHTML("beforeend", rowHtml(note)); wireRow(tbody.lastElementChild, note); }); } const page = Math.floor(offset / PAGE_SIZE) + 1; const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); pageLabel.textContent = `Page ${page} / ${pageCount}`; prevBtn.disabled = offset <= 0; nextBtn.disabled = offset + PAGE_SIZE >= total; setStatus("", false); } catch (err) { setStatus(`Failed to load notes: ${errorMessage(err)}`, true); } } container.querySelector(".rrn-admin-filter-apply").addEventListener("click", () => { target = filterInput.value.trim(); offset = 0; loadPage(); }); filterInput.addEventListener("keydown", (event) => { if (event.key === "Enter") container.querySelector(".rrn-admin-filter-apply").click(); }); container.querySelector(".rrn-admin-filter-clear").addEventListener("click", () => { filterInput.value = ""; target = ""; offset = 0; loadPage(); }); prevBtn.addEventListener("click", () => { offset = Math.max(0, offset - PAGE_SIZE); loadPage(); }); nextBtn.addEventListener("click", () => { offset += PAGE_SIZE; loadPage(); }); async function loadInvites() { try { const res = await api.adminListInvites(); if (!res || !res.ok) return; const invites = res.data && res.data.invites || []; invitesBody.innerHTML = invites.length ? invites.map(inviteRowHtml).join("") : '<tr><td colspan="4">No invite codes yet.</td></tr>'; } catch { } } invitesList.addEventListener("toggle", () => { if (invitesList.open) loadInvites(); }); container.querySelector(".rrn-admin-invite-generate").addEventListener("click", async () => { const btn = container.querySelector(".rrn-admin-invite-generate"); const labelInput = container.querySelector(".rrn-admin-invite-label"); btn.disabled = true; inviteResultEl.hidden = true; try { const res = await api.adminCreateInvite(labelInput.value.trim()); if (!res || !res.ok) { inviteResultEl.textContent = `Failed to generate invite: ${res && res.error || "unknown error"}`; inviteResultEl.classList.add("error"); inviteResultEl.hidden = false; return; } inviteResultEl.textContent = `Invite code: ${res.data.invite.code}`; inviteResultEl.classList.remove("error"); inviteResultEl.hidden = false; labelInput.value = ""; if (invitesList.open) loadInvites(); } catch (err) { inviteResultEl.textContent = `Failed to generate invite: ${errorMessage(err)}`; inviteResultEl.classList.add("error"); inviteResultEl.hidden = false; } finally { btn.disabled = false; } }); async function init2() { try { const auth = await api.getAuthState(); if (!auth || !auth.ok || !auth.loggedIn) { setStatus("Not logged in.", true); return; } if (!auth.isAdmin) { setStatus(`Logged in as ${auth.username}, but this account is not an admin.`, true); return; } setStatus(`Logged in as ${auth.username} (admin).`, false); bodyEl.hidden = false; loadPage(); } catch (err) { setStatus(`Error: ${errorMessage(err)}`, true); } } init2(); } // src/platforms/userscript/adminModal.js async function openAdminModal(api) { const auth = await api.getAuthState(); if (!auth || !auth.ok || !auth.loggedIn) { alert("Log in first (open any Reddit profile page to see the login form)."); return; } if (!auth.isAdmin) { alert(`Logged in as ${auth.username}, but this account is not an admin.`); return; } let overlay = document.getElementById("rrn-admin-overlay"); if (overlay) { overlay.hidden = false; return; } overlay = document.createElement("div"); overlay.id = "rrn-admin-overlay"; overlay.innerHTML = '<div id="rrn-admin-modal"><div class="rrn-admin-modal-title"><span>\u{1F6E0} Admin panel</span><button type="button" class="rrn-admin-close">\xD7</button></div><div class="rrn-admin-root"></div></div>'; document.body.appendChild(overlay); overlay.querySelector(".rrn-admin-close").addEventListener("click", () => { overlay.hidden = true; }); mountAdminApp(overlay.querySelector(".rrn-admin-root"), api); } // src/shared/ui/panel.css var panel_default = '/* Floating notes panel - fixed dark card so it stays readable on both\n Reddit light and dark themes. */\n\n#rrn-notes-panel {\n position: fixed;\n top: 64px;\n right: 16px;\n width: 320px;\n max-height: 70vh;\n overflow-y: auto;\n z-index: 2147483000;\n background: #1a1a1b;\n color: #d7dadc;\n font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;\n border: 1px solid #343536;\n border-radius: 8px;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);\n padding: 10px 12px 12px;\n box-sizing: border-box;\n text-align: left;\n}\n\n#rrn-notes-panel[hidden] {\n display: none;\n}\n\n#rrn-notes-panel .rrn-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n margin-bottom: 8px;\n}\n\n#rrn-notes-panel .rrn-title {\n font-weight: 600;\n color: #d7dadc;\n}\n\n#rrn-notes-panel .rrn-close {\n background: transparent;\n border: none;\n color: #818384;\n font-size: 16px;\n line-height: 1;\n cursor: pointer;\n padding: 2px 6px;\n border-radius: 4px;\n}\n\n#rrn-notes-panel .rrn-close:hover {\n color: #d7dadc;\n background: #272729;\n}\n\n#rrn-notes-panel .rrn-empty {\n margin: 6px 0;\n color: #818384;\n}\n\n#rrn-notes-panel .rrn-error-text {\n color: #ff8b60;\n}\n\n#rrn-notes-panel .rrn-list {\n list-style: none;\n margin: 0 0 10px;\n padding: 0;\n}\n\n#rrn-notes-panel .rrn-note {\n padding: 6px 0;\n border-top: 1px solid #343536;\n}\n\n#rrn-notes-panel .rrn-note:first-child {\n border-top: none;\n}\n\n#rrn-notes-panel .rrn-notemeta {\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n#rrn-notes-panel .rrn-reaction {\n font-size: 14px;\n}\n\n#rrn-notes-panel .rrn-author {\n font-weight: 600;\n color: #4fbcff;\n overflow-wrap: anywhere;\n}\n\n#rrn-notes-panel .rrn-time {\n margin-left: auto;\n color: #818384;\n font-size: 11px;\n white-space: nowrap;\n}\n\n#rrn-notes-panel .rrn-source {\n color: #4fbcff;\n font-size: 11px;\n text-decoration: none;\n white-space: nowrap;\n}\n\n#rrn-notes-panel .rrn-source:hover {\n text-decoration: underline;\n}\n\n#rrn-notes-panel .rrn-report {\n color: #818384;\n font-size: 11px;\n text-decoration: none;\n white-space: nowrap;\n}\n\n#rrn-notes-panel .rrn-report:hover {\n color: #ff8b60;\n text-decoration: underline;\n}\n\n#rrn-notes-panel .rrn-comment {\n margin-top: 3px;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n color: #d7dadc;\n}\n\n#rrn-notes-panel .rrn-form,\n#rrn-notes-panel .rrn-loginbox {\n border-top: 1px solid #343536;\n padding-top: 8px;\n}\n\n#rrn-notes-panel .rrn-rules {\n margin: 0 0 8px;\n padding: 6px 8px;\n background: #272729;\n border-radius: 6px;\n color: #818384;\n font-size: 11px;\n line-height: 1.4;\n}\n\n#rrn-notes-panel .rrn-label {\n display: block;\n margin-bottom: 4px;\n font-weight: 600;\n}\n\n#rrn-notes-panel .rrn-textarea {\n width: 100%;\n min-height: 56px;\n resize: vertical;\n background: #272729;\n color: #d7dadc;\n border: 1px solid #343536;\n border-radius: 6px;\n padding: 6px 8px;\n font: inherit;\n box-sizing: border-box;\n}\n\n#rrn-notes-panel .rrn-textarea:focus {\n outline: 1px solid #4fbcff;\n}\n\n#rrn-notes-panel .rrn-formrow {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n margin-top: 6px;\n}\n\n#rrn-notes-panel .rrn-reactbtns {\n display: flex;\n gap: 4px;\n}\n\n#rrn-notes-panel .rrn-reaction-btn {\n background: #272729;\n border: 1px solid #343536;\n border-radius: 6px;\n font-size: 15px;\n padding: 3px 8px;\n cursor: pointer;\n line-height: 1.2;\n}\n\n#rrn-notes-panel .rrn-reaction-btn:hover {\n background: #343536;\n}\n\n#rrn-notes-panel .rrn-reaction-btn.rrn-selected {\n border-color: #ff4500;\n box-shadow: 0 0 0 1px #ff4500;\n background: #3a1d12;\n}\n\n#rrn-notes-panel .rrn-counter {\n color: #818384;\n font-size: 11px;\n white-space: nowrap;\n}\n\n#rrn-notes-panel .rrn-anon-toggle {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-top: 8px;\n color: #818384;\n font-size: 11px;\n cursor: pointer;\n}\n\n#rrn-notes-panel .rrn-anon-toggle input {\n margin: 0;\n}\n\n#rrn-notes-panel .rrn-anon-label {\n font-style: italic;\n color: #818384;\n}\n\n#rrn-notes-panel .rrn-actions {\n display: flex;\n gap: 8px;\n margin-top: 8px;\n}\n\n#rrn-notes-panel .rrn-btn {\n background: #d7dadc;\n color: #1a1a1b;\n border: none;\n border-radius: 6px;\n padding: 5px 12px;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n}\n\n#rrn-notes-panel .rrn-btn:hover {\n background: #ffffff;\n}\n\n#rrn-notes-panel .rrn-btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n#rrn-notes-panel .rrn-delete {\n background: transparent;\n color: #ff8b60;\n border: 1px solid #ff8b60;\n}\n\n#rrn-notes-panel .rrn-delete:hover {\n background: rgba(255, 139, 96, 0.15);\n}\n\n#rrn-notes-panel .rrn-login {\n background: #ff4500;\n color: #ffffff;\n}\n\n#rrn-notes-panel .rrn-login:hover {\n background: #ff5b1f;\n}\n\n#rrn-notes-panel .rrn-loginbox p {\n margin: 0 0 8px;\n color: #d7dadc;\n}\n\n#rrn-notes-panel .rrn-loginform {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n#rrn-notes-panel .rrn-loginform input {\n background: #272729;\n color: #d7dadc;\n border: 1px solid #343536;\n border-radius: 6px;\n padding: 6px 8px;\n font: inherit;\n box-sizing: border-box;\n width: 100%;\n}\n\n#rrn-notes-panel .rrn-loginform input:focus {\n outline: 1px solid #4fbcff;\n}\n\n#rrn-notes-panel .rrn-loginform .rrn-login {\n width: 100%;\n}\n\n#rrn-notes-panel .rrn-error {\n margin-top: 8px;\n color: #ff8b60;\n font-size: 12px;\n}\n'; // src/shared/ui/admin.css var admin_default = "/* Admin panel chrome shared by the extension's admin.html page and the\n userscript's modal overlay. Class names are prefixed rrn-admin- and scoped\n to whatever container mountAdminApp() (admin.js) renders into, so this\n file works whether that container is the whole page body or a modal div. */\n\n.rrn-admin-status {\n margin: 0 0 12px;\n color: #555;\n}\n\n.rrn-admin-status.error {\n color: #c0392b;\n font-weight: 600;\n}\n\n.rrn-admin-toolbar {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 12px;\n flex-wrap: wrap;\n}\n\n.rrn-admin-toolbar input {\n font: inherit;\n padding: 6px 8px;\n border: 1px solid #ccc;\n border-radius: 4px;\n min-width: 220px;\n}\n\n.rrn-admin-toolbar button {\n font: inherit;\n padding: 6px 12px;\n border: 1px solid #ccc;\n border-radius: 4px;\n background: #fff;\n cursor: pointer;\n}\n\n.rrn-admin-toolbar button:hover {\n background: #eee;\n}\n\n.rrn-admin-spacer {\n flex: 1;\n}\n\n.rrn-admin-total {\n color: #555;\n}\n\n.rrn-admin-table-wrap {\n overflow-x: auto;\n background: #fff;\n border: 1px solid #e0e0e0;\n border-radius: 6px;\n}\n\n.rrn-admin-table-wrap table {\n border-collapse: collapse;\n width: 100%;\n min-width: 900px;\n}\n\n.rrn-admin-table-wrap th,\n.rrn-admin-table-wrap td {\n text-align: left;\n padding: 6px 10px;\n border-bottom: 1px solid #eee;\n vertical-align: top;\n}\n\n.rrn-admin-table-wrap th {\n background: #fafafa;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.03em;\n color: #666;\n position: sticky;\n top: 0;\n}\n\n.rrn-admin-comment-cell {\n max-width: 320px;\n}\n\n.rrn-admin-comment-cell textarea {\n width: 100%;\n min-height: 32px;\n font: inherit;\n padding: 4px 6px;\n border: 1px solid #ddd;\n border-radius: 4px;\n box-sizing: border-box;\n resize: vertical;\n}\n\n.rrn-admin-reaction-select {\n font: inherit;\n padding: 3px 4px;\n}\n\n.rrn-admin-dim {\n color: #999;\n}\n\n.rrn-admin-source-cell {\n max-width: 220px;\n overflow-wrap: anywhere;\n}\n\n.rrn-admin-source-cell a {\n color: #0079d3;\n text-decoration: none;\n}\n\n.rrn-admin-source-cell a:hover {\n text-decoration: underline;\n}\n\n.rrn-admin-actions-cell {\n white-space: nowrap;\n}\n\n.rrn-admin-actions-cell button {\n font: inherit;\n padding: 4px 8px;\n margin-right: 4px;\n border: 1px solid #ccc;\n border-radius: 4px;\n background: #fff;\n cursor: pointer;\n}\n\n.rrn-admin-actions-cell button:hover {\n background: #eee;\n}\n\n.rrn-admin-actions-cell .rrn-admin-save-btn {\n border-color: #0079d3;\n color: #0079d3;\n}\n\n.rrn-admin-actions-cell .rrn-admin-delete-btn {\n border-color: #c0392b;\n color: #c0392b;\n}\n\n.rrn-admin-actions-cell button:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.rrn-admin-body h2 {\n font-size: 14px;\n margin: 18px 0 8px;\n}\n\n.rrn-admin-invite-result {\n margin: 8px 0 14px;\n padding: 8px 10px;\n background: #eaf6ea;\n border: 1px solid #b7e0b7;\n border-radius: 4px;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n}\n\n.rrn-admin-invite-result.error {\n background: #fbeaea;\n border-color: #e0b7b7;\n font-family: inherit;\n color: #c0392b;\n}\n\n.rrn-admin-invites-list {\n margin-bottom: 8px;\n}\n\n.rrn-admin-invites-list summary {\n cursor: pointer;\n color: #0079d3;\n margin-bottom: 8px;\n}\n\n.rrn-admin-pager {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-top: 12px;\n}\n\n.rrn-admin-pager button {\n font: inherit;\n padding: 5px 10px;\n border: 1px solid #ccc;\n border-radius: 4px;\n background: #fff;\n cursor: pointer;\n}\n\n.rrn-admin-pager button:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n"; // src/platforms/userscript/overlay.css var overlay_default = `/* Modal chrome for the userscript's admin panel - the extension's admin.html is a real page and doesn't need any of this; only the overlay/backdrop/ dialog box itself is userscript-specific, the table/toolbar styling inside it comes from shared/ui/admin.css. */ #rrn-admin-overlay { position: fixed; inset: 0; z-index: 2147483001; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } #rrn-admin-overlay[hidden] { display: none; } #rrn-admin-modal { background: #f6f7f8; color: #1a1a1b; width: min(1100px, 95vw); max-height: 88vh; overflow: auto; border-radius: 8px; border: 1px solid #343536; padding: 14px 16px; box-sizing: border-box; } #rrn-admin-modal h2:first-child, #rrn-admin-modal .rrn-admin-modal-title { margin: 0 0 8px; font-size: 16px; display: flex; justify-content: space-between; align-items: center; } #rrn-admin-modal .rrn-admin-close { background: transparent; border: none; color: #818384; font-size: 18px; cursor: pointer; } `; // src/platforms/userscript/entry.js var AUTH_KEY = "rrn_auth"; var API_BASE_URL_KEY = "rrn_apiBaseUrl"; function gmRequest(url, opts) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: opts.method || "GET", url, headers: opts.headers, data: opts.body, onload: (res) => resolve({ status: res.status, text: res.responseText }), onerror: () => resolve({ status: 0, text: null, networkError: true }), ontimeout: () => resolve({ status: 0, text: null, networkError: true }) }); }); } var storage = { async getAuth() { const auth = GM_getValue(AUTH_KEY, null); return auth && typeof auth.token === "string" ? auth : null; }, async setAuth(auth) { GM_setValue(AUTH_KEY, auth); }, async clearAuth() { GM_deleteValue(AUTH_KEY); }, async getApiBaseUrl() { return String(GM_getValue(API_BASE_URL_KEY, DEFAULT_API_BASE_URL) || DEFAULT_API_BASE_URL).replace(/\/+$/, ""); }, async setApiBaseUrl(url) { GM_setValue(API_BASE_URL_KEY, String(url || DEFAULT_API_BASE_URL).replace(/\/+$/, "")); } }; function injectStyle(css) { const style = document.createElement("style"); style.textContent = css; document.head.appendChild(style); } function registerMenu(api) { GM_registerMenuCommand("\u{1F4DD} Show login status", async () => { const auth = await api.getAuth(); alert(auth ? `Logged in as ${auth.username}${auth.isAdmin ? " (admin)" : ""}.` : "Not logged in."); }); GM_registerMenuCommand("\u{1F527} Set backend URL", async () => { const current = await api.getApiBaseUrl(); const next = prompt("Backend API base URL:", current); if (next === null) return; await api.setApiBaseUrl(next.trim()); alert("Saved. Reload the page for it to take effect everywhere."); }); GM_registerMenuCommand("\u{1F6AA} Log out", async () => { await api.logout(); alert("Logged out. Reload the page to refresh the panel."); }); GM_registerMenuCommand("\u{1F6E0} Open admin panel", () => openAdminModal(api)); } function init() { if (window.__RRN_LOADED__) return; window.__RRN_LOADED__ = true; injectStyle(panel_default + "\n" + admin_default + "\n" + overlay_default); const api = createBackend({ httpRequest: gmRequest, storage }); registerMenu(api); startApp(api); } init(); })();