paytolaugh/assets/app.js

195 lines
7.1 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) {}
});
}
};
async function api(action, payload) {
var body = Object.assign({ action: action }, payload || {});
var tok = Session.token();
if (tok) body.session = tok;
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\.html/.test(location.pathname)) {
location.href = "/register.html?next=" + encodeURIComponent(location.pathname);
}
}
return r;
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>\"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '\"': "&quot;", "'": "&#39;" }[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 html = '<nav><div class="inner">'
+ '<a class="brand" href="/">Pay<span>To</span>Laugh</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="/register.html">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 = "/register.html";
};
}
paint(items);
if (Session.token()) {
api("whoami", {}).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 = "/register.html?next=" + encodeURIComponent(location.pathname);
return null;
}
return Session.get();
}
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);
});
}