From e6e1ef17f58295db86febeba91d68a5cf88312ac Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:02:45 -0400 Subject: [PATCH] feat(book): Manifest Links panel in Document settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an InDesign-style 'Links panel' to the book's Document settings modal β€” every external event the book references, with status. Each row shows: - Icon (🏷 cover Β· 🎨 master Β· πŸ“ page Β· πŸ“„ chapter Β· πŸ“š manifest) - Label + truncated coord (kind:pubkey…:dTag) - Status pill: resolved β€” sidecar data is in state (layers loaded / markdown fetched) πŸ”’ private β€” encrypted ref (decrypt happens at resolve time) unresolved β€” ref set but no data fetched yet (try Refresh) unpublished β€” surface has layers but no Nostr ref (publish to mint an event) local draft / empty β€” chapter content without a ref - Actions: πŸ“‹ copy naddr to clipboard Β· ↻ force re-fetch this sidecar via resolveBookSidecars(..., { force: true }) Plus a 'Refresh all' button under the list that forces a full re-resolve and repaints the panel when done. Counts shown in the section header summary so you can see at a glance how many references are resolved vs. pending. Closes the loop on the Manifest Links design the user asked for β€” this is the read-only view of what the casewrap-book event points at, alongside the chip / refresh affordances that have been on the sidebar and tile rows since D1.7. Co-Authored-By: Claude Opus 4.7 --- src/index.html | 241 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) diff --git a/src/index.html b/src/index.html index 22ddfb4..a9083c0 100644 --- a/src/index.html +++ b/src/index.html @@ -1946,6 +1946,58 @@ color: var(--muted); font-size: .85rem; border: 1px dashed var(--line); border-radius: 6px; } + + /* Manifest Links panel β€” book's "Links panel". Shows every + external event referenced (cover / master / page / chapter). + Each row has an icon, label, coord (truncated), status pill, + and action buttons (refresh + copy naddr). */ + .bookManifestLinksList { + display: flex; flex-direction: column; gap: 6px; + max-height: 320px; overflow-y: auto; + padding: 4px; margin-top: 6px; + border: 1px solid var(--line); border-radius: 4px; + } + .bookManifestLinkRow { + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: 8px; align-items: center; + padding: 6px 8px; + border-radius: 4px; + background: rgba(0,0,0,.02); + font-size: .78rem; + } + body.dark .bookManifestLinkRow { background: rgba(255,255,255,.04); } + .bookManifestLinkRow .icon { font-size: 1rem; opacity: .85; } + .bookManifestLinkRow .meta { min-width: 0; } + .bookManifestLinkRow .title { + font-weight: 600; font-size: .82rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .bookManifestLinkRow .coord { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: .68rem; color: var(--muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .bookManifestLinkRow .status { + font-size: .62rem; font-weight: 700; + letter-spacing: .06em; text-transform: uppercase; + padding: 2px 6px; border-radius: 3px; + white-space: nowrap; + } + .bookManifestLinkRow .status.resolved { background: rgba(34,197,94,.18); color: #15803d; } + .bookManifestLinkRow .status.pending { background: rgba(245,158,11,.18); color: #92400e; } + .bookManifestLinkRow .status.unresolved { background: rgba(220,38,38,.18); color: #991b1b; } + .bookManifestLinkRow .status.private { background: rgba(99,102,241,.18); color: #4338ca; } + .bookManifestLinkRow .status.local { background: rgba(148,163,184,.18); color: #475569; } + .bookManifestLinkRow .status.unpub { background: rgba(148,163,184,.18); color: #475569; } + .bookManifestLinkRow .actions { display: flex; gap: 4px; } + .bookManifestLinkRow .actions button { + font-size: .72rem; padding: 2px 8px; + } + .bookManifestLinkEmpty { + padding: 14px; text-align: center; + color: var(--muted); font-size: .8rem; + } .bookVisibilityChip { font-size: .76rem; padding: 2px 10px; border: 1px solid var(--line); background: #fff; @@ -5902,6 +5954,15 @@ +
+ Manifest links +

Every external event this book references β€” cover spread, masters, designed pages, chapter articles. Like InDesign's Links panel.

+ +
+ +
+
+
Typography (chapter text styles)

Overrides the v1 markdown stylesheet for reflow chapters. Empty fields fall back to defaults.

@@ -9147,6 +9208,168 @@ // Open the Document settings modal. Fills inputs from the live // state.book.spec; "Apply" copies values back. Cancel discards. + // Document settings β†’ Manifest links panel. Lists every external + // event the book references β€” cover spread, masters, designed + // pages, chapter articles. Status pill reflects what we know + // about each ref: resolved (data in state), local-only (draft + // chapter not yet published), private (encrypted ref), or + // unresolved (ref set but no data fetched yet). + function renderBookManifestLinks() { + const list = document.getElementById('bookSettingsManifestLinksList'); + const summary = document.getElementById('bookSettingsManifestLinksSummary'); + if (!list) return; + const book = state.book ? ensureBookState() : null; + list.innerHTML = ''; + if (!book) return; + const rows = []; + const pushRow = (icon, title, ref, status, statusClass, getResolvedFn) => { + rows.push({ icon, title, ref, status, statusClass, getResolved: getResolvedFn }); + }; + // Cover. + if (book.cover?.ref) { + const ref = book.cover.ref; + const resolved = (state.layers || []).some(l => /^book-cover-|^book-spine$/.test(String(l.target || ''))); + const isPrivate = !!ref.encrypted; + pushRow('🏷', 'Cover spread', ref, + isPrivate ? 'πŸ”’ private' : (resolved ? 'resolved' : 'unresolved'), + isPrivate ? 'private' : (resolved ? 'resolved' : 'unresolved')); + } else if ((state.layers || []).some(l => /^book-cover-|^book-spine$/.test(String(l.target || '')))) { + pushRow('🏷', 'Cover spread', null, 'unpublished', 'unpub'); + } + // Masters. + for (const m of (book.masters || [])) { + const label = `Master Β· ${m.label || 'Untitled'}`; + if (m.ref) { + const isPrivate = !!m.ref.encrypted; + const resolved = (state.layers || []).some(l => l.target === `book-master:${m.id}`); + pushRow('🎨', label, m.ref, + isPrivate ? 'πŸ”’ private' : (resolved ? 'resolved' : 'unresolved'), + isPrivate ? 'private' : (resolved ? 'resolved' : 'unresolved')); + } else { + pushRow('🎨', label, null, 'unpublished', 'unpub'); + } + } + // Pages + chapters. + const designedPages = (book.pages || []).filter(p => p.type === 'design'); + designedPages.forEach((p, idx) => { + const label = `Page ${idx + 1}${p.label ? ' β€” ' + p.label : ''}`; + if (p.ref) { + const isPrivate = !!p.ref.encrypted; + const resolved = (state.layers || []).some(l => l.target === `book-page:${p.id}`); + pushRow('πŸ“', label, p.ref, + isPrivate ? 'πŸ”’ private' : (resolved ? 'resolved' : 'unresolved'), + isPrivate ? 'private' : (resolved ? 'resolved' : 'unresolved')); + } else { + // Only flag as unpublished if any layers exist on the page; + // a brand-new blank page with no content isn't "unpublished", + // it's just empty. + const hasLayers = (state.layers || []).some(l => l.target === `book-page:${p.id}`); + if (hasLayers) pushRow('πŸ“', label, null, 'unpublished', 'unpub'); + } + }); + for (const p of (book.pages || [])) { + if (p.type !== 'reflow') continue; + const label = `Chapter Β· ${p.chapterTitle || p.label || 'Untitled'}`; + if (p.textRef) { + const isPrivate = !!p.textRef.encrypted; + const resolved = !!String(p.chapterMarkdown || '').trim(); + pushRow('πŸ“„', label, p.textRef, + isPrivate ? 'πŸ”’ private' : (resolved ? 'resolved' : 'unresolved'), + isPrivate ? 'private' : (resolved ? 'resolved' : 'unresolved')); + } else { + const hasContent = !!String(p.chapterMarkdown || '').trim(); + pushRow('πŸ“„', label, null, hasContent ? 'local draft' : 'empty', 'local'); + } + } + // Manifest itself, if known. + if (book.manifestRef) { + const ref = book.manifestRef; + pushRow('πŸ“š', 'Manifest (this book)', ref, ref.encrypted ? 'πŸ”’ private' : 'resolved', ref.encrypted ? 'private' : 'resolved'); + } + + if (!rows.length) { + const empty = document.createElement('div'); + empty.className = 'bookManifestLinkEmpty'; + empty.textContent = 'No external links yet. Publish the cover / pages / masters / chapters and the manifest to mint Nostr events for each.'; + list.appendChild(empty); + if (summary) summary.textContent = '0 links'; + return; + } + + const resolvedCount = rows.filter(r => r.statusClass === 'resolved' || r.statusClass === 'private').length; + if (summary) summary.textContent = `${rows.length} link${rows.length === 1 ? '' : 's'} Β· ${resolvedCount} resolved`; + + const truncate = (s, n) => (s && s.length > n) ? (s.slice(0, n) + '…') : (s || ''); + for (const row of rows) { + const r = document.createElement('div'); + r.className = 'bookManifestLinkRow'; + const icon = document.createElement('div'); + icon.className = 'icon'; + icon.textContent = row.icon; + r.appendChild(icon); + const meta = document.createElement('div'); + meta.className = 'meta'; + const title = document.createElement('div'); + title.className = 'title'; + title.textContent = row.title; + meta.appendChild(title); + const coord = document.createElement('div'); + coord.className = 'coord'; + if (row.ref) { + coord.textContent = `${row.ref.kind || 30078}:${truncate(row.ref.pubkey, 8)}…:${row.ref.dTag || '(no d)'}`; + } else { + coord.textContent = '(not yet published)'; + } + meta.appendChild(coord); + r.appendChild(meta); + const status = document.createElement('span'); + status.className = `status ${row.statusClass}`; + status.textContent = row.status; + r.appendChild(status); + const actions = document.createElement('div'); + actions.className = 'actions'; + if (row.ref) { + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.textContent = 'πŸ“‹'; + copyBtn.title = 'Copy naddr to clipboard'; + copyBtn.addEventListener('click', async () => { + try { + const naddr = encodeNaddr({ + kind: row.ref.kind || 30078, + pubkey: row.ref.pubkey, + dTag: row.ref.dTag, + relays: row.ref.relays || [], + }); + await navigator.clipboard.writeText(naddr); + toastSuccess?.('naddr copied.'); + } catch (err) { + toastError?.('Couldn\'t encode naddr.'); + } + }); + actions.appendChild(copyBtn); + const refreshBtn = document.createElement('button'); + refreshBtn.type = 'button'; + refreshBtn.textContent = '↻'; + refreshBtn.title = 'Force re-fetch this sidecar from your relays'; + refreshBtn.addEventListener('click', async () => { + refreshBtn.disabled = true; + refreshBtn.textContent = '…'; + try { + await resolveBookSidecars(state.book, { force: true }); + renderBookManifestLinks(); + } finally { + refreshBtn.disabled = false; + refreshBtn.textContent = '↻'; + } + }); + actions.appendChild(refreshBtn); + } + r.appendChild(actions); + list.appendChild(r); + } + } + function openBookDocumentSettings() { const book = ensureBookState(); const spec = book.spec; @@ -9176,6 +9399,7 @@ _syncBookSettingsSpineNote(); _syncBookSettingsPresetLock(); renderBookTypographyEditor(spec.textStyles); + renderBookManifestLinks(); modal.style.display = 'flex'; modal.setAttribute('aria-hidden', 'false'); if (typeof bringModalToFront === 'function') bringModalToFront(modal); @@ -39274,6 +39498,23 @@ _checkDocsHash(); on('bookDocumentSettingsBtn', 'click', openBookDocumentSettings); + on('bookSettingsManifestRefreshBtn', 'click', async () => { + const btn = document.getElementById('bookSettingsManifestRefreshBtn'); + if (!btn) return; + btn.disabled = true; + const orig = btn.textContent; + btn.textContent = 'Refreshing…'; + try { + await resolveBookSidecars(state.book, { force: true }); + renderBookManifestLinks(); + toastSuccess?.('Manifest links refreshed.'); + } catch (err) { + toastError?.('Refresh failed: ' + (err?.message || 'unknown')); + } finally { + btn.disabled = false; + btn.textContent = orig; + } + }); on('bookImportManifestNostrBtn', 'click', importBookManifestFromNostr); on('bookImportManifestJsonBtn', 'click', () => { const input = document.getElementById('bookImportManifestJsonFile');