bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

client.js (32625B)


      1 
      2 (function () {
      3   const PLUGIN_ID = "library";
      4   const PDF_CHUNK_BYTES = 256 * 1024;
      5   const TEXT_FILE_MAX_BYTES = 512 * 1024;
      6   const TAG_SUGGESTIONS = ["fic", "fix-it-fic", "journal", "fiction", "fantasy", "sci-fi", "lore", "poetry", "notes", "history"];
      7 
      8   function escapeHtml(text) {
      9     return String(text || "")
     10       .replace(/&/g, "&")
     11       .replace(/</g, "&lt;")
     12       .replace(/>/g, "&gt;")
     13       .replace(/\"/g, "&quot;")
     14       .replace(/'/g, "&#039;");
     15   }
     16 
     17   function formatBytes(n) {
     18     const v = Number(n || 0);
     19     if (!Number.isFinite(v) || v <= 0) return "0 B";
     20     const units = ["B", "KB", "MB", "GB"];
     21     let idx = 0;
     22     let x = v;
     23     while (x >= 1024 && idx < units.length - 1) {
     24       x /= 1024;
     25       idx += 1;
     26     }
     27     return `${x.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
     28   }
     29 
     30   function bytesToBase64(bytes) {
     31     let bin = "";
     32     const chunk = 0x8000;
     33     for (let i = 0; i < bytes.length; i += chunk) {
     34       // eslint-disable-next-line prefer-spread
     35       bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
     36     }
     37     return btoa(bin);
     38   }
     39 
     40   function sleep(ms) {
     41     return new Promise((r) => setTimeout(r, ms));
     42   }
     43 
     44   function normalizeTag(raw) {
     45     return String(raw || "")
     46       .trim()
     47       .toLowerCase()
     48       .replace(/[^a-z0-9 _-]+/g, "")
     49       .replace(/\s+/g, "-")
     50       .replace(/-+/g, "-")
     51       .replace(/^[-_]+|[-_]+$/g, "")
     52       .slice(0, 32);
     53   }
     54 
     55   function parseTags(raw) {
     56     const parts = String(raw || "")
     57       .split(",")
     58       .map((x) => normalizeTag(x))
     59       .filter(Boolean);
     60     return [...new Set(parts)].slice(0, 16);
     61   }
     62 
     63   function paginateText(text, opts = {}) {
     64     const maxLines = Number(opts.maxLines || 40);
     65     const maxChars = Number(opts.maxChars || 2400);
     66     const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
     67     const pages = [];
     68     let buf = "";
     69     let lineCount = 0;
     70     const flush = () => {
     71       pages.push(buf);
     72       buf = "";
     73       lineCount = 0;
     74     };
     75     for (let i = 0; i < lines.length; i++) {
     76       const lineWithNl = i === lines.length - 1 ? lines[i] : `${lines[i]}\n`;
     77       if (lineWithNl.length > maxChars) {
     78         let r = lineWithNl;
     79         while (r.length) {
     80           const take = r.slice(0, maxChars);
     81           r = r.slice(maxChars);
     82           if (buf && (buf.length + take.length > maxChars || lineCount >= maxLines)) flush();
     83           buf += take;
     84           lineCount += 1;
     85           if (buf.length >= maxChars || lineCount >= maxLines) flush();
     86         }
     87         continue;
     88       }
     89       if (buf && (buf.length + lineWithNl.length > maxChars || lineCount + 1 > maxLines)) flush();
     90       buf += lineWithNl;
     91       lineCount += 1;
     92     }
     93     if (buf || !pages.length) pages.push(buf);
     94     return pages;
     95   }
     96 
     97   function ensureStyles() {
     98     if (document.getElementById("bzlLibraryPanelsStyle")) return;
     99     const el = document.createElement("style");
    100     el.id = "bzlLibraryPanelsStyle";
    101     el.textContent = `
    102       .lib3 { color: var(--text, #f3f7ff); display:flex; flex-direction:column; gap:10px; min-height:0; height:100%; flex:1 1 auto; }
    103       .lib3Row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
    104       .lib3 input[type="text"], .lib3 input[type="number"], .lib3 textarea, .lib3 select { background: rgba(255,255,255,0.08); color:#f6e8f0; border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; padding: 7px 9px; }
    105       .lib3Btn { border-radius: 999px; padding: 7px 11px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08); color:#f6e8f0; cursor:pointer; }
    106       .lib3Btn.primary { border:0; background: linear-gradient(180deg, rgba(255,145,50,0.95), rgba(255,90,160,0.95)); color:#1b0a12; font-weight:700; }
    107       .lib3Scroll { overflow:auto; min-height:0; flex:1 1 auto; }
    108       .lib3ReaderViewport { display:flex; flex:1 1 auto; min-height:0; }
    109       .lib3Card { border: 1px solid rgba(255,255,255,0.13); border-radius: 12px; padding: 9px; background: rgba(255,255,255,0.04); margin-bottom:8px; }
    110       .lib3Meta { opacity:.82; font-size:12px; margin-top:3px; }
    111       .lib3Tag { display:inline-block; font-size:11px; border:1px solid rgba(255,255,255,0.18); border-radius:999px; padding:2px 7px; margin-right:4px; margin-top:4px; }
    112       .lib3ReaderPage { white-space:pre-wrap; border:1px solid rgba(255,255,255,0.15); border-radius:10px; padding:10px; overflow:auto; height:100%; background: rgba(0,0,0,0.18); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:13px; }
    113       .lib3Hint { opacity:.78; font-size:12px; }
    114       .lib3Iframe { width:100%; height:100%; border:1px solid rgba(255,255,255,0.12); border-radius:10px; }
    115     `;
    116     document.head.appendChild(el);
    117   }
    118 
    119   window.BzlPluginHost?.register(PLUGIN_ID, (ctx) => {
    120     ensureStyles();
    121 
    122     let wsAttachedTo = null;
    123     let snapshot = { me: "", myShelfId: "", shelves: [] };
    124     let activeShelfId = "";
    125     let readerBookId = "";
    126     let readerPage = 1;
    127     let searchQuery = "";
    128     let searchTag = "";
    129     let statusLine = "";
    130     let uploadInProgress = false;
    131 
    132     const textByBookId = new Map();
    133     const mounts = { library: null, shelf: null, reader: null };
    134 
    135     function setStatus(msg) {
    136       statusLine = String(msg || "");
    137       renderAll();
    138     }
    139 
    140     function ownedShelves() {
    141       return (snapshot.shelves || []).filter((s) => Boolean(s?.isOwner));
    142     }
    143 
    144     function activeShelf() {
    145       return (snapshot.shelves || []).find((s) => String(s?.id || "") === String(activeShelfId || "")) || null;
    146     }
    147 
    148     function defaultShelfId() {
    149       if (snapshot.myShelfId && ownedShelves().some((s) => s.id === snapshot.myShelfId)) return snapshot.myShelfId;
    150       return ownedShelves()[0]?.id || "";
    151     }
    152 
    153     function allShelfEntries() {
    154       const out = [];
    155       for (const shelf of snapshot.shelves || []) {
    156         for (const entry of shelf.items || []) out.push({ shelf, entry, book: entry?.book || null });
    157       }
    158       return out;
    159     }
    160 
    161     function getBookById(bookId) {
    162       const id = String(bookId || "");
    163       if (!id) return null;
    164       for (const x of allShelfEntries()) {
    165         if (String(x.book?.id || "") === id) return x.book;
    166       }
    167       return null;
    168     }
    169 
    170     function activeReaderBook() {
    171       return getBookById(readerBookId);
    172     }
    173 
    174     function requestList() {
    175       ctx.send("list", {});
    176     }
    177 
    178     function requestText(bookId) {
    179       ctx.send("textGet", { id: bookId });
    180     }
    181 
    182     function attachWsListener() {
    183       const ws = window.__bzlWs;
    184       if (!ws || ws.readyState !== WebSocket.OPEN) return;
    185       if (wsAttachedTo === ws) return;
    186       try {
    187         if (wsAttachedTo) wsAttachedTo.removeEventListener("message", onWsMsg);
    188       } catch {}
    189       wsAttachedTo = ws;
    190       ws.addEventListener("message", onWsMsg);
    191     }
    192 
    193     function openInReader(bookId) {
    194       const id = String(bookId || "");
    195       if (!id) return;
    196       readerBookId = id;
    197       readerPage = 1;
    198       const book = getBookById(id);
    199       if (book && String(book.kind || "") === "text" && !textByBookId.has(id)) requestText(id);
    200       renderReader();
    201     }
    202     async function uploadPdfToShelf(file, meta, shelfId) {
    203       if (uploadInProgress) return;
    204       uploadInProgress = true;
    205       window.__bzlLibraryUploadId = "";
    206       ctx.send("uploadStart", {
    207         filename: file.name,
    208         mime: String(file.type || "").trim().toLowerCase(),
    209         size: file.size,
    210         title: meta.title,
    211         author: meta.author,
    212         isOriginal: meta.isOriginal,
    213         tags: meta.tags,
    214         shelfId,
    215       });
    216 
    217       const t0 = Date.now();
    218       while (!window.__bzlLibraryUploadId && Date.now() - t0 < 3000) {
    219         // eslint-disable-next-line no-await-in-loop
    220         await sleep(35);
    221       }
    222       const uploadId = String(window.__bzlLibraryUploadId || "");
    223       if (!uploadId) {
    224         uploadInProgress = false;
    225         setStatus("Upload failed to start.");
    226         return;
    227       }
    228 
    229       let sent = 0;
    230       for (let off = 0; off < file.size; off += PDF_CHUNK_BYTES) {
    231         const slice = file.slice(off, Math.min(file.size, off + PDF_CHUNK_BYTES));
    232         // eslint-disable-next-line no-await-in-loop
    233         const buf = await slice.arrayBuffer();
    234         const bytes = new Uint8Array(buf);
    235         const b64 = bytesToBase64(bytes);
    236         ctx.send("uploadChunk", { uploadId, data: b64 });
    237         sent += bytes.length;
    238         setStatus(`Uploading PDF... ${formatBytes(sent)} / ${formatBytes(file.size)}`);
    239         // eslint-disable-next-line no-await-in-loop
    240         await sleep(0);
    241       }
    242       ctx.send("uploadFinish", { uploadId });
    243     }
    244 
    245     async function createBookFromFile(file, payload) {
    246       const name = String(file?.name || "").toLowerCase();
    247       const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : "";
    248       if (ext === ".pdf" || String(file.type || "").toLowerCase() === "application/pdf") {
    249         await uploadPdfToShelf(file, payload, payload.shelfId);
    250         return;
    251       }
    252       if (![".txt", ".md", ".rtf"].includes(ext)) {
    253         setStatus("Supported upload types: PDF, TXT, MD, RTF.");
    254         return;
    255       }
    256       if (file.size > TEXT_FILE_MAX_BYTES) {
    257         setStatus(`Text/RTF too large. Max is ${formatBytes(TEXT_FILE_MAX_BYTES)}.`);
    258         return;
    259       }
    260       const text = await file.text();
    261       ctx.send("textCreate", {
    262         shelfId: payload.shelfId,
    263         title: payload.title,
    264         author: payload.author,
    265         isOriginal: payload.isOriginal,
    266         tags: payload.tags,
    267         format: ext === ".rtf" ? "rtf" : "text",
    268         text,
    269       });
    270       setStatus("Book created.");
    271     }
    272 
    273     function filteredBrowseRows() {
    274       const q = String(searchQuery || "").trim().toLowerCase();
    275       const tag = String(searchTag || "").trim().toLowerCase();
    276       return allShelfEntries().filter((x) => {
    277         const book = x.book || {};
    278         if (!book.id) return false;
    279         if (q) {
    280           const hay = `${book.title || ""} ${book.author || ""} ${(book.tags || []).join(" ")} ${x.shelf?.name || ""}`.toLowerCase();
    281           if (!hay.includes(q)) return false;
    282         }
    283         if (tag) {
    284           const tags = Array.isArray(book.tags) ? book.tags.map((t) => String(t || "").toLowerCase()) : [];
    285           if (!tags.includes(tag)) return false;
    286         }
    287         return true;
    288       });
    289     }
    290 
    291     function uniqueTags() {
    292       const out = new Set();
    293       for (const x of allShelfEntries()) {
    294         for (const t of x?.book?.tags || []) out.add(String(t || "").toLowerCase());
    295       }
    296       return [...out].filter(Boolean).sort();
    297     }
    298 
    299     function renderLibrary() {
    300       const mount = mounts.library;
    301       if (!(mount instanceof HTMLElement)) return;
    302       const rows = filteredBrowseRows();
    303       const tags = uniqueTags();
    304       const targetShelfId = defaultShelfId();
    305       const shelfOpt = ownedShelves().map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === targetShelfId ? "selected" : ""}>${escapeHtml(s.name)}</option>`).join("");
    306 
    307       mount.innerHTML = `
    308         <div class="lib3">
    309           <div class="lib3Row">
    310             <input id="lib3Search" type="text" placeholder="Search title, author, tags" value="${escapeHtml(searchQuery)}" style="flex:1 1 220px;" />
    311             <select id="lib3TagFilter"><option value="">All tags</option>${tags.map((t) => `<option value="${escapeHtml(t)}" ${t === searchTag ? "selected" : ""}>${escapeHtml(t)}</option>`).join("")}</select>
    312             <button type="button" class="lib3Btn" id="lib3Refresh">Refresh</button>
    313           </div>
    314           <div class="lib3Row"><span class="lib3Hint">Browse shelves and discover books. Choose one of your shelves for pin/check out:</span>
    315             <select id="lib3LibraryTargetShelf">${shelfOpt || "<option value=''>No owned shelf</option>"}</select>
    316           </div>
    317           <div class="lib3Scroll">
    318             ${rows.map((x) => {
    319               const b = x.book || {};
    320               const s = x.shelf || {};
    321               const tagsHtml = (b.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
    322               return `<div class="lib3Card">
    323                 <div><b>${escapeHtml(b.title || "Untitled")}</b> ${x.entry?.kind === "checkout" ? "<span title='Checked out'>↩</span>" : ""}</div>
    324                 <div class="lib3Meta">by ${escapeHtml(b.author || b.createdBy || "Unknown")} | shelf ${escapeHtml(s.name || "Shelf")}${s.owner ? ` · @${escapeHtml(s.owner)}` : ""} | ${formatBytes(b.bytes)} | pins ${Number(b?.popularity?.pins || 0)} | checkouts ${Number(b?.popularity?.checkouts || 0)}</div>
    325                 <div>${tagsHtml}</div>
    326                 <div class="lib3Row" style="margin-top:6px;">
    327                   <button type="button" class="lib3Btn primary" data-open-book="${escapeHtml(b.id)}">Read</button>
    328                   <button type="button" class="lib3Btn" data-pin-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(s.id || "")}">Pin</button>
    329                   <button type="button" class="lib3Btn" data-checkout-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(s.id || "")}">Check out</button>
    330                   <button type="button" class="lib3Btn" data-wander-shelf="${escapeHtml(s.id || "")}">Wander shelf</button>
    331                 </div>
    332               </div>`;
    333             }).join("") || "<div class='lib3Hint'>No matches.</div>"}
    334           </div>
    335           <div class="lib3Hint">Wander shelves and open a book in Reader.</div>
    336         </div>
    337       `;
    338 
    339       mount.querySelector("#lib3Search")?.addEventListener("input", (e) => {
    340         searchQuery = String(e?.target?.value || "");
    341         renderLibrary();
    342       });
    343       mount.querySelector("#lib3TagFilter")?.addEventListener("change", (e) => {
    344         searchTag = String(e?.target?.value || "").toLowerCase();
    345         renderLibrary();
    346       });
    347       mount.querySelector("#lib3Refresh")?.addEventListener("click", () => requestList());
    348       mount.querySelectorAll("[data-open-book]").forEach((btn) => btn.addEventListener("click", () => openInReader(btn.getAttribute("data-open-book") || "")));
    349       mount.querySelectorAll("[data-wander-shelf]").forEach((btn) => {
    350         btn.addEventListener("click", () => {
    351           activeShelfId = String(btn.getAttribute("data-wander-shelf") || "");
    352           renderShelf();
    353         });
    354       });
    355       mount.querySelectorAll("[data-pin-book]").forEach((btn) => {
    356         btn.addEventListener("click", () => {
    357           const shelfId = String(mount.querySelector("#lib3LibraryTargetShelf")?.value || "");
    358           const bookId = String(btn.getAttribute("data-pin-book") || "");
    359           const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
    360           if (!shelfId || !bookId) return setStatus("Pick one of your shelves first.");
    361           ctx.send("pinBook", { shelfId, bookId, sourceShelfId });
    362           setStatus("Pinned.");
    363         });
    364       });
    365       mount.querySelectorAll("[data-checkout-book]").forEach((btn) => {
    366         btn.addEventListener("click", () => {
    367           const targetShelfId = String(mount.querySelector("#lib3LibraryTargetShelf")?.value || "");
    368           const sourceBookId = String(btn.getAttribute("data-checkout-book") || "");
    369           const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
    370           if (!targetShelfId || !sourceBookId) return setStatus("Pick one of your shelves first.");
    371           ctx.send("checkoutBook", { targetShelfId, sourceBookId, sourceShelfId });
    372           setStatus("Checked out.");
    373         });
    374       });
    375     }
    376     function renderShelf() {
    377       const mount = mounts.shelf;
    378       if (!(mount instanceof HTMLElement)) return;
    379       const mine = ownedShelves();
    380       if (!activeShelfId || !(snapshot.shelves || []).some((s) => s.id === activeShelfId)) activeShelfId = snapshot.myShelfId || snapshot.shelves?.[0]?.id || "";
    381       const shelf = activeShelf();
    382       const shelfList = (snapshot.shelves || []).map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === activeShelfId ? "selected" : ""}>${escapeHtml(s.name)}${s.owner ? ` (@${escapeHtml(s.owner)})` : ""}</option>`).join("");
    383       const mineDefault = defaultShelfId();
    384       const mineList = mine.map((s) => `<option value="${escapeHtml(s.id)}" ${s.id === mineDefault ? "selected" : ""}>${escapeHtml(s.name)}</option>`).join("");
    385 
    386       mount.innerHTML = `
    387         <div class="lib3">
    388           <div class="lib3Row">
    389             <select id="lib3ShelfPick" style="min-width:220px;">${shelfList || "<option>No shelves</option>"}</select>
    390             ${shelf && !shelf.isOwner ? `<button type="button" class="lib3Btn" id="lib3SubBtn">${shelf.isSubscribed ? "Unsubscribe" : "Subscribe"}</button>` : ""}
    391             <button type="button" class="lib3Btn" id="lib3RefreshShelf">Refresh</button>
    392           </div>
    393           <div class="lib3Card">
    394             <div class="lib3Row">
    395               <input id="lib3NewShelfName" type="text" placeholder="New shelf name" style="flex:1 1 180px;" />
    396               <input id="lib3NewShelfDesc" type="text" placeholder="Shelf description" style="flex:2 1 220px;" />
    397               <button type="button" class="lib3Btn" id="lib3CreateShelf">Create shelf</button>
    398             </div>
    399           </div>
    400           <div class="lib3Card">
    401             <div class="lib3Row"><b>Add Book</b> <span class="lib3Hint">(PDF, TXT, MD, RTF)</span></div>
    402             <div class="lib3Row">
    403               <select id="lib3CreateTargetShelf">${mineList || "<option value=''>No owned shelves</option>"}</select>
    404               <input id="lib3BookTitle" type="text" placeholder="Book name" style="flex:1 1 180px;" />
    405               <input id="lib3BookAuthor" type="text" placeholder="Author" style="flex:1 1 160px;" value="${escapeHtml(snapshot.me || "")}" />
    406               <label class="lib3Hint"><input id="lib3BookOriginal" type="checkbox" checked/> original</label>
    407             </div>
    408             <div class="lib3Row">
    409               <input id="lib3BookTags" type="text" placeholder="tags: fic, fantasy, journal" style="flex:1 1 260px;" />
    410               <select id="lib3TagQuick"><option value="">Tag quick-add</option>${TAG_SUGGESTIONS.map((t) => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join("")}</select>
    411             </div>
    412             <div class="lib3Row">
    413               <input id="lib3BookFile" type="file" accept=".pdf,.txt,.md,.rtf,application/pdf,text/plain,application/rtf,text/rtf" />
    414               <button type="button" class="lib3Btn primary" id="lib3UploadBook">Upload File</button>
    415             </div>
    416             <div class="lib3Row">
    417               <select id="lib3TextFormat"><option value="text">Text</option><option value="rtf">Rich text (RTF)</option></select>
    418               <textarea id="lib3BookText" rows="5" placeholder="Or create a book directly" style="width:100%; box-sizing:border-box;"></textarea>
    419               <button type="button" class="lib3Btn" id="lib3CreateText">Create Text Book</button>
    420             </div>
    421           </div>
    422           <div class="lib3Scroll">
    423             ${(shelf?.items || []).map((x) => {
    424               const b = x.book || {};
    425               const tHtml = (b.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
    426               return `<div class="lib3Card">
    427                 <div><b>${escapeHtml(b.title || "Untitled")}</b> ${x.kind === "checkout" ? "<span title='Checked out'>↩</span>" : ""}</div>
    428                 <div class="lib3Meta">by ${escapeHtml(b.author || b.createdBy || "Unknown")} | ${String(b.kind || "").toUpperCase()} | ${formatBytes(b.bytes)} | original: ${b.isOriginal !== false ? "yes" : "no"}</div>
    429                 <div>${tHtml}</div>
    430                 <div class="lib3Row" style="margin-top:6px;">
    431                   <button type="button" class="lib3Btn primary" data-open-book="${escapeHtml(b.id)}">Read</button>
    432                   ${x.canReturn ? `<button type="button" class="lib3Btn" data-return-item="${escapeHtml(x.id)}">Return</button>` : ""}
    433                   ${x.canRemoveItem ? `<button type="button" class="lib3Btn" data-remove-item="${escapeHtml(x.id)}">Remove</button>` : ""}
    434                   ${b.canDeleteBook ? `<button type="button" class="lib3Btn" data-delete-book="${escapeHtml(b.id)}">Delete book</button>` : ""}
    435                   <button type="button" class="lib3Btn" data-checkout-book="${escapeHtml(b.id)}" data-source-shelf="${escapeHtml(shelf?.id || "")}">Check out</button>
    436                 </div>
    437               </div>`;
    438             }).join("") || "<div class='lib3Hint'>No books on this shelf.</div>"}
    439           </div>
    440           <div class="lib3Hint">${escapeHtml(statusLine)}</div>
    441         </div>
    442       `;
    443 
    444       mount.querySelector("#lib3ShelfPick")?.addEventListener("change", (e) => {
    445         activeShelfId = String(e?.target?.value || "");
    446         renderShelf();
    447       });
    448       mount.querySelector("#lib3RefreshShelf")?.addEventListener("click", () => requestList());
    449       mount.querySelector("#lib3SubBtn")?.addEventListener("click", () => {
    450         if (!shelf || shelf.isOwner) return;
    451         ctx.send("shelfSubscribe", { shelfId: shelf.id, subscribe: !shelf.isSubscribed });
    452       });
    453       mount.querySelector("#lib3CreateShelf")?.addEventListener("click", () => {
    454         const name = String(mount.querySelector("#lib3NewShelfName")?.value || "").trim();
    455         const description = String(mount.querySelector("#lib3NewShelfDesc")?.value || "").trim();
    456         if (!name) return setStatus("Shelf name required.");
    457         ctx.send("shelfCreate", { name, description, isPublic: true });
    458         setStatus("Shelf created.");
    459       });
    460       mount.querySelector("#lib3TagQuick")?.addEventListener("change", (e) => {
    461         const v = String(e?.target?.value || "").trim();
    462         if (!v) return;
    463         const inp = mount.querySelector("#lib3BookTags");
    464         const next = parseTags(`${String(inp?.value || "")}, ${v}`);
    465         if (inp) inp.value = next.join(", ");
    466         e.target.value = "";
    467       });
    468       mount.querySelector("#lib3UploadBook")?.addEventListener("click", async () => {
    469         const shelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
    470         const title = String(mount.querySelector("#lib3BookTitle")?.value || "").trim();
    471         const author = String(mount.querySelector("#lib3BookAuthor")?.value || "").trim();
    472         const isOriginal = Boolean(mount.querySelector("#lib3BookOriginal")?.checked);
    473         const tags = parseTags(String(mount.querySelector("#lib3BookTags")?.value || ""));
    474         const file = mount.querySelector("#lib3BookFile")?.files?.[0];
    475         if (!shelfId) return setStatus("Pick one of your shelves.");
    476         if (!file) return setStatus("Choose a file first.");
    477         try {
    478           await createBookFromFile(file, { shelfId, title: title || file.name, author: author || snapshot.me || "Unknown", isOriginal, tags });
    479         } catch {
    480           setStatus("Failed to create book from file.");
    481         }
    482       });
    483       mount.querySelector("#lib3CreateText")?.addEventListener("click", () => {
    484         const shelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
    485         const title = String(mount.querySelector("#lib3BookTitle")?.value || "").trim();
    486         const author = String(mount.querySelector("#lib3BookAuthor")?.value || "").trim();
    487         const isOriginal = Boolean(mount.querySelector("#lib3BookOriginal")?.checked);
    488         const tags = parseTags(String(mount.querySelector("#lib3BookTags")?.value || ""));
    489         const format = String(mount.querySelector("#lib3TextFormat")?.value || "text").toLowerCase() === "rtf" ? "rtf" : "text";
    490         const text = String(mount.querySelector("#lib3BookText")?.value || "");
    491         if (!shelfId) return setStatus("Pick one of your shelves.");
    492         if (!title) return setStatus("Book name required.");
    493         ctx.send("textCreate", { shelfId, title, author: author || snapshot.me || "Unknown", isOriginal, tags, format, text });
    494         setStatus("Book created.");
    495       });
    496       mount.querySelectorAll("[data-open-book]").forEach((btn) => btn.addEventListener("click", () => openInReader(btn.getAttribute("data-open-book") || "")));
    497       mount.querySelectorAll("[data-checkout-book]").forEach((btn) => {
    498         btn.addEventListener("click", () => {
    499           const targetShelfId = String(mount.querySelector("#lib3CreateTargetShelf")?.value || "");
    500           const sourceBookId = String(btn.getAttribute("data-checkout-book") || "");
    501           const sourceShelfId = String(btn.getAttribute("data-source-shelf") || "");
    502           if (!targetShelfId || !sourceBookId) return;
    503           ctx.send("checkoutBook", { targetShelfId, sourceBookId, sourceShelfId });
    504           setStatus("Checked out.");
    505         });
    506       });
    507       mount.querySelectorAll("[data-remove-item]").forEach((btn) => {
    508         btn.addEventListener("click", () => {
    509           if (!shelf) return;
    510           const shelfItemId = String(btn.getAttribute("data-remove-item") || "");
    511           if (!shelfItemId) return;
    512           ctx.send("shelfItemRemove", { shelfId: shelf.id, shelfItemId });
    513         });
    514       });
    515       mount.querySelectorAll("[data-return-item]").forEach((btn) => {
    516         btn.addEventListener("click", () => {
    517           if (!shelf) return;
    518           const shelfItemId = String(btn.getAttribute("data-return-item") || "");
    519           if (!shelfItemId) return;
    520           ctx.send("returnBook", { shelfId: shelf.id, shelfItemId });
    521         });
    522       });
    523       mount.querySelectorAll("[data-delete-book]").forEach((btn) => {
    524         btn.addEventListener("click", () => {
    525           const bookId = String(btn.getAttribute("data-delete-book") || "");
    526           if (!bookId) return;
    527           ctx.send("delete", { id: bookId });
    528         });
    529       });
    530     }
    531 
    532     function renderReader() {
    533       const mount = mounts.reader;
    534       if (!(mount instanceof HTMLElement)) return;
    535       const book = activeReaderBook();
    536       if (!book) {
    537         mount.innerHTML = `<div class="lib3"><div class="lib3Hint">Select a book from Shelf or Library to start reading.</div></div>`;
    538         return;
    539       }
    540 
    541       const isText = String(book.kind || "") === "text";
    542       const fullText = textByBookId.get(String(book.id || "")) || "";
    543       const pages = isText ? paginateText(fullText) : [];
    544       const totalPages = isText ? Math.max(1, pages.length) : 1;
    545       readerPage = Math.max(1, Math.min(isText ? totalPages : 9999, Number(readerPage || 1)));
    546 
    547       const tagsHtml = (book.tags || []).map((t) => `<span class="lib3Tag">${escapeHtml(t)}</span>`).join("");
    548       mount.innerHTML = `
    549         <div class="lib3">
    550           <div class="lib3Row" style="justify-content:space-between;">
    551             <div>
    552               <div><b>${escapeHtml(book.title || "Untitled")}</b> ${book.checkedOutBy ? "<span title='Checked out'>↩</span>" : ""}</div>
    553               <div class="lib3Meta">by ${escapeHtml(book.author || book.createdBy || "Unknown")} | ${String(book.kind || "").toUpperCase()} | ${formatBytes(book.bytes)}</div>
    554               <div>${tagsHtml}</div>
    555             </div>
    556             ${
    557               isText
    558                 ? `<div class="lib3Row">
    559               <button type="button" class="lib3Btn" id="lib3ReadPrev">&lt;</button>
    560               <input id="lib3ReadPage" type="number" min="1" value="${readerPage}" style="width:84px;" />
    561               <button type="button" class="lib3Btn" id="lib3ReadGo">Go</button>
    562               <button type="button" class="lib3Btn" id="lib3ReadNext">&gt;</button>
    563             </div>`
    564                 : `<div class="lib3Hint">Use the PDF toolbar to navigate pages.</div>`
    565             }
    566           </div>
    567           <div class="lib3ReaderViewport">
    568             ${isText ? `<div id="lib3ReaderText" class="lib3ReaderPage">${escapeHtml(pages[readerPage - 1] || (fullText ? "" : "Loading text..."))}</div>` : `<iframe class="lib3Iframe" src="${escapeHtml(`${book.url || ""}#page=${readerPage}`)}" title="Reader"></iframe>`}
    569           </div>
    570           <div class="lib3Hint">Page ${readerPage}${isText ? ` / ${totalPages}` : ""}</div>
    571         </div>
    572       `;
    573 
    574       if (isText && !textByBookId.has(String(book.id || ""))) requestText(book.id);
    575       if (isText) {
    576         mount.querySelector("#lib3ReadPrev")?.addEventListener("click", () => {
    577           readerPage -= 1;
    578           renderReader();
    579         });
    580         mount.querySelector("#lib3ReadNext")?.addEventListener("click", () => {
    581           readerPage += 1;
    582           renderReader();
    583         });
    584         mount.querySelector("#lib3ReadGo")?.addEventListener("click", () => {
    585           readerPage = Number(mount.querySelector("#lib3ReadPage")?.value || 1);
    586           renderReader();
    587         });
    588       }
    589     }
    590     function renderAll() {
    591       renderLibrary();
    592       renderShelf();
    593       renderReader();
    594     }
    595 
    596     function onWsMsg(ev) {
    597       try {
    598         const msg = JSON.parse(String(ev?.data || ""));
    599         const type = String(msg?.type || "");
    600         if (!type.startsWith("plugin:library:")) return;
    601 
    602         if (type === "plugin:library:list") {
    603           snapshot = { me: String(msg.me || ""), myShelfId: String(msg.myShelfId || ""), shelves: Array.isArray(msg.shelves) ? msg.shelves : [] };
    604           if (!activeShelfId || !snapshot.shelves.some((s) => s.id === activeShelfId)) activeShelfId = snapshot.myShelfId || snapshot.shelves[0]?.id || "";
    605           renderAll();
    606           return;
    607         }
    608         if (type === "plugin:library:changed") {
    609           requestList();
    610           return;
    611         }
    612         if (type === "plugin:library:text") {
    613           const it = msg.item || null;
    614           if (!it || !it.id) return;
    615           textByBookId.set(String(it.id), String(it.text || ""));
    616           renderReader();
    617           return;
    618         }
    619         if (type === "plugin:library:uploadStarted") {
    620           window.__bzlLibraryUploadId = String(msg.uploadId || "");
    621           return;
    622         }
    623         if (type === "plugin:library:uploadProgress") {
    624           setStatus(`Uploading PDF... ${formatBytes(msg.received)} / ${formatBytes(msg.expected)}`);
    625           return;
    626         }
    627         if (type === "plugin:library:uploadFinished") {
    628           uploadInProgress = false;
    629           window.__bzlLibraryUploadId = "";
    630           setStatus("Upload complete.");
    631           requestList();
    632           return;
    633         }
    634         if (type === "plugin:library:error") {
    635           uploadInProgress = false;
    636           setStatus(String(msg.message || "Error."));
    637           ctx.toast("Library", String(msg.message || "Error."));
    638           return;
    639         }
    640         if (
    641           type === "plugin:library:textCreated" ||
    642           type === "plugin:library:textUpdated" ||
    643           type === "plugin:library:shelfCreated" ||
    644           type === "plugin:library:shelfUpdated" ||
    645           type === "plugin:library:shelfSubscribed" ||
    646           type === "plugin:library:pinned" ||
    647           type === "plugin:library:checkedOut" ||
    648           type === "plugin:library:shelfItemRemoved" ||
    649           type === "plugin:library:returned" ||
    650           type === "plugin:library:deleted"
    651         ) {
    652           requestList();
    653         }
    654       } catch {
    655         // ignore
    656       }
    657     }
    658 
    659     function mountPanel(kind, mount) {
    660       if (!(mount instanceof HTMLElement)) return;
    661       mount.style.height = "100%";
    662       mount.style.minHeight = "0";
    663       mount.style.display = "flex";
    664       mounts[kind] = mount;
    665       renderAll();
    666     }
    667 
    668     const panelOpts = { defaultRack: "main", role: "primary" };
    669     if (ctx?.ui?.registerPanel) {
    670       ctx.ui.registerPanel({
    671         id: "library-reader",
    672         title: "Reader",
    673         icon: "Read",
    674         ...panelOpts,
    675         render(mount) {
    676           mountPanel("reader", mount);
    677           return () => {
    678             if (mounts.reader === mount) mounts.reader = null;
    679           };
    680         },
    681       });
    682       ctx.ui.registerPanel({
    683         id: "library-shelf",
    684         title: "Shelf",
    685         icon: "Shelf",
    686         ...panelOpts,
    687         render(mount) {
    688           mountPanel("shelf", mount);
    689           return () => {
    690             if (mounts.shelf === mount) mounts.shelf = null;
    691           };
    692         },
    693       });
    694       ctx.ui.registerPanel({
    695         id: "library-browser",
    696         title: "Library",
    697         icon: "Books",
    698         ...panelOpts,
    699         render(mount) {
    700           mountPanel("library", mount);
    701           return () => {
    702             if (mounts.library === mount) mounts.library = null;
    703           };
    704         },
    705       });
    706     }
    707 
    708     setInterval(attachWsListener, 1000);
    709     attachWsListener();
    710     requestList();
    711     ctx.devLog("info", "library:init", { ok: true, panels: ["library-reader", "library-shelf", "library-browser"] });
    712   });
    713 })();