paytolaugh/pets.html

375 lines
18 KiB
HTML

<!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>
<div class="field">
<label for="pvoiceid">How they sound</label>
<span class="hint">Pick the voice and listen to it. Every video is recorded in this exact voice, so your pet sounds like the same character each time instead of a different one in every episode.</span>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<select id="pvoiceid" style="flex:1 1 260px"><option value="">Loading voices…</option></select>
<button type="button" class="btn ghost" id="hear" disabled>&#9654;&nbsp; Hear it</button>
</div>
<p id="vdesc" style="color:var(--muted);font-size:13.5px;margin:10px 0 0"></p>
</div>
<div class="field">
<label for="pformula">How the voice is described to the camera</label>
<span class="hint">This exact wording goes into every shot prompt, unchanged, so your pet sounds
like one character rather than a different one each time. Keep it to sound only — pitch, weight,
pace, age. Words like “actress” or “warrior” describe a role and end up in the picture.</span>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
<select id="pformulapreset" style="flex:1 1 240px"><option value="">Start from a preset…</option></select>
<button type="button" class="btn ghost" id="resetformula">Use the voice&rsquo;s own</button>
</div>
<input type="text" id="pformula" placeholder="deep gravelly middle-aged american male baritone">
</div>
<div class="field">
<label for="pvoice">Anything else about how they talk <span style="color:var(--muted);font-weight:400">(optional)</span></label>
<span class="hint">This goes to the writer, not the recording — it shapes the words your pet uses, not the voice itself.</span>
<textarea id="pvoice" placeholder="Talks like he is explaining something obvious to a slow audience. Never swears, but gets close."></textarea>
</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 = {}, voices = [], sample = null;
function voiceById(id) {
for (var i = 0; i < voices.length; i++) if (voices[i].id === id) return voices[i];
return null;
}
// What to show on a card. Falls back to the stored formula for pets saved
// before voices existed, so an old profile still reads as something.
function voiceName(p) {
var v = voiceById(p.eleven_voice_id || "");
return v ? v.name + " — " + v.formula : (p.voice_formula || "");
}
function describeVoice() {
var v = voiceById(document.getElementById("pvoiceid").value);
var d = document.getElementById("vdesc");
document.getElementById("hear").disabled = !(v && v.preview);
if (!v) { d.textContent = ""; return; }
d.innerHTML = esc(v.description)
+ '<br><span style="font:12px/1.7 var(--mono)">'
+ esc(v.accent + " · " + v.age + " · about " + v.words + " words per 8 second shot")
+ '</span>';
}
// Filled from the server so the presets cannot drift from the ones the prompt
// builder knows about.
var formulaPresets = [];
function fillFormulaPresets(list) {
formulaPresets = list || [];
document.getElementById("pformulapreset").innerHTML =
'<option value="">Start from a preset…</option>'
+ formulaPresets.map(function (d) {
return '<option value="' + esc(d.formula) + '">' + esc(d.label) + ' — ' + esc(d.formula) + '</option>';
}).join("");
}
document.getElementById("pformulapreset").addEventListener("change", function () {
if (this.value) document.getElementById("pformula").value = this.value;
});
// Empty means “whatever the chosen voice says”, which is what the server does
// with a blank field, so clearing it is the honest way to reset.
document.getElementById("resetformula").addEventListener("click", function () {
var v = voiceById(document.getElementById("pvoiceid").value);
document.getElementById("pformula").value = v ? v.formula : "";
document.getElementById("pformulapreset").value = "";
});
async function loadVoices() {
var r = await api("camera_presets", {});
fillFormulaPresets(r.voice_descriptions);
var sel = document.getElementById("pvoiceid");
if (!r.ok || !(r.voices || []).length) {
sel.innerHTML = '<option value="">Voices unavailable</option>';
return;
}
voices = r.voices;
// Grouped, because twenty-one flat names is a list nobody reads.
// Big and rough first: a talking animal usually wants weight, not polish.
var groups = [["big", "Big and rough — good for large or heavy animals"],
["male", "Male"], ["female", "Female"], ["neutral", "Neutral"]];
var html = '<option value="">No voice yet — pick one</option>';
groups.forEach(function (g) {
var inG = voices.filter(function (v) { return v.gender === g[0]; });
if (!inG.length) return;
html += '<optgroup label="' + esc(g[1]) + '">'
+ inG.map(function (v) {
return '<option value="' + esc(v.id) + '">' + esc(v.name + " — " + v.formula) + '</option>';
}).join("")
+ '</optgroup>';
});
sel.innerHTML = html;
describeVoice();
}
document.getElementById("pvoiceid").addEventListener("change", describeVoice);
document.getElementById("hear").addEventListener("click", function () {
var v = voiceById(document.getElementById("pvoiceid").value);
if (!v || !v.preview) return;
if (sample) { sample.pause(); sample = null; }
sample = new Audio(v.preview);
var btn = this;
btn.textContent = "■ Playing…";
sample.addEventListener("ended", function () { btn.innerHTML = "&#9654;&nbsp; Hear it"; });
sample.addEventListener("error", function () { btn.innerHTML = "&#9654;&nbsp; Hear it"; });
sample.play().catch(function () { btn.innerHTML = "&#9654;&nbsp; Hear it"; });
});
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) + '" '
+ 'data-full="' + esc(s.url) + '" data-cap="' + esc(p.name + " · " + s.kind) + '" '
+ 'title="Click to see it full size" '
+ 'style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block;cursor:zoom-in">'
+ '<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>' : '')
+ (voiceName(p)
? '<p style="color:var(--muted);font-size:13.5px;margin:10px 0 0"><strong style="color:var(--ink)">Voice:</strong> ' + esc(voiceName(p)) + '</p>'
: '<p style="color:var(--accent2);font-size:13.5px;margin:10px 0 0">No voice picked, so they will sound like a different character in every video.</p>')
+ (p.voice ? '<p style="color:var(--muted);font-size:13.5px;margin:6px 0 0">' + esc(p.voice) + '</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(),
voice: document.getElementById("pvoice").value.trim(),
voice_formula: document.getElementById("pformula").value.trim(),
eleven_voice_id: document.getElementById("pvoiceid").value
};
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", "pvoice", "pformula"].forEach(function (i) { document.getElementById(i).value = ""; });
document.getElementById("pformulapreset").value = "";
document.getElementById("pvoiceid").value = "";
describeVoice();
document.getElementById("pspecies").value = "dog";
document.getElementById("formtitle").textContent = "Add a pet";
document.getElementById("cancel").style.display = "none";
}
document.getElementById("cancel").addEventListener("click", resetForm);
// A 110px square crop is too small to check the collar, the eye colour or the
// markings — which is exactly what these photographs exist to record.
function showFull(src, caption) {
var back = document.createElement("div");
back.setAttribute("role", "dialog");
back.setAttribute("aria-label", caption || "Photo");
back.style.cssText = "position:fixed;inset:0;background:rgba(6,8,12,.9);z-index:9999;"
+ "display:flex;flex-direction:column;align-items:center;justify-content:center;"
+ "gap:14px;padding:26px;cursor:zoom-out";
back.innerHTML = '<img src="' + esc(src) + '" alt="' + esc(caption || "") + '" '
+ 'style="max-width:min(100%,900px);max-height:82vh;border-radius:12px;'
+ 'border:1px solid var(--line);display:block;background:#000">'
+ '<p style="margin:0;color:#c9cedb;font:13px/1.5 var(--mono);text-align:center">'
+ esc(caption || "") + '<br><span style="opacity:.65">click anywhere, or press Escape, to close</span></p>';
function close() {
document.removeEventListener("keydown", onKey);
if (back.parentNode) back.parentNode.removeChild(back);
}
function onKey(e) { if (e.key === "Escape") close(); }
back.addEventListener("click", close);
document.addEventListener("keydown", onKey);
document.body.appendChild(back);
}
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("pvoice").value = p.voice || "";
document.getElementById("pvoiceid").value = p.eleven_voice_id || "";
document.getElementById("pformula").value = p.voice_formula || "";
document.getElementById("pformulapreset").value = "";
describeVoice();
document.getElementById("formtitle").textContent = "Edit " + (p.name || "pet");
document.getElementById("cancel").style.display = "";
document.getElementById("formcard").scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
var full = e.target.closest("[data-full]");
if (full) {
showFull(full.getAttribute("data-full"), full.getAttribute("data-cap"));
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();
});
loadVoices().then(refresh);
</script>
</body>
</html>