paytolaugh/book.html
gator d1c9320966 book.html: a real Choose file button, and drop the paste box
"Drop the manuscript here or click to choose a file" is a drop zone pretending
to be a button - it reads as an instruction rather than a control, and on a
laptop nobody drags a file from anywhere. There is now an actual button. The
zone still accepts a drop, because that costs nothing once the button exists.

The paste box is gone. It was there for a manuscript that would not upload
cleanly, but a book is 100,000 words and nobody pastes that; keeping it meant
offering a route that only works for the case it was not for. Word and PDF
cover it, and a PDF that turns out to be a scan is refused with the reason.
2026-09-17 02:22:45 +00:00

318 lines
13 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Book trailer — PayToLaugh</title>
<meta name="description" content="Upload a manuscript and have several models each write a cinematic trailer from it.">
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/assets/app.css">
<style>
.books{margin-top:18px}
.bk{display:flex;gap:14px;align-items:flex-start;padding:14px 0;border-top:1px solid var(--line)}
.bk:first-child{border-top:0}
.bk .meta{flex:1;min-width:0}
.bk h3{margin:0 0 4px;font-size:16px}
.bk .sub{color:var(--muted);font-size:13px}
.bk .acts{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.state{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--line)}
.state.ready{color:var(--accent2);border-color:var(--accent2)}
.state.reading{color:var(--muted)}
.state.failed{color:#e0685f;border-color:#e0685f}
.why{color:#e0685f;font-size:13px;margin-top:4px}
.tiny{font-size:12px;color:var(--muted)}
.pick{display:flex;gap:14px;align-items:center;flex-wrap:wrap;
border:1px dashed var(--line);border-radius:12px;padding:18px}
.pick.on{border-color:var(--accent);border-style:solid}
.pick .or{color:var(--muted);font-size:13px}
.writers{display:flex;flex-wrap:wrap;gap:10px;margin-top:8px}
.wr{display:flex;gap:8px;align-items:center;border:1px solid var(--line);
border-radius:10px;padding:9px 12px;cursor:pointer}
.wr.on{border-color:var(--accent)}
.wr input{margin:0}
.lead{margin-left:6px;font-size:12px;color:var(--muted)}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1>Make a trailer from a book</h1>
<p>Upload the manuscript and say what you want the trailer to do. Several models each read the whole book and write their own cinematic trailer &mdash; script, cast and shot list. You read them side by side, mark up the shots you want changed, and let them critique each other before one gets filmed.</p>
</header>
<section>
<div class="card">
<div class="msg" id="msg"></div>
<h2 class="sec">1 &middot; The manuscript</h2>
<div class="field">
<label>The book</label>
<span class="hint">A Word <strong>.docx</strong> or a <strong>.pdf</strong>, up to 6MB. A scanned PDF has no text in it and will be refused &mdash; upload the original document instead.</span>
<div class="pick" id="pick">
<button type="button" class="btn primary" id="choose">Choose a file&hellip;</button>
<span class="or" id="pickmsg">or drag the manuscript onto this panel</span>
</div>
<input type="file" id="file" accept=".docx,.pdf,.txt,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" style="display:none">
</div>
<div class="books" id="books"><p class="empty">No manuscripts yet.</p></div>
</div>
</section>
<section id="runcard" style="display:none">
<div class="card">
<h2 class="sec">2 &middot; What you want from the trailer</h2>
<div class="field">
<label for="guidance">Your guidance</label>
<span class="hint">You know this book better than any of them will. Say what the trailer has to carry, which scenes matter, which it must not give away, who the audience is, and the tone. This outranks the house defaults.</span>
<textarea id="guidance" rows="7" placeholder="What the trailer has to carry, what it must not give away, and the tone you want."></textarea>
</div>
<h2 class="sec" style="margin-top:34px">3 &middot; Who writes it</h2>
<div class="field">
<label>Writers</label>
<span class="hint">Each one reads the whole book and writes its own trailer, with its own cast. Tick at least two if you want them to critique each other. The <em>lead</em> is the one that will write the revision after the reviews come back &mdash; click a ticked name to make it the lead.</span>
<div class="writers" id="writers"></div>
</div>
<div class="field">
<p class="tiny" id="quote"></p>
<button type="button" class="btn primary" id="go">Write the trailers</button>
</div>
</div>
</section>
</div>
<script src="/assets/app.js"></script>
<script>
// Send a document as the bytes it actually is.
//
// app.js has resizeImage, and it is image-only by construction - FileReader
// into an <img> into a canvas. That is right for a 10MB phone photo and fatal
// for a .docx, which must arrive byte for byte or the zip will not open. This
// lives here rather than in app.js because this is the only page that uploads
// a document, and app.js is loaded by every page.
//
// The ceiling matches the server's MAX_BOOK_BYTES, so an oversized file is
// refused instantly with a suggestion rather than after a slow upload that was
// always going to be rejected.
var MAX_DOC_BYTES = 6 * 1024 * 1024;
function readFileB64(file) {
return new Promise(function (resolve, reject) {
if (!file) { reject(new Error("No file was chosen.")); return; }
if (file.size > MAX_DOC_BYTES) {
reject(new Error("That file is " + (file.size / 1048576).toFixed(1)
+ "MB and the limit is 6MB. Upload the .docx rather than an "
+ "illustrated PDF."));
return;
}
var reader = new FileReader();
reader.onerror = function () { reject(new Error("Could not read that file.")); };
reader.onload = function () {
var s = String(reader.result || "");
var i = s.indexOf(",");
resolve({ b64: i >= 0 ? s.slice(i + 1) : s, name: file.name, bytes: file.size });
};
reader.readAsDataURL(file);
});
}
(function () {
var email = requireSession();
if (!email) return;
navBar("trailer");
var BOOKS = [], PICKED = null, WRITERS = [], CHOSEN = {}, LEAD = "";
var pollTimer = null;
// ---------------------------------------------------------------- books
function bookHtml(b) {
var st = b.status || "reading";
var sub;
if (st === "ready") {
sub = Number(b.words || 0).toLocaleString() + " words &middot; "
+ Number(b.chars || 0).toLocaleString() + " characters &middot; " + esc(b.kind || "");
} else if (st === "failed") {
sub = "could not be read";
} else {
sub = "reading the manuscript…";
}
return '<div class="bk" data-id="' + esc(b.id) + '">'
+ '<div class="meta">'
+ '<h3>' + esc(b.title || "Untitled") + '</h3>'
+ '<div class="sub">' + sub + (b.created_at ? ' &middot; ' + esc(when(b.created_at)) : '') + '</div>'
+ (st === "failed" && b.note ? '<div class="why">' + esc(b.note) + '</div>' : '')
+ '</div>'
+ '<div class="acts">'
+ '<span class="state ' + esc(st) + '">' + esc(st) + '</span>'
+ (st === "ready"
? '<button type="button" class="btn" data-pick="' + esc(b.id) + '">'
+ (PICKED === b.id ? "Selected" : "Use this book") + '</button>'
: '')
+ '<button type="button" class="btn" data-del="' + esc(b.id) + '">Remove</button>'
+ '</div></div>';
}
function paintBooks() {
var el = document.getElementById("books");
if (!BOOKS.length) { el.innerHTML = '<p class="empty">No manuscripts yet.</p>'; return; }
el.innerHTML = BOOKS.map(bookHtml).join("");
var ready = BOOKS.filter(function (b) { return b.status === "ready"; });
if (!PICKED && ready.length === 1) PICKED = ready[0].id;
document.getElementById("runcard").style.display = PICKED ? "" : "none";
}
async function loadBooks() {
var r = await api("list_books", {});
if (!r.ok) { showMsg("msg", r.error || "Could not load your manuscripts."); return; }
BOOKS = r.books || [];
paintBooks();
// Extraction runs on the server after the upload returns, so a book sits
// at "reading" for a moment. Poll only while one actually is.
var busy = BOOKS.some(function (b) { return b.status === "reading"; });
if (busy && !pollTimer) pollTimer = setInterval(loadBooks, 2500);
if (!busy && pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
document.getElementById("books").addEventListener("click", async function (e) {
var pick = e.target.getAttribute("data-pick");
var del = e.target.getAttribute("data-del");
if (pick) { PICKED = pick; hideMsg("msg"); paintBooks(); paintQuote(); return; }
if (del) {
e.target.disabled = true;
var r = await api("delete_book", { book_id: del });
if (!r.ok) { showMsg("msg", r.error || "Could not remove that."); e.target.disabled = false; return; }
if (PICKED === del) PICKED = null;
loadBooks();
}
});
// ---------------------------------------------------------------- upload
var pick = document.getElementById("pick");
var pickMsg = document.getElementById("pickmsg");
var fileInput = document.getElementById("file");
document.getElementById("choose").addEventListener("click", function () {
fileInput.click();
});
["dragenter", "dragover"].forEach(function (k) {
pick.addEventListener(k, function (e) { e.preventDefault(); pick.classList.add("on"); });
});
["dragleave", "drop"].forEach(function (k) {
pick.addEventListener(k, function (e) { e.preventDefault(); pick.classList.remove("on"); });
});
pick.addEventListener("drop", function (e) {
var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) send(f);
});
fileInput.addEventListener("change", function () {
if (fileInput.files && fileInput.files[0]) send(fileInput.files[0]);
fileInput.value = "";
});
async function send(file) {
hideMsg("msg");
pickMsg.textContent = "reading " + file.name + "…";
var doc;
try { doc = await readFileB64(file); }
catch (err) { resetPick(); showMsg("msg", err.message); return; }
pickMsg.textContent = "uploading " + file.name + " ("
+ (doc.bytes / 1048576).toFixed(1) + "MB)…";
var r = await api("upload_book", { filename: doc.name, data_b64: doc.b64 });
resetPick();
if (!r.ok) { showMsg("msg", r.error || "That upload did not go through."); return; }
PICKED = null;
loadBooks();
}
function resetPick() {
pickMsg.textContent = "or drag the manuscript onto this panel";
}
// ---------------------------------------------------------------- writers
function paintWriters() {
document.getElementById("writers").innerHTML = WRITERS.map(function (w) {
var on = !!CHOSEN[w.id];
return '<label class="wr' + (on ? ' on' : '') + '" data-w="' + esc(w.id) + '">'
+ '<input type="checkbox" data-pick="' + esc(w.id) + '"' + (on ? ' checked' : '') + '>'
+ esc(w.label)
+ (LEAD === w.id ? '<span class="lead">lead</span>' : '')
+ '</label>';
}).join("");
}
document.getElementById("writers").addEventListener("change", function (e) {
var id = e.target.getAttribute("data-pick");
if (!id) return;
if (e.target.checked) { CHOSEN[id] = true; if (!LEAD) LEAD = id; }
else { delete CHOSEN[id]; if (LEAD === id) LEAD = Object.keys(CHOSEN)[0] || ""; }
paintWriters(); paintQuote();
});
// Clicking the name of a ticked writer makes it the lead.
document.getElementById("writers").addEventListener("click", function (e) {
var lab = e.target.closest ? e.target.closest(".wr") : null;
if (!lab || e.target.tagName === "INPUT") return;
var id = lab.getAttribute("data-w");
if (CHOSEN[id]) { LEAD = id; paintWriters(); paintQuote(); }
});
function paintQuote() {
var n = Object.keys(CHOSEN).length;
var el = document.getElementById("quote");
var go = document.getElementById("go");
if (!n) {
el.textContent = "Tick at least one writer.";
go.disabled = true; return;
}
go.disabled = false;
// Writing is the cheap half. Drawing the storyboards is priced separately,
// per writer, once you have read the scripts.
el.innerHTML = n + " writer" + (n > 1 ? "s" : "")
+ " &middot; about $" + (n * 0.6).toFixed(2) + " to write"
+ " &middot; storyboards are priced separately once you have read them";
}
(async function loadWriters() {
var r = await api("camera_presets", {});
WRITERS = (r && r.writers) || [];
if (!WRITERS.length) {
showMsg("msg", "The server has not published a writer list yet.");
return;
}
WRITERS.forEach(function (w) { if (w.trailer_default) { CHOSEN[w.id] = true; } });
LEAD = Object.keys(CHOSEN)[0] || "";
paintWriters(); paintQuote();
})();
document.getElementById("go").addEventListener("click", async function () {
var btn = this;
var writers = Object.keys(CHOSEN);
var guidance = document.getElementById("guidance").value.trim();
if (!PICKED) { showMsg("msg", "Pick a manuscript first."); return; }
if (!writers.length) { showMsg("msg", "Tick at least one writer."); return; }
hideMsg("msg");
btn.disabled = true; btn.textContent = "Starting…";
var r = await api("start_run", {
book_id: PICKED, guidance: guidance,
writers: writers.join(","), lead_writer: LEAD || writers[0]
});
if (!r.ok) {
btn.disabled = false; btn.textContent = "Write the trailers";
showMsg("msg", r.error || "That did not start.");
return;
}
location.href = "/trailer.html?run=" + encodeURIComponent(r.run.id);
});
loadBooks();
})();
</script>
</body>
</html>