519 lines
18 KiB
HTML
519 lines
18 KiB
HTML
<!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 {
|
|
/* Mono palette with an indigo/violet accent */
|
|
--bg: #0b0d17;
|
|
--bg-elev: #131522;
|
|
--bg-card: rgba(22, 24, 40, 0.72);
|
|
--line: rgba(255, 255, 255, 0.08);
|
|
--line-strong: rgba(255, 255, 255, 0.14);
|
|
--text: #f2f3f7;
|
|
--muted: #9aa0b4;
|
|
--muted-2: #6b7188;
|
|
--accent: #6366f1;
|
|
--accent-2: #8b5cf6;
|
|
--accent-soft: rgba(139, 92, 246, 0.16);
|
|
--ok: #34d399;
|
|
--err: #f87171;
|
|
--radius: 18px;
|
|
--radius-sm: 11px;
|
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
|
--shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.6);
|
|
--shadow-soft: 0 8px 24px -12px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
font-family: var(--sans);
|
|
color: var(--text);
|
|
background: var(--bg);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
-webkit-font-smoothing: antialiased;
|
|
background-image:
|
|
radial-gradient(120% 90% at 50% -10%, rgba(99, 102, 241, 0.18), transparent 55%),
|
|
radial-gradient(120% 80% at 80% 0%, rgba(139, 92, 246, 0.12), transparent 50%),
|
|
var(--bg);
|
|
position: relative;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* ambient grid backdrop */
|
|
body::before {
|
|
content: "";
|
|
position: fixed;
|
|
inset: 0;
|
|
background-image: linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
|
background-size: 48px 48px;
|
|
mask-image: radial-gradient(100% 70% at 50% 0%, #000 0%, transparent 75%);
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
|
|
.wrap {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 100%;
|
|
max-width: 780px;
|
|
padding: 0 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
}
|
|
|
|
/* ---- logo + nav ---- */
|
|
.topbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 26px 0 8px;
|
|
}
|
|
.wordmark {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
font-weight: 700;
|
|
font-size: 1.25rem;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
.wordmark .glyph {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 32px; height: 32px;
|
|
border-radius: 9px;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
color: white;
|
|
font-size: 1rem;
|
|
box-shadow: 0 6px 16px -4px rgba(99, 102, 241, 0.6);
|
|
}
|
|
.pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
font-size: 0.78rem;
|
|
color: var(--muted);
|
|
padding: 6px 12px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255, 255, 255, 0.03);
|
|
font-family: var(--mono);
|
|
}
|
|
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted-2); }
|
|
.pill .dot.online { background: var(--ok); box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.18); }
|
|
.pill .dot.offline { background: var(--err); box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.18); }
|
|
|
|
/* ---- hero ---- */
|
|
.masthead { padding: 46px 0 34px; text-align: center; }
|
|
.headline {
|
|
font-size: clamp(2rem, 5vw, 3rem);
|
|
font-weight: 700;
|
|
letter-spacing: -0.025em;
|
|
line-height: 1.08;
|
|
background: linear-gradient(180deg, #ffffff 30%, #b7bce0 100%);
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
max-width: 22ch;
|
|
margin: 0 auto;
|
|
}
|
|
.subline {
|
|
color: var(--muted);
|
|
font-size: 1.02rem;
|
|
line-height: 1.55;
|
|
max-width: 42ch;
|
|
margin: 16px auto 0;
|
|
}
|
|
.trust {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 18px;
|
|
color: var(--muted-2);
|
|
font-size: 0.82rem;
|
|
}
|
|
.trust svg { color: var(--accent-2); }
|
|
|
|
/* ---- drop zone ---- */
|
|
.drop {
|
|
position: relative;
|
|
border: 1.5px dashed var(--line-strong);
|
|
border-radius: var(--radius);
|
|
background: var(--bg-card);
|
|
backdrop-filter: blur(14px);
|
|
-webkit-backdrop-filter: blur(14px);
|
|
padding: 44px 26px 40px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
outline: none;
|
|
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s, background 0.2s;
|
|
}
|
|
.drop::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: var(--radius);
|
|
padding: 1.5px;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
-webkit-mask-composite: xor;
|
|
mask-composite: exclude;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
pointer-events: none;
|
|
}
|
|
.drop:hover { border-color: transparent; }
|
|
.drop:hover::before { opacity: 1; }
|
|
.drop:focus-visible { box-shadow: 0 0 0 4px var(--accent-soft); }
|
|
.drop.over { border-color: transparent; transform: translateY(-2px); background: rgba(139, 92, 246, 0.08); }
|
|
.drop.over::before { opacity: 1; }
|
|
.drop input { display: none; }
|
|
|
|
.drop-visual {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 64px; height: 64px;
|
|
margin: 0 auto 20px;
|
|
border-radius: 16px;
|
|
background: var(--accent-soft);
|
|
color: var(--accent-2);
|
|
}
|
|
.drop h1 { font-size: 1.32rem; font-weight: 650; letter-spacing: -0.01em; }
|
|
.drop-note { color: var(--muted); margin-top: 11px; font-size: 0.92rem; line-height: 1.5; }
|
|
.drop-note .browse { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
|
|
.drop-note code {
|
|
font-family: var(--mono);
|
|
color: var(--text);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
padding: 2px 7px;
|
|
border-radius: 6px;
|
|
font-size: 0.8em;
|
|
}
|
|
.drop-err {
|
|
margin-top: 16px;
|
|
color: var(--err);
|
|
font-size: 0.88rem;
|
|
min-height: 1.1em;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
}
|
|
.drop-err.show { opacity: 1; }
|
|
|
|
/* ---- status card ---- */
|
|
.status {
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
background: var(--bg-card);
|
|
backdrop-filter: blur(14px);
|
|
-webkit-backdrop-filter: blur(14px);
|
|
padding: 28px 26px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.status-top { display: flex; align-items: center; gap: 22px; }
|
|
.book-meta { flex: 1; min-width: 0; }
|
|
.eyebrow { font-family: var(--mono); font-size: 0.7rem; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent-2); }
|
|
.bookname { display: block; font-family: var(--mono); font-weight: 600; font-size: 1rem; margin-top: 8px; word-break: break-all; line-height: 1.35; }
|
|
.chapter { display: block; font-family: var(--mono); font-size: 0.82rem; color: var(--muted); margin-top: 6px; min-height: 1.1em; }
|
|
|
|
.book-block {
|
|
position: relative; flex: 0 0 auto; width: 96px; height: 128px;
|
|
border-radius: 8px;
|
|
background: linear-gradient(135deg, #1c1f33, #141627);
|
|
border: 1px solid var(--line-strong);
|
|
box-shadow: var(--shadow-soft), inset 0 0 0 1px rgba(255,255,255,0.03);
|
|
overflow: hidden;
|
|
}
|
|
.book-block::before {
|
|
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
|
|
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
|
}
|
|
.book-glow {
|
|
position: absolute; inset: 0;
|
|
background: radial-gradient(120% 80% at 50% 0%, var(--accent-soft), transparent 70%);
|
|
opacity: 0.5;
|
|
}
|
|
.progress-track {
|
|
position: absolute; left: 0; right: 0; bottom: 0; height: 4px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
.progress-fill {
|
|
height: 100%; width: 0%;
|
|
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
|
box-shadow: 0 0 10px rgba(139, 92, 246, 0.7);
|
|
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.status-line { margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--line); color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
|
|
|
|
.actions { margin-top: 18px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 24px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid transparent;
|
|
font-size: 0.94rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
font-family: var(--sans);
|
|
min-height: 44px;
|
|
transition: transform 0.15s, background 0.15s, border-color 0.15s, opacity 0.15s;
|
|
}
|
|
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
.btn:active { transform: translateY(1px); }
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
color: white;
|
|
box-shadow: 0 8px 20px -8px rgba(99, 102, 241, 0.7);
|
|
}
|
|
.btn-primary:hover { filter: brightness(1.08); }
|
|
.btn-ghost {
|
|
background: transparent;
|
|
color: var(--muted);
|
|
border-color: var(--line-strong);
|
|
}
|
|
.btn-ghost:hover { color: var(--text); border-color: #5b617a; background: rgba(255,255,255,0.03); }
|
|
.err { color: var(--err); font-size: 0.9rem; }
|
|
|
|
/* ---- footer ---- */
|
|
footer {
|
|
width: 100%;
|
|
max-width: 780px;
|
|
padding: 22px 20px 30px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
color: var(--muted-2);
|
|
font-size: 0.78rem;
|
|
}
|
|
footer .mono { font-family: var(--mono); color: var(--muted); }
|
|
footer .version { color: var(--muted); }
|
|
|
|
@media (max-width: 560px) {
|
|
.topbar { padding: 20px 0 4px; }
|
|
.masthead { padding: 34px 0 26px; }
|
|
.status-top { flex-direction: column; align-items: stretch; }
|
|
.book-block { margin: 0 auto; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* { transition: none !important; animation: none !important; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="topbar">
|
|
<div class="wordmark">
|
|
<span class="glyph">❦</span> Bindery
|
|
</div>
|
|
<span class="pill" id="health-pill">
|
|
<span class="dot" id="health-dot"></span>
|
|
<span id="health-state">checking…</span>
|
|
</span>
|
|
</div>
|
|
|
|
<header class="masthead">
|
|
<h1 class="headline">Turn any document into a typeset PDF</h1>
|
|
<p class="subline">Drop an EPUB, Markdown, DOCX, or 20+ other formats — get a clean, self-hosted PDF back in seconds. Your files never leave this machine.</p>
|
|
<span class="trust">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
Local & private · nothing leaves your machine
|
|
</span>
|
|
</header>
|
|
|
|
<main>
|
|
<section id="drop" class="drop" role="button" tabindex="0" aria-label="Upload a document">
|
|
<div class="drop-visual" aria-hidden="true">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="m7 8 5-5 5 5"/><path d="M5 21h14"/></svg>
|
|
</div>
|
|
<h1>Drop a file to set it in type</h1>
|
|
<p class="drop-note">or <span class="browse">browse your files</span> · up to <span id="maxmb">400</span> MB · <code>.epub .docx .odt .md .html .txt .csv .svg .png .pdf</code></p>
|
|
<p id="droperr" class="drop-err" role="alert"></p>
|
|
<input type="file" id="file" accept=".epub,.html,.htm,.txt,.log,.md,.markdown,.csv,.tsv,.json,.xml,.svg,.png,.jpg,.jpeg,.webp,.tiff,.bmp,.pdf,.docx,.odt,.rtf,.tex,.latex,.org,.adoc,.rst">
|
|
</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="book-glow"></div>
|
|
<div class="progress-track"><div class="progress-fill" id="pages"></div></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>
|
|
</div>
|
|
|
|
<footer>
|
|
<span>Bindery · EPUB → PDF · WeasyPress engine</span>
|
|
<span class="version">v<span id="foot">—</span></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 chapterEl = document.getElementById('chapter');
|
|
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 healthDot = document.getElementById('health-dot');
|
|
var healthState = document.getElementById('health-state');
|
|
var timer = null, errTimer = null;
|
|
|
|
fetch('/health').then(function (r) { return r.json(); })
|
|
.then(function (d) {
|
|
healthDot.classList.add(d.status === 'ok' ? 'online' : 'offline');
|
|
healthState.textContent = d.status === 'ok' ? 'Ready' : 'Degraded';
|
|
var maxmb = document.getElementById('maxmb');
|
|
if (maxmb && d.max_upload_mb != null) maxmb.textContent = d.max_upload_mb;
|
|
if (d.version) foot.textContent = 'v' + d.version;
|
|
})
|
|
.catch(function () {
|
|
healthDot.classList.add('offline');
|
|
healthState.textContent = 'Offline';
|
|
foot.textContent = 'v';
|
|
});
|
|
|
|
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.width = '0%';
|
|
chapterEl.textContent = '';
|
|
stateEl.textContent = 'Setting in type';
|
|
msg.textContent = '';
|
|
errtext.hidden = true;
|
|
download.hidden = true;
|
|
again.hidden = true;
|
|
hideStatus();
|
|
}
|
|
|
|
function start(f) {
|
|
var OKEXT = /\.(epub|html|htm|txt|log|md|markdown|csv|tsv|json|xml|svg|png|jpe?g|webp|tiff|bmp|pdf|docx|odt|rtf|tex|latex|org|adoc|rst)$/i;
|
|
if (!OKEXT.test(f.name)) { flashError('Unsupported file type — accepted: .epub .html .htm .txt .log .md .markdown .csv .tsv .json .xml .svg .png .jpg .jpeg .webp .tiff .bmp .pdf .docx .odt .rtf .tex .latex .org .adoc .rst'); return; }
|
|
var form = new FormData();
|
|
form.append('file', f);
|
|
showStatus();
|
|
bookname.textContent = f.name;
|
|
stateEl.textContent = 'Sending to the press';
|
|
chapterEl.textContent = '';
|
|
msg.textContent = 'Uploading…';
|
|
pages.style.width = '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.width = p + '%';
|
|
var m = (j.message || '').match(/\((\d+)\/(\d+)\)/);
|
|
chapterEl.textContent = m ? ('step ' + 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.width = '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>
|