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 \`
Page \${i + 1}
⠿
Scan
\`; }).join(""); container.querySelectorAll("textarea").forEach(t => { t.addEventListener("input", e => { pages[+e.target.dataset.index].markdown = e.target.value; }); t.addEventListener("paste", e => { const text = e.clipboardData?.getData("text"); if (!text || !text.includes("---")) return; e.preventDefault(); parseAndDistribute(text, +t.dataset.index); }); }); container.querySelectorAll('[data-action="remove"]').forEach(b => { b.addEventListener("click", e => { const p = pages[+e.target.dataset.index]; if (p && p.objectUrl) URL.revokeObjectURL(p.objectUrl); pages.splice(+e.target.dataset.index, 1); render(); }); }); container.querySelectorAll('input[type="file"]').forEach(f => { f.addEventListener("change", e => { const files = Array.from(e.target.files); if (!files.length) return; addFiles(files, +e.target.dataset.index); }); }); container.querySelectorAll(".page-card.filled").forEach(card => { card.addEventListener("dragstart", e => { draggedIndex = +card.dataset.index; card.classList.add("dragging"); }); card.addEventListener("dragend", () => { card.classList.remove("dragging"); draggedIndex = null; }); card.addEventListener("dragover", e => e.preventDefault()); card.addEventListener("drop", e => { e.preventDefault(); const targetIndex = +card.dataset.index; if (draggedIndex === null || draggedIndex === targetIndex) return; const [moved] = pages.splice(draggedIndex, 1); pages.splice(targetIndex, 0, moved); render(); }); }); container.querySelectorAll(".page-card.empty").forEach(zone => { zone.addEventListener("dragover", e => { e.preventDefault(); zone.classList.add("dragover"); }); zone.addEventListener("dragleave", () => zone.classList.remove("dragover")); zone.addEventListener("drop", e => { e.preventDefault(); zone.classList.remove("dragover"); const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith("image/") || f.name.endsWith(".svg")); if (!files.length) return; addFiles(files, +zone.dataset.index); }); }); } window.addEventListener("paste", e => { const items = e.clipboardData?.items; if (!items) return; const files = []; for (const item of items) { if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) files.push(file); } } if (files.length) { const emptyIndex = pages.findIndex(p => !hasImage(p)); addFiles(files, emptyIndex === -1 ? pages.length : emptyIndex); } }); document.getElementById("copyPrompt").addEventListener("click", async () => { const btn = document.getElementById("copyPrompt"); if (btn.dataset.busy) return; btn.dataset.busy = "1"; const text = document.getElementById("promptText").textContent; try { await navigator.clipboard.writeText(text); btn.innerHTML = ' Copied'; setTimeout(() => { btn.innerHTML = ' Copy prompt'; delete btn.dataset.busy; }, 1500); } catch { toast("Copy failed"); delete btn.dataset.busy; } }); document.getElementById("save").addEventListener("click", async () => { const title = document.getElementById("title").value; const date = document.getElementById("date").value; const filledPages = pages.filter(p => hasImage(p) || p.markdown.trim()); if (!title || !date || filledPages.length === 0) { const missing = []; if (!title) missing.push("title"); if (!date) missing.push("date"); if (filledPages.length === 0) missing.push("at least one page"); const last = missing.pop(); const pretty = missing.length ? missing.join(", ") + ", and " + last : last; toast("Missing " + pretty); return; } const saveBtn = document.getElementById("save"); saveBtn.disabled = true; const originalLabel = saveBtn.textContent; const controller = new AbortController(); // Abort in-flight uploads if the page starts to unload window.addEventListener("pagehide", () => controller.abort(), { once: true }); try { // Upload any staged files to R2 in parallel saveBtn.textContent = "Uploading..."; const uploaded = await Promise.all(filledPages.map(async (p) => { if (p.file) { const key = await uploadFile(p.file, controller.signal); return { markdown: p.markdown, image_key: key }; } return { markdown: p.markdown, image_key: p.image_key }; })); saveBtn.textContent = "Saving..."; const method = DOC ? "PUT" : "POST"; const url = DOC ? "/api/docs/" + DOC.id : "/api/docs"; const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, date, pages: uploaded }), signal: controller.signal, }); const data = await res.json(); if (data.success) { toast("Saved!"); setTimeout(() => location.href = data.url, 800); } else { toast(data.error || "Save failed"); saveBtn.disabled = false; saveBtn.textContent = originalLabel; } } catch (err) { if (!controller.signal.aborted) { toast(err?.message || "Something went wrong"); saveBtn.disabled = false; saveBtn.textContent = originalLabel; } } }); if (pages.length === 0) pages.push({ markdown: "", image_key: "", file: null, objectUrl: "" }); render(); `; } export function editorHtml(publicUrl: string, doc?: DocumentData): string { const docJson = doc ? JSON.stringify(doc) : "null"; const viewLink = doc ? `View` : ""; return ` ${doc ? "~ Edit Document" : "~ New Document"}
Sharepoint
${viewLink}

${doc ? "Edit Document" : "New Document"}

Pages

`; }