@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 +318 -20
- package/config.js +15 -3
- package/index.html +32 -0
- package/package.json +1 -1
- package/style.css +25 -0
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
633
|
-
|
|
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
|
|
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: "
|
|
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
|
-
//
|
|
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:
|
|
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
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); }
|