Death242 commited on
Commit
b1787b9
·
verified ·
1 Parent(s): eada153

Static site

Browse files
Files changed (2) hide show
  1. index.html +1 -1
  2. static/hub.js +206 -204
index.html CHANGED
@@ -29,7 +29,7 @@
29
 
30
  <nav id="nav">
31
  <button class="logo" data-go="home" aria-label="VOID home">VOID</button>
32
- <span class="credit">Made by Legion</span>
33
  <div class="right">
34
  <button id="snd" class="pill" aria-pressed="false" title="Music and engine sound">Sound off</button>
35
  <button id="exit" class="pill" title="Back home (Esc)">&#10005;</button>
 
29
 
30
  <nav id="nav">
31
  <button class="logo" data-go="home" aria-label="VOID home">VOID</button>
32
+ <span class="credit"><img src="data:image/png;base64,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" alt="" width="14" height="14" style="width:14px;height:14px;border-radius:3px;vertical-align:-3px;margin-right:5px">Made by Legion</span>
33
  <div class="right">
34
  <button id="snd" class="pill" aria-pressed="false" title="Music and engine sound">Sound off</button>
35
  <button id="exit" class="pill" title="Back home (Esc)">&#10005;</button>
static/hub.js CHANGED
@@ -1,204 +1,206 @@
1
- /* Universal layer, client side. One Cramly account works in every app, and the owner's switches reach every app:
2
- off / ban / time-out screens, messages, and harmless trolls. Add to a page with:
3
- <script src="/static/hub.js" data-app="alibi" defer></script>
4
- It fails open: if Cramly cannot be reached the app simply works as normal. */
5
- (() => {
6
- "use strict";
7
- const me = document.currentScript;
8
- const APP = (me && me.dataset.app) || "app";
9
- const HUB = ((me && me.dataset.hub) || "https://cramly-twz5.onrender.com").replace(/\/$/, "");
10
- const store = {
11
- get: k => { try { return localStorage.getItem("hub." + k) || ""; } catch (e) { return ""; } },
12
- set: (k, v) => { try { v ? localStorage.setItem("hub." + k, v) : localStorage.removeItem("hub." + k); } catch (e) { /* private mode */ } },
13
- };
14
- const keyOf = () => { // inside Cramly the signed-in account is used as it is
15
- const k = store.get("key");
16
- if (k || APP !== "cramly") return k;
17
- try { return JSON.parse(localStorage.getItem("cramly.key") || "null") || ""; } catch (e) { return ""; }
18
- };
19
- let dev = store.get("dev");
20
- if (!dev) { dev = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36)).replace(/-/g, "").slice(0, 24); store.set("dev", dev); }
21
- window.HUB = { dev: () => dev, key: keyOf, app: APP };
22
-
23
- /* every same-origin request carries who is asking, so the server can enforce a ban too */
24
- const realFetch = window.fetch.bind(window);
25
- window.fetch = (input, init = {}) => {
26
- try {
27
- const url = new URL(typeof input === "string" ? input : input.url, location.href);
28
- if (url.origin === location.origin) { init = { ...init, headers: { ...(init.headers instanceof Headers ? Object.fromEntries(init.headers) : init.headers || {}), "X-Hub-Dev": dev, ...(keyOf() ? { "X-Hub-Key": keyOf() } : {}) } }; }
29
- } catch (e) { /* leave it alone */ }
30
- return realFetch(input, init);
31
- };
32
-
33
- const css = `
34
- .hub-ov{position:fixed;inset:0;z-index:2147483000;display:grid;place-items:center;background:#0a0a0b;color:#ececf0;font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;padding:24px;text-align:center}
35
- .hub-ov h2{margin:0 0 8px;font-size:26px;font-weight:700;letter-spacing:-.01em}.hub-ov p{margin:0 auto 14px;max-width:34ch;color:#9a9aa3}.hub-ov .em{font-size:48px;margin-bottom:10px}
36
- .hub-ov small{color:#6c6c75}
37
- .hub-chip{position:fixed;left:12px;bottom:12px;z-index:2147482000;width:34px;height:34px;border-radius:50%;border:1px solid #2a2a30;background:#121214;color:#b9b9c2;font:600 13px system-ui;cursor:pointer;opacity:.55;display:grid;place-items:center;padding:0}
38
- .hub-chip:hover,.hub-chip:focus-visible{opacity:1;outline:2px solid #5b93ff}
39
- .hub-box{position:fixed;left:12px;bottom:54px;z-index:2147482001;width:min(320px,calc(100vw - 24px));background:#121214;border:1px solid #2a2a30;border-radius:16px;padding:16px;color:#ececf0;font:14px/1.45 system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.6)}
40
- .hub-box h3{margin:0 0 6px;font-size:16px}.hub-box p{margin:0 0 10px;color:#9a9aa3}.hub-box input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid #2a2a30;background:#0a0a0b;color:#ececf0;font:600 18px ui-monospace,monospace;letter-spacing:.2em;text-align:center}
41
- .hub-box button{margin-top:10px;width:100%;padding:10px;border-radius:10px;border:0;background:#5b93ff;color:#08101f;font:700 14px system-ui;cursor:pointer}.hub-box button.alt{background:#1b1b1f;color:#ececf0}
42
- .hub-box .err{color:#ff6b74;margin-top:8px;min-height:1em}
43
- .hub-box input.hub-in{font:15px system-ui,sans-serif;letter-spacing:0;text-align:left;margin-top:8px}.hub-box button:disabled{opacity:.6}
44
- .hub-links{margin:10px 0 0!important;font-size:12.5px}.hub-links a{color:#8fb4ff}.hub-small{margin:10px 0 0!important;font-size:11.5px;color:#6c6c75!important}
45
- .hub-toast{position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:2147483100;max-width:min(92vw,420px);background:#121214;border:1px solid #2a2a30;color:#ececf0;padding:12px 16px;border-radius:14px;font:14px/1.45 system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.6);animation:hubIn .3s ease}
46
- .hub-toast b{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#8d8d96;margin-bottom:2px}
47
- @keyframes hubIn{from{opacity:0;transform:translate(-50%,-10px)}}
48
- @keyframes hubFlip{to{transform:rotate(180deg)}}@keyframes hubShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-8px)}75%{transform:translateX(8px)}}
49
- @keyframes hubHue{to{filter:hue-rotate(360deg)}}@keyframes hubSpin{to{transform:rotate(360deg)}}@keyframes hubFall{to{transform:translateY(110vh) rotate(540deg)}}
50
- @keyframes hubDisco{0%{background:#f0b429}33%{background:#3dd49b}66%{background:#5b93ff}100%{background:#ff6b74}}
51
- .hub-credit{position:fixed;right:10px;bottom:calc(8px + env(safe-area-inset-bottom,0px));z-index:2147481000;text-decoration:none;font:600 10.5px/1 system-ui,-apple-system,Segoe UI,sans-serif;letter-spacing:.06em;color:#9a9aa3;background:rgba(14,14,16,.62);border:1px solid rgba(255,255,255,.08);padding:5px 8px;border-radius:999px;opacity:.75;backdrop-filter:blur(6px)}
52
- .hub-credit u{text-decoration:none;color:#c9c9d1}.hub-credit:hover,.hub-credit:focus-visible{opacity:1;color:#ececf0}
53
- .hub-credit.l{right:auto;left:54px}.hub-credit.t{bottom:auto;top:calc(8px + env(safe-area-inset-top,0px))}
54
- @media print{.hub-credit{display:none}}
55
- .hub-fall{position:fixed;top:-30px;z-index:2147483050;pointer-events:none;animation:hubFall linear forwards}
56
- @media (prefers-reduced-motion:reduce){.hub-fall{display:none}}`;
57
- const style = document.createElement("style"); style.textContent = css; document.head.append(style);
58
- const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
59
- const esc = s => String(s == null ? "" : s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
60
-
61
- /* full-screen states: off, banned, on a break */
62
- let overlay = null, tick = null;
63
- function block(emoji, title, body, extra) {
64
- if (!overlay) { overlay = el("div", "hub-ov"); overlay.setAttribute("role", "alert"); document.body.append(overlay); }
65
- overlay.innerHTML = `<div><div class="em">${emoji}</div><h2>${esc(title)}</h2><p>${esc(body)}</p>${extra || ""}</div>`;
66
- }
67
- function unblock() { if (overlay) { overlay.remove(); overlay = null; } if (tick) { clearInterval(tick); tick = null; } }
68
-
69
- /* harmless trolls: all end by themselves */
70
- const toast = (label, text, ms = 9000) => { const t = el("div", "hub-toast", `<b>${esc(label)}</b>${esc(text)}`); t.setAttribute("role", "status"); document.body.append(t); setTimeout(() => t.remove(), ms); t.onclick = () => t.remove(); };
71
- function rain(chars, n = 60) {
72
- for (let i = 0; i < n; i++) {
73
- const s = el("div", "hub-fall", chars[i % chars.length]); const d = 2.5 + Math.random() * 3;
74
- s.style.cssText = `left:${Math.random() * 100}vw;font-size:${18 + Math.random() * 22}px;animation-duration:${d}s;animation-delay:${Math.random() * 2}s`;
75
- document.body.append(s); setTimeout(() => s.remove(), (d + 2.5) * 1000);
76
- }
77
- }
78
- const onRoot = (anim, ms) => { const r = document.documentElement; r.style.animation = anim; setTimeout(() => { r.style.animation = ""; r.style.filter = ""; r.style.transform = ""; }, ms); };
79
- const EFFECTS = {
80
- confetti: () => rain(["🎉", "🎊", "✨", "🟦", "🟨", "🟥", "🟩"], 70),
81
- emoji: () => rain(["😂", "🤪", "🫠", "🥳", "😜", "🤡"], 60),
82
- flip: () => { const r = document.documentElement; r.style.transition = "transform 1s"; r.style.transform = "rotate(180deg)"; setTimeout(() => { r.style.transform = ""; setTimeout(() => r.style.transition = "", 1000); }, 7000); },
83
- shake: () => onRoot("hubShake .35s linear 14", 5000),
84
- rainbow: () => onRoot("hubHue 2s linear 4", 8000),
85
- spin: () => onRoot("hubSpin 2s ease-in-out 1", 2200),
86
- blur: () => { const r = document.documentElement; r.style.transition = "filter 1s"; r.style.filter = "blur(6px)"; setTimeout(() => { r.style.filter = ""; }, 5000); },
87
- disco: () => { const d = el("div"); d.style.cssText = "position:fixed;inset:0;z-index:2147483040;opacity:.25;pointer-events:none;animation:hubDisco .5s steps(1) infinite"; document.body.append(d); setTimeout(() => d.remove(), 6000); },
88
- fakecrash: () => { block("💥", "Something went terribly wrong", "Just kidding. The owner is messing with you."); setTimeout(unblock, 3500); },
89
- typewriter: () => toast("psst", "Somebody is typing…", 4000),
90
- };
91
- function note(n) {
92
- if (n.kind === "troll") { try { (EFFECTS[n.effect] || EFFECTS.confetti)(); } catch (e) { /* ignore */ } if (n.body) toast("From the owner", n.body); }
93
- else toast("From the owner", n.body, 14000);
94
- }
95
-
96
- /* the account button: sign in or make an account with email and password (the same account in every app),
97
- ask the owner for a new password, or link with a Cramly pairing code */
98
- let chip = null, box = null, linked = false, who = "";
99
- const api = async (path, opts = {}) => {
100
- const key = keyOf(), r = await realFetch(HUB + path, { ...opts, headers: { "Content-Type": "application/json", ...(key ? { Authorization: "Bearer " + key } : {}), ...(opts.headers || {}) } });
101
- let j = {}; try { j = await r.json(); } catch (e) { /* empty */ }
102
- if (!r.ok) throw new Error(j.error || "That did not work.");
103
- return j;
104
- };
105
- const signedIn = (j, how) => { store.set("key", j.key); store.set("email", j.email || ""); store.set("ticket", ""); linked = true; who = j.email || j.name || ""; if (box) { box.remove(); box = null; } drawChip(); poll(); toast(how, `Signed in as ${who}`, 4000); window.dispatchEvent(new CustomEvent("hub:account", { detail: { email: who } })); };
106
- function drawChip() {
107
- if (!chip) { chip = el("button", "hub-chip", "☁"); chip.title = "Your account"; chip.setAttribute("aria-label", "Your account"); chip.onclick = () => togglePanel(); document.body.append(chip); }
108
- chip.textContent = linked ? (who[0] || "✓").toUpperCase() : "👤";
109
- chip.style.opacity = linked ? ".9" : "";
110
- }
111
- function form(view) {
112
- const f = (id, type, ph, extra = "") => `<input class="hub-in" id="${id}" type="${type}" placeholder="${ph}" ${extra}>`;
113
- const notice = `<p class="hub-small">The site owner can see the email you sign up with, never your password.</p>`;
114
- if (view === "up") return `<h3>Create your account</h3><p>One account for every app by Legion.</p>${f("hubE", "email", "Email", 'autocomplete="email"')}${f("hubP", "password", "Password (8+ characters)", 'autocomplete="new-password"')}<button id="hubGo">Create account</button><div class="err" id="hubErr" role="alert"></div><p class="hub-links"><a href="#" data-v="in">I already have an account</a></p>${notice}`;
115
- if (view === "forgot") return `<h3>Forgot your password?</h3><p>Your request goes to the owner. Once they approve it you can set a new password right here.</p>${f("hubE", "email", "Your account's email", 'autocomplete="email"')}<button id="hubGo">Ask for a new password</button><div class="err" id="hubErr" role="alert"></div><p class="hub-links"><a href="#" data-v="in">Back to sign in</a></p>`;
116
- if (view === "wait") return `<h3>Waiting for the owner</h3><p>Your request <b id="hubCode2"></b> is with the owner. Keep this page open or come back later: when it's approved you can set a new password here.</p><div class="err" id="hubErr" role="alert"></div><button class="alt" id="hubCheck">Check again</button><p class="hub-links"><a href="#" data-v="cancel">Cancel the request</a></p>`;
117
- if (view === "newpw") return `<h3>Set a new password</h3><p>The owner approved your request.</p>${f("hubP", "password", "New password (8+ characters)", 'autocomplete="new-password"')}<button id="hubGo">Save and sign in</button><div class="err" id="hubErr" role="alert"></div>`;
118
- if (view === "code") return `<h3>Link with a code</h3><p>In Cramly open <b>Settings</b>, tap <b>Use Cramly on another device</b>, and type the 6 digits here.</p><input id="hubCode" inputmode="numeric" maxlength="6" placeholder="000000" aria-label="6 digit code"><button id="hubGo">Link</button><div class="err" id="hubErr" role="alert"></div><p class="hub-links"><a href="#" data-v="in">Sign in with email instead</a></p>`;
119
- return `<h3>Sign in</h3><p>One account for every app by Legion.</p>${f("hubE", "email", "Email", 'autocomplete="email"')}${f("hubP", "password", "Password", 'autocomplete="current-password"')}<button id="hubGo">Sign in</button><div class="err" id="hubErr" role="alert"></div><p class="hub-links"><a href="#" data-v="up">Create an account</a> · <a href="#" data-v="forgot">Forgot password?</a> · <a href="#" data-v="code">Cramly code</a></p>`;
120
- }
121
- async function togglePanel(view) {
122
- if (box && !view) { box.remove(); box = null; return; }
123
- if (!box) { box = el("div", "hub-box"); box.setAttribute("role", "dialog"); document.body.append(box); }
124
- if (linked) {
125
- box.innerHTML = `<h3>Signed in</h3><p><b>${esc(store.get("email") || who || "your account")}</b><br>The same account works in every app by Legion.</p><button class="alt" id="hubOut">Sign out here</button><p style="margin:10px 0 0;font-size:11px">Device id: ${esc(dev)}</p>`;
126
- box.querySelector("#hubOut").onclick = () => { store.set("key", ""); store.set("email", ""); linked = false; who = ""; box.remove(); box = null; drawChip(); poll(); window.dispatchEvent(new CustomEvent("hub:account", { detail: { email: "" } })); };
127
- if (!store.get("email")) api("/api/uni/acct/me").then(j => { if (j.email) { store.set("email", j.email); const b = box && box.querySelector("p b"); if (b) b.textContent = j.email; } }).catch(() => { });
128
- return;
129
- }
130
- if (!view) { const t = store.get("ticket"); view = t ? "wait" : "in"; }
131
- box.innerHTML = form(view);
132
- const $ = id => box.querySelector("#" + id), err = $("hubErr");
133
- box.querySelectorAll("[data-v]").forEach(a => a.onclick = e => { e.preventDefault(); if (a.dataset.v === "cancel") store.set("ticket", ""); togglePanel(a.dataset.v === "cancel" ? "in" : a.dataset.v); });
134
- const busyBtn = async (fn) => { const b = $("hubGo"); if (b) b.disabled = true; err.textContent = "…"; try { await fn(); } catch (e) { err.textContent = e.message === "Failed to fetch" ? "The account server didn't answer. It may be waking up: try again in a minute." : e.message; } finally { if (b) b.disabled = false; } };
135
- const enter = (id, fn) => { const i = $(id); if (i) i.addEventListener("keydown", e => { if (e.key === "Enter") fn(); }); };
136
- if (view === "in" || view === "up") {
137
- const go = () => busyBtn(async () => { const j = await api(`/api/uni/acct/${view === "up" ? "signup" : "login"}`, { method: "POST", body: JSON.stringify({ email: $("hubE").value, password: $("hubP").value, app: APP }) }); signedIn(j, view === "up" ? "Account made" : "Welcome back"); });
138
- $("hubGo").onclick = go; enter("hubP", go); $("hubE").focus();
139
- } else if (view === "forgot") {
140
- const go = () => busyBtn(async () => { const j = await api("/api/uni/acct/forgot", { method: "POST", body: JSON.stringify({ email: $("hubE").value, app: APP }) }); store.set("ticket", j.ticket); store.set("tcode", j.code); togglePanel("wait"); });
141
- $("hubGo").onclick = go; enter("hubE", go); $("hubE").focus();
142
- } else if (view === "wait") {
143
- $("hubCode2").textContent = "#" + (store.get("tcode") || "");
144
- const check = () => busyBtn(async () => { const j = await api("/api/uni/acct/forgot?ticket=" + encodeURIComponent(store.get("ticket"))); if (j.status === "approved") togglePanel("newpw"); else if (j.status === "pending") err.textContent = "Still waiting for the owner."; else { store.set("ticket", ""); err.textContent = j.status === "denied" ? "The owner said no to this request." : "This request expired. Ask again."; } });
145
- $("hubCheck").onclick = check; check();
146
- } else if (view === "newpw") {
147
- const go = () => busyBtn(async () => { const j = await api("/api/uni/acct/reset", { method: "POST", body: JSON.stringify({ ticket: store.get("ticket"), password: $("hubP").value, app: APP }) }); signedIn(j, "Password changed"); });
148
- $("hubGo").onclick = go; enter("hubP", go); $("hubP").focus();
149
- } else if (view === "code") {
150
- const go = () => busyBtn(async () => { const code = $("hubCode").value.trim(); if (code.length < 6) throw new Error("Type all 6 digits."); const j = await api("/api/uni/link", { method: "POST", body: JSON.stringify({ code }) }); signedIn(j, "Linked"); });
151
- $("hubGo").onclick = go; enter("hubCode", go); $("hubCode").focus();
152
- }
153
- }
154
- window.HUB.open = v => togglePanel(v || "in");
155
- window.HUB.email = () => linked ? (store.get("email") || who) : "";
156
- // a pending password request: tell the person when the owner has answered
157
- async function checkTicket() {
158
- const t = store.get("ticket"); if (!t || linked) return;
159
- try { const j = await api("/api/uni/acct/forgot?ticket=" + encodeURIComponent(t)); if (j.status === "approved" && !box) toast("Good news", "Your password reset was approved. Tap the 👤 button to set a new password.", 15000); } catch (e) { /* later */ }
160
- }
161
-
162
- /* ask Cramly what this visitor should see */
163
- let busy = false;
164
- async function poll() {
165
- if (busy) return; busy = true;
166
- try {
167
- const since = store.get("since") || "0", key = keyOf();
168
- const ctl = new AbortController(), to = setTimeout(() => ctl.abort(), 25000);
169
- const r = await realFetch(`${HUB}/api/uni/guard?app=${encodeURIComponent(APP)}&dev=${encodeURIComponent(dev)}&since=${since}`, { headers: key ? { Authorization: "Bearer " + key } : {}, signal: ctl.signal });
170
- clearTimeout(to);
171
- if (!r.ok) return;
172
- const g = await r.json();
173
- linked = !!g.linked; who = g.name || ""; if (key && !g.linked) store.set("key", ""); // the account was deleted
174
- drawChip();
175
- const contact = g.contact ? `<small>Questions? ${esc(g.contact)}</small>` : "";
176
- if (g.banned) block("🚫", "This account has been blocked", g.reason || "The owner blocked access.", contact);
177
- else if (g.timeout_until > g.now) {
178
- const draw = () => { const s = Math.max(0, Math.round(g.timeout_until - g.now - (Date.now() - t0) / 1000)); const m = Math.floor(s / 60);
179
- block("⏳", "You are on a break", (g.reason ? g.reason + " " : "") + `Back in ${m >= 1 ? m + " min " : ""}${s % 60}s.`, contact); if (s <= 0) { unblock(); poll(); } };
180
- const t0 = Date.now(); if (tick) clearInterval(tick); draw(); tick = setInterval(draw, 1000);
181
- } else if (g.off) block("🛠️", APP.charAt(0).toUpperCase() + APP.slice(1) + " is switched off", g.message || "Back soon.", contact);
182
- else unblock();
183
- if (!g.banned && !g.off) {
184
- if (g.notes && g.notes.length) g.notes.forEach(note);
185
- if (g.inbox && g.inbox.length) { g.inbox.forEach(m => toast("Message for you", m.body, 20000)); realFetch(HUB + "/api/uni/ack", { method: "POST", headers: { Authorization: "Bearer " + key } }).catch(() => {}); }
186
- }
187
- if (g.last != null) store.set("since", String(g.last));
188
- checkTicket();
189
- } catch (e) { /* fail open: Cramly may be asleep */ }
190
- finally { busy = false; }
191
- }
192
- /* the credit on every app: "Made by Legion". A page can move it with data-credit="bl|br|tl|tr" on this script tag, or hide it with "none". */
193
- const credit = () => {
194
- if (!document.querySelector('meta[name="author"]')) { const m = document.createElement("meta"); m.name = "author"; m.content = "Legion"; document.head.append(m); }
195
- const pos = (me && me.dataset.credit) || "br";
196
- if (pos === "none" || document.querySelector(".hub-credit")) return;
197
- const c = el("a", "hub-credit" + (pos[0] === "t" ? " t" : "") + (pos[1] === "l" ? " l" : ""), "Made by Legion · <u>Contact me</u>");
198
- c.href = "mailto:Awsaa-Legion@proton.me"; c.title = "Awsaa-Legion@proton.me";
199
- c.setAttribute("aria-label", "Made by Legion. Contact me: Awsaa-Legion@proton.me");
200
- document.body.append(c);
201
- };
202
- const start = () => { credit(); drawChip(); poll(); setInterval(poll, 30000); document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); }); };
203
- if (document.body) start(); else addEventListener("DOMContentLoaded", start);
204
- })();
 
 
 
