@ywkrlerche/richardwho 1.0.27 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js CHANGED
@@ -222,6 +222,14 @@
222
222
  };
223
223
 
224
224
  try {
225
+ // Played before? Start straight from the browser's saved copy, no download
226
+ const cached = await gameCache.get(urls[0]);
227
+ if (cached && currentLoad?.id === game.id) {
228
+ els.barFill.style.width = "100%";
229
+ els.loaderStatus.textContent = "Starting (saved copy)…";
230
+ startGameHtml(game, await cached.text());
231
+ return;
232
+ }
225
233
  const pieces = await Promise.all(urls.map(async (url, i) => {
226
234
  // Try the main link, then backups. jsDelivr sometimes fails the first fetch of a file it
227
235
  // hasn't cached yet, and a download can drop mid-way, so a retry usually succeeds.
@@ -257,17 +265,9 @@
257
265
  }));
258
266
  if (currentLoad?.id !== game.id) return;
259
267
  els.loaderStatus.textContent = "Starting…";
260
- let html = await new Blob(pieces.flat()).text();
261
- if (currentLoad?.id !== game.id) return;
262
- if (game.src === "gn") html = stripTracking(html);
263
- html = guardHistory(html);
264
- const doc = freshFrame().contentDocument;
265
- doc.open();
266
- doc.write(html);
267
- doc.close();
268
- els.loader.hidden = true;
269
- hookFrameKeys();
270
- try { els.frame.contentWindow.focus(); } catch {}
268
+ const blob = new Blob(pieces.flat());
269
+ gameCache.put(urls[0], blob); // in the background; a full disk just means no saved copy
270
+ startGameHtml(game, await blob.text());
271
271
  } catch (err) {
272
272
  if (err.name === "AbortError") return;
273
273
  console.error(err);
@@ -276,6 +276,52 @@
276
276
  }
277
277
  }
278
278
 
279
+ function startGameHtml(game, html) {
280
+ if (currentLoad?.id !== game.id) return;
281
+ if (game.src === "gn") html = stripTracking(html);
282
+ html = guardHistory(html);
283
+ const doc = freshFrame().contentDocument;
284
+ doc.open();
285
+ doc.write(html);
286
+ doc.close();
287
+ els.loader.hidden = true;
288
+ hookFrameKeys();
289
+ try { els.frame.contentWindow.focus(); } catch {}
290
+ }
291
+
292
+ // Saved copies of recently played games (browser Cache Storage), so replays start instantly.
293
+ // Keyed by the game's first file URL, which includes the version tag, so new versions don't reuse old copies.
294
+ const gameCache = {
295
+ NAME: "rug-games-v1",
296
+ KEEP: 12,
297
+ async get(key) {
298
+ try {
299
+ if (!window.caches) return null;
300
+ const hit = await (await caches.open(this.NAME)).match(key);
301
+ if (hit) this.touch(key);
302
+ return hit || null;
303
+ } catch { return null; }
304
+ },
305
+ async put(key, blob) {
306
+ try {
307
+ if (!window.caches) return;
308
+ await (await caches.open(this.NAME)).put(key, new Response(blob, { headers: { "Content-Type": "text/html" } }));
309
+ await this.touch(key);
310
+ } catch { /* out of space or not allowed: just skip saving */ }
311
+ },
312
+ async touch(key) {
313
+ // most-recent-first list; drop anything past KEEP
314
+ const list = store.get("rug-cache-list", []).filter((k) => k !== key);
315
+ list.unshift(key);
316
+ const drop = list.splice(this.KEEP);
317
+ store.set("rug-cache-list", list);
318
+ if (drop.length) {
319
+ const c = await caches.open(this.NAME);
320
+ await Promise.all(drop.map((k) => c.delete(k)));
321
+ }
322
+ },
323
+ };
324
+
279
325
  // Backup sources for a jsDelivr GitHub link, in the order to try them:
280
326
  // the same link again, jsDelivr's second network, then GitHub's own file server.
281
327
  // The extra collection's files are also published as gn-math/html at the same commit.
@@ -339,7 +385,10 @@
339
385
  els.player.hidden = false;
