Reddit User Notes

Attach shared notes and 👍/👎/🤡 reactions to Reddit usernames. Visible to all extension/script users.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
  }
  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();
})();