From a476b1331716511dff0bf9ffc859d7dd1fc55175 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 6 Jul 2026 12:21:48 +1000 Subject: [PATCH] admin: click-to-copy invite codes + list open (unclaimed) codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.py admin_stats now returns open_codes (all unclaimed invite codes, newest first); unused_codes derived from it. - account panel: renders every open code as a click-to-copy chip ("copied ✓" flash); "issue new code" prepends the new code and auto-copies it. Chips are user-selectable (the body-wide user-select:none no longer traps them). copyText uses navigator.clipboard with an execCommand fallback for non-secure contexts. Co-Authored-By: Claude Fable 5 --- auth.py | 8 +++++--- viz/index.html | 47 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/auth.py b/auth.py index 340f783..317dcab 100644 --- a/auth.py +++ b/auth.py @@ -230,7 +230,9 @@ def admin_stats(db: str = DEFAULT_DB) -> dict: con = _con(db) try: users = con.execute("SELECT COUNT(*) FROM users").fetchone()[0] - unused = con.execute("SELECT COUNT(*) FROM invites WHERE used_by IS NULL").fetchone()[0] + open_codes = [c for (c,) in con.execute( + "SELECT code FROM invites WHERE used_by IS NULL ORDER BY created DESC")] + unused = len(open_codes) presets = con.execute("SELECT COUNT(*) FROM presets").fetchone()[0] accounts = [{"username": u, "email": e, "joined": time.strftime("%Y-%m-%d", time.localtime(c))} @@ -240,8 +242,8 @@ def admin_stats(db: str = DEFAULT_DB) -> dict: for u, t, c in con.execute("SELECT username,text,created FROM feedback ORDER BY created DESC LIMIT 100")] finally: con.close() - return {"users": users, "unused_codes": unused, "presets": presets, - "accounts": accounts, "feedback": fb} + return {"users": users, "unused_codes": unused, "open_codes": open_codes, + "presets": presets, "accounts": accounts, "feedback": fb} # ---- presets (a full personal patch, stored as one JSON blob) --------------- diff --git a/viz/index.html b/viz/index.html index 62b1006..424d5f0 100644 --- a/viz/index.html +++ b/viz/index.html @@ -285,6 +285,12 @@ #account .ghost { background: rgba(255,255,255,0.05); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.2); border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 11px; } #account .code { font-family: monospace; color: #7cffb2; margin-left: 8px; font-size: 12px; } + #account .codes { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; } + #account .codechip { font-family: monospace; color: #7cffb2; font-size: 12px; cursor: pointer; + background: rgba(124,255,178,0.08); border: 1px solid rgba(124,255,178,0.3); border-radius: 6px; + padding: 4px 9px; user-select: text; -webkit-user-select: text; transition: background 0.15s; } + #account .codechip:hover { background: rgba(124,255,178,0.18); } + #account .codechip.copied { color: #fff; border-color: #7cffb2; background: rgba(124,255,178,0.3); } /* ---- gated landing page (shown until you sign in) ---- */ #landing { @@ -5791,19 +5797,56 @@ const s = r.data, box = acctBox.querySelector("#adminbox"); const plural = (n, w) => n + " " + w + (n === 1 ? "" : "s"); box.style.display = "block"; + const codes = s.open_codes || []; box.innerHTML = "

admin

" + '
' + plural(s.users, "user") + " · " + plural(s.unused_codes, "code") + " left · " + plural(s.presets, "preset") + "
" + - '' + + '' + + '
open invite codes — click any to copy
' + + '
' + + (codes.length ? codes.map(c => '' + esc(c) + "").join("") + : 'none — issue one above') + "
" + '
feedback
' + '
' + (s.feedback.length ? s.feedback.map(f => '
@' + esc(f.username) + ' ' + esc(f.when) + "
" + esc(f.text) + "
").join("") : '
none yet
') + "
"; + const wireChip = (chip) => { + chip.onclick = () => copyText(chip.textContent, () => { + const prev = chip.textContent; + chip.classList.add("copied"); chip.textContent = "copied ✓"; + setTimeout(() => { chip.classList.remove("copied"); chip.textContent = prev; }, 1100); + }); + }; + box.querySelectorAll(".codechip").forEach(wireChip); box.querySelector("#newcode").onclick = async () => { const cr = await api("/api/admin/invite", "POST"); - box.querySelector("#codeout").textContent = cr.ok ? cr.data.code : "failed"; + if (!cr.ok) return; + const holder = box.querySelector("#codes"); + if (holder.querySelector(".sub")) holder.innerHTML = ""; // clear the "none" placeholder + const chip = document.createElement("span"); + chip.className = "codechip"; chip.textContent = cr.data.code; + holder.insertBefore(chip, holder.firstChild); + wireChip(chip); + copyText(cr.data.code, () => { // fresh code copies itself + chip.classList.add("copied"); const c = cr.data.code; chip.textContent = "copied ✓"; + setTimeout(() => { chip.classList.remove("copied"); chip.textContent = c; }, 1100); + }); }; } + // clipboard with a fallback for non-secure contexts + function copyText(text, onOk) { + const done = () => { if (onOk) onOk(); }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done)); + } else fallbackCopy(text, done); + } + function fallbackCopy(text, done) { + const ta = document.createElement("textarea"); + ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; + document.body.appendChild(ta); ta.select(); + try { document.execCommand("copy"); } catch (e) {} + document.body.removeChild(ta); done(); + } loginForm.addEventListener("submit", async (e) => { e.preventDefault(); clearErr();