340
386
  document.body.classList.add("playing");
341
387
  addRecent(game.id);
342
- if (currentLoad?.id !== game.id) loadGame(game);
388
+ if (currentLoad?.id !== game.id) {
389
+ loadGame(game);
390
+ track(`Play: ${game.name}`, { name: "play_game", params: { game_name: game.name, game_category: game.tag } });
391
+ }
343
392
  return;
344
393
  }
345
394
  stopGame();
@@ -347,11 +396,14 @@
347
396
  document.body.classList.remove("playing");
348
397
  document.title = tabTitle();
349
398
  const h = location.hash;
350
- const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : "home";
399
+ const view = h === "#games" || h === "#recent" ? "games" : h === "#settings" ? "settings" : h === "#chat" ? "chat" : "home";
351
400
  els.homeView.hidden = view !== "home";
352
401
  els.gamesView.hidden = view !== "games";
353
402
  $("view-settings").hidden = view !== "settings";
354
- if (view === "settings") renderSettings();
403
+ $("view-chat").hidden = view !== "chat";
404
+ if (view === "settings") { renderSettings(); renderAdmin(); }
405
+ if (view === "chat") openChat();
406
+ track(view[0].toUpperCase() + view.slice(1));
355
407
  document.querySelectorAll(".rail-btn[data-nav]").forEach((b) =>
356
408
  b.classList.toggle("active", b.dataset.nav === (location.hash === "#recent" ? "recent" : view)));
357
409
  if (view === "games") renderRecent();
@@ -621,18 +673,51 @@
621
673
  $("wheel-wrap").addEventListener("click", (e) => { if (e.target.id === "wheel-wrap") closeWheel(); });
622
674
  }
623
675
 
