Front doors by hostname, waiting list, invitation and texted-code sign-in pages, Examples, People

This commit is contained in:
gator 2026-10-12 04:03:38 +00:00
parent 793de70406
commit 4203b931ab
8 changed files with 599 additions and 15 deletions

View file

@ -49,10 +49,58 @@ var Session = {
} }
}; };
// 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) { async function api(action, payload) {
var body = Object.assign({ action: action }, payload || {}); var body = Object.assign({ action: action }, payload || {});
var tok = Session.token(); var tok = Session.token();
if (tok) body.session = tok; if (tok) body.session = tok;
body.site = Site.told;
var res; var res;
try { try {
res = await fetch(API, { res = await fetch(API, {
@ -72,10 +120,11 @@ async function api(action, payload) {
// failing oddly on every call afterwards. // failing oddly on every call afterwards.
if (r && r.signed_out) { if (r && r.signed_out) {
Session.clear(); Session.clear();
if (!/register\.html/.test(location.pathname)) { if (!/(register|login|join|demo)\.html/.test(location.pathname)) {
location.href = "/register.html?next=" + encodeURIComponent(location.pathname); location.href = Site.signIn() + "?next=" + encodeURIComponent(location.pathname + location.search);
} }
} }
if (r && r.site) Site.set(r.site);
return r; return r;
} }
@ -93,8 +142,13 @@ function navBar(current) {
function paint(list) { function paint(list) {
var old = document.querySelector("nav"); 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">' var html = '<nav><div class="inner">'
+ '<a class="brand" href="/">Pay<span>To</span>Laugh</a>' + '<a class="brand" href="' + esc(site.home || "/") + '">' + esc(b3[0]) + '<span>' + esc(b3[1] || "") + '</span>' + esc(b3[2] || "") + '</a>'
+ list.map(function (l) { + list.map(function (l) {
return '<a class="link' + (l.id === current ? ' on' : '') + '" href="' return '<a class="link' + (l.id === current ? ' on' : '') + '" href="'
+ esc(l.href) + '">' + esc(l.label) + '</a>'; + esc(l.href) + '">' + esc(l.label) + '</a>';
@ -102,7 +156,7 @@ function navBar(current) {
+ '<div class="who">' + '<div class="who">'
+ (email + (email
? esc(email) + ' <button type="button" id="ptl-signout">Sign out</button>' ? esc(email) + ' <button type="button" id="ptl-signout">Sign out</button>'
: '<a class="link" href="/register.html">Sign in</a>') : '<a class="link" href="' + Site.signIn() + '">Sign in</a>')
+ '</div></div></nav>'; + '</div></div></nav>';
if (old) old.outerHTML = html; if (old) old.outerHTML = html;
else document.body.insertAdjacentHTML("afterbegin", html); else document.body.insertAdjacentHTML("afterbegin", html);
@ -110,29 +164,36 @@ function navBar(current) {
if (b) b.onclick = async function () { if (b) b.onclick = async function () {
await api("sign_out", {}); await api("sign_out", {});
Session.clear(); Session.clear();
location.href = "/register.html"; location.href = Site.signIn();
}; };
} }
paint(items); paint(items);
if (Session.token()) { // The server says what this visitor's menu is - signed in or not - and what
api("whoami", {}).then(function (r) { // this front door looks like. api() stores the site settings as they arrive.
if (r && r.ok && r.menu) { api(Session.token() ? "whoami" : "site_config", {}).then(function (r) {
try { localStorage.setItem(Session.menuKey, JSON.stringify(r.menu)); } catch (e) {} if (r && r.ok && r.menu) {
paint(r.menu); try { localStorage.setItem(Session.menuKey, JSON.stringify(r.menu)); } catch (e) {}
} paint(r.menu);
}); }
} });
} }
function requireSession() { function requireSession() {
if (!Session.token()) { if (!Session.token()) {
location.href = "/register.html?next=" + encodeURIComponent(location.pathname); location.href = Site.signIn() + "?next=" + encodeURIComponent(location.pathname + location.search);
return null; return null;
} }
return Session.get(); 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) { function showMsg(id, text, kind) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (!el) return; if (!el) return;

28
assets/site-book.css Normal file
View file

@ -0,0 +1,28 @@
/* The "Book to Film" front door: the same pages, a different look.
Loaded on top of app.css when the site settings name the theme "book"
(assets/app.js, Site.apply). Everything in app.css is drawn from these
variables, so a look is mostly a palette and a typeface. */
:root{
--bg:#f6f1e7; --panel:#fffdf8; --line:#ddd3c0;
--ink:#231f1a; --muted:#6f6657; --accent:#8a2f1c; --accent2:#a8761b;
--bad:#b4531f; --good:#2f7d4f;
}
body{font-family:Georgia,"Iowan Old Style","Times New Roman",serif;font-size:17px}
nav{background:#231f1a;border-bottom:0;margin-bottom:0}
nav .brand{color:#f6f1e7;font-family:Georgia,serif;letter-spacing:.01em}
nav .brand span{color:#e0a458}
nav a.link{color:#c9bfae}
nav a.link:hover,nav a.link.on{color:#fff}
nav .who,nav .who button{color:#c9bfae}
header.page h1{font-family:Georgia,serif;font-weight:600;letter-spacing:0}
h2.sec{color:var(--accent)}
input[type=text],input[type=email],input[type=url],textarea,select{background:#fff;color:var(--ink)}
.btn{background:var(--accent);border-radius:4px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.btn.ghost{color:var(--ink)}
.card,.item,.ex{border-radius:6px;box-shadow:0 1px 0 rgba(35,31,26,.06)}
.msg.err{background:#fbe9e2;border-color:#e6b9a8;color:#7a2c14}
.msg.ok{background:#e6f3ea;border-color:#b5d8c1;color:#1f5a38}
.chip,.quote{background:#fff}
.chip.on{background:#f3e7df}
.item .ph{background:#fff}
footer{color:var(--muted)}

84
demo.html Normal file
View file

@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Examples</title>
<meta name="description" content="Videos people have made here, each with the script it was filmed from.">
<link rel="stylesheet" href="/assets/app.css">
<style>
.ex{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:18px;
display:grid;grid-template-columns:minmax(200px,320px) 1fr;gap:24px;align-items:start}
@media(max-width:700px){.ex{grid-template-columns:1fr}}
.ex video{width:100%;border-radius:10px;background:#000;border:1px solid var(--line);max-height:560px}
.ex h3{margin:0 0 4px;font-size:20px}
.ex .kind{font:600 11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
.ex .about{color:var(--muted);margin:0 0 12px}
.ex ol{margin:0;padding-left:22px}
.ex ol li{margin-bottom:8px}
.ex details summary{cursor:pointer;color:var(--accent);font-size:14.5px;margin-bottom:10px}
.cta{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1 id="h1">See what it makes</h1>
<p id="lead">Each of these was written, voiced and filmed here. The script is beside the video it became.</p>
</header>
<section>
<div class="msg" id="msg"></div>
<div id="list"><p style="color:var(--muted)">Loading the examples&hellip;</p></div>
</section>
<section id="ctawrap" style="display:none">
<div class="card cta">
<div style="flex:1;min-width:240px">
<b>Want to make one?</b><br>
<span style="color:var(--muted)">We are letting people in a few at a time.</span>
</div>
<a class="btn" href="/join.html" style="text-decoration:none">Join the waiting list</a>
</div>
</section>
<footer id="foot"></footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("demo");
function words(site) {
if (site && site.key === "book") {
document.getElementById("h1").textContent = "A book, on screen";
document.getElementById("lead").textContent = "Scenes filmed from a manuscript: the screenplay lines are beside the film they became.";
}
document.getElementById("foot").textContent = ((site && site.name) || "PayToLaugh") + (site && site.tagline ? " · " + site.tagline : "");
}
words(Site.get());
(async function () {
var r = await api("demo_list", {});
words(Site.get());
var box = document.getElementById("list");
if (!r || !r.ok) { box.innerHTML = ""; showMsg("msg", (r && r.error) || "The examples could not be loaded."); return; }
if (!Session.token()) document.getElementById("ctawrap").style.display = "block";
if (!r.examples.length) { box.innerHTML = '<p style="color:var(--muted)">Examples are on their way.</p>'; return; }
box.innerHTML = r.examples.map(function (x) {
return '<div class="ex"><div><video controls preload="metadata" playsinline'
+ (x.poster ? ' poster="' + esc(x.poster) + '"' : '') + ' src="' + esc(x.video) + '"></video></div>'
+ '<div><div class="kind">' + (x.kind === "book" ? "from a book" : "a pet with opinions") + '</div>'
+ '<h3>' + esc(x.title) + '</h3>'
+ (x.note ? '<p class="about">' + esc(x.note) + '</p>' : '')
+ (x.script.length
? '<details' + (x.script.length <= 6 ? ' open' : '') + '><summary>The script, ' + x.script.length + (x.script.length === 1 ? ' line' : ' lines') + '</summary><ol>'
+ x.script.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join("") + '</ol></details>'
: '')
+ '</div></div>';
}).join("");
})();
</script>
</body>
</html>

View file

@ -9,6 +9,15 @@ try {
var __m = location.pathname.match(/^\/v\/([0-9a-fA-F]{6,12})\/?$/); var __m = location.pathname.match(/^\/v\/([0-9a-fA-F]{6,12})\/?$/);
if (__m) location.replace("/v/index.html?c=" + __m[1] + location.hash); if (__m) location.replace("/v/index.html?c=" + __m[1] + location.hash);
} catch (e) {} } catch (e) {}
/* Each front door has its own first page (assets/app.js, Site). When the
settings remembered for this hostname name a different one, go there before
anything is painted; a first-ever visit is sent on by the script at the foot
of this page once the settings arrive. */
try {
var __s = JSON.parse(localStorage.getItem("ptl_site:" + location.hostname.toLowerCase()) || "null");
if (__s && __s.home && __s.home !== "/" && location.pathname === "/"
&& !/[?&]site=/.test(location.search)) location.replace(__s.home);
} catch (e) {}
</script> </script>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -216,5 +225,12 @@ try {
</footer> </footer>
</div> </div>
<script src="/assets/app.js"></script>
<script>
// Learn this front door's settings, and go to its first page if it is not this one.
api("site_config", {}).then(function (r) {
if (r && r.ok && r.site && r.site.home && r.site.home !== "/" && location.pathname === "/") location.replace(r.site.home);
});
</script>
</body> </body>
</html> </html>

103
join.html Normal file
View file

@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Join the waiting list</title>
<meta name="description" content="We are letting people in a few at a time. Leave your name, email and cell phone and we will send you an invitation.">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="wrap">
<header class="page">
<h1>Join the waiting list</h1>
<p>We are letting people in a few at a time. Leave your details and we will email you an
invitation when your place comes up. <a href="/demo.html">See what people have made</a>.</p>
</header>
<section style="max-width:560px">
<div class="card" id="formcard">
<div class="msg" id="msg"></div>
<form id="form" novalidate>
<div class="row">
<div class="field">
<label for="first">First name</label>
<input type="text" id="first" autocomplete="given-name" maxlength="60" required>
</div>
<div class="field">
<label for="last">Last name</label>
<input type="text" id="last" autocomplete="family-name" maxlength="60" required>
</div>
</div>
<div class="field">
<label for="email">Email address</label>
<span class="hint">Your invitation is sent here, and it is how you sign in.</span>
<input type="email" id="email" autocomplete="email" placeholder="you@example.com" required>
</div>
<div class="field">
<label for="phone">Cell phone (United States)</label>
<span class="hint">When you sign in we text a short code to this number. It has to be a US cell phone that can receive texts.</span>
<input type="text" id="phone" inputmode="tel" autocomplete="tel-national" placeholder="(555) 555-0142" maxlength="20" required>
</div>
<!-- Not for people. Left empty by anyone filling this in by hand. -->
<div style="position:absolute;left:-6000px" aria-hidden="true">
<label for="company">Company</label>
<input type="text" id="company" tabindex="-1" autocomplete="off">
</div>
<button class="btn" id="go" type="submit">Put me on the list</button>
</form>
</div>
<div class="card" id="done" style="display:none">
<h2 style="margin:0 0 10px;font-size:22px">You are on the list.</h2>
<p style="margin:0;color:var(--muted)">We will email <b id="doneEmail"></b> when your place comes up.
The email has a link; open it and we text a code to your phone to finish signing in.
There is nothing else to do for now.</p>
</div>
<p class="note" style="margin-top:22px">
We use your phone number only to text sign-in codes, and your email only for your
invitation and your account. Message and data rates may apply to the texts.
</p>
</section>
<footer id="foot"></footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("join");
(function () { var s = Site.get() || {}; document.getElementById("foot").textContent = (s.name || "PayToLaugh") + (s.tagline ? " · " + s.tagline : ""); })();
// Ten digits, shown the way people write them.
document.getElementById("phone").addEventListener("input", function () {
var d = this.value.replace(/\D/g, "");
if (d.length === 11 && d[0] === "1") d = d.slice(1);
d = d.slice(0, 10);
this.value = d.length > 6 ? "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6)
: d.length > 3 ? "(" + d.slice(0, 3) + ") " + d.slice(3) : d;
});
document.getElementById("form").addEventListener("submit", async function (e) {
e.preventDefault();
hideMsg("msg");
var v = function (id) { return document.getElementById(id).value.trim(); };
var digits = v("phone").replace(/\D/g, "");
if (!v("first") || !v("last")) { showMsg("msg", "Please give your first and last name."); return; }
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(v("email"))) { showMsg("msg", "That email address does not look right."); return; }
if (digits.length !== 10) { showMsg("msg", "Please give a US cell phone number, ten digits with the area code."); return; }
var btn = document.getElementById("go");
btn.disabled = true; btn.textContent = "One moment…";
var r = await api("waitlist_join", { first_name: v("first"), last_name: v("last"), email: v("email"),
phone: v("phone"), company: v("company") });
btn.disabled = false; btn.textContent = "Put me on the list";
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "That did not work. Please try again."); return; }
document.getElementById("doneEmail").textContent = v("email");
document.getElementById("formcard").style.display = "none";
document.getElementById("done").style.display = "block";
});
</script>
</body>
</html>

129
login.html Normal file
View file

@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in</title>
<meta name="robots" content="noindex">
<meta name="referrer" content="no-referrer">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="wrap">
<header class="page">
<h1 id="h1">Sign in</h1>
<p id="lead">Enter your email address and we will text a code to the phone on your account.</p>
</header>
<section style="max-width:520px">
<div class="card">
<div class="msg" id="msg"></div>
<!-- Step 1, returning visitors only: which account. An invitation skips this. -->
<form id="who" style="display:none">
<div class="field">
<label for="email">Email address</label>
<input type="email" id="email" autocomplete="email" placeholder="you@example.com" required>
</div>
<button class="btn" id="send" type="submit">Text me a code</button>
</form>
<!-- Step 2: the code from the text message. -->
<form id="code" style="display:none">
<div class="field">
<label for="digits">The code we texted you</label>
<span class="hint" id="sentto"></span>
<input type="text" id="digits" inputmode="numeric" autocomplete="one-time-code" maxlength="8"
placeholder="123456" style="font-size:22px;letter-spacing:.3em;max-width:220px" required>
</div>
<button class="btn" id="check" type="submit">Sign in</button>
<button class="btn ghost" id="again" type="button" style="margin-left:8px">Send a new code</button>
</form>
</div>
<p class="note" style="margin-top:22px" id="foot">
No invitation yet? <a href="/join.html">Join the waiting list</a>.
</p>
</section>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("");
var params = new URLSearchParams(location.search);
var invite = params.get("invite") || "";
var next = safeNext(params.get("next"), "");
var email = "";
// The invitation is a credential. Take it out of the address bar straight
// away so it is not left in the history or copied with the link.
if (invite && history.replaceState) history.replaceState(null, "", "/login.html");
function show(id) {
["who", "code"].forEach(function (f) { document.getElementById(f).style.display = f === id ? "block" : "none"; });
var first = document.querySelector("#" + id + " input");
if (first) first.focus();
}
function signedIn(r) {
Session.start(r, r.user.email, r.user.display_name || "");
showMsg("msg", "You are in. Taking you through…", "ok");
var home = (r.site && r.site.home && r.site.home !== "/") ? r.site.home : "/create.html";
setTimeout(function () { location.href = next || home; }, 500);
}
async function sendCode() {
hideMsg("msg");
var r = invite ? await api("invite_open", { invite: invite }) : await api("login_start", { email: email });
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "That did not work. Please try again."); return false; }
if (invite) {
document.getElementById("h1").textContent = r.first_name ? "Welcome, " + r.first_name : "Welcome";
document.getElementById("lead").textContent = "One step to finish: we have texted a code to your phone.";
document.getElementById("sentto").textContent = "Sent to " + (r.phone_hint || "your phone") + ". It can take a minute to arrive.";
} else if (r.sms === false) {
showMsg("msg", "Text messages are not switched on yet, so codes cannot be sent. Please try again later.");
return false;
} else {
document.getElementById("sentto").textContent = "If that address has an account, a code is on its way to the phone on it.";
}
show("code");
return true;
}
document.getElementById("who").addEventListener("submit", async function (e) {
e.preventDefault();
email = document.getElementById("email").value.trim();
if (!email) { showMsg("msg", "Please enter your email address."); return; }
var b = document.getElementById("send");
b.disabled = true;
await sendCode();
b.disabled = false;
});
document.getElementById("again").addEventListener("click", async function () {
this.disabled = true;
if (await sendCode()) showMsg("msg", "A new code is on its way.", "ok");
this.disabled = false;
});
document.getElementById("code").addEventListener("submit", async function (e) {
e.preventDefault();
hideMsg("msg");
var digits = document.getElementById("digits").value.replace(/\D/g, "");
if (digits.length < 4) { showMsg("msg", "Enter the code from the text message."); return; }
var b = document.getElementById("check");
b.disabled = true;
var r = invite ? await api("invite_verify", { invite: invite, code: digits })
: await api("login_verify", { email: email, code: digits });
b.disabled = false;
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "That code is not right."); return; }
signedIn(r);
});
if (Session.token() && !invite) location.replace(next || "/");
else if (invite) sendCode().then(function (ok) { if (!ok) document.getElementById("foot").style.display = "block"; });
else show("who");
</script>
</body>
</html>

160
people.html Normal file
View file

@ -0,0 +1,160 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>People</title>
<meta name="robots" content="noindex">
<link rel="stylesheet" href="/assets/app.css">
<style>
table{width:100%;border-collapse:collapse;font-size:14px}
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font:600 11px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
td .sub{color:var(--muted);font-size:12.5px}
.pill{display:inline-block;font:600 11px/1 var(--mono);padding:4px 8px;border-radius:5px;border:1px solid var(--line);color:var(--muted)}
.pill.joined{color:var(--good);border-color:var(--good)} .pill.invited{color:var(--accent2);border-color:var(--accent2)}
.linkbox{margin-top:8px;display:flex;gap:8px}
.linkbox input{font:12px var(--mono)}
.btn.small{padding:7px 12px;font-size:13px}
.state{display:flex;gap:18px;flex-wrap:wrap;color:var(--muted);font-size:14px;margin-bottom:6px}
.state b{color:var(--ink)}
.exrow{display:grid;grid-template-columns:150px 1fr;gap:16px;padding:14px 0;border-bottom:1px solid var(--line)}
.exrow video{width:100%;border-radius:8px;background:#000}
.exrow input[type=text]{margin-bottom:8px}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1>People</h1>
<p>Who is waiting, who has been invited, who is in &mdash; and which finished videos are shown on the Examples page.</p>
</header>
<div class="msg" id="msg"></div>
<section>
<h2 class="sec">How people get in</h2>
<div class="card"><div class="state" id="state">Loading&hellip;</div>
<p style="color:var(--muted);margin:8px 0 0;font-size:14px" id="statenote"></p></div>
</section>
<section>
<h2 class="sec">The waiting list</h2>
<div class="card" style="overflow-x:auto"><table id="wl"></table></div>
</section>
<section>
<h2 class="sec">Accounts</h2>
<div class="card" style="overflow-x:auto"><table id="users"></table></div>
</section>
<section>
<h2 class="sec">Examples page</h2>
<div class="card" id="examples">Loading&hellip;</div>
</section>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("people");
requireSession();
function day(s) { return s ? when(s) : ""; }
async function load() {
var r = await api("waitlist_list", {});
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "That could not be loaded."); return; }
document.getElementById("state").innerHTML =
'<span>Sign-in today: <b>' + (r.auth === "sms" ? "invitation and a texted code" : "an email address is enough (prototype rule)") + '</b></span>'
+ '<span>Text messages: <b>' + (r.sms ? "switched on" : "not switched on yet") + '</b></span>';
document.getElementById("statenote").textContent = r.sms
? "An invitation link texts a code to the person's phone and signs them in."
: "Until texting is switched on, an invitation link opens but cannot send its code. Invitations made now work as soon as it is.";
document.getElementById("wl").innerHTML =
'<tr><th>Who</th><th>Phone</th><th>Asked</th><th>Where they are</th><th></th></tr>'
+ (r.waitlist.length ? r.waitlist.slice().reverse().map(function (w) {
return '<tr><td><b>' + esc(w.first_name) + ' ' + esc(w.last_name) + '</b><div class="sub">' + esc(w.email) + '</div></td>'
+ '<td>' + esc(w.phone || "") + '</td>'
+ '<td>' + esc(day(w.created_at)) + '<div class="sub">' + esc(w.site || "") + '</div></td>'
+ '<td><span class="pill ' + esc(w.status) + '">' + esc(w.status) + '</span>'
+ (w.invited_at ? '<div class="sub">invited ' + esc(day(w.invited_at)) + (w.opened ? ', link opened' : ', not opened yet') + '</div>' : '')
+ (w.joined_at ? '<div class="sub">joined ' + esc(day(w.joined_at)) + '</div>' : '') + '</td>'
+ '<td>' + (w.status === "joined" ? '' : '<button type="button" class="btn small" data-invite="' + esc(w.id) + '">'
+ (w.invites ? 'New invitation link' : 'Invite') + '</button>') + '<div data-link="' + esc(w.id) + '"></div></td></tr>';
}).join("") : '<tr><td colspan="5" style="color:var(--muted)">Nobody has joined the list yet. The page people use is <a href="/join.html">/join.html</a>.</td></tr>');
document.getElementById("users").innerHTML =
'<tr><th>Account</th><th>Phone</th><th>Since</th><th>Video made</th></tr>'
+ r.users.map(function (u) {
return '<tr><td><b>' + esc(u.name || "") + '</b><div class="sub">' + esc(u.email) + '</div></td><td>'
+ (u.phone ? esc(u.phone) : '<span class="sub">none &mdash; cannot receive a code</span> '
+ '<button type="button" class="btn small ghost" data-phone="' + esc(u.email) + '">Add a phone</button>')
+ '</td><td>' + esc(day(u.created_at)) + '</td><td>' + esc(u.minutes) + ' min</td></tr>';
}).join("");
}
async function loadExamples() {
var r = await api("demo_candidates", {});
var box = document.getElementById("examples");
if (!r || !r.ok) { box.textContent = (r && r.error) || "That could not be loaded."; return; }
box.innerHTML = '<p style="color:var(--muted);margin-top:0">Tick a finished video to show it on <a href="/demo.html">the Examples page</a>, '
+ 'with its script beside it. Anybody can see that page, signed in or not.</p>'
+ (r.projects.length ? r.projects.map(function (p) {
return '<div class="exrow"><video controls preload="none" src="' + esc(p.video) + '"></video><div>'
+ '<label><input type="checkbox" data-demo="' + esc(p.id) + '"' + (p.demo ? ' checked' : '') + '> Show on the Examples page</label> '
+ '<span class="sub" style="color:var(--muted);font-size:12.5px">' + esc(p.kind === "book" ? "from a book" : "pet video") + ' &middot; ' + esc(day(p.created_at)) + ' &middot; ' + esc(p.owner || "") + '</span>'
+ '<input type="text" maxlength="120" data-demotitle="' + esc(p.id) + '" value="' + esc(p.title || "") + '" placeholder="Title shown to visitors">'
+ '<input type="text" maxlength="400" data-demonote="' + esc(p.id) + '" value="' + esc(p.demo_note || "") + '" placeholder="One line about it (optional)">'
+ '<button type="button" class="btn small ghost" data-demosave="' + esc(p.id) + '">Save the words</button></div></div>';
}).join("") : '<p>No finished videos yet.</p>');
}
document.addEventListener("click", async function (e) {
var b = e.target.closest && e.target.closest("button");
if (!b) return;
var id;
if ((id = b.getAttribute("data-invite"))) {
b.disabled = true;
var r = await api("waitlist_invite", { waitlist_id: id });
b.disabled = false;
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "The invitation could not be made."); return; }
var slot = document.querySelector('[data-link="' + id + '"]');
slot.innerHTML = '<div class="linkbox"><input type="text" readonly value="' + esc(r.link) + '"><button type="button" class="btn small ghost" data-copy="1">Copy</button></div>'
+ '<div class="sub" style="color:var(--muted);font-size:12.5px;margin-top:4px">Send this link to ' + esc(r.first_name || r.email)
+ ' yourself for now &mdash; we do not send email yet. It works once, until ' + esc(day(r.expires_at)) + ', and is shown only this one time.</div>';
showMsg("msg", "Invitation made for " + r.email + ". Copy the link below their name and send it to them.", "ok");
} else if (b.getAttribute("data-copy")) {
var inp = b.parentElement.querySelector("input");
inp.select();
try { await navigator.clipboard.writeText(inp.value); b.textContent = "Copied"; } catch (x) { document.execCommand("copy"); b.textContent = "Copied"; }
} else if ((id = b.getAttribute("data-phone"))) {
var ph = prompt("US cell phone for " + id + " (ten digits). A sign-in code will be texted to it.");
if (!ph) return;
var r2 = await api("set_user_phone", { target_email: id, phone: ph });
if (!r2 || !r2.ok) { showMsg("msg", (r2 && r2.error) || "That phone could not be saved."); return; }
showMsg("msg", "Phone saved for " + id + ": " + r2.phone, "ok");
load();
} else if ((id = b.getAttribute("data-demosave"))) {
var r3 = await api("set_demo", { project_id: id,
demo: document.querySelector('[data-demo="' + id + '"]').checked,
demo_title: document.querySelector('[data-demotitle="' + id + '"]').value,
demo_note: document.querySelector('[data-demonote="' + id + '"]').value });
showMsg("msg", r3 && r3.ok ? "Saved." : ((r3 && r3.error) || "That could not be saved."), r3 && r3.ok ? "ok" : "err");
}
});
document.addEventListener("change", async function (e) {
var id = e.target.getAttribute && e.target.getAttribute("data-demo");
if (!id) return;
var r = await api("set_demo", { project_id: id, demo: e.target.checked,
demo_title: document.querySelector('[data-demotitle="' + id + '"]').value,
demo_note: document.querySelector('[data-demonote="' + id + '"]').value });
if (!r || !r.ok) { e.target.checked = !e.target.checked; showMsg("msg", (r && r.error) || "That could not be saved."); return; }
showMsg("msg", e.target.checked ? "Now on the Examples page." : "Taken off the Examples page.", "ok");
});
load();
loadExamples();
</script>
</body>
</html>

View file

@ -46,7 +46,9 @@
<script> <script>
navBar(""); navBar("");
var params = new URLSearchParams(location.search); var params = new URLSearchParams(location.search);
var next = params.get("next") || "/create.html"; var next = safeNext(params.get("next"), "/create.html");
// Once sign-in is by texted code, this page has nothing to offer.
if ((Site.get() || {}).auth === "sms") location.replace("/login.html" + location.search);
document.getElementById("form").addEventListener("submit", async function (e) { document.getElementById("form").addEventListener("submit", async function (e) {
e.preventDefault(); e.preventDefault();
@ -60,6 +62,7 @@ document.getElementById("form").addEventListener("submit", async function (e) {
var r = await api("register", { email: email, display_name: name }); var r = await api("register", { email: email, display_name: name });
btn.disabled = false; btn.textContent = "Continue"; btn.disabled = false; btn.textContent = "Continue";
if (r.sms_only) { location.replace("/login.html" + location.search); return; }
if (!r.ok) { showMsg("msg", r.error || "That did not work. Please try again."); return; } if (!r.ok) { showMsg("msg", r.error || "That did not work. Please try again."); return; }
// The token is the credential from here on; the email is only for display. // The token is the credential from here on; the email is only for display.
Session.start(r, r.user.email, r.user.display_name || name); Session.start(r, r.user.email, r.user.display_name || name);