fix(rpubs): fix flipbook white screen and align flow with /press landing

- Flipbook: wrap async initFlipbook in try-catch with scroll fallback,
  add 500ms verification timeout for silent StPageFlip failures, quote
  data URLs in background-image, expand shadow DOM CSS coverage
- Flow: rename steps Create/Preview/Publish → Write/Press/Print to
  match rpubs.online/press landing page description
- Routes: add /press editor route, bump script cache to v=3
- Publish panel: fix getEditorContent() reading from cached content
  instead of missing textarea (not rendered during print step)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-30 23:52:24 -07:00
parent 1cc083a655
commit 636360ce5f
4 changed files with 164 additions and 50 deletions

View File

@ -83,6 +83,11 @@ export class FolkPubsEditor extends HTMLElement {
private _pdfInfo: string | null = null;
private _pdfPageCount = 0;
// ── Cached content (for publish panel access when textarea isn't in DOM) ──
private _cachedContent = "";
private _cachedTitle = "";
private _cachedAuthor = "";
// ── Wizard view state ──
private _view: "write" | "preview" | "publish" = "write";
@ -104,7 +109,7 @@ export class FolkPubsEditor extends HTMLElement {
private static readonly TOUR_STEPS = [
{ target: '.content-area', title: "Editor", message: "Write or paste markdown content here. Drag-and-drop text files also works.", advanceOnClick: false },
{ target: '.format-dropdown-btn', title: "Format", message: "Choose a pocket-book format — digest, half-letter, A6, and more.", advanceOnClick: false },
{ target: '.btn-generate', title: "Generate PDF", message: "Generate a print-ready PDF and advance to the preview step.", advanceOnClick: false },
{ target: '.btn-generate', title: "Press It", message: "Generate a print-ready PDF and advance to the press step.", advanceOnClick: false },
{ target: '.drafts-dropdown-btn', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", advanceOnClick: false },
{ target: '.btn-zine-gen', title: "Zine Generator", message: "Generate an AI-illustrated 8-page zine — pick a topic, style, and tone, then edit any section before printing.", advanceOnClick: false },
];
@ -129,6 +134,9 @@ export class FolkPubsEditor extends HTMLElement {
}
};
/** Expose cached content for the publish panel (which can't access the textarea when it's not rendered) */
get cachedContent() { return { content: this._cachedContent, title: this._cachedTitle, author: this._cachedAuthor }; }
set formats(val: BookFormat[]) {
this._formats = val;
if (this.shadowRoot) this.render();
@ -468,17 +476,17 @@ export class FolkPubsEditor extends HTMLElement {
<div class="steps">
<button class="step ${this._view === 'write' ? 'active' : ''} ${this._pdfUrl && this._view !== 'write' ? 'done' : ''}" data-step="write">
<span class="step-circle">${this._pdfUrl && this._view !== 'write' ? SVG_CHECK : '1'}</span>
<span class="step-label">Create</span>
<span class="step-label">Write</span>
</button>
<div class="step-line ${this._view !== 'write' ? 'filled' : ''}"></div>
<button class="step ${this._view === 'preview' ? 'active' : ''} ${this._view === 'publish' ? 'done' : ''}" data-step="preview" ${!this._pdfUrl ? 'disabled' : ''}>
<span class="step-circle">${this._view === 'publish' ? SVG_CHECK : '2'}</span>
<span class="step-label">Preview</span>
<span class="step-label">Press</span>
</button>
<div class="step-line ${this._view === 'publish' ? 'filled' : ''}"></div>
<button class="step ${this._view === 'publish' ? 'active' : ''}" data-step="publish" ${!this._pdfUrl ? 'disabled' : ''}>
<span class="step-circle">3</span>
<span class="step-label">Publish</span>
<span class="step-label">Print</span>
</button>
</div>
${this._pdfInfo ? `<span class="step-info">${this._pdfInfo}</span>` : ''}
@ -515,8 +523,8 @@ export class FolkPubsEditor extends HTMLElement {
${this._error ? `<div class="inline-error">${this.escapeHtml(this._error)}</div>` : ''}
<button class="btn-generate" ${this._loading ? "disabled" : ""}>
${this._loading
? `${SVG_SPINNER} Generating...`
: `Generate Preview ${SVG_ARROW_RIGHT}`}
? `${SVG_SPINNER} Pressing...`
: `Press It ${SVG_ARROW_RIGHT}`}
</button>
</div>
</div>
@ -539,7 +547,7 @@ export class FolkPubsEditor extends HTMLElement {
</div>
<div class="preview-bottom-bar">
<span class="preview-info">${this._pdfInfo || ''}</span>
<button class="btn-publish-next">Publish ${SVG_ARROW_RIGHT}</button>
<button class="btn-publish-next">Print Locally ${SVG_ARROW_RIGHT}</button>
</div>
</div>
`;
@ -548,7 +556,7 @@ export class FolkPubsEditor extends HTMLElement {
private renderPublishStep(): string {
return `
<div class="publish-step">
<button class="back-link" data-action="back-to-preview">\u2190 Back to preview</button>
<button class="back-link" data-action="back-to-preview">\u2190 Back to press</button>
<folk-pubs-publish-panel
pdf-url="${this._pdfUrl}"
format-id="${this._selectedFormat}"
@ -747,6 +755,11 @@ export class FolkPubsEditor extends HTMLElement {
return;
}
// Cache content before switching views (publish panel can't access textarea)
this._cachedContent = content;
this._cachedTitle = titleInput?.value?.trim() || "";
this._cachedAuthor = authorInput?.value?.trim() || "";
this._loading = true;
this._error = null;
if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl);
@ -764,8 +777,8 @@ export class FolkPubsEditor extends HTMLElement {
headers: { "Content-Type": "application/json", ...authHeaders },
body: JSON.stringify({
content,
title: titleInput?.value?.trim() || undefined,
author: authorInput?.value?.trim() || undefined,
title: this._cachedTitle || undefined,
author: this._cachedAuthor || undefined,
format: this._selectedFormat,
}),
});
@ -784,7 +797,7 @@ export class FolkPubsEditor extends HTMLElement {
this._pdfInfo = `${pageCount} pages \u00B7 ${format?.name || this._selectedFormat}`;
this._loading = false;
// Auto-advance to preview
// Auto-advance to press (preview) step
this._view = "preview";
this.render();
} catch (e: any) {

View File

@ -139,7 +139,7 @@ export class FolkPubsFlipbook extends HTMLElement {
const maxW = Math.min((this.parentElement?.clientWidth || window.innerWidth) - 40, 700);
const maxH = 500;
let pageW = maxW / 2;
let pageW = Math.max(maxW / 2, 100);
let pageH = pageW / this._aspectRatio;
if (pageH > maxH) {
pageH = maxH;
@ -151,7 +151,7 @@ export class FolkPubsFlipbook extends HTMLElement {
<div class="reader">
<div class="flipbook-row">
<button class="nav-btn" data-dir="prev" title="Previous page">&#8249;</button>
<div class="flipbook-container" style="width:${pageW * 2}px; height:${pageH}px;"></div>
<div class="flipbook-container" style="width:${Math.round(pageW * 2)}px; height:${Math.round(pageH)}px;"></div>
<button class="nav-btn" data-dir="next" title="Next page">&#8250;</button>
</div>
<div class="page-info">
@ -160,7 +160,10 @@ export class FolkPubsFlipbook extends HTMLElement {
</div>
`;
this.initFlipbook(pageW, pageH);
this.initFlipbook(Math.round(pageW), Math.round(pageH)).catch((e) => {
console.warn('[folk-pubs-flipbook] Flipbook init failed, using fallback:', e);
this.renderFallback();
});
this.bindEvents();
}
@ -183,36 +186,69 @@ export class FolkPubsFlipbook extends HTMLElement {
return;
}
this._flipBook = new PageFlip(container, {
width: Math.round(pageW),
height: Math.round(pageH),
showCover: true,
maxShadowOpacity: 0.5,
mobileScrollSupport: false,
useMouseEvents: true,
swipeDistance: 30,
clickEventForward: false,
flippingTime: 600,
startPage: this._currentPage,
});
try {
this._flipBook = new PageFlip(container, {
width: pageW,
height: pageH,
showCover: true,
maxShadowOpacity: 0.5,
mobileScrollSupport: false,
useMouseEvents: true,
swipeDistance: 30,
clickEventForward: false,
flippingTime: 600,
startPage: this._currentPage,
});
const pages: HTMLElement[] = [];
for (let i = 0; i < this._pageImages.length; i++) {
const page = document.createElement("div");
page.style.cssText = `
width: 100%; height: 100%;
background-image: url(${this._pageImages[i]});
background-size: cover;
background-position: center;
`;
pages.push(page);
const pages: HTMLElement[] = [];
for (let i = 0; i < this._pageImages.length; i++) {
const page = document.createElement("div");
page.className = "flipbook-page";
page.style.cssText = `
width: 100%; height: 100%;
background-image: url("${this._pageImages[i]}");
background-size: cover;
background-position: center;
background-color: #fff;
`;
pages.push(page);
}
this._flipBook.loadFromHTML(pages);
this._flipBook.on("flip", (e: any) => {
this._currentPage = e.data;
this.updatePageInfo();
});
} catch (e) {
console.warn('[folk-pubs-flipbook] StPageFlip render failed, using fallback:', e);
this._flipBook = null;
this.renderFallback();
return;
}
this._flipBook.loadFromHTML(pages);
this._flipBook.on("flip", (e: any) => {
this._currentPage = e.data;
this.updatePageInfo();
});
// Verify pages actually rendered — if not, fall back after a short delay
setTimeout(() => {
if (!this.shadowRoot) return;
const items = this.shadowRoot.querySelectorAll('.stf__item');
// If StPageFlip created items but none are visible, fall back
if (items.length > 0) {
const anyVisible = Array.from(items).some(
(el) => (el as HTMLElement).style.display !== 'none'
);
if (!anyVisible) {
console.warn('[folk-pubs-flipbook] No visible pages after init, using fallback');
this._flipBook?.destroy();
this._flipBook = null;
this.renderFallback();
}
} else if (!this.shadowRoot.querySelector('.stf__parent')) {
// StPageFlip didn't create its structure at all
console.warn('[folk-pubs-flipbook] StPageFlip structure missing, using fallback');
this._flipBook = null;
this.renderFallback();
}
}, 500);
// Initial page info update for spread display
this.updatePageInfo();
}
@ -272,9 +308,14 @@ export class FolkPubsFlipbook extends HTMLElement {
${this.getStyles()}
<div class="reader">
<div class="fallback-pages">
${this._pageImages.map((src, i) => `<img src="${src}" alt="Page ${i + 1}" />`).join('')}
${this._pageImages.map((src, i) => `
<div class="fallback-page-wrap">
<span class="fallback-page-num">${i + 1}</span>
<img src="${src}" alt="Page ${i + 1}" />
</div>
`).join('')}
</div>
<div class="page-info">${this._numPages} pages</div>
<div class="page-info">${this._numPages} pages (scroll view)</div>
</div>
`;
}
@ -288,7 +329,7 @@ export class FolkPubsFlipbook extends HTMLElement {
}
/* StPageFlip injects these into document.head, but they don't
penetrate shadow DOM so we replicate them here. */
penetrate shadow DOM replicate complete CSS here. */
.stf__parent {
position: relative;
display: block;
@ -296,10 +337,15 @@ export class FolkPubsFlipbook extends HTMLElement {
transform: translateZ(0);
-ms-touch-action: pan-y;
touch-action: pan-y;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
.stf__wrapper {
position: relative;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.stf__parent canvas {
@ -308,6 +354,7 @@ export class FolkPubsFlipbook extends HTMLElement {
height: 100%;
left: 0;
top: 0;
z-index: 2;
}
.stf__block {
position: absolute;
@ -315,11 +362,17 @@ export class FolkPubsFlipbook extends HTMLElement {
height: 100%;
box-sizing: border-box;
perspective: 2000px;
z-index: 1;
}
.stf__item {
display: none;
position: absolute;
box-sizing: border-box;
transform-style: preserve-3d;
top: 0;
left: 0;
overflow: hidden;
backface-visibility: hidden;
}
.stf__outerShadow,
.stf__innerShadow,
@ -328,6 +381,11 @@ export class FolkPubsFlipbook extends HTMLElement {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
/* Ensure page content fills the flipbook page */
.flipbook-page {
box-sizing: border-box;
}
.loading {
@ -361,6 +419,20 @@ export class FolkPubsFlipbook extends HTMLElement {
display: flex; flex-direction: column; align-items: center; gap: 1rem;
padding: 1rem; max-height: 500px; overflow-y: auto;
}
.fallback-page-wrap {
position: relative;
max-width: 100%;
}
.fallback-page-num {
position: absolute;
top: 4px;
right: 8px;
font-size: 0.6rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
background: rgba(0,0,0,0.5);
color: #fff;
}
.fallback-pages img {
max-width: 100%; border-radius: 2px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);

View File

@ -593,11 +593,23 @@ export class FolkPubsPublishPanel extends HTMLElement {
}
}
/** Get content from the parent editor by reading its textarea */
/** Get content from the parent editor — uses cached values since textarea may not be in DOM during publish step */
private getEditorContent(): { content: string; title?: string; author?: string } {
const editor = this.closest("folk-pubs-editor") || document.querySelector("folk-pubs-editor");
if (!editor?.shadowRoot) return { content: "" };
if (!editor) return { content: "" };
// Try cached content first (always available after PDF generation)
const cached = (editor as any).cachedContent;
if (cached?.content) {
return {
content: cached.content,
title: cached.title || undefined,
author: cached.author || undefined,
};
}
// Fallback: try reading from shadow DOM (only works during write step)
if (!editor.shadowRoot) return { content: "" };
const textarea = editor.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = editor.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = editor.shadowRoot.querySelector(".author-input") as HTMLInputElement;

View File

@ -657,7 +657,24 @@ routes.get("/zine", (c) => {
return c.redirect(`/${spaceSlug}?tool=folk-zine-gen`);
});
// ── Page: Editor ──
// ── Page: Editor (also served at /press) ──
routes.get("/press", (c) => {
const spaceSlug = c.req.param("space") || "personal";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
return c.html(renderShell({
title: `${spaceSlug} — rPubs Press | rSpace`,
moduleId: "rpubs",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js?v=3"></script>
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js?v=3"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
}));
});
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "personal";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
@ -668,9 +685,9 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js?v=2"></script>
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js"></script>`,
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js?v=3"></script>
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js?v=3"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
}));
});