375 lines
18 KiB
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>▶ 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’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 · 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 = "▶ Hear it"; });
|
|
sample.addEventListener("error", function () { btn.innerHTML = "▶ Hear it"; });
|
|
sample.play().catch(function () { btn.innerHTML = "▶ 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>
|