import type { DocumentData } from "./render"; const FONTS_URL = "https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"; const CSS = ` :root{ --paper:#F3EFE6; --paper-2:#FBF9F3; --ink:#1C1B19; --ink-soft:#37342F; --muted:#77726A; --rule:#D9D2C5; --rule-soft:#E7E1D5; --blue:#2559F4; --blue-hover:#1242D4; --radius:10px; } *{ box-sizing:border-box; } body{ margin:0; background:var(--paper); color:var(--ink-soft); font-family:"Inter",system-ui,sans-serif; font-size:15px; line-height:1.5; } .topbar{ position:sticky; top:0; z-index:10; display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 28px; background:color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter:saturate(120%) blur(8px); border-bottom:1px solid var(--rule-soft); } .brand{ display:inline-flex; align-items:center; gap:9px; font-family:"Newsreader",Georgia,serif; font-weight:600; font-size:1.2rem; color:var(--ink); text-decoration:none; letter-spacing:-0.01em; } .brand svg{ display:block; } .actions{ display:flex; align-items:center; gap:6px; } .icon-btn{ width:38px; height:38px; display:inline-flex; align-items:center; justify-content:center; border:0; border-radius:6px; background:transparent; color:var(--muted); cursor:pointer; transition:background .15s ease, color .15s ease; } .icon-btn:hover{ background:rgba(0,0,0,.05); color:var(--ink); } :focus-visible{ outline:2px solid var(--blue); outline-offset:2px; border-radius:6px; } .container{ max-width:900px; margin:0 auto; padding:48px 32px 140px; } h1{ font-family:"Newsreader",Georgia,serif; font-weight:500; font-size:2.2rem; color:var(--ink); margin:0 0 32px; letter-spacing:-0.01em; } .meta-grid{ display:grid; grid-template-columns:2fr 1fr; gap:16px; margin-bottom:48px; } .field{ margin-bottom:0; } label{ display:block; font-weight:500; font-size:.85rem; color:var(--muted); margin-bottom:8px; text-transform:uppercase; letter-spacing:.04em; } input[type="text"], input[type="date"], textarea{ width:100%; padding:12px 16px; border:1px solid var(--rule); border-radius:var(--radius); background:var(--paper-2); font:inherit; color:var(--ink); transition:border-color .15s ease; } input[type="text"]:focus, input[type="date"]:focus, textarea:focus{ outline:none; border-color:var(--ink); } input::placeholder, textarea::placeholder{ color:color-mix(in srgb, var(--muted) 50%, transparent); } input[type="date"]::-webkit-datetime-edit{ color:color-mix(in srgb, var(--muted) 50%, transparent); } input[type="date"]:focus::-webkit-datetime-edit, input[type="date"].has-value::-webkit-datetime-edit{ color:var(--ink); } textarea{ min-height:180px; resize:vertical; font-family:"JetBrains Mono",ui-monospace,monospace; font-size:.85rem; line-height:1.5; } .section-title{ font-family:"Newsreader",Georgia,serif; font-weight:500; font-size:1.4rem; color:var(--ink); margin:0 0 20px; letter-spacing:-0.01em; } .pages{ display:flex; flex-direction:column; gap:24px; margin:0 0 32px; } .page-card{ background:var(--paper-2); border:1px solid var(--rule); border-radius:var(--radius); overflow:hidden; transition:box-shadow .2s ease; } .page-card:hover{ box-shadow:0 2px 12px rgba(30,25,15,.06); } .page-card.dragging{ opacity:.4; } .page-card.empty{ background:var(--paper); border:2px dashed var(--rule); border-radius:var(--radius); padding:48px 32px; display:flex; align-items:center; justify-content:center; cursor:pointer; transition:border-color .2s ease, background .2s ease; } .page-card.empty:hover{ border-color:color-mix(in srgb, var(--ink) 20%, var(--rule)); background:color-mix(in srgb, var(--ink) 3%, var(--paper)); } .page-card.empty.dragover{ border-color:color-mix(in srgb, var(--ink) 25%, var(--rule)); background:color-mix(in srgb, var(--ink) 6%, var(--paper)); } .page-card.empty svg{ width:32px; height:32px; opacity:.4; } .page-card.empty input[type="file"]{ display:none; } .page-card.filled{ display:grid; grid-template-columns:1fr 200px; } .page-card.filled .card-header{ grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; padding:14px 20px; border-bottom:1px solid var(--rule-soft); } .page-card.filled .card-header .page-num{ font-weight:600; font-size:.85rem; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; } .page-card.filled .card-header .header-actions{ display:flex; align-items:center; gap:8px; } .page-card.filled .drag-handle{ cursor:grab; color:var(--muted); font-size:1rem; user-select:none; padding:4px 8px; border-radius:6px; transition:background .15s ease; } .page-card.filled .drag-handle:hover{ background:rgba(0,0,0,.04); } .page-card.filled .drag-handle:active{ cursor:grabbing; } .page-card.filled .remove-btn{ width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; border:0; border-radius:6px; background:transparent; color:var(--muted); cursor:pointer; transition:background .15s ease, color .15s ease; } .page-card.filled .remove-btn:hover{ background:rgba(204,68,68,.08); color:#c44; } .page-card.filled .page-fields{ padding:20px; display:flex; flex-direction:column; gap:16px; } .page-card.filled .page-fields textarea{ min-height:200px; } .page-card.filled .page-preview{ padding:0; display:flex; flex-direction:column; gap:12px; border-left:none; background:none; } .page-card.filled .page-preview img{ width:100%; border-radius:6px; border:1px solid var(--rule); box-shadow:0 1px 1px rgba(30,25,15,.03), 0 8px 20px -18px rgba(40,32,15,.22); } .page-card.filled .page-preview .replace-btn{ font-size:.82rem; color:var(--muted); text-align:center; cursor:pointer; padding:8px; margin-top:4px; border-radius:6px; transition:background .15s ease, color .15s ease; } .page-card.filled .page-preview .replace-btn:hover{ background:rgba(0,0,0,.04); color:var(--ink); } .page-card.filled .page-preview input[type="file"]{ display:none; } .btn{ font:inherit; font-size:.92rem; font-weight:500; border:0; cursor:pointer; border-radius:6px; padding:9px 16px; display:inline-flex; align-items:center; gap:8px; color:#fff; background:var(--ink); transition:filter .15s ease, transform .1s ease, background .2s ease; } .btn:hover{ filter:brightness(1.15); } .btn:active{ transform:scale(.97); } .btn.copied{ background:var(--blue); pointer-events:none; } .btn.copied svg{ transform:scale(1.15); } .btn svg{ display:block; transition:transform .2s ease; } .btn.secondary{ background:var(--muted); } .btn.publish{ background:var(--blue); } .btn.publish:hover{ background:var(--blue-hover); } .btn.ghost{ background:transparent; color:var(--ink); border:0; } .btn.ghost:hover{ background:rgba(28,27,25,.05); } .btn.ghost:active{ background:transparent; } .toast{ position:fixed; left:50%; bottom:28px; transform:translateX(-50%) translateY(12px); background:var(--ink); color:#fff; font-size:.88rem; padding:9px 16px; border-radius:6px; opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease; } .toast.show{ opacity:1; transform:translateX(-50%) translateY(0); } @media (max-width:768px){ .meta-grid{ grid-template-columns:1fr; } .page-card.filled{ grid-template-columns:1fr; } .page-card.filled .page-preview{ border-top:1px solid var(--rule-soft); } .container{ padding:32px 20px 100px; } } `; function clientJs(docJson: string): string { return ` const DOC = ${docJson}; let pages = DOC ? DOC.pages.map(p => ({ markdown: p.markdown, image_key: p.image_key, file: null, objectUrl: "" })) : []; let draggedIndex = null; const titleEl = document.getElementById("title"); titleEl.addEventListener("input", () => { document.title = titleEl.value ? "~ " + titleEl.value : "~ " + (DOC ? "Edit Document" : "New Document"); }); const PLACEHOLDERS = [ "Your greatest literary work...", "What will you put here?", "A descriptive title? or a fanciful title?", "This is a really awesome placeholder title!", "A book not yet written but with a sick title", ]; titleEl.placeholder = PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)]; function toast(msg){ const t = document.getElementById("toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove("show"), 1800); } function syncDateClass(){ const d = document.getElementById("date"); d.classList.toggle("has-value", !!d.value); } document.getElementById("date").addEventListener("input", syncDateClass); function hasImage(p) { return !!(p.image_key || p.objectUrl); } function previewSrc(p) { return p.objectUrl || ("/i/" + p.image_key); } function stageFile(file, index) { if (pages[index].objectUrl) URL.revokeObjectURL(pages[index].objectUrl); pages[index].file = file; pages[index].objectUrl = URL.createObjectURL(file); } async function uploadFile(file, signal) { const fd = new FormData(); fd.append("file", file); let lastErr; for (let attempt = 0; attempt < 3; attempt++) { try { // Timeout guards against a stalled R2 put hanging the save forever. // Combined with the external signal so a page unload still aborts cleanly. const timeout = AbortSignal.timeout(15000); const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; const res = await fetch("/upload", { method: "POST", body: fd, signal: combined }); if (!res.ok) throw new Error("HTTP " + res.status); const data = await res.json(); if (!data.success) throw new Error(data.error || "Upload failed"); return data.key; } catch (err) { // A genuine navigation abort (page leaving) can't be retried — bail. // A timeout leaves the external signal untouched, so we retry it. if (signal?.aborted) throw err; lastErr = err; if (attempt < 2) await new Promise(r => setTimeout(r, 300 * 2 ** attempt)); } } throw lastErr; } function addFiles(files, startIndex) { let index = startIndex; const dateEl = document.getElementById("date"); for (const file of files) { while (index >= pages.length) { pages.push({ markdown: "", image_key: "", file: null, objectUrl: "" }); } while (index < pages.length && hasImage(pages[index])) { index++; while (index >= pages.length) { pages.push({ markdown: "", image_key: "", file: null, objectUrl: "" }); } } stageFile(file, index); // Prefill date from first file's lastModified if (!dateEl.value && file.lastModified) { const d = new Date(file.lastModified); dateEl.value = d.toISOString().slice(0, 10); syncDateClass(); } index++; } if (pages.length === 0 || hasImage(pages[pages.length - 1])) { pages.push({ markdown: "", image_key: "", file: null, objectUrl: "" }); } render(); } function parseAndDistribute(text, startIndex) { let md = text.trim(); const titleEl = document.getElementById("title"); // Extract # Title from the top const titleMatch = md.match(/^#\\s+(.+?)(\\n|$)/); if (titleMatch) { if (!titleEl.value) titleEl.value = titleMatch[1].trim(); md = md.slice(titleMatch[0].length).trim(); } // Split on --- delimiters const sections = md.split(/^---\\s*$/m).map(s => s.trim()).filter(Boolean); // Distribute to existing pages with images let pageIdx = startIndex; for (const section of sections) { // Find the next page with an image starting from pageIdx while (pageIdx < pages.length && !hasImage(pages[pageIdx])) pageIdx++; if (pageIdx >= pages.length) break; // drop extras pages[pageIdx].markdown = section; pageIdx++; } render(); toast("Distributed " + sections.length + " page" + (sections.length > 1 ? "s" : "")); } function render(){ const titleEl = document.getElementById("title"); const dateEl = document.getElementById("date"); // Only prefill from DOC if the fields are empty (first load) if (!titleEl.value && DOC?.title) titleEl.value = DOC.title; if (!dateEl.value && DOC?.date) dateEl.value = DOC.date; syncDateClass(); const container = document.getElementById("pages"); container.innerHTML = pages.map((p, i) => { if (!hasImage(p)) { return \` \`; } return \`