epub2pdf: title-based export names, .pdf download headers, security hardening (68 tests green)
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bindery — EPUB to PDF</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink-0:#131a17; --ink-1:#1a221e; --ink-2:#212a25; --line:#2c362f;
|
||||
--paper:#efe9dc; --muted:#8b938a; --gilt:#c9a24b; --gilt-hi:#e2c06e;
|
||||
--ok:#86a878; --err:#d07f6b; --sheet:#f4efe2; --sheet-line:#d8d1c0;
|
||||
--serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
|
||||
--sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
font-family:var(--sans); color:var(--paper); background:var(--ink-0);
|
||||
background-image:radial-gradient(120% 80% at 50% -10%, #1d2721 0%, var(--ink-0) 60%);
|
||||
min-height:100vh; display:flex; flex-direction:column; align-items:center;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.mono{font-family:var(--mono)}
|
||||
|
||||
/* ---- masthead ---- */
|
||||
.masthead{width:100%;max-width:720px;padding:44px 20px 8px;text-align:center}
|
||||
.wordmark{
|
||||
font-family:var(--serif); font-size:2.5rem; font-weight:600; letter-spacing:.5px;
|
||||
color:var(--paper); display:inline-flex; align-items:baseline; gap:.4rem;
|
||||
}
|
||||
.wordmark .fleuron{color:var(--gilt); font-size:1.5rem; transform:translateY(-2px)}
|
||||
.tagline{color:var(--muted); margin-top:10px; font-size:.95rem; max-width:34ch; margin-left:auto; margin-right:auto; line-height:1.5}
|
||||
.rule{width:720px;max-width:calc(100% - 40px);height:1px;background:linear-gradient(90deg,transparent,var(--line),transparent);margin:22px auto 0}
|
||||
|
||||
main{width:100%;max-width:720px;padding:26px 20px 48px;flex:1}
|
||||
|
||||
/* ---- drop zone (shelf of spines) ---- */
|
||||
.drop{
|
||||
border:1px dashed #3a463d; border-radius:14px; background:linear-gradient(180deg,var(--ink-1),var(--ink-0));
|
||||
padding:34px 24px 40px; text-align:center; cursor:pointer; outline:none;
|
||||
transition:border-color .18s, background .18s, transform .18s;
|
||||
}
|
||||
.drop:hover{border-color:#4b5a50}
|
||||
.drop:focus-visible{border-color:var(--gilt); box-shadow:0 0 0 3px rgba(201,162,75,.25)}
|
||||
.drop.over{border-color:var(--gilt); background:linear-gradient(180deg,var(--ink-2),var(--ink-1))}
|
||||
.drop input{display:none}
|
||||
|
||||
.shelf{position:relative;display:flex;align-items:flex-end;justify-content:center;gap:7px;min-height:188px;padding:0 6px 14px}
|
||||
.shelf::after{
|
||||
content:"";position:absolute;left:-16px;right:-16px;bottom:2px;height:11px;border-radius:2px;
|
||||
background:linear-gradient(180deg,#2b2118,#181209);box-shadow:0 10px 18px rgba(0,0,0,.5);
|
||||
}
|
||||
.spine{
|
||||
position:relative;width:var(--w);height:var(--h);background:var(--c);border-radius:2px 2px 1px 1px;
|
||||
box-shadow:inset 3px 0 4px rgba(255,255,255,.07),inset -5px 0 8px rgba(0,0,0,.4);
|
||||
transition:transform .22s cubic-bezier(.2,.8,.3,1);
|
||||
}
|
||||
.spine::before{content:"";position:absolute;top:15px;left:50%;transform:translateX(-50%);width:66%;height:3px;background:rgba(232,206,142,.5);border-radius:1px}
|
||||
.spine::after{content:"";position:absolute;bottom:13px;left:50%;transform:translateX(-50%);width:54%;height:2px;background:rgba(232,206,142,.32);border-radius:1px}
|
||||
.drop:hover .spine,.drop.over .spine{transform:translateY(-7px)}
|
||||
.drop:hover .spine:nth-child(2n),.drop.over .spine:nth-child(2n){transform:translateY(-11px)}
|
||||
|
||||
.drop-copy{margin-top:26px}
|
||||
.drop-copy h1{font-family:var(--serif);font-weight:600;font-size:1.5rem;letter-spacing:.2px}
|
||||
.drop-copy p{color:var(--muted);margin-top:10px;font-size:.9rem}
|
||||
.drop-copy .browse{color:var(--gilt);text-decoration:underline;text-underline-offset:3px;text-decoration-color:rgba(201,162,75,.4)}
|
||||
.drop-copy code{font-family:var(--mono);color:var(--paper);background:var(--ink-2);padding:1px 6px;border-radius:4px;font-size:.82em}
|
||||
.drop-err{margin-top:14px;color:var(--err);font-size:.9rem;min-height:1.1em;opacity:0;transition:opacity .2s}
|
||||
.drop-err.show{opacity:1}
|
||||
|
||||
/* ---- status / press state ---- */
|
||||
.status{border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--ink-1),var(--ink-0));padding:26px 24px}
|
||||
.status-top{display:flex;justify-content:space-between;align-items:flex-end;gap:20px}
|
||||
.book-meta{min-width:0;flex:1}
|
||||
.eyebrow{font-family:var(--mono);font-size:.72rem;letter-spacing:.14em;text-transform:uppercase;color:var(--gilt)}
|
||||
.bookname{display:block;font-family:var(--mono);font-weight:600;font-size:1.02rem;margin-top:8px;word-break:break-all;line-height:1.35}
|
||||
.chapter{display:block;font-family:var(--mono);font-size:.85rem;color:var(--muted);margin-top:6px;min-height:1.1em}
|
||||
|
||||
.book-block{
|
||||
position:relative;flex:0 0 auto;width:112px;height:148px;border-radius:3px;overflow:hidden;
|
||||
background:linear-gradient(180deg,#241b11,#171109);border:1px solid var(--line);
|
||||
box-shadow:inset 0 0 0 3px #0c0906,inset 9px 0 0 rgba(0,0,0,.35);
|
||||
}
|
||||
.pages{
|
||||
position:absolute;left:0;right:0;bottom:0;height:0%;
|
||||
background:repeating-linear-gradient(180deg,var(--sheet) 0 2px,var(--sheet-line) 2px 3px);
|
||||
box-shadow:0 -1px 0 rgba(0,0,0,.45);transition:height .25s ease;
|
||||
}
|
||||
.current{position:absolute;left:-2px;right:-2px;bottom:0%;height:2px;background:var(--gilt);box-shadow:0 0 7px rgba(201,162,75,.65);transition:bottom .25s ease}
|
||||
|
||||
.status-line{margin-top:20px;padding-top:16px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem;min-height:1.2em}
|
||||
|
||||
.actions{margin-top:18px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
.btn{display:inline-block;padding:11px 22px;border-radius:8px;border:1px solid transparent;font-size:.92rem;font-weight:600;cursor:pointer;text-decoration:none;font-family:var(--sans)}
|
||||
.btn:focus-visible{outline:2px solid var(--gilt);outline-offset:2px}
|
||||
.btn-primary{background:var(--gilt);color:#1a1408}
|
||||
.btn-primary:hover{background:var(--gilt-hi)}
|
||||
.btn-ghost{background:transparent;color:var(--muted);border-color:var(--line)}
|
||||
.btn-ghost:hover{color:var(--paper);border-color:#4b5a50}
|
||||
.err{color:var(--err);font-size:.9rem}
|
||||
|
||||
footer{width:100%;max-width:720px;padding:16px 20px 26px;display:flex;justify-content:space-between;align-items:center;color:#5f665e;font-size:.78rem}
|
||||
footer .dot{color:var(--ok)}
|
||||
|
||||
@media (max-width:560px){
|
||||
.wordmark{font-size:2rem}
|
||||
.status-top{flex-direction:column;align-items:stretch}
|
||||
.book-block{margin:0 auto}
|
||||
.shelf{min-height:150px;gap:5px}
|
||||
.spine{--h:calc(var(--h) * .82)}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
*{transition:none !important}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="masthead">
|
||||
<div class="wordmark"><span class="fleuron">❦</span>Bindery</div>
|
||||
<p class="tagline">A local typesetter. Drop an EPUB, take a properly set PDF. Nothing leaves your machine.</p>
|
||||
</header>
|
||||
<div class="rule"></div>
|
||||
|
||||
<main>
|
||||
<section id="drop" class="drop" role="button" tabindex="0" aria-label="Upload an EPUB file">
|
||||
<div class="shelf" aria-hidden="true">
|
||||
<span class="spine" style="--c:#6e3b34;--h:150px;--w:24px"></span>
|
||||
<span class="spine" style="--c:#33513f;--h:164px;--w:20px"></span>
|
||||
<span class="spine" style="--c:#33415c;--h:138px;--w:26px"></span>
|
||||
<span class="spine" style="--c:#5a5934;--h:158px;--w:18px"></span>
|
||||
<span class="spine" style="--c:#54394b;--h:146px;--w:22px"></span>
|
||||
<span class="spine" style="--c:#315550;--h:168px;--w:24px"></span>
|
||||
<span class="spine" style="--c:#7a4a38;--h:134px;--w:20px"></span>
|
||||
<span class="spine" style="--c:#46525e;--h:156px;--w:26px"></span>
|
||||
</div>
|
||||
<div class="drop-copy">
|
||||
<h1>Drop a book to set it in type</h1>
|
||||
<p>or <span class="browse">browse your files</span> · up to <span id="maxmb">400</span> MB · <code>.epub</code></p>
|
||||
</div>
|
||||
<p id="droperr" class="drop-err" role="alert"></p>
|
||||
<input type="file" id="file" accept=".epub">
|
||||
</section>
|
||||
|
||||
<section id="status" class="status" hidden>
|
||||
<div class="status-top">
|
||||
<div class="book-meta">
|
||||
<span class="eyebrow" id="state">Setting in type</span>
|
||||
<b id="bookname" class="bookname"></b>
|
||||
<span id="chapter" class="chapter"></span>
|
||||
</div>
|
||||
<div class="book-block" aria-hidden="true">
|
||||
<div class="pages" id="pages"></div>
|
||||
<div class="current" id="current"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-line mono" id="msg"></div>
|
||||
<div class="actions" id="actions">
|
||||
<a class="btn btn-primary" id="download" href="#" hidden>Download PDF</a>
|
||||
<button class="btn btn-ghost" id="again" type="button" hidden>Set another book</button>
|
||||
<span class="err" id="errtext" hidden></span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>Bindery · EPUB → PDF · WeasyPress engine</span>
|
||||
<span class="mono" id="foot">press: …</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var drop = document.getElementById('drop');
|
||||
var fileInput = document.getElementById('file');
|
||||
var status = document.getElementById('status');
|
||||
var bookname = document.getElementById('bookname');
|
||||
var chapter = document.getElementById('chapter');
|
||||
var stateEl = document.getElementById('state');
|
||||
var pages = document.getElementById('pages');
|
||||
var current = document.getElementById('current');
|
||||
var msg = document.getElementById('msg');
|
||||
var errtext = document.getElementById('errtext');
|
||||
var download = document.getElementById('download');
|
||||
var again = document.getElementById('again');
|
||||
var droperr = document.getElementById('droperr');
|
||||
var foot = document.getElementById('foot');
|
||||
var timer = null, errTimer = null;
|
||||
|
||||
fetch('/health').then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
foot.innerHTML = 'press: <span class="dot">ok</span>';
|
||||
var maxmb = document.getElementById('maxmb');
|
||||
if (maxmb && d.max_upload_mb != null) maxmb.textContent = d.max_upload_mb;
|
||||
})
|
||||
.catch(function () { foot.textContent = 'press: down'; });
|
||||
|
||||
function openPicker() { fileInput.click(); }
|
||||
drop.addEventListener('click', openPicker);
|
||||
drop.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker(); }
|
||||
});
|
||||
['dragover', 'dragenter'].forEach(function (ev) {
|
||||
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('over'); });
|
||||
});
|
||||
['dragleave', 'dragend', 'drop'].forEach(function (ev) {
|
||||
drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('over'); });
|
||||
});
|
||||
drop.addEventListener('drop', function (e) {
|
||||
var f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
if (f) start(f);
|
||||
});
|
||||
fileInput.addEventListener('change', function () { if (fileInput.files[0]) start(fileInput.files[0]); });
|
||||
again.addEventListener('click', resetUI);
|
||||
|
||||
function flashError(text) {
|
||||
droperr.textContent = text;
|
||||
droperr.classList.add('show');
|
||||
if (errTimer) clearTimeout(errTimer);
|
||||
errTimer = setTimeout(function () { droperr.classList.remove('show'); }, 4200);
|
||||
}
|
||||
|
||||
function showStatus() { drop.hidden = true; status.hidden = false; }
|
||||
function hideStatus() { status.hidden = true; drop.hidden = false; }
|
||||
|
||||
function resetUI() {
|
||||
if (timer) clearInterval(timer);
|
||||
fileInput.value = '';
|
||||
pages.style.height = '0%';
|
||||
current.style.bottom = '0%';
|
||||
chapter.textContent = '';
|
||||
stateEl.textContent = 'Setting in type';
|
||||
msg.textContent = '';
|
||||
errtext.hidden = true;
|
||||
download.hidden = true;
|
||||
again.hidden = true;
|
||||
hideStatus();
|
||||
}
|
||||
|
||||
function start(f) {
|
||||
if (!/\.epub$/i.test(f.name)) { flashError('That is not an .epub — pick a book with the .epub extension.'); return; }
|
||||
var form = new FormData();
|
||||
form.append('file', f);
|
||||
showStatus();
|
||||
bookname.textContent = f.name;
|
||||
stateEl.textContent = 'Sending to the press';
|
||||
chapter.textContent = '';
|
||||
msg.textContent = 'Uploading…';
|
||||
pages.style.height = '0%';
|
||||
current.style.bottom = '0%';
|
||||
|
||||
fetch('/api/convert', { method: 'POST', body: form })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
|
||||
.then(function (res) { if (!res.ok) throw new Error(res.data.error || 'Upload failed'); poll(res.data.job_id); })
|
||||
.catch(function (e) { fail(e.message); });
|
||||
}
|
||||
|
||||
function poll(jobId) {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(function () {
|
||||
fetch('/api/jobs/' + jobId)
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, data: d }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
fail('Job no longer known to the server (it may have been evicted). Reload and try again.');
|
||||
return;
|
||||
}
|
||||
throw new Error((res.data && res.data.error) || 'Job lookup failed');
|
||||
}
|
||||
var j = res.data;
|
||||
if (j.status === 'error') return fail(j.error || 'Conversion failed');
|
||||
var p = j.progress || 0;
|
||||
pages.style.height = p + '%';
|
||||
current.style.bottom = p + '%';
|
||||
var m = (j.message || '').match(/\((\d+)\/(\d+)\)/);
|
||||
chapter.textContent = m ? ('ch ' + m[1] + ' / ' + m[2]) : '';
|
||||
stateEl.textContent = /merging/i.test(j.message || '') ? 'Collating pages' : 'Setting in type';
|
||||
msg.textContent = j.message || 'Working…';
|
||||
if (j.status === 'done') {
|
||||
clearInterval(timer);
|
||||
pages.style.height = '100%';
|
||||
current.style.bottom = '100%';
|
||||
stateEl.textContent = 'Set & bound';
|
||||
msg.textContent = 'Done — ready to download.';
|
||||
download.href = '/download/' + encodeURIComponent(j.filename);
|
||||
download.hidden = false;
|
||||
again.hidden = false;
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function fail(text) {
|
||||
if (timer) clearInterval(timer);
|
||||
stateEl.textContent = 'Stopped at the press';
|
||||
errtext.textContent = text;
|
||||
errtext.hidden = false;
|
||||
again.hidden = false;
|
||||
msg.textContent = '';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user