1
+ /* Universal layer, client side. One Cramly account works in every app, and the owner's switches reach every app:
2
+ off / ban / time-out screens, messages, and harmless trolls. Add to a page with:
3
+ <script src="/static/hub.js" data-app="alibi" defer></script>
4
+ It fails open: if Cramly cannot be reached the app simply works as normal. */
5
+ (() => {
6
+ "use strict";
7
+ const me = document.currentScript;
8
+ const APP = (me && me.dataset.app) || "app";
9
+ const HUB = ((me && me.dataset.hub) || "https://cramly-twz5.onrender.com").replace(/\/$/, "");
10
+ const store = {
11
+ get: k => { try { return localStorage.getItem("hub." + k) || ""; } catch (e) { return ""; } },
12
+ set: (k, v) => { try { v ? localStorage.setItem("hub." + k, v) : localStorage.removeItem("hub." + k); } catch (e) { /* private mode */ } },
13
+ };
14
+ const keyOf = () => { // inside Cramly the signed-in account is used as it is
15
+ const k = store.get("key");
16
+ if (k || APP !== "cramly") return k;
17
+ try { return JSON.parse(localStorage.getItem("cramly.key") || "null") || ""; } catch (e) { return ""; }
18
+ };
19
+ let dev = store.get("dev");
20
+ if (!dev) { dev = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36)).replace(/-/g, "").slice(0, 24); store.set("dev", dev); }
21
+ window.HUB = { dev: () => dev, key: keyOf, app: APP };
22
+
23
+ /* every same-origin request carries who is asking, so the server can enforce a ban too */
24
+ const realFetch = window.fetch.bind(window);
25
+ window.fetch = (input, init = {}) => {
26
+ try {
27
+ const url = new URL(typeof input === "string" ? input : input.url, location.href);
28
+ if (url.origin === location.origin) { init = { ...init, headers: { ...(init.headers instanceof Headers ? Object.fromEntries(init.headers) : init.headers || {}), "X-Hub-Dev": dev, ...(keyOf() ? { "X-Hub-Key": keyOf() } : {}) } }; }
29
+ } catch (e) { /* leave it alone */ }
30
+ return realFetch(input, init);
31
+ };
32
+
33
+ const css = `
34
+ .hub-ov{position:fixed;inset:0;z-index:2147483000;display:grid;place-items:center;background:#0a0a0b;color:#ececf0;font:16px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;padding:24px;text-align:center}
35
+ .hub-ov h2{margin:0 0 8px;font-size:26px;font-weight:700;letter-spacing:-.01em}.hub-ov p{margin:0 auto 14px;max-width:34ch;color:#9a9aa3}.hub-ov .em{font-size:48px;margin-bottom:10px}
36
+ .hub-ov small{color:#6c6c75}
37
+ .hub-chip{position:fixed;left:12px;bottom:12px;z-index:2147482000;width:34px;height:34px;border-radius:50%;border:1px solid #2a2a30;background:#121214;color:#b9b9c2;font:600 13px system-ui;cursor:pointer;opacity:.55;display:grid;place-items:center;padding:0}
38
+ .hub-chip:hover,.hub-chip:focus-visible{opacity:1;outline:2px solid #5b93ff}
39
+ .hub-box{position:fixed;left:12px;bottom:54px;z-index:2147482001;width:min(320px,calc(100vw - 24px));background:#121214;border:1px solid #2a2a30;border-radius:16px;padding:16px;color:#ececf0;font:14px/1.45 system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.6)}
40
+ .hub-box h3{margin:0 0 6px;font-size:16px}.hub-box p{margin:0 0 10px;color:#9a9aa3}.hub-box input{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid #2a2a30;background:#0a0a0b;color:#ececf0;font:600 18px ui-monospace,monospace;letter-spacing:.2em;text-align:center}
41
+ .hub-box button{margin-top:10px;width:100%;padding:10px;border-radius:10px;border:0;background:#5b93ff;color:#08101f;font:700 14px system-ui;cursor:pointer}.hub-box button.alt{background:#1b1b1f;color:#ececf0}
42
+ .hub-box .err{color:#ff6b74;margin-top:8px;min-height:1em}
43
+ .hub-box input.hub-in{font:15px system-ui,sans-serif;letter-spacing:0;text-align:left;margin-top:8px}.hub-box button:disabled{opacity:.6}
44
+ .hub-links{margin:10px 0 0!important;font-size:12.5px}.hub-links a{color:#8fb4ff}.hub-small{margin:10px 0 0!important;font-size:11.5px;color:#6c6c75!important}
45
+ .hub-toast{position:fixed;left:50%;top:14px;transform:translateX(-50%);z-index:2147483100;max-width:min(92vw,420px);background:#121214;border:1px solid #2a2a30;color:#ececf0;padding:12px 16px;border-radius:14px;font:14px/1.45 system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.6);animation:hubIn .3s ease}
46
+ .hub-toast b{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#8d8d96;margin-bottom:2px}
47
+ @keyframes hubIn{from{opacity:0;transform:translate(-50%,-10px)}}
48
+ @keyframes hubFlip{to{transform:rotate(180deg)}}@keyframes hubShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-8px)}75%{transform:translateX(8px)}}
49
+ @keyframes hubHue{to{filter:hue-rotate(360deg)}}@keyframes hubSpin{to{transform:rotate(360deg)}}@keyframes hubFall{to{transform:translateY(110vh) rotate(540deg)}}
50
+ @keyframes hubDisco{0%{background:#f0b429}33%{background:#3dd49b}66%{background:#5b93ff}100%{background:#ff6b74}}
51
+ .hub-credit{position:fixed;right:10px;bottom:calc(8px + env(safe-area-inset-bottom,0px));z-index:2147481000;text-decoration:none;font:600 10.5px/1 system-ui,-apple-system,Segoe UI,sans-serif;letter-spacing:.06em;color:#9a9aa3;background:rgba(14,14,16,.62);border:1px solid rgba(255,255,255,.08);padding:5px 8px;border-radius:999px;opacity:.75;backdrop-filter:blur(6px)}
52
+ .hub-credit img{width:14px;height:14px;border-radius:3px;vertical-align:-3px;margin-right:5px}
53
+ .hub-credit u{text-decoration:none;color:#c9c9d1}.hub-credit:hover,.hub-credit:focus-visible{opacity:1;color:#ececf0}
54
+ .hub-credit.l{right:auto;left:54px}.hub-credit.t{bottom:auto;top:calc(8px + env(safe-area-inset-top,0px))}
55
+ @media print{.hub-credit{display:none}}
56
+ .hub-fall{position:fixed;top:-30px;z-index:2147483050;pointer-events:none;animation:hubFall linear forwards}
57
+ @media (prefers-reduced-motion:reduce){.hub-fall{display:none}}`;
58
+ const style = document.createElement("style"); style.textContent = css; document.head.append(style);
59
+ const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
60
+ const esc = s => String(s == null ? "" : s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
61
+
62
+ /* full-screen states: off, banned, on a break */
63
+ let overlay = null, tick = null;
64
+ function block(emoji, title, body, extra) {
65
+ if (!overlay) { overlay = el("div", "hub-ov"); overlay.setAttribute("role", "alert"); document.body.append(overlay); }
66
+ overlay.innerHTML = `<div><div class="em">${emoji}</div><h2>${esc(title)}</h2><p>${esc(body)}</p>${extra || ""}</div>`;
67
+ }
68
+ function unblock() { if (overlay) { overlay.remove(); overlay = null; } if (tick) { clearInterval(tick); tick = null; } }
69
+
70
+ /* harmless trolls: all end by themselves */
71
+ const toast = (label, text, ms = 9000) => { const t = el("div", "hub-toast", `<b>${esc(label)}</b>${esc(text)}`); t.setAttribute("role", "status"); document.body.append(t); setTimeout(() => t.remove(), ms); t.onclick = () => t.remove(); };
72
+ function rain(chars, n = 60) {
73
+ for (let i = 0; i < n; i++) {
74
+ const s = el("div", "hub-fall", chars[i % chars.length]); const d = 2.5 + Math.random() * 3;
75
+ s.style.cssText = `left:${Math.random() * 100}vw;font-size:${18 + Math.random() * 22}px;animation-duration:${d}s;animation-delay:${Math.random() * 2}s`;
76
+ document.body.append(s); setTimeout(() => s.remove(), (d + 2.5) * 1000);
77
+ }
78
+ }
79
+ const onRoot = (anim, ms) => { const r = document.documentElement; r.style.animation = anim; setTimeout(() => { r.style.animation = ""; r.style.filter = ""; r.style.transform = ""; }, ms); };
80
+ const EFFECTS = {
81
+ confetti: () => rain(["🎉", "🎊", "✨", "🟦", "🟨", "🟥", "🟩"], 70),
82
+ emoji: () => rain(["😂", "🤪", "🫠", "🥳", "😜", "🤡"], 60),
83
+ flip: () => { const r = document.documentElement; r.style.transition = "transform 1s"; r.style.transform = "rotate(180deg)"; setTimeout(() => { r.style.transform = ""; setTimeout(() => r.style.transition = "", 1000); }, 7000); },
84
+ shake: () => onRoot("hubShake .35s linear 14", 5000),
85
+ rainbow: () => onRoot("hubHue 2s linear 4", 8000),
86
+ spin: () => onRoot("hubSpin 2s ease-in-out 1", 2200),
87
+ blur: () => { const r = document.documentElement; r.style.transition = "filter 1s"; r.style.filter = "blur(6px)"; setTimeout(() => { r.style.filter = ""; }, 5000); },
88
+ disco: () => { const d = el("div"); d.style.cssText = "position:fixed;inset:0;z-index:2147483040;opacity:.25;pointer-events:none;animation:hubDisco .5s steps(1) infinite"; document.body.append(d); setTimeout(() => d.remove(), 6000); },
89
+ fakecrash: () => { block("💥", "Something went terribly wrong", "Just kidding. The owner is messing with you."); setTimeout(unblock, 3500); },
90
+ typewriter: () => toast("psst", "Somebody is typing…", 4000),
91
+ };
92
+ function note(n) {
93
+ if (n.kind === "troll") { try { (EFFECTS[n.effect] || EFFECTS.confetti)(); } catch (e) { /* ignore */ } if (n.body) toast("From the owner", n.body); }
94
+ else toast("From the owner", n.body, 14000);
95
+ }
96
+
97
+ /* the account button: sign in or make an account with email and password (the same account in every app),
98
+ ask the owner for a new password, or link with a Cramly pairing code */
99
+ let chip = null, box = null, linked = false, who = "";
100
+ const api = async (path, opts = {}) => {
101
+ const key = keyOf(), r = await realFetch(HUB + path, { ...opts, headers: { "Content-Type": "application/json", ...(key ? { Authorization: "Bearer " + key } : {}), ...(opts.headers || {}) } });
102
+ let j = {}; try { j = await r.json(); } catch (e) { /* empty */ }
103
+ if (!r.ok) throw new Error(j.error || "That did not work.");
104
+ return j;
105
+ };
106
+ const signedIn = (j, how) => { store.set("key", j.key); store.set("email", j.email || ""); store.set("ticket", ""); linked = true; who = j.email || j.name || ""; if (box) { box.remove(); box = null; } drawChip(); poll(); toast(how, `Signed in as ${who}`, 4000); window.dispatchEvent(new CustomEvent("hub:account", { detail: { email: who } })); };
107
+ function drawChip() {
108
+ if (!chip) { chip = el("button", "hub-chip", "☁"); chip.title = "Your account"; chip.setAttribute("aria-label", "Your account"); chip.onclick = () => togglePanel(); document.body.append(chip); }
109
+ chip.textContent = linked ? (who[0] || "✓").toUpperCase() : "👤";
110
+ chip.style.opacity = linked ? ".9" : "";
111
+ }
112
+ function form(view) {
113
+ const f = (id, type, ph, extra = "") => `<input class="hub-in" id="${id}" type="${type}" placeholder="${ph}" ${extra}>`;
114
+ const notice = `<p class="hub-small">The site owner can see the email you sign up with, never your password.</p>`;
115
+ if (view === "up") return `<h3>Create your account</h3><p>One account for every app by Legion.</p>${f("hubE", "email", "Email", 'autocomplete="email"')}${f("hubP", "password", "Password (8+ characters)", 'autocomplete="new-password"')}<button id="hubGo">Create account</button><div class="err" id="hubErr" role="alert"></div><p class="hub-links"><a href="#" data-v="in">I already have an account</a></p>${notice}`;
116
+ if (view === "forgot") return `<h3>Forgot your password?</h3><p>Your request goes to the owner. Once they approve it you can set a new password right here.</p>${f("hubE", "email", "Your account's email", 'autocomplete="email"')}<button id="hubGo">Ask for a new password</button><div class="err" id="hubErr" role="alert"></div><p class="hub-links"><a href="#" data-v="in">Back to sign in</a></p>`;
117
+ if (view === "wait") return `<h3>Waiting for the owner</h3><p>Your request <b id="hubCode2"></b> is with the owner. Keep this page open or come back later: when it's approved you can set a new password here.</p><div class="err" id="hubErr" role="alert"></div><button class="alt" id="hubCheck">Check again</button><p class="hub-links"><a href="#" data-v="cancel">Cancel the request</a></p>`;
118
+ if (view === "newpw") return `<h3>Set a new password</h3><p>The owner approved your request.</p>${f("hubP", "password", "New password (8+ characters)", 'autocomplete="new-password"')}<button id="hubGo">Save and sign in</button><div class="err" id="hubErr" role="alert"></div>`;
119
+ if (view === "code") return `<h3>Link with a code</h3><p>In Cramly open <b>Settings</b>, tap <b>Use Cramly on another device</b>, and type the 6 digits here.</p><input id="hubCode" inputmode="numeric" maxlength="6" placeholder="000000" aria-label="6 digit code"><button id="hubGo">Link</button><div class="err" id="hubErr" role="alert"></div><p class="hub-links"><a href="#" data-v="in">Sign in with email instead</a></p>`;
120
+ return `<h3>Sign in</h3><p>One account for every app by Legion.</p>${f("hubE", "email", "Email", 'autocomplete="email"')}${f("hubP", "password", "Password", 'autocomplete="current-password"')}<button id="hubGo">Sign in</button><div class="err" id="hubErr" role="alert"></div><p class="hub-links"><a href="#" data-v="up">Create an account</a> · <a href="#" data-v="forgot">Forgot password?</a> · <a href="#" data-v="code">Cramly code</a></p>`;
121
+ }
122
+ async function togglePanel(view) {
123
+ if (box && !view) { box.remove(); box = null; return; }
124
+ if (!box) { box = el("div", "hub-box"); box.setAttribute("role", "dialog"); document.body.append(box); }
125
+ if (linked) {
126
+ box.innerHTML = `<h3>Signed in</h3><p><b>${esc(store.get("email") || who || "your account")}</b><br>The same account works in every app by Legion.</p><button class="alt" id="hubOut">Sign out here</button><p style="margin:10px 0 0;font-size:11px">Device id: ${esc(dev)}</p>`;
127
+ box.querySelector("#hubOut").onclick = () => { store.set("key", ""); store.set("email", ""); linked = false; who = ""; box.remove(); box = null; drawChip(); poll(); window.dispatchEvent(new CustomEvent("hub:account", { detail: { email: "" } })); };
128
+ if (!store.get("email")) api("/api/uni/acct/me").then(j => { if (j.email) { store.set("email", j.email); const b = box && box.querySelector("p b"); if (b) b.textContent = j.email; } }).catch(() => { });
129
+ return;
130
+ }
131
+ if (!view) { const t = store.get("ticket"); view = t ? "wait" : "in"; }
132
+ box.innerHTML = form(view);
133
+ const $ = id => box.querySelector("#" + id), err = $("hubErr");
134
+ box.querySelectorAll("[data-v]").forEach(a => a.onclick = e => { e.preventDefault(); if (a.dataset.v === "cancel") store.set("ticket", ""); togglePanel(a.dataset.v === "cancel" ? "in" : a.dataset.v); });
135
+ const busyBtn = async (fn) => { const b = $("hubGo"); if (b) b.disabled = true; err.textContent = "…"; try { await fn(); } catch (e) { err.textContent = e.message === "Failed to fetch" ? "The account server didn't answer. It may be waking up: try again in a minute." : e.message; } finally { if (b) b.disabled = false; } };
136
+ const enter = (id, fn) => { const i = $(id); if (i) i.addEventListener("keydown", e => { if (e.key === "Enter") fn(); }); };
137
+ if (view === "in" || view === "up") {
138
+ const go = () => busyBtn(async () => { const j = await api(`/api/uni/acct/${view === "up" ? "signup" : "login"}`, { method: "POST", body: JSON.stringify({ email: $("hubE").value, password: $("hubP").value, app: APP }) }); signedIn(j, view === "up" ? "Account made" : "Welcome back"); });
139
+ $("hubGo").onclick = go; enter("hubP", go); $("hubE").focus();
140
+ } else if (view === "forgot") {
141
+ const go = () => busyBtn(async () => { const j = await api("/api/uni/acct/forgot", { method: "POST", body: JSON.stringify({ email: $("hubE").value, app: APP }) }); store.set("ticket", j.ticket); store.set("tcode", j.code); togglePanel("wait"); });
142
+ $("hubGo").onclick = go; enter("hubE", go); $("hubE").focus();
143
+ } else if (view === "wait") {
144
+ $("hubCode2").textContent = "#" + (store.get("tcode") || "");
145
+ const check = () => busyBtn(async () => { const j = await api("/api/uni/acct/forgot?ticket=" + encodeURIComponent(store.get("ticket"))); if (j.status === "approved") togglePanel("newpw"); else if (j.status === "pending") err.textContent = "Still waiting for the owner."; else { store.set("ticket", ""); err.textContent = j.status === "denied" ? "The owner said no to this request." : "This request expired. Ask again."; } });
146
+ $("hubCheck").onclick = check; check();
147
+ } else if (view === "newpw") {
148
+ const go = () => busyBtn(async () => { const j = await api("/api/uni/acct/reset", { method: "POST", body: JSON.stringify({ ticket: store.get("ticket"), password: $("hubP").value, app: APP }) }); signedIn(j, "Password changed"); });
149
+ $("hubGo").onclick = go; enter("hubP", go); $("hubP").focus();
150
+ } else if (view === "code") {
151
+ const go = () => busyBtn(async () => { const code = $("hubCode").value.trim(); if (code.length < 6) throw new Error("Type all 6 digits."); const j = await api("/api/uni/link", { method: "POST", body: JSON.stringify({ code }) }); signedIn(j, "Linked"); });
152
+ $("hubGo").onclick = go; enter("hubCode", go); $("hubCode").focus();
153
+ }
154
+ }
155
+ window.HUB.open = v => togglePanel(v || "in");
156
+ window.HUB.email = () => linked ? (store.get("email") || who) : "";
157
+ // a pending password request: tell the person when the owner has answered
158
+ async function checkTicket() {
159
+ const t = store.get("ticket"); if (!t || linked) return;
160
+ try { const j = await api("/api/uni/acct/forgot?ticket=" + encodeURIComponent(t)); if (j.status === "approved" && !box) toast("Good news", "Your password reset was approved. Tap the 👤 button to set a new password.", 15000); } catch (e) { /* later */ }
161
+ }
162
+
163
+ /* ask Cramly what this visitor should see */
164
+ let busy = false;
165
+ async function poll() {
166
+ if (busy) return; busy = true;
167
+ try {
168
+ const since = store.get("since") || "0", key = keyOf();
169
+ const ctl = new AbortController(), to = setTimeout(() => ctl.abort(), 25000);
170
+ const r = await realFetch(`${HUB}/api/uni/guard?app=${encodeURIComponent(APP)}&dev=${encodeURIComponent(dev)}&since=${since}`, { headers: key ? { Authorization: "Bearer " + key } : {}, signal: ctl.signal });
171
+ clearTimeout(to);
172
+ if (!r.ok) return;
173
+ const g = await r.json();
174
+ linked = !!g.linked; who = g.name || ""; if (key && !g.linked) store.set("key", ""); // the account was deleted
175
+ drawChip();
176
+ const contact = g.contact ? `<small>Questions? ${esc(g.contact)}</small>` : "";
177
+ if (g.banned) block("🚫", "This account has been blocked", g.reason || "The owner blocked access.", contact);
178
+ else if (g.timeout_until > g.now) {
179
+ const draw = () => { const s = Math.max(0, Math.round(g.timeout_until - g.now - (Date.now() - t0) / 1000)); const m = Math.floor(s / 60);
180
+ block("⏳", "You are on a break", (g.reason ? g.reason + " " : "") + `Back in ${m >= 1 ? m + " min " : ""}${s % 60}s.`, contact); if (s <= 0) { unblock(); poll(); } };
181
+ const t0 = Date.now(); if (tick) clearInterval(tick); draw(); tick = setInterval(draw, 1000);
182
+ } else if (g.off) block("🛠️", APP.charAt(0).toUpperCase() + APP.slice(1) + " is switched off", g.message || "Back soon.", contact);
183
+ else unblock();
184
+ if (!g.banned && !g.off) {
185
+ if (g.notes && g.notes.length) g.notes.forEach(note);
186
+ if (g.inbox && g.inbox.length) { g.inbox.forEach(m => toast("Message for you", m.body, 20000)); realFetch(HUB + "/api/uni/ack", { method: "POST", headers: { Authorization: "Bearer " + key } }).catch(() => {}); }
187
+ }
188
+ if (g.last != null) store.set("since", String(g.last));
189
+ checkTicket();
190
+ } catch (e) { /* fail open: Cramly may be asleep */ }
191
+ finally { busy = false; }
192
+ }
193
+ /* the credit on every app: "Made by Legion". A page can move it with data-credit="bl|br|tl|tr" on this script tag, or hide it with "none". */
194
+ const LOGO = "data:image/png;base64,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";
195
+ const credit = () => {
196
+ if (!document.querySelector('meta[name="author"]')) { const m = document.createElement("meta"); m.name = "author"; m.content = "Legion"; document.head.append(m); }
197
+ const pos = (me && me.dataset.credit) || "br";
198
+ if (pos === "none" || document.querySelector(".hub-credit")) return;
199
+ const c = el("a", "hub-credit" + (pos[0] === "t" ? " t" : "") + (pos[1] === "l" ? " l" : ""), `<img src="${LOGO}" alt="">Made by Legion · <u>Contact me</u>`);
200
+ c.href = "mailto:Awsaa-Legion@proton.me"; c.title = "Awsaa-Legion@proton.me";
201
+ c.setAttribute("aria-label", "Made by Legion. Contact me: Awsaa-Legion@proton.me");
202
+ document.body.append(c);
203
+ };
204
+ const start = () => { credit(); drawChip(); poll(); setInterval(poll, 30000); document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); }); };
205
+ if (document.body) start(); else addEventListener("DOMContentLoaded", start);
206
+ })();