676
+ // ---------- Google Analytics (only when a measurement ID is set in config.js) ----------
677
+ // The tab title is disguised, so pages and games are reported by their real names instead.
678
+ function track(page, extra) {
679
+ if (!window.gtag) return;
680
+ window.gtag("event", "page_view", { page_title: page, page_location: location.href.split("#")[0] + (location.hash || "#home") });
681
+ if (extra) window.gtag("event", extra.name, extra.params);
682
+ }
683
+ function startAnalytics() {
684
+ const id = CFG.googleAnalytics;
685
+ if (!id || !/^G-[A-Z0-9]+$/i.test(id)) return;
686
+ window.dataLayer = window.dataLayer || [];
687
+ window.gtag = function () { window.dataLayer.push(arguments); };
688
+ window.gtag("js", new Date());
689
+ // We send page views ourselves (see track); no ad features
690
+ window.gtag("config", id, { send_page_view: false, allow_google_signals: false, allow_ad_personalization_signals: false });
691
+ loadScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`).catch(() => {});
692
+ }
693
+
624
694
  // ---------- Live "people online" count ----------
625
695
  // Needs a free Firebase Realtime Database (see SETUP.md). Without one, the count stays hidden.
626
696
  function loadScript(src) {
627
697
  return new Promise((ok, fail) => { const s = document.createElement("script"); s.src = src; s.onload = ok; s.onerror = fail; document.head.appendChild(s); });
628
698
  }
699
+ // One shared Firebase connection for the online count and the chat (null when not configured)
700
+ let firebaseApp = null;
701
+ function getFirebase() {
702
+ if (!CFG.firebase || !CFG.firebase.databaseURL) return Promise.resolve(null);
703
+ if (!firebaseApp) {
704
+ firebaseApp = (async () => {
705
+ const v = "10.12.2";
706
+ await loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-app-compat.js`);
707
+ await Promise.all([
708
+ loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-database-compat.js`),
709
+ loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-auth-compat.js`),
710
+ ]);
711
+ return window.firebase.initializeApp(CFG.firebase, "rug");
712
+ })().catch((err) => { console.warn("Firebase unavailable:", err); return null; });
713
+ }
714
+ return firebaseApp;
715
+ }
716
+
629
717
  async function startPresence() {
630
- if (!CFG.firebase || !CFG.firebase.databaseURL) return;
631
718
  try {
632
- const v = "10.12.2";
633
- await loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-app-compat.js`);
634
- await loadScript(`https://www.gstatic.com/firebasejs/${v}/firebase-database-compat.js`);
635
- const app = window.firebase.initializeApp(CFG.firebase, "presence");
719
+ const app = await getFirebase();
720
+ if (!app) return;
636
721
  const db = app.database();
637
722
  const me = db.ref("online").push();
638
723
  db.ref(".info/connected").on("value", (snap) => {
@@ -650,6 +735,216 @@
650
735
  }
651
736
  }
652
737
 
738
+ // ---------- Censored chat room ----------
739
+ // Two layers: this filter (stars out swears, refuses links/personal info) and the database's own
740
+ // rules (see SETUP.md), which reject the same things even if someone skips this page's code.
741
+ const BAD_ANYWHERE = ["fuck", "shit", "bitch", "cunt", "nigg", "faggot", "fagot", "whore", "slut", "pussy",
742
+ "penis", "vagina", "porn", "retard", "asshole", "bastard", "molest", "pedo", "dildo", "boob", "nazi", "hitler",
743
+ "kill yourself", "killyourself"];
744
+ const BAD_WHOLE_WORD = ["ass", "fag", "cum", "tit", "tits", "hoe", "kys", "sex", "nig", "dick", "cock", "rape",
745
+ "raped", "rapist", "raping", "damn", "piss", "crap", "wank", "twat", "prick"];
746
+ const LEET = { 0: "o", 1: "i", 3: "e", 4: "a", 5: "s", 7: "t", 8: "b", "@": "a", $: "s", "!": "i", "|": "i" };
747
+ // Real words that happen to contain a filtered one
748
+ const SAFE_WORDS = ["scunthorpe", "shiitake", "cockpit", "peacock", "hancock", "dickens", "assassin", "cocktail", "therapist"];
749
+ const plain = (s) => s.toLowerCase().replace(/[0134578@$!|]/g, (c) => LEET[c] || c).replace(/[^a-z ]/g, "");
750
+ const squash = (s) => plain(s).replace(/(.)\1+/g, "$1"); // "fuuuck" -> "fuck"
751
+ function isBadWord(word) {
752
+ const p = plain(word).replace(/ /g, ""), w = squash(word).replace(/ /g, "");
753
+ if (!w || SAFE_WORDS.includes(p)) return false;
754
+ // whole-word list: check before and after squashing, since squashing turns "ass" into "as"
755
+ return BAD_WHOLE_WORD.includes(p) || BAD_WHOLE_WORD.includes(w) ||
756
+ BAD_ANYWHERE.some((b) => w.includes(squash(b).replace(/ /g, "")));
757
+ }
758
+ // Returns { text } (with swears starred out) or { error } when the message can't be sent at all
759
+ function cleanMessage(raw) {
760
+ const text = raw.replace(/\s+/g, " ").trim();
761
+ if (!text) return { error: "Type a message first." };
762
+ if (/(https?:|www\.|discord\.gg|\b[a-z0-9-]+\.(com|net|org|io|gg|xyz|co|me|tv|ly|app|dev|link)\b)/i.test(text)) return { error: "Links aren't allowed in chat." };
763
+ if (/\S+@\S+\.\S+/.test(text)) return { error: "Don't share email addresses." };
764
+ if (/(\d[\s().-]*){7,}/.test(text)) return { error: "Don't share phone numbers." };
765
+ const masked = text.split(" ").map((w) => (isBadWord(w) ? "*".repeat(w.length) : w)).join(" ");
766
+ // spaced-out tricks like "f u c k" survive word-by-word checks; catch them on the whole line
767
+ const joined = squash(masked.split(" ").filter((w) => !SAFE_WORDS.includes(plain(w))).join(" ")).replace(/ /g, "");
768
+ if (BAD_ANYWHERE.some((b) => joined.includes(squash(b).replace(/ /g, "")))) return { error: "That message was blocked." };
769
+ return { text: masked };
770
+ }
771
+ function cleanName(raw) {
772
+ // Refuse (don't silently strip) other characters: a wrong or not-yet-saved admin code like "Code123!"
773
+ // must never be posted as a nickname
774
+ if (/[^\w .-]/.test(raw)) return { error: "Nicknames can only use letters, numbers, spaces, . _ and -." };
775
+ const name = raw.replace(/\s+/g, " ").trim().slice(0, 16);
776
+ if (name.length < 2) return { error: "Pick a nickname (2–16 letters)." };
777
+ if (name.split(/[ ._-]/).some(isBadWord) || isBadWord(name.replace(/[ ._-]/g, ""))) return { error: "Pick a different nickname." };
778
+ return { name };
779
+ }
780
+
781
+ const chat = { started: false, uid: null, lastSent: 0, admin: false, triedName: "" };
782
+ const ADMIN_NAME = "Richard"; // what the admin's messages show as (reserved: the database refuses it from non-admins)
783
+ function setAdminName() {
784
+ $("chat-name").value = ADMIN_NAME;
785
+ $("chat-name").readOnly = !!chat.admin;
786
+ }
787
+ function chatNote(text, err) { const n = $("chat-note"); n.textContent = text || ""; n.classList.toggle("err", !!err); }
788
+
789
+ // Who is this browser? Reuses a saved sign-in (Google for admins), otherwise signs in anonymously:
790
+ // no account, just a random per-browser id used for rate limits and bans.
791
+ async function ensureUser(app) {
792
+ const auth = app.auth();
793
+ const existing = await new Promise((ok) => { const off = auth.onAuthStateChanged((u) => { off(); ok(u); }); });
794
+ const user = existing || (await auth.signInAnonymously()).user;
795
+ chat.uid = user.uid;
796
+ // Admins are listed under chat/admins in the database (only editable from the Firebase console)
797
+ // The entry holds the admin code (the database only accepts the right one), so any entry means admin.
798
+ // If the code is changed later, old entries stop working on the database side.
799
+ try { chat.admin = (await app.database().ref(`chat/admins/${user.uid}`).once("value")).val() !== null; } catch { chat.admin = false; }
800
+ $("chat-log").classList.toggle("admin", chat.admin);
801
+ return user;
802
+ }
803
+
804
+ async function openChat() {
805
+ if (chat.started) return;
806
+ const app = await getFirebase();
807
+ if (!app) { $("chat-off").hidden = false; $("chat-log").hidden = true; $("chat-form").hidden = true; return; }
808
+ chat.started = true;
809
+ $("chat-form").hidden = false;
810
+ $("chat-name").value = store.get("rug-chat-name", "");
811
+ chatNote("Connecting…");
812
+ try {
813
+ await ensureUser(app);
814
+ } catch (err) {
815
+ chatNote("Chat couldn't connect. Try again later.", true);
816
+ return;
817
+ }
818
+ if (chat.admin) setAdminName();
819
+ chatNote(chat.admin ? "You're admin: 🗑 deletes a message, 🚫 bans its sender." : "");
820
+ const log = $("chat-log");
821
+ log.innerHTML = `<p class="chat-empty" id="chat-empty">No messages yet. Say hi!</p>`;
822
+ if (chat.ref) chat.ref.off(); // reconnecting (signed in/out): drop the old listener so messages don't double
823
+ const ref = chat.ref = app.database().ref("chat/messages").orderByChild("ts").limitToLast(60);
824
+ ref.on("child_added", (snap) => {
825
+ const m = snap.val();
826
+ if (!m || typeof m.text !== "string") return;
827
+ $("chat-empty")?.remove();
828
+ const nearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
829
+ const t = new Date(m.ts || Date.now());
830
+ const div = document.createElement("div");
831
+ div.className = "chat-msg" + (m.uid === chat.uid ? " me" : "");
832
+ div.dataset.key = snap.key;
833
+ div.dataset.uid = m.uid || "";
834
+ // filter again on display, in case anything slipped past older rules
835
+ const shown = cleanMessage(m.text).text || "[removed]";
836
+ div.innerHTML = `${m.admin ? `<span class="badge-admin">⭐ ADMIN</span>` : ""}<span class="who">${escapeHtml(m.name || "?")}</span>${escapeHtml(shown)}<span class="when">${t.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}</span>` +
837
+ `<button class="mod" type="button" data-mod="delete" title="Delete message">🗑</button><button class="mod" type="button" data-mod="ban" title="Ban this person">🚫</button>`;
838
+ log.appendChild(div);
839
+ while (log.children.length > 80) log.firstChild.remove();
840
+ if (nearBottom || m.uid === chat.uid) log.scrollTop = log.scrollHeight;
841
+ });
842
+ // Deleted by an admin: disappears for everyone
843
+ ref.on("child_removed", (snap) => {
844
+ log.querySelector(`[data-key="${CSS.escape(snap.key)}"]`)?.remove();
845
+ if (!log.querySelector(".chat-msg")) log.innerHTML = `<p class="chat-empty" id="chat-empty">No messages yet. Say hi!</p>`;
846
+ });
847
+ }
848
+
849
+ // ---------- Admin tools (chat) ----------
850
+ async function adminAction(e) {
851
+ const btn = e.target.closest("[data-mod]");
852
+ if (!btn || !chat.admin) return;
853
+ const row = btn.closest(".chat-msg");
854
+ const db = (await getFirebase()).database();
855
+ try {
856
+ if (btn.dataset.mod === "delete") {
857
+ await db.ref(`chat/messages/${row.dataset.key}`).remove();
858
+ } else if (btn.dataset.mod === "ban" && row.dataset.uid && row.dataset.uid !== chat.uid) {
859
+ if (!confirm("Ban this person from chat? They'll still be able to read.")) return;
860
+ await db.ref(`chat/banned/${row.dataset.uid}`).set(true);
861
+ chatNote("Banned. Their old messages stay until you delete them.");
862
+ }
863
+ } catch {
864
+ chatNote("That didn't work. Are you still signed in as an admin?", true);
865
+ }
866
+ }
867
+
868
+ async function renderAdmin() {
869
+ const app = await getFirebase();
870
+ $("admin-group").hidden = !app;
871
+ if (!app) return;
872
+ await ensureUser(app);
873
+ $("admin-status").textContent = chat.admin ? "Admin on this browser ✓" : "Not an admin on this browser";
874
+ $("admin-leave").hidden = !chat.admin;
875
+ $("admin-tools-row").hidden = !chat.admin;
876
+ }
877
+
878
+ // "#code" as a nickname: the database compares it with the secret code (which visitors can't read).
879
+ // Returns true if this browser is now admin.
880
+ async function claimAdmin(code) {
881
+ const app = await getFirebase();
882
+ try {
883
+ await app.database().ref(`chat/admins/${chat.uid}`).set(code);
884
+ } catch {
885
+ return false;
886
+ }
887
+ chat.admin = true;
888
+ $("chat-log").classList.add("admin");
889
+ return true;
890
+ }
891
+
892
+ function bindAdmin() {
893
+ $("chat-log").addEventListener("click", adminAction);
894
+ $("admin-leave").addEventListener("click", async () => {
895
+ try { await (await getFirebase()).database().ref(`chat/admins/${chat.uid}`).remove(); } catch {}
896
+ chat.admin = false;
897
+ chat.triedName = "";
898
+ $("chat-log").classList.remove("admin");
899
+ $("chat-name").readOnly = false;
900
+ $("chat-name").value = store.get("rug-chat-name", "");
901
+ await renderAdmin();
902
+ });
903
+ $("admin-clear").addEventListener("click", async () => {
904
+ if (!chat.admin || !confirm("Delete every chat message for everyone?")) return;
905
+ try { await (await getFirebase()).database().ref("chat/messages").remove(); }
906
+ catch { alert("Couldn't clear the chat."); }
907
+ });
908
+ }
909
+ async function sendChat(e) {
910
+ e.preventDefault();
911
+ const app = await getFirebase();
912
+ if (!app || !chat.uid) return;
913
+ // Typing the admin code as your nickname makes this browser admin (the database checks it;
914
+ // the code itself is never posted). Each new nickname is tried once.
915
+ const rawName = $("chat-name").value.trim();
916
+ if (!chat.admin && rawName && rawName !== chat.triedName) {
917
+ chat.triedName = rawName;
918
+ if (await claimAdmin(rawName)) {
919
+ setAdminName();
920
+ chatNote("You're admin now ✓ Your messages show as Richard, with 🗑 delete and 🚫 ban on every message.");
921
+ if (!$("chat-text").value.trim()) return;
922
+ }
923
+ }
924
+ const n = chat.admin ? { name: ADMIN_NAME } : cleanName(rawName);
925
+ if (n.error) { chatNote(n.error, true); $("chat-name").focus(); return; }
926
+ if (!chat.admin && n.name.toLowerCase() === ADMIN_NAME.toLowerCase()) { chatNote("That name is taken.", true); return; }
927
+ const m = cleanMessage($("chat-text").value);
928
+ if (m.error) { chatNote(m.error, true); return; }
929
+ const wait = 3000 - (Date.now() - chat.lastSent);
930
+ if (wait > 0) { chatNote(`Slow down. Wait ${Math.ceil(wait / 1000)}s.`, true); return; }
931
+ if (!chat.admin) store.set("rug-chat-name", n.name); // never save the admin code as a remembered name
932
+ const db = app.database();
933
+ const key = db.ref("chat/messages").push().key;
934
+ const TS = window.firebase.database.ServerValue.TIMESTAMP;
935
+ chat.lastSent = Date.now();
936
+ try {
937
+ await db.ref().update({
938
+ [`chat/messages/${key}`]: { name: n.name, text: m.text, ts: TS, uid: chat.uid, ...(chat.admin ? { admin: true } : {}) },
939
+ [`chat/last/${chat.uid}`]: TS,
940
+ });
941
+ $("chat-text").value = "";
942
+ chatNote("");
943
+ } catch {
944
+ chatNote("Message blocked (filtered word, or sending too fast).", true);
945
+ }
946
+ }
947
+
653
948
  // ---------- Arrow keys and controller move focus between tiles and buttons ----------
654
949
  function focusables() {
655
950
  const scope = els.player.hidden ? document : els.player;
@@ -901,6 +1196,9 @@
901
1196
  bind();
902
1197
  bindSettings();
903
1198
  bindWheel();
1199
+ $("chat-form").addEventListener("submit", sendChat);
1200
+ bindAdmin();
1201
+ startAnalytics();
904
1202
  startPresence();
905
1203
  // long enough to read a joke or two (skipped when the loading screen is off)
906
1204
  const minShow = new Promise((r) => setTimeout(r, settings.boot !== false ? 1500 : 0));
@@ -933,7 +1231,7 @@
933
1231
  renderGrid();
934
1232
  startMarquee();
935
1233
  route();
936
- finishBoot("Done! (that was not 3 hours)");
1234
+ finishBoot("Done");
937
1235
  }
938
1236
 
939
1237
  init();
package/config.js CHANGED
@@ -6,7 +6,7 @@ window.RUG_CONFIG = {
6
6
 
7
7
  // Branch the games live on. jsDelivr caches branches for up to 12h;
8
8
  // use a tag like "v1" here if you want instant, permanent URLs.
9
- branch: "main",
9
+ branch: "v1", // a fixed tag on every rug-games repo: jsDelivr caches tags for a year (branches only 12h)
10
10
 
11
11
  // Where the panic button sends you
12
12
  panicUrl: "https://classroom.google.com/",
@@ -14,9 +14,21 @@ window.RUG_CONFIG = {
14
14
  // Keys that trigger panic (KeyboardEvent.key values)
15
15
  panicKeys: ["`", "]"],
16
16
 
17
- // Live "people online" count on the home page. Paste your Firebase web config here
17
+ // Google Analytics 4 measurement ID, e.g. "G-ABC123XYZ" (from analytics.google.com).
18
+ // Leave null to turn analytics off. Shows visitors, live users and top games in YOUR GA dashboard.
19
+ googleAnalytics: "G-6VWCQ493PZ", // property "richardsunblockedgames"
20
+
21
+ // Live "people online" count on the home page and the chat room. Paste your Firebase web config here
18
22
  // (see SETUP.md). Leave null to hide the count.
19
- firebase: null,
23
+ firebase: {
24
+ apiKey: "AIzaSyBxVxBFMwlSLx5FGP-Wna3ozHGZEAGXSKs",
25
+ authDomain: "richardwho-e8f22.firebaseapp.com",
26
+ databaseURL: "https://richardwho-e8f22-default-rtdb.firebaseio.com",
27
+ projectId: "richardwho-e8f22",
28
+ storageBucket: "richardwho-e8f22.firebasestorage.app",
29
+ messagingSenderId: "991096262738",
30
+ appId: "1:991096262738:web:8b6460399396051ee6cf8b",
31
+ },
20
32
 
21
33
  // npm package the site is published as (used to find the newest version)
22
34
  npmPackage: "@ywkrlerche/richardwho",
package/index.html CHANGED
@@ -46,6 +46,9 @@
46
46
  <button class="rail-btn" type="button" id="random-btn" title="Random game" aria-label="Random game">
47
47
  <svg viewBox="0 0 24 24"><path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm2.5 3a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM12 10.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3ZM7.5 15a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
48
48
  </button>
49
+ <button class="rail-btn" type="button" data-nav="chat" title="Chat" aria-label="Chat">
50
+ <svg viewBox="0 0 24 24"><path d="M4 3h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H9l-5 4v-4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm3 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm5 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm5 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
51
+ </button>
49
52
  <button class="rail-btn" type="button" data-nav="recent" title="Recently played" aria-label="Recently played">
50
53
  <svg viewBox="0 0 24 24"><path d="M13 3a9 9 0 1 1-8.95 10h2.02A7 7 0 1 0 13 5a6.98 6.98 0 0 0-4.94 2.06L10 9H4V3l2.64 2.64A8.97 8.97 0 0 1 13 3Zm-1 5h2v4.6l3.2 1.9-1 1.7L12 13.7V8Z"/></svg>
51
54
  </button>
@@ -129,6 +132,24 @@
129
132
  </section>
130
133
  </section>
131
134
 
135
+ <!-- CHAT -->
136
+ <section id="view-chat" class="view chat" hidden>
137
+ <header class="games-head"><div><h1>Chat</h1><p class="lede">Be nice. Messages are filtered, and links and personal info are blocked.</p></div></header>
138
+ <div class="chat-box">
139
+ <div class="chat-off" id="chat-off" hidden>
140
+ <p><strong>Chat isn't switched on yet.</strong></p>
141
+ <p>It needs the site's free Firebase database (see SETUP.md).</p>
142
+ </div>
143
+ <div class="chat-log" id="chat-log" aria-live="polite"></div>
144
+ <form class="chat-form" id="chat-form" autocomplete="off" hidden>
145
+ <input id="chat-name" class="chat-name" maxlength="16" placeholder="Nickname" spellcheck="false" aria-label="Nickname">
146
+ <input id="chat-text" class="chat-text" maxlength="200" placeholder="Say something…" aria-label="Message">
147
+ <button class="pill-btn" type="submit" id="chat-send">Send</button>
148
+ </form>
149
+ <p class="chat-note" id="chat-note"></p>
150
+ </div>
151
+ </section>
152
+
132
153
  <!-- SETTINGS -->
133
154
  <section id="view-settings" class="view settings" hidden>
134
155
  <header class="games-head"><div><h1>Settings</h1><p class="lede">Saved on this device only.</p></div></header>
@@ -174,6 +195,17 @@
174
195
  <label class="set-row"><span>Funny loading screen</span><input type="checkbox" id="set-boot"></label>
175
196
  </div>
176
197
 
198
+ <div class="set-group" id="admin-group" hidden>
199
+ <h2>Chat admin</h2>
200
+ <p class="set-help">Admins can delete chat messages and ban people. To become admin, type the admin code as your chat nickname and press Send. Your messages then show as Richard.</p>
201
+ <div class="set-row"><span id="admin-status">Not an admin on this browser</span>
202
+ <button class="pill-btn" type="button" id="admin-leave" hidden>Stop being admin here</button>
203
+ </div>
204
+ <div class="set-row" id="admin-tools-row" hidden><span>Chat</span>
205
+ <button class="pill-btn danger" type="button" id="admin-clear">Clear all chat</button>
206
+ </div>
207
+ </div>
208
+
177
209
  <div class="set-group">
178
210
  <h2>Your data</h2>
179
211
  <div class="set-row"><span>Recently played</span><button class="pill-btn" type="button" id="set-clear-recent">Clear</button></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ywkrlerche/richardwho",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "Richards Unblocked Games",
5
5
  "license": "UNLICENSED",
6
6
  "files": [
package/style.css CHANGED
@@ -221,6 +221,31 @@ body:has(#view-games:not([hidden])) .aurora { opacity: .7; }
221
221
  .disguise .none-icon { width: 18px; height: 18px; flex: none; display: grid; place-items: center; font-size: 13px; }
222
222
  .disguise[aria-pressed="true"] { border-color: var(--text); background: var(--soft-2); box-shadow: inset 0 0 0 1px var(--text); }
223
223
 
224
+ /* ---------- Chat ---------- */
225
+ .chat { padding-top: 26px; padding-bottom: 40px; max-width: 820px; }
226
+ .chat-box { border: 1px solid var(--line); border-radius: 6px; background: color-mix(in srgb, var(--panel) 88%, transparent); display: flex; flex-direction: column; height: min(68vh, 640px); }
227
+ .chat-log { flex: 1; overflow-y: auto; padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; }
228
+ .chat-msg { font-size: 14px; line-height: 1.4; word-wrap: break-word; }
229
+ .chat-msg .who { font-weight: 700; margin-right: 6px; }
230
+ .chat-msg .when { color: var(--muted); font-size: 11px; margin-left: 6px; }
231
+ .chat-msg.me .who { text-decoration: underline; text-underline-offset: 3px; }
232
+ .chat-empty { color: var(--muted); font-size: 13px; text-align: center; margin: auto; }
233
+ .chat-form { display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
234
+ .chat-form input { height: 40px; padding: 0 12px; border: 1px solid var(--line-2); border-radius: 4px; background: var(--bg); color: var(--text); }
235
+ .chat-name { width: 130px; flex: none; }
236
+ .chat-text { flex: 1; min-width: 0; }
237
+ .chat .pill-btn { color: var(--bg); background: var(--text); border-color: var(--text); height: 40px; padding: 0 18px; }
238
+ .chat-note { margin: 0; padding: 0 12px 10px; min-height: 1.3em; color: var(--muted); font-size: 12px; }
239
+ .chat-note.err { color: var(--danger); }
240
+ .chat-off { margin: auto; text-align: center; color: var(--muted); padding: 30px; }
241
+ .chat-off p { margin: 4px 0; }
242
+ .chat-msg .mod { display: none; margin-left: 8px; border: 1px solid var(--line-2); background: var(--soft); color: var(--text); border-radius: 3px; font-size: 11px; padding: 1px 6px; }
243
+ .chat-msg .mod:hover { background: color-mix(in srgb, var(--danger) 30%, transparent); }
244
+ .chat-log.admin .chat-msg .mod { display: inline-block; }
245
+ .chat-msg .badge-admin { color: #facc15; font-size: 11px; font-weight: 700; margin-right: 6px; }
246
+ .admin-id { font: 12px ui-monospace, Consolas, monospace; padding: 4px 8px; border: 1px solid var(--line-2); border-radius: 4px; user-select: all; max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
247
+ @media (max-width: 720px) { .chat-name { width: 90px; } }
248
+
224
249
  /* ---------- Random game wheel ---------- */
225
250
  .wheel-wrap { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 16px; background: rgba(0,0,0,.7); backdrop-filter: blur(6px); }
226
251
  .wheel-card { position: relative; width: min(440px, 100%); text-align: center; background: var(--panel); border: 1px solid var(--line-2); border-radius: 8px; padding: 20px 20px 18px; box-shadow: 0 30px 80px rgba(0,0,0,.6); }