book(D2 polish): manifest dirty tracking + feed-card preview

Two pieces that close out Phase D2:

Dirty tracking
- isBookManifestDirty() compares the current bookManifestPayload
  serial to state.book.lastPublishedManifestSerial; a missing
  snapshot but any existing ref counts as dirty so the sidebar
  prompts the first publish.
- markBookManifestClean() snapshots the serial on success; called
  from both the public and private manifest publish paths in
  publishCurrentTemplateToNostr.
- syncBookSidebarSummary renders a one-line hint —
  "⚠ Manifest has unsaved changes" or "✓ Manifest is in sync" —
  so authors see when sidecar publishes have outpaced the manifest
  publish.

Feed-card preview
- buildFeedCard now has a `book` branch: a chapbook-shaped card
  (cream-to-amber gradient, Georgia serif title, page + chapter
  counts) so book manifests don't fall through to a blank "Preview
  unavailable" tile in the community browser.
- MODE_LABELS gets 'book': 'Book' so the type chip + fallback label
  read correctly.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-06-03 13:27:52 -04:00
1 parent 5b19c25e2b
commit 55f53aeb49
1 file changed
+73 -2
+73 -2
View File
@@ -10531,6 +10531,36 @@
return summary;
}
// Manifest dirty tracking. After a successful manifest publish
// we snapshot the serialised payload onto state.book.lastPublishedManifestSerial;
// isBookManifestDirty() compares the current serialisation to
// that snapshot. Used by the sidebar hint so the user knows when
// the manifest needs another publish.
function isBookManifestDirty() {
const book = state.book;
if (!book) return false;
const snapshot = book.lastPublishedManifestSerial;
if (!snapshot) {
// No snapshot but we have refs → counted as dirty so the
// sidebar prods the user to publish for the first time.
const hasAnyRef = !!book.cover?.ref
|| (book.masters || []).some(m => m.ref)
|| (book.pages || []).some(p => p.ref || p.textRef);
return hasAnyRef;
}
try {
const current = JSON.stringify(bookManifestPayload());
return current !== snapshot;
} catch {
return false;
}
}
function markBookManifestClean() {
const book = state.book;
if (!book) return;
try { book.lastPublishedManifestSerial = JSON.stringify(bookManifestPayload()); } catch {}
}
// Build the casewrap-book manifest payload. Lean — references
// sibling page / master / cover / chapter events via {kind, pubkey,
// dTag} coords instead of inlining their layers. Phase D1 hands
@@ -11157,11 +11187,24 @@
? `derived from ${pageCount} page${pageCount === 1 ? '' : 's'}`
: 'manual';
const fmt = (n) => Number(n).toFixed(n % 1 === 0 ? 0 : 3);
// Manifest dirty hint — sidecars publish independently, so the
// manifest can be "out of sync" with the per-page events. Tell
// the user explicitly so they don't think Publish-page was the
// last step.
let dirtyHint = '';
try {
if (isBookManifestDirty()) {
dirtyHint = '<br><span style="color:#92400e">⚠ Manifest has unsaved changes — Publish to update the casewrap-book event.</span>';
} else if (state.book.lastPublishedManifestSerial) {
dirtyHint = '<br><span style="color:#15803d">✓ Manifest is in sync with the published casewrap-book event.</span>';
}
} catch {}
el.innerHTML = ''
+ `<b>${s.bookSize || 'Custom'}</b> · ${fmt(s.trimW)} × ${fmt(s.trimH)} in`
+ `<br>Bleed ${fmt(s.bleed)} in · Paper ${fmt(s.paperThickness)} in/page`
+ `<br>Spine ${fmt(spineW)} in <span class="hint">(${spineNote})</span>`
+ `<br>${pageCount} page${pageCount === 1 ? '' : 's'} · ${(state.book.masters || []).length} master${(state.book.masters || []).length === 1 ? '' : 's'}`;
+ `<br>${pageCount} page${pageCount === 1 ? '' : 's'} · ${(state.book.masters || []).length} master${(state.book.masters || []).length === 1 ? '' : 's'}`
+ dirtyHint;
syncBookVisibilityChip();
}
// Sync the Public/Private chip in the book sidebar from
@@ -21648,6 +21691,9 @@
const signed = await publishEncryptedDesign(payload);
const label = isEditing() ? `Updated "${state.meta.title}" (private)` : `Private "${state.meta.title}"`;
await finalizePublishedTemplateEvent(signed, { label });
if (state.templateMode === 'book' && typeof markBookManifestClean === 'function') {
try { markBookManifestClean(); } catch {}
}
if (isEditing()) exitEditMode();
return signed;
}
@@ -21677,6 +21723,12 @@
const signed = await publishCommunityEvent(evt);
const label = isEditing() ? `Updated "${state.meta.title}"` : `Template "${state.meta.title}"`;
await finalizePublishedTemplateEvent(signed, { label });
// Book manifest: snapshot the published serialisation so the
// sidebar's dirty hint flips to "Saved" until the user makes
// another manifest-affecting change.
if (state.templateMode === 'book' && typeof markBookManifestClean === 'function') {
try { markBookManifestClean(); } catch {}
}
if (isEditing()) exitEditMode();
return signed;
}
@@ -29098,6 +29150,25 @@
else if (row.mode === 'customart' || row.mode === 'slide') renderCustomArtPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'deck') renderDeckPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'pixelart') renderPixelArtPreviewDOM(previewWrap, payload, { compact: true });
else if (row.mode === 'book') {
// Book manifests don't ship layer data — render a
// chapbook-shaped card with title + entry counts so the
// feed has something to display instead of "Preview
// unavailable".
const spec = payload.book?.spec || {};
const pages = payload.book?.pages || [];
const designed = pages.filter(p => p.type === 'design').length;
const reflow = pages.filter(p => p.type === 'reflow').length;
const trimLabel = spec.bookSize || `${spec.trimW || 6} × ${spec.trimH || 9} in`;
previewWrap.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:14px;box-sizing:border-box;background:linear-gradient(135deg,#fef3c7 0%,#fde68a 50%,#f59e0b 100%)';
previewWrap.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;font-family:Georgia,serif;color:#1f2937;padding:18px 14px;background:rgba(255,255,255,.85);border:1px solid #d97706;border-radius:4px;max-width:90%">
<div style="font-size:1rem;font-weight:700;line-height:1.2;margin-bottom:6px;word-break:break-word">${escapeHtml(payload.meta?.title || 'Untitled book')}</div>
<div style="font-size:.72rem;color:#92400e">${escapeHtml(trimLabel)}</div>
<div style="font-size:.7rem;color:#92400e;margin-top:8px">${designed} page${designed === 1 ? '' : 's'}${reflow ? ` · ${reflow} chapter${reflow === 1 ? '' : 's'}` : ''}</div>
</div>
`;
}
else renderTemplatePreviewDOM(previewWrap, payload, { compact: true });
} catch {}
}
@@ -29865,7 +29936,7 @@
return btn;
}
const MODE_LABELS = { cover: 'Case cover', disc: 'Disc sheet', jewel: 'CD jewel inserts', 'disc-design': 'Disc design', customart: 'Custom art', slide: 'Slide', deck: 'Slide deck', pixelart: 'Pixel art' };
const MODE_LABELS = { cover: 'Case cover', disc: 'Disc sheet', jewel: 'CD jewel inserts', 'disc-design': 'Disc design', customart: 'Custom art', slide: 'Slide', deck: 'Slide deck', pixelart: 'Pixel art', book: 'Book' };
function buildDiscSheetThumb(thumb, payload, compact = false) {
const slots = payload?.discTemplate?.discs || payload?.discs || {};