256 lines
10 KiB
JavaScript
256 lines
10 KiB
JavaScript
// PayToLaugh front end.
|
|
//
|
|
// IDENTITY
|
|
// The browser holds an opaque session token and nothing else. Every call
|
|
// carries that token; the server resolves it to an account and ignores any
|
|
// email in the payload. Sending an email address no longer proves anything,
|
|
// which is the point — it used to.
|
|
//
|
|
// THE MENU COMES FROM THE SERVER
|
|
// The nav is built from what the server says this session may reach, rather
|
|
// than rendered and then hidden. Hidden markup is not a permission; anyone
|
|
// can unhide it. It also means a menu changes without a deploy.
|
|
//
|
|
// THE INVOKE TOKEN BELOW IS PUBLIC by construction — a static page cannot keep
|
|
// a secret. It gates nothing on its own; the session does that.
|
|
|
|
var API = "https://api.corben.world/functions/da0474ea-4e56-405c-9f0d-35c3e4037a69/invoke"
|
|
+ "?token=97cd624cd2bd6a74eb692a940e71851f";
|
|
|
|
var PHOTO_KINDS = ["head shot", "profile", "full body", "sitting",
|
|
"playing", "sleeping", "action", "other"];
|
|
var REFERENCE_KINDS = ["head shot", "profile", "full body"];
|
|
|
|
var Session = {
|
|
tokKey: "ptl_session",
|
|
emailKey: "ptl_email",
|
|
menuKey: "ptl_menu",
|
|
nameKey: "ptl_name",
|
|
token: function () { try { return localStorage.getItem(this.tokKey); } catch (e) { return null; } },
|
|
get: function () { try { return localStorage.getItem(this.emailKey); } catch (e) { return null; } },
|
|
name: function () { try { return localStorage.getItem(this.nameKey) || ""; } catch (e) { return ""; } },
|
|
menu: function () {
|
|
try { return JSON.parse(localStorage.getItem(this.menuKey) || "null"); }
|
|
catch (e) { return null; }
|
|
},
|
|
start: function (r, email, name) {
|
|
try {
|
|
if (r && r.session) localStorage.setItem(this.tokKey, r.session);
|
|
if (email) localStorage.setItem(this.emailKey, email);
|
|
if (name) localStorage.setItem(this.nameKey, name);
|
|
if (r && r.menu) localStorage.setItem(this.menuKey, JSON.stringify(r.menu));
|
|
} catch (e) {}
|
|
},
|
|
clear: function () {
|
|
var self = this;
|
|
["tokKey", "emailKey", "menuKey", "nameKey"].forEach(function (k) {
|
|
try { localStorage.removeItem(self[k]); } catch (e) {}
|
|
});
|
|
}
|
|
};
|
|
|
|
// ONE WEBSITE, SEVERAL FRONT DOORS
|
|
// The hostname the page was loaded from decides the look (a stylesheet), the
|
|
// name in the corner, the menu and which pages exist. The settings come from
|
|
// the server (`site_config`), keyed on the hostname sent with every call, and
|
|
// are cached so the look is right on the first paint of the next page.
|
|
// `?site=book` on any address previews another front door for this tab.
|
|
// This is presentation, not permission: what an account may do is decided by
|
|
// its session on the server.
|
|
var Site = {
|
|
told: (function () {
|
|
var pick = null;
|
|
try {
|
|
pick = new URLSearchParams(location.search).get("site");
|
|
if (pick) sessionStorage.setItem("ptl_site_pick", pick);
|
|
else pick = sessionStorage.getItem("ptl_site_pick");
|
|
} catch (e) {}
|
|
return (pick || location.hostname || "").toLowerCase();
|
|
})(),
|
|
key: function () { return "ptl_site:" + this.told; },
|
|
get: function () {
|
|
try { return JSON.parse(localStorage.getItem(this.key()) || "null"); } catch (e) { return null; }
|
|
},
|
|
set: function (cfg) {
|
|
if (!cfg) return;
|
|
try { localStorage.setItem(this.key(), JSON.stringify(cfg)); } catch (e) {}
|
|
this.apply(cfg);
|
|
},
|
|
apply: function (cfg) {
|
|
cfg = cfg || this.get();
|
|
if (!cfg) return;
|
|
document.documentElement.setAttribute("data-site", cfg.key || "");
|
|
var want = cfg.theme ? "/assets/site-" + cfg.theme + ".css" : "";
|
|
var link = document.getElementById("ptl-site-css");
|
|
if (want && !link) {
|
|
link = document.createElement("link");
|
|
link.id = "ptl-site-css"; link.rel = "stylesheet"; link.href = want;
|
|
document.head.appendChild(link);
|
|
} else if (link && link.getAttribute("href") !== want) {
|
|
if (want) link.href = want; else link.remove();
|
|
}
|
|
},
|
|
// Where somebody with no session is sent: the texted-code page once that is
|
|
// the rule, the email-only page until then.
|
|
signIn: function () { return ((this.get() || {}).auth === "sms") ? "/login.html" : "/register.html"; }
|
|
};
|
|
Site.apply();
|
|
|
|
async function api(action, payload) {
|
|
var body = Object.assign({ action: action }, payload || {});
|
|
var tok = Session.token();
|
|
if (tok) body.session = tok;
|
|
body.site = Site.told;
|
|
var res;
|
|
try {
|
|
res = await fetch(API, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body)
|
|
});
|
|
} catch (e) {
|
|
return { ok: false, error: "Could not reach the server. Check your connection and try again." };
|
|
}
|
|
var outer;
|
|
try { outer = await res.json(); }
|
|
catch (e) { return { ok: false, error: "The server sent something unreadable." }; }
|
|
var r = (outer && outer.result) ? outer.result : outer;
|
|
|
|
// An expired or revoked session should land somewhere useful rather than
|
|
// failing oddly on every call afterwards.
|
|
if (r && r.signed_out) {
|
|
Session.clear();
|
|
if (!/(register|login|join|demo)\.html/.test(location.pathname)) {
|
|
location.href = Site.signIn() + "?next=" + encodeURIComponent(location.pathname + location.search);
|
|
}
|
|
}
|
|
if (r && r.site) Site.set(r.site);
|
|
return r;
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s == null ? "" : s).replace(/[&<>\"']/g, function (c) {
|
|
return { "&": "&", "<": "<", ">": ">", '\"': """, "'": "'" }[c];
|
|
});
|
|
}
|
|
|
|
// Menu items come from the server. What is cached renders immediately so the
|
|
// nav does not flicker; the refresh then corrects it.
|
|
function navBar(current) {
|
|
var email = Session.get();
|
|
var items = Session.menu() || [{ id: "home", label: "Home", href: "/" }];
|
|
|
|
function paint(list) {
|
|
var old = document.querySelector("nav");
|
|
var site = Site.get() || { brand: ["Pay", "To", "Laugh"], home: "/" };
|
|
var b3 = site.brand || ["Pay", "To", "Laugh"];
|
|
// A page that does not exist at this front door sends the visitor home.
|
|
if (current && site.pages && site.pages.indexOf(current) < 0 && site.home
|
|
&& location.pathname !== site.home) { location.replace(site.home); return; }
|
|
var html = '<nav><div class="inner">'
|
|
+ '<a class="brand" href="' + esc(site.home || "/") + '">' + esc(b3[0]) + '<span>' + esc(b3[1] || "") + '</span>' + esc(b3[2] || "") + '</a>'
|
|
+ list.map(function (l) {
|
|
return '<a class="link' + (l.id === current ? ' on' : '') + '" href="'
|
|
+ esc(l.href) + '">' + esc(l.label) + '</a>';
|
|
}).join("")
|
|
+ '<div class="who">'
|
|
+ (email
|
|
? esc(email) + ' <button type="button" id="ptl-signout">Sign out</button>'
|
|
: '<a class="link" href="' + Site.signIn() + '">Sign in</a>')
|
|
+ '</div></div></nav>';
|
|
if (old) old.outerHTML = html;
|
|
else document.body.insertAdjacentHTML("afterbegin", html);
|
|
var b = document.getElementById("ptl-signout");
|
|
if (b) b.onclick = async function () {
|
|
await api("sign_out", {});
|
|
Session.clear();
|
|
location.href = Site.signIn();
|
|
};
|
|
}
|
|
|
|
paint(items);
|
|
// The server says what this visitor's menu is - signed in or not - and what
|
|
// this front door looks like. api() stores the site settings as they arrive.
|
|
api(Session.token() ? "whoami" : "site_config", {}).then(function (r) {
|
|
if (r && r.ok && r.menu) {
|
|
try { localStorage.setItem(Session.menuKey, JSON.stringify(r.menu)); } catch (e) {}
|
|
paint(r.menu);
|
|
}
|
|
});
|
|
}
|
|
|
|
function requireSession() {
|
|
if (!Session.token()) {
|
|
location.href = Site.signIn() + "?next=" + encodeURIComponent(location.pathname + location.search);
|
|
return null;
|
|
}
|
|
return Session.get();
|
|
}
|
|
|
|
// Only ever a path on this site: a `next` from the address bar is not trusted
|
|
// to be one.
|
|
function safeNext(next, fallback) {
|
|
return (typeof next === "string" && /^\/[A-Za-z0-9_\-\/.]*(\?[^#]*)?$/.test(next) && next.indexOf("//") < 0)
|
|
? next : (fallback || "/");
|
|
}
|
|
|
|
function showMsg(id, text, kind) {
|
|
var el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.className = "msg " + (kind || "err");
|
|
el.textContent = text;
|
|
}
|
|
function hideMsg(id) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.className = "msg";
|
|
}
|
|
|
|
// Mirrors the server's arithmetic exactly: $0.40 per 8s clip plus $0.17 for
|
|
// script and plate, at a 5x markup. If one changes, change both.
|
|
function priceFor(segments) {
|
|
var cost = segments * 0.40 + 0.17;
|
|
return { cost: cost, price: Math.round(cost * 5 * 100) / 100, seconds: segments * 8 };
|
|
}
|
|
|
|
// Date AND time: several videos a day makes a bare date useless for telling
|
|
// them apart.
|
|
function when(s) {
|
|
if (!s) return "";
|
|
var d = new Date(s);
|
|
if (isNaN(d)) return s;
|
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
|
+ " " + d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
}
|
|
|
|
// Shrink in the browser before upload. Phone photos run to 5-10MB, which is
|
|
// slow to send and far more detail than a reference plate needs.
|
|
function resizeImage(file, maxPx, quality) {
|
|
maxPx = maxPx || 1600;
|
|
quality = quality || 0.85;
|
|
return new Promise(function (resolve, reject) {
|
|
var reader = new FileReader();
|
|
reader.onerror = function () { reject(new Error("Could not read that file.")); };
|
|
reader.onload = function () {
|
|
var img = new Image();
|
|
img.onerror = function () { reject(new Error("That file is not an image we can read.")); };
|
|
img.onload = function () {
|
|
var w = img.naturalWidth, h = img.naturalHeight;
|
|
if (!w || !h) { reject(new Error("That image looks empty.")); return; }
|
|
var scale = Math.min(1, maxPx / Math.max(w, h));
|
|
var cw = Math.round(w * scale), ch = Math.round(h * scale);
|
|
var c = document.createElement("canvas");
|
|
c.width = cw; c.height = ch;
|
|
var g = c.getContext("2d");
|
|
g.fillStyle = "#ffffff";
|
|
g.fillRect(0, 0, cw, ch);
|
|
g.drawImage(img, 0, 0, cw, ch);
|
|
var url;
|
|
try { url = c.toDataURL("image/jpeg", quality); }
|
|
catch (e) { reject(new Error("That image could not be processed.")); return; }
|
|
resolve({ b64: url.split(",")[1], width: cw, height: ch });
|
|
};
|
|
img.src = reader.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|