Add register, create and my-videos pages with shared app assets

This commit is contained in:
gator 2026-09-05 12:47:47 +00:00
parent 7674aad942
commit 5509c03627
5 changed files with 521 additions and 0 deletions

77
assets/app.css Normal file
View file

@ -0,0 +1,77 @@
:root{
--bg:#0e0f13; --panel:#16181f; --line:#262a35;
--ink:#e9ecf2; --muted:#9aa3b2; --accent:#4B8BF5; --accent2:#f5c04b;
--bad:#f5714b; --good:#4bf58b;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
.wrap{max-width:940px;margin:0 auto;padding:0 22px}
a{color:var(--accent)}
nav{border-bottom:1px solid var(--line);padding:18px 0;margin-bottom:8px}
nav .inner{max-width:940px;margin:0 auto;padding:0 22px;display:flex;gap:22px;align-items:center;flex-wrap:wrap}
nav .brand{font-weight:700;letter-spacing:-.01em;color:var(--ink);text-decoration:none}
nav .brand span{color:var(--accent)}
nav a.link{color:var(--muted);text-decoration:none;font-size:14.5px}
nav a.link:hover,nav a.link.on{color:var(--ink)}
nav .who{margin-left:auto;font:12.5px/1 var(--mono);color:var(--muted);display:flex;gap:12px;align-items:center}
nav .who button{background:none;border:0;color:var(--muted);cursor:pointer;font:inherit;text-decoration:underline}
header.page{padding:46px 0 30px}
header.page h1{font-size:clamp(28px,5vw,42px);line-height:1.08;margin:0 0 12px;letter-spacing:-.02em}
header.page p{color:var(--muted);max-width:62ch;margin:0;font-size:17px}
section{padding:34px 0}
h2.sec{font-size:13px;font-family:var(--mono);letter-spacing:.16em;text-transform:uppercase;
color:var(--muted);margin:0 0 20px;font-weight:600}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:26px}
.field{margin-bottom:20px}
.field label{display:block;font-size:14px;font-weight:600;margin-bottom:6px}
.field .hint{display:block;color:var(--muted);font-size:13.5px;margin-bottom:9px;font-weight:400}
input[type=text],input[type=email],input[type=url],textarea,select{
width:100%;background:#0f1117;border:1px solid var(--line);border-radius:9px;
color:var(--ink);padding:11px 13px;font:15px/1.5 inherit}
input:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
textarea{min-height:110px;resize:vertical}
.row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:640px){.row{grid-template-columns:1fr}}
.btn{background:var(--accent);color:#fff;border:0;border-radius:9px;padding:12px 22px;
font:600 15px/1 inherit;cursor:pointer}
.btn:hover{filter:brightness(1.08)}
.btn[disabled]{opacity:.5;cursor:default}
.btn.ghost{background:none;border:1px solid var(--line);color:var(--ink)}
.msg{border-radius:9px;padding:12px 15px;margin-bottom:18px;font-size:14.5px;display:none}
.msg.err{display:block;background:#2a1512;border:1px solid #5b2a20;color:#ffb9a7}
.msg.ok{display:block;background:#10241a;border:1px solid #1f5138;color:#a7f0c8}
.chips{display:flex;gap:10px;flex-wrap:wrap}
.chip{border:1px solid var(--line);border-radius:999px;padding:8px 15px;cursor:pointer;font-size:14px;color:var(--muted);background:#0f1117}
.chip.on{border-color:var(--accent);color:var(--ink);background:#141a28}
.quote{border:1px solid var(--line);border-radius:10px;padding:15px 18px;background:#0f1117;
display:flex;justify-content:space-between;align-items:baseline;gap:14px;flex-wrap:wrap}
.quote .big{font-size:26px;font-weight:700;letter-spacing:-.01em}
.quote .sub{color:var(--muted);font-size:13.5px;font-family:var(--mono)}
.list{display:grid;gap:16px}
.item{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;
display:grid;grid-template-columns:150px 1fr;gap:22px}
@media(max-width:640px){.item{grid-template-columns:1fr}}
.item video{width:100%;aspect-ratio:9/16;border-radius:10px;background:#000;border:1px solid var(--line)}
.item .ph{width:100%;aspect-ratio:9/16;border-radius:10px;background:#0f1117;border:1px dashed var(--line);
display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;text-align:center;padding:12px}
.item h3{margin:0 0 6px;font-size:18px}
.item .meta{color:var(--muted);font-size:13.5px;margin:0 0 10px;word-break:break-word}
.badge{display:inline-block;font:600 11px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;
padding:5px 9px;border-radius:5px;margin-bottom:10px}
.badge.submitted{background:#1d1a0d;color:var(--accent2);border:1px solid #3a3212}
.badge.ready{background:#10241a;color:#7fe3a8;border:1px solid #1f5138}
.badge.failed{background:#2a1512;color:#ffb9a7;border:1px solid #5b2a20}
.empty{border:1px dashed var(--line);border-radius:14px;padding:44px 26px;text-align:center;color:var(--muted)}
.note{background:#1a1410;border:1px solid #3d2f1c;border-radius:12px;padding:16px 19px;color:#d9c9ae;font-size:14.5px}
footer{padding:44px 0 70px;color:var(--muted);font-size:13.5px;border-top:1px solid var(--line);margin-top:40px}

97
assets/app.js Normal file
View file

@ -0,0 +1,97 @@
// PayToLaugh front end.
//
// NOTE ON THE TOKEN BELOW: it is public by construction. A static page cannot
// keep a secret, so the endpoint behind it is built to be safe in public
// hands. It reads and writes small records only and cannot spend money.
// Script and video generation are deliberately NOT reachable from here.
//
// NOTE ON "LOGIN": an email address identifies you, it does not authenticate
// you. Anyone who knows an address can see that account. Temporary, by
// agreement, until real auth lands.
var API = "https://api.corben.world/functions/da0474ea-4e56-405c-9f0d-35c3e4037a69/invoke"
+ "?token=97cd624cd2bd6a74eb692a940e71851f";
async function api(action, payload) {
var body = Object.assign({ action: action }, payload || {});
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." }; }
// The invoke route wraps the function result.
return outer && outer.result ? outer.result : outer;
}
var Session = {
key: "ptl_email",
nameKey: "ptl_name",
get: function () { try { return localStorage.getItem(this.key); } catch (e) { return null; } },
name: function () { try { return localStorage.getItem(this.nameKey) || ""; } catch (e) { return ""; } },
set: function (email, name) {
try {
localStorage.setItem(this.key, email);
if (name) localStorage.setItem(this.nameKey, name);
} catch (e) {}
},
clear: function () {
try { localStorage.removeItem(this.key); localStorage.removeItem(this.nameKey); } catch (e) {}
}
};
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>\"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '\"': "&quot;", "'": "&#39;" }[c];
});
}
function navBar(current) {
var email = Session.get();
var links = [["/", "Home", "home"], ["/create.html", "New video", "create"], ["/videos.html", "My videos", "videos"]];
var html = '<nav><div class="inner">'
+ '<a class="brand" href="/">Pay<span>To</span>Laugh</a>'
+ links.map(function (l) {
return '<a class="link' + (l[2] === current ? ' on' : '') + '" href="' + l[0] + '">' + l[1] + '</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>';
document.body.insertAdjacentHTML("afterbegin", html);
var b = document.getElementById("ptl-signout");
if (b) b.onclick = function () { Session.clear(); location.href = "/register.html"; };
}
// Pages that need an identity send you to sign in rather than failing oddly.
function requireSession() {
var e = Session.get();
if (!e) { location.href = "/register.html?next=" + encodeURIComponent(location.pathname); return null; }
return e;
}
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 };
}

191
create.html Normal file
View file

@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New video — PayToLaugh</title>
<meta name="description" content="Pick your pet, paste a link, tell the director what you think.">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="wrap">
<header class="page">
<h1>Make a new video</h1>
<p>Pick the pet, paste whatever you want them to react to, and tell the director what you actually think. The opinion is yours; the delivery is theirs.</p>
</header>
<section>
<div class="card">
<div class="msg" id="msg"></div>
<form id="form">
<h2 class="sec">1 &middot; Who is talking</h2>
<div class="field">
<label for="pet">Pet</label>
<select id="pet"><option value="__new">Add a new pet…</option></select>
</div>
<div id="newpet">
<div class="row">
<div class="field">
<label for="pname">Name</label>
<input type="text" id="pname" placeholder="Astro">
</div>
<div class="field">
<label for="pbreed">Breed</label>
<input type="text" id="pbreed" placeholder="Labrador Retriever">
</div>
</div>
<div class="field">
<label for="pappear">What do they look like</label>
<span class="hint">Colour and markings matter more than you would think. Naming them plainly is what keeps the likeness.</span>
<input type="text" id="pappear" placeholder="a yellow Labrador with a broad head, dark eyes and a black nose">
</div>
<div class="field">
<label for="pperson">What are they like</label>
<span class="hint">This is what makes it sound like your pet and not a generic one.</span>
<input type="text" id="pperson" placeholder="convinced he runs the household, suspicious of anything with wheels">
</div>
</div>
<h2 class="sec" style="margin-top:34px">2 &middot; What they are reacting to</h2>
<div class="field">
<label for="url">Link to an article, post or story</label>
<span class="hint">Anything with a claim in it. News piece, a thread, a video.</span>
<input type="url" id="url" placeholder="https://…">
</div>
<div class="field">
<label for="notes">Your opinion, and notes for the director</label>
<span class="hint">Say what you think and how you want it played. Two sentences is plenty.</span>
<textarea id="notes" placeholder="Astro has hated our robot vacuum since day one and barks at it every time it docks. Play it like he is filing a formal grievance with management."></textarea>
</div>
<div class="field">
<label>Do they agree or disagree?</label>
<div class="chips" id="stance">
<span class="chip on" data-v="auto">Let the writer decide</span>
<span class="chip" data-v="agree">Agree</span>
<span class="chip" data-v="disagree">Disagree</span>
<span class="chip" data-v="mixed">A bit of both</span>
</div>
</div>
<h2 class="sec" style="margin-top:34px">3 &middot; How long</h2>
<div class="field">
<div class="chips" id="len">
<span class="chip" data-v="2">16 seconds</span>
<span class="chip on" data-v="4">32 seconds</span>
<span class="chip" data-v="6">48 seconds</span>
</div>
</div>
<div class="quote">
<div><div class="big" id="price">$8.85</div><div class="sub" id="plen">32 seconds &middot; 4 shots</div></div>
<div class="sub">You approve the script before anything is filmed.</div>
</div>
<button class="btn" id="go" type="submit" style="margin-top:26px">Submit for scripting</button>
</form>
</div>
<p class="note" style="margin-top:22px">
<strong>What happens next.</strong> Submitting records your request and puts it in the
queue. Nothing is charged and nothing is filmed yet. Payment and automatic
production get switched on once proper sign-in is in place.
</p>
</section>
<footer>PayToLaugh &middot; your pet has opinions.</footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("create");
var email = requireSession();
var stance = "auto", segments = 4;
function chipGroup(id, cb) {
var g = document.getElementById(id);
g.addEventListener("click", function (e) {
var c = e.target.closest(".chip");
if (!c) return;
[].forEach.call(g.querySelectorAll(".chip"), function (x) { x.classList.remove("on"); });
c.classList.add("on");
cb(c.getAttribute("data-v"));
});
}
chipGroup("stance", function (v) { stance = v; });
chipGroup("len", function (v) { segments = parseInt(v, 10); paint(); });
function paint() {
var p = priceFor(segments);
document.getElementById("price").textContent = "$" + p.price.toFixed(2);
document.getElementById("plen").textContent = p.seconds + " seconds · " + segments + " shots";
}
paint();
var petSel = document.getElementById("pet");
var newPet = document.getElementById("newpet");
function togglePet() { newPet.style.display = petSel.value === "__new" ? "" : "none"; }
petSel.addEventListener("change", togglePet);
(async function loadPets() {
if (!email) return;
var r = await api("list_pets", { email: email });
if (r.ok && r.pets && r.pets.length) {
r.pets.forEach(function (p) {
var o = document.createElement("option");
o.value = p.id || p.name;
o.textContent = p.name + (p.breed ? " · " + p.breed : "");
o.dataset.name = p.name;
petSel.insertBefore(o, petSel.firstChild);
});
petSel.selectedIndex = 0;
}
togglePet();
})();
document.getElementById("form").addEventListener("submit", async function (e) {
e.preventDefault();
hideMsg("msg");
var btn = document.getElementById("go");
var url = document.getElementById("url").value.trim();
var notes = document.getElementById("notes").value.trim();
var petId = "", petName = "";
if (petSel.value === "__new") {
petName = document.getElementById("pname").value.trim();
if (!petName) { showMsg("msg", "Your pet needs a name."); return; }
} else {
petId = petSel.value;
petName = petSel.options[petSel.selectedIndex].dataset.name || "";
}
if (!url) { showMsg("msg", "Paste a link to whatever you want them to react to."); return; }
if (notes.length < 10) { showMsg("msg", "Tell the director what you think, in a sentence or two."); return; }
btn.disabled = true; btn.textContent = "Submitting…";
if (petSel.value === "__new") {
var pr = await api("save_pet", { email: email, pet: {
name: petName,
breed: document.getElementById("pbreed").value.trim(),
appearance: document.getElementById("pappear").value.trim(),
personality: document.getElementById("pperson").value.trim(),
species: "dog"
}});
if (!pr.ok) { btn.disabled = false; btn.textContent = "Submit for scripting"; showMsg("msg", pr.error || "Could not save that pet."); return; }
petId = pr.pet.id || "";
}
var r = await api("create_project", {
email: email, pet_id: petId, pet_name: petName,
source_url: url, owner_notes: notes, stance: stance, segments: segments
});
btn.disabled = false; btn.textContent = "Submit for scripting";
if (!r.ok) { showMsg("msg", r.error || "That did not go through. Please try again."); return; }
showMsg("msg", "Submitted. Taking you to your videos…", "ok");
setTimeout(function () { location.href = "/videos.html"; }, 650);
});
</script>
</body>
</html>

70
register.html Normal file
View file

@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — PayToLaugh</title>
<meta name="description" content="Create a PayToLaugh account and start making videos of your pet weighing in.">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="wrap">
<header class="page">
<h1>Sign in</h1>
<p>Your email is all we need for now. If you have not been here before, this creates your account.</p>
</header>
<section style="max-width:520px">
<div class="card">
<div class="msg" id="msg"></div>
<form id="form">
<div class="field">
<label for="email">Email address</label>
<span class="hint">We use this to keep your pets and videos together.</span>
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com">
</div>
<div class="field">
<label for="name">Your name <span style="color:var(--muted);font-weight:400">(optional)</span></label>
<input type="text" id="name" name="name" autocomplete="name" placeholder="Scooter">
</div>
<button class="btn" id="go" type="submit">Continue</button>
</form>
</div>
<p class="note" style="margin-top:22px">
<strong>Early access.</strong> There is no password yet, so an email address identifies
you rather than proves who you are. Do not put anything here you would mind
another person seeing. Proper sign-in is coming.
</p>
</section>
<footer>PayToLaugh &middot; your pet has opinions.</footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("");
var params = new URLSearchParams(location.search);
var next = params.get("next") || "/create.html";
document.getElementById("form").addEventListener("submit", async function (e) {
e.preventDefault();
hideMsg("msg");
var btn = document.getElementById("go");
var email = document.getElementById("email").value.trim();
var name = document.getElementById("name").value.trim();
if (!email) { showMsg("msg", "Please enter your email address."); return; }
btn.disabled = true; btn.textContent = "One moment…";
var r = await api("register", { email: email, display_name: name });
btn.disabled = false; btn.textContent = "Continue";
if (!r.ok) { showMsg("msg", r.error || "That did not work. Please try again."); return; }
Session.set(r.user.email, r.user.display_name || name);
showMsg("msg", r.returning ? "Welcome back. Taking you through…" : "Account created. Taking you through…", "ok");
setTimeout(function () { location.href = next; }, 550);
});
</script>
</body>
</html>

86
videos.html Normal file
View file

@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My videos — PayToLaugh</title>
<meta name="description" content="Everything you have submitted, and every video that has come back.">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="wrap">
<header class="page">
<h1>My videos</h1>
<p>Everything you have submitted, newest first.</p>
</header>
<section>
<div class="msg" id="msg"></div>
<div id="out"><p style="color:var(--muted)">Loading…</p></div>
</section>
<footer>PayToLaugh &middot; your pet has opinions.</footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("videos");
var email = requireSession();
var LABEL = {
submitted: ["submitted", "In the queue"],
scripting: ["submitted", "Being written"],
script_ready: ["submitted", "Script ready for you"],
rendering: ["submitted", "Filming"],
ready: ["ready", "Ready"],
failed: ["failed", "Did not work"]
};
function when(s) {
if (!s) return "";
var d = new Date(s);
return isNaN(d) ? s : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function card(p) {
var st = LABEL[p.status] || ["submitted", p.status || "In the queue"];
var media = p.video_url
? '<video controls playsinline preload="metadata" src="' + esc(p.video_url) + '"></video>'
: '<div class="ph">Not filmed yet</div>';
var dl = p.video_url
? '<a class="btn ghost" style="display:inline-block;text-decoration:none;margin-top:10px" href="'
+ esc(p.video_url) + '" download>Download</a>'
: "";
return '<div class="item">'
+ '<div>' + media + '</div>'
+ '<div>'
+ '<span class="badge ' + st[0] + '">' + esc(st[1]) + '</span>'
+ '<h3>' + esc(p.pet_name || "Your pet") + ' reacts</h3>'
+ '<p class="meta">' + (p.source_url ? '<a href="' + esc(p.source_url) + '" rel="noopener noreferrer nofollow" target="_blank">' + esc(p.source_url) + '</a>' : "") + '</p>'
+ '<p class="meta">' + esc(p.owner_notes || "") + '</p>'
+ '<p class="meta">' + (p.segments || 4) * 8 + ' seconds · '
+ (p.price_usd != null ? "$" + Number(p.price_usd).toFixed(2) : "") + ' · ' + esc(when(p.created_at)) + '</p>'
+ dl
+ '</div></div>';
}
(async function () {
if (!email) return;
var r = await api("list_projects", { email: email });
var out = document.getElementById("out");
if (!r.ok) {
out.innerHTML = "";
showMsg("msg", r.error || "Could not load your videos.");
return;
}
if (!r.projects || !r.projects.length) {
out.innerHTML = '<div class="empty"><p style="margin:0 0 16px">Nothing here yet.</p>'
+ '<a class="btn" style="display:inline-block;text-decoration:none" href="/create.html">Make your first video</a></div>';
return;
}
out.innerHTML = '<div class="list">' + r.projects.map(card).join("") + '</div>';
})();
</script>
</body>
</html>