Interactive profile and a new logo
Browse files- hub.js +190 -0
- index.html +1 -0
hub.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-fall{position:fixed;top:-30px;z-index:2147483050;pointer-events:none;animation:hubFall linear forwards}
|
| 52 |
+
@media (prefers-reduced-motion:reduce){.hub-fall{display:none}}`;
|
| 53 |
+
const style = document.createElement("style"); style.textContent = css; document.head.append(style);
|
| 54 |
+
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
|
| 55 |
+
const esc = s => String(s == null ? "" : s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
| 56 |
+
|
| 57 |
+
/* full-screen states: off, banned, on a break */
|
| 58 |
+
let overlay = null, tick = null;
|
| 59 |
+
function block(emoji, title, body, extra) {
|
| 60 |
+
if (!overlay) { overlay = el("div", "hub-ov"); overlay.setAttribute("role", "alert"); document.body.append(overlay); }
|
| 61 |
+
overlay.innerHTML = `<div><div class="em">${emoji}</div><h2>${esc(title)}</h2><p>${esc(body)}</p>${extra || ""}</div>`;
|
| 62 |
+
}
|
| 63 |
+
function unblock() { if (overlay) { overlay.remove(); overlay = null; } if (tick) { clearInterval(tick); tick = null; } }
|
| 64 |
+
|
| 65 |
+
/* harmless trolls: all end by themselves */
|
| 66 |
+
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(); };
|
| 67 |
+
function rain(chars, n = 60) {
|
| 68 |
+
for (let i = 0; i < n; i++) {
|
| 69 |
+
const s = el("div", "hub-fall", chars[i % chars.length]); const d = 2.5 + Math.random() * 3;
|
| 70 |
+
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`;
|
| 71 |
+
document.body.append(s); setTimeout(() => s.remove(), (d + 2.5) * 1000);
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
const onRoot = (anim, ms) => { const r = document.documentElement; r.style.animation = anim; setTimeout(() => { r.style.animation = ""; r.style.filter = ""; r.style.transform = ""; }, ms); };
|
| 75 |
+
const EFFECTS = {
|
| 76 |
+
confetti: () => rain(["π", "π", "β¨", "π¦", "π¨", "π₯", "π©"], 70),
|
| 77 |
+
emoji: () => rain(["π", "π€ͺ", "π« ", "π₯³", "π", "π€‘"], 60),
|
| 78 |
+
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); },
|
| 79 |
+
shake: () => onRoot("hubShake .35s linear 14", 5000),
|
| 80 |
+
rainbow: () => onRoot("hubHue 2s linear 4", 8000),
|
| 81 |
+
spin: () => onRoot("hubSpin 2s ease-in-out 1", 2200),
|
| 82 |
+
blur: () => { const r = document.documentElement; r.style.transition = "filter 1s"; r.style.filter = "blur(6px)"; setTimeout(() => { r.style.filter = ""; }, 5000); },
|
| 83 |
+
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); },
|
| 84 |
+
fakecrash: () => { block("π₯", "Something went terribly wrong", "Just kidding. The owner is messing with you."); setTimeout(unblock, 3500); },
|
| 85 |
+
typewriter: () => toast("psst", "Somebody is typingβ¦", 4000),
|
| 86 |
+
};
|
| 87 |
+
function note(n) {
|
| 88 |
+
if (n.kind === "troll") { try { (EFFECTS[n.effect] || EFFECTS.confetti)(); } catch (e) { /* ignore */ } if (n.body) toast("From the owner", n.body); }
|
| 89 |
+
else toast("From the owner", n.body, 14000);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* the account button: sign in or make an account with email and password (the same account in every app),
|
| 93 |
+
ask the owner for a new password, or link with a Cramly pairing code */
|
| 94 |
+
let chip = null, box = null, linked = false, who = "";
|
| 95 |
+
const api = async (path, opts = {}) => {
|
| 96 |
+
const key = keyOf(), r = await realFetch(HUB + path, { ...opts, headers: { "Content-Type": "application/json", ...(key ? { Authorization: "Bearer " + key } : {}), ...(opts.headers || {}) } });
|
| 97 |
+
let j = {}; try { j = await r.json(); } catch (e) { /* empty */ }
|
| 98 |
+
if (!r.ok) throw new Error(j.error || "That did not work.");
|
| 99 |
+
return j;
|
| 100 |
+
};
|
| 101 |
+
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 } })); };
|
| 102 |
+
function drawChip() {
|
| 103 |
+
if (!chip) { chip = el("button", "hub-chip", "β"); chip.title = "Your account"; chip.setAttribute("aria-label", "Your account"); chip.onclick = () => togglePanel(); document.body.append(chip); }
|
| 104 |
+
chip.textContent = linked ? (who[0] || "β").toUpperCase() : "π€";
|
| 105 |
+
chip.style.opacity = linked ? ".9" : "";
|
| 106 |
+
}
|
| 107 |
+
function form(view) {
|
| 108 |
+
const f = (id, type, ph, extra = "") => `<input class="hub-in" id="${id}" type="${type}" placeholder="${ph}" ${extra}>`;
|
| 109 |
+
const notice = `<p class="hub-small">The site owner can see the email you sign up with, never your password.</p>`;
|
| 110 |
+
if (view === "up") return `<h3>Create your account</h3><p>One account for every app by Awsaa.</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}`;
|
| 111 |
+
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>`;
|
| 112 |
+
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>`;
|
| 113 |
+
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>`;
|
| 114 |
+
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>`;
|
| 115 |
+
return `<h3>Sign in</h3><p>One account for every app by Awsaa.</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>`;
|
| 116 |
+
}
|
| 117 |
+
async function togglePanel(view) {
|
| 118 |
+
if (box && !view) { box.remove(); box = null; return; }
|
| 119 |
+
if (!box) { box = el("div", "hub-box"); box.setAttribute("role", "dialog"); document.body.append(box); }
|
| 120 |
+
if (linked) {
|
| 121 |
+
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 Awsaa.</p><button class="alt" id="hubOut">Sign out here</button><p style="margin:10px 0 0;font-size:11px">Device id: ${esc(dev)}</p>`;
|
| 122 |
+
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: "" } })); };
|
| 123 |
+
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(() => { });
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
if (!view) { const t = store.get("ticket"); view = t ? "wait" : "in"; }
|
| 127 |
+
box.innerHTML = form(view);
|
| 128 |
+
const $ = id => box.querySelector("#" + id), err = $("hubErr");
|
| 129 |
+
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); });
|
| 130 |
+
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; } };
|
| 131 |
+
const enter = (id, fn) => { const i = $(id); if (i) i.addEventListener("keydown", e => { if (e.key === "Enter") fn(); }); };
|
| 132 |
+
if (view === "in" || view === "up") {
|
| 133 |
+
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"); });
|
| 134 |
+
$("hubGo").onclick = go; enter("hubP", go); $("hubE").focus();
|
| 135 |
+
} else if (view === "forgot") {
|
| 136 |
+
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"); });
|
| 137 |
+
$("hubGo").onclick = go; enter("hubE", go); $("hubE").focus();
|
| 138 |
+
} else if (view === "wait") {
|
| 139 |
+
$("hubCode2").textContent = "#" + (store.get("tcode") || "");
|
| 140 |
+
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."; } });
|
| 141 |
+
$("hubCheck").onclick = check; check();
|
| 142 |
+
} else if (view === "newpw") {
|
| 143 |
+
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"); });
|
| 144 |
+
$("hubGo").onclick = go; enter("hubP", go); $("hubP").focus();
|
| 145 |
+
} else if (view === "code") {
|
| 146 |
+
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"); });
|
| 147 |
+
$("hubGo").onclick = go; enter("hubCode", go); $("hubCode").focus();
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
window.HUB.open = v => togglePanel(v || "in");
|
| 151 |
+
window.HUB.email = () => linked ? (store.get("email") || who) : "";
|
| 152 |
+
// a pending password request: tell the person when the owner has answered
|
| 153 |
+
async function checkTicket() {
|
| 154 |
+
const t = store.get("ticket"); if (!t || linked) return;
|
| 155 |
+
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 */ }
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/* ask Cramly what this visitor should see */
|
| 159 |
+
let busy = false;
|
| 160 |
+
async function poll() {
|
| 161 |
+
if (busy) return; busy = true;
|
| 162 |
+
try {
|
| 163 |
+
const since = store.get("since") || "0", key = keyOf();
|
| 164 |
+
const ctl = new AbortController(), to = setTimeout(() => ctl.abort(), 25000);
|
| 165 |
+
const r = await realFetch(`${HUB}/api/uni/guard?app=${encodeURIComponent(APP)}&dev=${encodeURIComponent(dev)}&since=${since}`, { headers: key ? { Authorization: "Bearer " + key } : {}, signal: ctl.signal });
|
| 166 |
+
clearTimeout(to);
|
| 167 |
+
if (!r.ok) return;
|
| 168 |
+
const g = await r.json();
|
| 169 |
+
linked = !!g.linked; who = g.name || ""; if (key && !g.linked) store.set("key", ""); // the account was deleted
|
| 170 |
+
drawChip();
|
| 171 |
+
const contact = g.contact ? `<small>Questions? ${esc(g.contact)}</small>` : "";
|
| 172 |
+
if (g.banned) block("π«", "This account has been blocked", g.reason || "The owner blocked access.", contact);
|
| 173 |
+
else if (g.timeout_until > g.now) {
|
| 174 |
+
const draw = () => { const s = Math.max(0, Math.round(g.timeout_until - g.now - (Date.now() - t0) / 1000)); const m = Math.floor(s / 60);
|
| 175 |
+
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(); } };
|
| 176 |
+
const t0 = Date.now(); if (tick) clearInterval(tick); draw(); tick = setInterval(draw, 1000);
|
| 177 |
+
} else if (g.off) block("π οΈ", APP.charAt(0).toUpperCase() + APP.slice(1) + " is switched off", g.message || "Back soon.", contact);
|
| 178 |
+
else unblock();
|
| 179 |
+
if (!g.banned && !g.off) {
|
| 180 |
+
if (g.notes && g.notes.length) g.notes.forEach(note);
|
| 181 |
+
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(() => {}); }
|
| 182 |
+
}
|
| 183 |
+
if (g.last != null) store.set("since", String(g.last));
|
| 184 |
+
checkTicket();
|
| 185 |
+
} catch (e) { /* fail open: Cramly may be asleep */ }
|
| 186 |
+
finally { busy = false; }
|
| 187 |
+
}
|
| 188 |
+
const start = () => { drawChip(); poll(); setInterval(poll, 30000); document.addEventListener("visibilitychange", () => { if (!document.hidden) poll(); }); };
|
| 189 |
+
if (document.body) start(); else addEventListener("DOMContentLoaded", start);
|
| 190 |
+
})();
|
index.html
CHANGED
|
@@ -195,4 +195,5 @@ $$(".card").forEach(c => { const p = P[c.dataset.id], dot = c.querySelector("[da
|
|
| 195 |
$$(".flow .box").forEach(b => { const on = v => $$(`tr[data-layer="${b.dataset.layer}"]`).forEach(r => r.classList.toggle("hl", v));
|
| 196 |
b.addEventListener("mouseenter", () => on(true)); b.addEventListener("mouseleave", () => on(false)); });
|
| 197 |
</script>
|
|
|
|
| 198 |
</body></html>
|
|
|
|
| 195 |
$$(".flow .box").forEach(b => { const on = v => $$(`tr[data-layer="${b.dataset.layer}"]`).forEach(r => r.classList.toggle("hl", v));
|
| 196 |
b.addEventListener("mouseenter", () => on(true)); b.addEventListener("mouseleave", () => on(false)); });
|
| 197 |
</script>
|
| 198 |
+
<script src="hub.js" data-app="awsaa" defer></script>
|
| 199 |
</body></html>
|