Split pet profiles onto their own page with typed photo uploads

This commit is contained in:
gator 2026-09-05 14:09:23 +00:00
parent 6439a89daa
commit 8202feb299
3 changed files with 275 additions and 64 deletions

View file

@ -12,6 +12,11 @@
var API = "https://api.corben.world/functions/da0474ea-4e56-405c-9f0d-35c3e4037a69/invoke" var API = "https://api.corben.world/functions/da0474ea-4e56-405c-9f0d-35c3e4037a69/invoke"
+ "?token=97cd624cd2bd6a74eb692a940e71851f"; + "?token=97cd624cd2bd6a74eb692a940e71851f";
var PHOTO_KINDS = ["head shot", "profile", "full body", "sitting",
"playing", "sleeping", "action", "other"];
// A plate is built from a head-on shot; these are the ones worth having.
var REFERENCE_KINDS = ["head shot", "profile", "full body"];
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 res; var res;
@ -27,7 +32,6 @@ async function api(action, payload) {
var outer; var outer;
try { outer = await res.json(); } try { outer = await res.json(); }
catch (e) { return { ok: false, error: "The server sent something unreadable." }; } 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; return outer && outer.result ? outer.result : outer;
} }
@ -55,7 +59,8 @@ function esc(s) {
function navBar(current) { function navBar(current) {
var email = Session.get(); var email = Session.get();
var links = [["/", "Home", "home"], ["/create.html", "New video", "create"], ["/videos.html", "My videos", "videos"]]; var links = [["/", "Home", "home"], ["/pets.html", "My pets", "pets"],
["/create.html", "New video", "create"], ["/videos.html", "My videos", "videos"]];
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="/">Pay<span>To</span>Laugh</a>'
+ links.map(function (l) { + links.map(function (l) {
@ -71,7 +76,6 @@ function navBar(current) {
if (b) b.onclick = function () { Session.clear(); location.href = "/register.html"; }; 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() { function requireSession() {
var e = Session.get(); var e = Session.get();
if (!e) { location.href = "/register.html?next=" + encodeURIComponent(location.pathname); return null; } if (!e) { location.href = "/register.html?next=" + encodeURIComponent(location.pathname); return null; }
@ -95,3 +99,37 @@ function priceFor(segments) {
var cost = segments * 0.40 + 0.17; var cost = segments * 0.40 + 0.17;
return { cost: cost, price: Math.round(cost * 5 * 100) / 100, seconds: segments * 8 }; return { cost: cost, price: Math.round(cost * 5 * 100) / 100, seconds: segments * 8 };
} }
// 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; 1600px is
// ample. Returns base64 without the data: prefix.
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); // flatten transparency for JPEG
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);
});
}

View file

@ -23,30 +23,8 @@
<h2 class="sec">1 &middot; Who is talking</h2> <h2 class="sec">1 &middot; Who is talking</h2>
<div class="field"> <div class="field">
<label for="pet">Pet</label> <label for="pet">Pet</label>
<select id="pet"><option value="__new">Add a new pet…</option></select> <span class="hint">Profiles and photos live on <a href="/pets.html">My pets</a>.</span>
</div> <select id="pet"></select>
<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> </div>
<h2 class="sec" style="margin-top:34px">2 &middot; What they are reacting to</h2> <h2 class="sec" style="margin-top:34px">2 &middot; What they are reacting to</h2>
@ -101,8 +79,7 @@
<script> <script>
navBar("create"); navBar("create");
var email = requireSession(); var email = requireSession();
var stance = "auto", segments = 4, havePets = false;
var stance = "auto", segments = 4;
function chipGroup(id, cb) { function chipGroup(id, cb) {
var g = document.getElementById(id); var g = document.getElementById(id);
@ -125,60 +102,42 @@ function paint() {
paint(); paint();
var petSel = document.getElementById("pet"); 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() { (async function loadPets() {
if (!email) return; if (!email) return;
var r = await api("list_pets", { email: email }); var r = await api("list_pets", { email: email });
if (r.ok && r.pets && r.pets.length) { if (!r.ok) { showMsg("msg", r.error || "Could not load your pets."); return; }
r.pets.forEach(function (p) { var pets = r.pets || [];
var o = document.createElement("option"); if (!pets.length) {
o.value = p.id || p.name; petSel.innerHTML = '<option value="">No pets yet</option>';
o.textContent = p.name + (p.breed ? " · " + p.breed : ""); petSel.disabled = true;
o.dataset.name = p.name; document.getElementById("go").disabled = true;
petSel.insertBefore(o, petSel.firstChild); showMsg("msg", "Add a pet first — it only takes a moment on the My pets page.");
}); return;
petSel.selectedIndex = 0;
} }
togglePet(); havePets = true;
petSel.innerHTML = pets.map(function (p) {
return '<option value="' + esc(p.id) + '" data-name="' + esc(p.name) + '">'
+ esc(p.name) + (p.breed ? " · " + esc(p.breed) : "") + '</option>';
}).join("");
})(); })();
document.getElementById("form").addEventListener("submit", async function (e) { document.getElementById("form").addEventListener("submit", async function (e) {
e.preventDefault(); e.preventDefault();
hideMsg("msg"); hideMsg("msg");
if (!havePets) { showMsg("msg", "Add a pet first on the My pets page."); return; }
var btn = document.getElementById("go"); var btn = document.getElementById("go");
var url = document.getElementById("url").value.trim(); var url = document.getElementById("url").value.trim();
var notes = document.getElementById("notes").value.trim(); var notes = document.getElementById("notes").value.trim();
var opt = petSel.options[petSel.selectedIndex];
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 (!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; } if (notes.length < 10) { showMsg("msg", "Tell the director what you think, in a sentence or two."); return; }
btn.disabled = true; btn.textContent = "Submitting…"; 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", { var r = await api("create_project", {
email: email, pet_id: petId, pet_name: petName, email: email, pet_id: petSel.value,
pet_name: opt ? opt.getAttribute("data-name") : "",
source_url: url, owner_notes: notes, stance: stance, segments: segments source_url: url, owner_notes: notes, stance: stance, segments: segments
}); });
btn.disabled = false; btn.textContent = "Submit for scripting"; btn.disabled = false; btn.textContent = "Submit for scripting";

214
pets.html Normal file
View file

@ -0,0 +1,214 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My pets — PayToLaugh</title>
<meta name="description" content="Set up your pet's profile and photos once, then reuse it for every video.">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="wrap">
<header class="page">
<h1>My pets</h1>
<p>Set a pet up once and reuse them for every video. The description does more work than you would expect — naming the coat and markings plainly is what keeps the likeness.</p>
</header>
<section>
<div class="msg" id="msg"></div>
<div class="card" id="formcard">
<h2 class="sec" id="formtitle">Add a pet</h2>
<form id="petform">
<input type="hidden" id="petid" value="">
<div class="row">
<div class="field">
<label for="pname">Name</label>
<input type="text" id="pname" placeholder="Astro" required>
</div>
<div class="field">
<label for="pspecies">Species</label>
<select id="pspecies">
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div class="field">
<label for="pbreed">Breed</label>
<input type="text" id="pbreed" placeholder="American Bulldog">
</div>
<div class="field">
<label for="pappear">What do they look like</label>
<span class="hint">Coat colour and markings, in plain words. This matters more than photo resolution does.</span>
<input type="text" id="pappear" placeholder="a white American Bulldog, short coat, broad head with a wide muzzle, dark eyes, 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 rather than a generic one.</span>
<input type="text" id="pperson" placeholder="convinced he runs the household, suspicious of anything with wheels">
</div>
<button class="btn" id="save" type="submit">Save pet</button>
<button class="btn ghost" id="cancel" type="button" style="display:none;margin-left:8px">Cancel</button>
</form>
</div>
<div id="list" style="margin-top:34px"><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("pets");
var email = requireSession();
var pets = [], photos = {};
function kindOptions(sel) {
return PHOTO_KINDS.map(function (k) {
return '<option value="' + esc(k) + '"' + (k === sel ? ' selected' : '') + '>'
+ esc(k.charAt(0).toUpperCase() + k.slice(1)) + '</option>';
}).join("");
}
function petCard(p) {
var shots = (photos[p.id] || []);
var strip = shots.length
? shots.map(function (s) {
return '<figure style="margin:0">'
+ '<img src="' + esc(s.url) + '" alt="' + esc(p.name + " " + s.kind) + '" '
+ 'style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block">'
+ '<figcaption style="font:11px/1.5 var(--mono);color:var(--muted);margin-top:5px;display:flex;justify-content:space-between;gap:6px">'
+ '<span>' + esc(s.kind) + '</span>'
+ '<button type="button" data-del="' + esc(s.id) + '" style="background:none;border:0;color:var(--muted);cursor:pointer;text-decoration:underline;font:inherit">remove</button>'
+ '</figcaption></figure>';
}).join("")
: '<p style="color:var(--muted);font-size:14px;margin:0;grid-column:1/-1">No photos yet. A head shot is the most useful one to start with.</p>';
return '<div class="card" style="margin-bottom:20px">'
+ '<div style="display:flex;justify-content:space-between;gap:16px;align-items:baseline;flex-wrap:wrap">'
+ '<div><h3 style="margin:0 0 4px;font-size:20px">' + esc(p.name) + '</h3>'
+ '<p style="margin:0;color:var(--muted);font-size:14px">' + esc([p.breed, p.species].filter(Boolean).join(" · ")) + '</p></div>'
+ '<button type="button" class="btn ghost" data-edit="' + esc(p.id) + '">Edit</button>'
+ '</div>'
+ (p.appearance ? '<p style="color:var(--muted);font-size:14.5px;margin:14px 0 0">' + esc(p.appearance) + '</p>' : '')
+ (p.personality ? '<p style="color:var(--muted);font-size:14.5px;margin:8px 0 0">' + esc(p.personality) + '</p>' : '')
+ '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:12px;margin-top:20px">' + strip + '</div>'
+ '<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:18px;padding-top:18px;border-top:1px solid var(--line)">'
+ '<select data-kind="' + esc(p.id) + '" style="width:auto">' + kindOptions("head shot") + '</select>'
+ '<input type="file" accept="image/*" data-file="' + esc(p.id) + '" style="width:auto;font-size:14px">'
+ '<span data-status="' + esc(p.id) + '" style="font:12.5px/1 var(--mono);color:var(--muted)"></span>'
+ '</div></div>';
}
async function refresh() {
var r = await api("list_pets", { email: email });
if (!r.ok) { showMsg("msg", r.error || "Could not load your pets."); return; }
pets = r.pets || [];
var ph = await api("list_photos", { email: email });
photos = {};
if (ph.ok) (ph.photos || []).forEach(function (s) {
(photos[s.pet_id] = photos[s.pet_id] || []).push(s);
});
var el = document.getElementById("list");
if (!pets.length) {
el.innerHTML = '<div class="empty">No pets yet. Add one above and they are ready for every video.</div>';
return;
}
el.innerHTML = pets.map(petCard).join("");
}
document.getElementById("petform").addEventListener("submit", async function (e) {
e.preventDefault();
hideMsg("msg");
var btn = document.getElementById("save");
var id = document.getElementById("petid").value;
var pet = {
name: document.getElementById("pname").value.trim(),
species: document.getElementById("pspecies").value,
breed: document.getElementById("pbreed").value.trim(),
appearance: document.getElementById("pappear").value.trim(),
personality: document.getElementById("pperson").value.trim()
};
if (!pet.name) { showMsg("msg", "Your pet needs a name."); return; }
btn.disabled = true; btn.textContent = "Saving…";
var r = id
? await api("update_pet", { email: email, pet_id: id, pet: pet })
: await api("save_pet", { email: email, pet: pet });
btn.disabled = false; btn.textContent = "Save pet";
if (!r.ok) { showMsg("msg", r.error || "That did not save."); return; }
resetForm();
showMsg("msg", id ? "Saved." : "Added. You can upload photos below.", "ok");
await refresh();
});
function resetForm() {
document.getElementById("petid").value = "";
["pname", "pbreed", "pappear", "pperson"].forEach(function (i) { document.getElementById(i).value = ""; });
document.getElementById("pspecies").value = "dog";
document.getElementById("formtitle").textContent = "Add a pet";
document.getElementById("cancel").style.display = "none";
}
document.getElementById("cancel").addEventListener("click", resetForm);
document.getElementById("list").addEventListener("click", async function (e) {
var ed = e.target.closest("[data-edit]");
if (ed) {
var p = pets.filter(function (x) { return x.id === ed.getAttribute("data-edit"); })[0];
if (!p) return;
document.getElementById("petid").value = p.id;
document.getElementById("pname").value = p.name || "";
document.getElementById("pspecies").value = p.species || "dog";
document.getElementById("pbreed").value = p.breed || "";
document.getElementById("pappear").value = p.appearance || "";
document.getElementById("pperson").value = p.personality || "";
document.getElementById("formtitle").textContent = "Edit " + (p.name || "pet");
document.getElementById("cancel").style.display = "";
document.getElementById("formcard").scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
var del = e.target.closest("[data-del]");
if (del) {
if (!confirm("Remove this photo?")) return;
var r = await api("delete_photo", { email: email, photo_id: del.getAttribute("data-del") });
if (!r.ok) { showMsg("msg", r.error || "Could not remove that photo."); return; }
await refresh();
}
});
document.getElementById("list").addEventListener("change", async function (e) {
var f = e.target.closest("[data-file]");
if (!f || !f.files || !f.files[0]) return;
var petId = f.getAttribute("data-file");
var kindSel = document.querySelector('[data-kind="' + petId + '"]');
var status = document.querySelector('[data-status="' + petId + '"]');
var file = f.files[0];
hideMsg("msg");
if (!/^image\//.test(file.type)) { showMsg("msg", "Please choose an image file."); f.value = ""; return; }
status.textContent = "preparing…";
var img;
try { img = await resizeImage(file, 1600, 0.85); }
catch (err) { status.textContent = ""; showMsg("msg", err.message || "Could not read that image."); f.value = ""; return; }
status.textContent = "uploading…";
var r = await api("add_photo", {
email: email, pet_id: petId,
kind: kindSel ? kindSel.value : "other",
data_b64: img.b64
});
f.value = "";
status.textContent = "";
if (!r.ok) { showMsg("msg", r.error || "That upload did not work."); return; }
await refresh();
});
refresh();
</script>
</body>
</html>