From 031ffbbbfa9b592e97cd5eb65dd9e56190c3fab3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 21 Mar 2026 12:26:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(rpubs):=20replace=20sidebar=20with=203-ste?= =?UTF-8?q?p=20wizard=20flow=20(Create=20=E2=86=92=20Preview=20=E2=86=92?= =?UTF-8?q?=20Publish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the editor from a cramped sidebar layout to a full-width stepped wizard, matching the rpubs.online/press UX. Format and drafts moved to toolbar dropdowns, auto-advances to preview after PDF generation. Co-Authored-By: Claude Opus 4.6 --- modules/rpubs/components/folk-pubs-editor.ts | 988 +++++++++++------- .../components/folk-pubs-publish-panel.ts | 2 +- 2 files changed, 613 insertions(+), 377 deletions(-) diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts index 1e6ba79..d82af88 100644 --- a/modules/rpubs/components/folk-pubs-editor.ts +++ b/modules/rpubs/components/folk-pubs-editor.ts @@ -1,7 +1,10 @@ /** - * — Collaborative markdown editor with format selector and PDF generation. + * — Collaborative markdown editor with 3-step wizard flow. + * + * Step 1: Write — full-width markdown editor with format dropdown + * Step 2: Preview — full-width flipbook with download/edit actions + * Step 3: Publish — centered publish panel (Share/DIY/Order) * - * Drop in markdown text, pick a pocket-book format, generate a print-ready PDF. * Supports file drag-and-drop, sample content, PDF preview/download, * and real-time collaborative editing via Automerge CRDT. */ @@ -71,6 +74,13 @@ export class FolkPubsEditor extends HTMLElement { private _pdfInfo: string | null = null; private _pdfPageCount = 0; + // ── Wizard view state ── + private _view: "write" | "preview" | "publish" = "write"; + + // ── Dropdown state ── + private _formatDropdownOpen = false; + private _draftsDropdownOpen = false; + // ── Automerge collaborative state ── private _runtime: any = null; private _activeDocId: DocumentId | null = null; @@ -83,12 +93,32 @@ export class FolkPubsEditor extends HTMLElement { private _tour!: TourEngine; 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-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 in the selected format.", advanceOnClick: false }, - { target: '.btn-new-draft', title: "Drafts", message: "Save multiple drafts with real-time collaborative sync.", 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: '.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 }, ]; + // ── Close dropdowns on outside click ── + private _outsideClickHandler = (e: MouseEvent) => { + if (!this.shadowRoot) return; + const path = e.composedPath(); + if (this._formatDropdownOpen) { + const dd = this.shadowRoot.querySelector('.format-dropdown'); + if (dd && !path.includes(dd)) { + this._formatDropdownOpen = false; + this.renderDropdowns(); + } + } + if (this._draftsDropdownOpen) { + const dd = this.shadowRoot.querySelector('.drafts-dropdown'); + if (dd && !path.includes(dd)) { + this._draftsDropdownOpen = false; + this.renderDropdowns(); + } + } + }; + set formats(val: BookFormat[]) { this._formats = val; if (this.shadowRoot) this.render(); @@ -120,6 +150,8 @@ export class FolkPubsEditor extends HTMLElement { if (!localStorage.getItem("rpubs_tour_done")) { setTimeout(() => this._tour.start(), 1200); } + + document.addEventListener("click", this._outsideClickHandler); } private async initRuntime() { @@ -129,11 +161,9 @@ export class FolkPubsEditor extends HTMLElement { this._runtime = runtime; try { - // Discover all existing drafts for this space const docs = await runtime.subscribeModule('pubs', 'drafts', pubsDraftSchema); this._syncConnected = runtime.isOnline; - // Build draft list from discovered docs this._drafts = []; for (const [docId, doc] of docs) { const parts = (docId as string).split(':'); @@ -146,10 +176,8 @@ export class FolkPubsEditor extends HTMLElement { }); } - // Sort by most recently updated this._drafts.sort((a, b) => b.updatedAt - a.updatedAt); - // Open the most recent draft, or create one if none exist if (this._drafts.length > 0) { await this.switchDraft(this._drafts[0].docId, this._drafts[0].draftId); } else { @@ -169,7 +197,6 @@ export class FolkPubsEditor extends HTMLElement { const doc = await this._runtime.subscribe(docId, pubsDraftSchema); - // Initialize the draft metadata this._runtime.change(docId, 'Initialize draft', (d: PubsDoc) => { d.meta.spaceSlug = this._spaceSlug; d.meta.createdAt = Date.now(); @@ -178,7 +205,6 @@ export class FolkPubsEditor extends HTMLElement { d.draft.updatedAt = Date.now(); }); - // Add to local list this._drafts.unshift({ docId, draftId, @@ -190,7 +216,6 @@ export class FolkPubsEditor extends HTMLElement { } private async switchDraft(docId: DocumentId, draftId: string) { - // Unsubscribe from previous doc if (this._unsubChange) { this._unsubChange(); this._unsubChange = null; @@ -199,15 +224,19 @@ export class FolkPubsEditor extends HTMLElement { this._activeDocId = docId; this._activeDraftId = draftId; + // Reset to write view on draft switch + this._view = "write"; + if (this._pdfUrl) { + URL.revokeObjectURL(this._pdfUrl); + this._pdfUrl = null; + this._pdfInfo = null; + } + if (!this._runtime) return; - // Subscribe to this doc (may already be subscribed from subscribeModule) const doc = await this._runtime.subscribe(docId, pubsDraftSchema); - - // Populate UI from doc this.populateFromDoc(doc); - // Listen for remote changes this._unsubChange = this._runtime.onChange(docId, (updated: any) => { this._isRemoteUpdate = true; this.applyRemoteUpdate(updated); @@ -233,7 +262,6 @@ export class FolkPubsEditor extends HTMLElement { if (doc.draft?.format) { this._selectedFormat = doc.draft.format; - this.updateFormatButtons(); } }); } @@ -245,7 +273,6 @@ export class FolkPubsEditor extends HTMLElement { const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement; const authorInput = this.shadowRoot.querySelector(".author-input") as HTMLInputElement; - // Preserve cursor position when applying remote content changes if (textarea && doc.content != null && textarea.value !== doc.content) { const start = textarea.selectionStart; const end = textarea.selectionEnd; @@ -264,34 +291,14 @@ export class FolkPubsEditor extends HTMLElement { if (doc.draft?.format && doc.draft.format !== this._selectedFormat) { this._selectedFormat = doc.draft.format; - this.updateFormatButtons(); } } - private updateFormatButtons() { - if (!this.shadowRoot) return; - this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => { - const el = btn as HTMLElement; - if (el.dataset.format === this._selectedFormat) { - el.classList.add("active"); - } else { - el.classList.remove("active"); - } - }); - - // Update format details visibility - this.shadowRoot.querySelectorAll(".format-detail").forEach((el) => { - const htmlEl = el as HTMLElement; - htmlEl.hidden = htmlEl.dataset.for !== this._selectedFormat; - }); - } - private updateDraftListEntry(docId: DocumentId, doc: any) { const entry = this._drafts.find((d) => d.docId === docId); if (entry) { entry.title = doc.draft?.title || 'Untitled'; entry.updatedAt = doc.draft?.updatedAt || Date.now(); - this.renderDraftList(); } } @@ -315,13 +322,11 @@ export class FolkPubsEditor extends HTMLElement { d.draft.updatedAt = Date.now(); }); - // Update local draft list entry if (field === 'title') { const entry = this._drafts.find((d) => d.docId === this._activeDocId); if (entry) { entry.title = value || 'Untitled'; entry.updatedAt = Date.now(); - this.renderDraftList(); } } } @@ -342,31 +347,32 @@ export class FolkPubsEditor extends HTMLElement { if (this._pdfUrl) URL.revokeObjectURL(this._pdfUrl); if (this._unsubChange) this._unsubChange(); if (this._syncTimer) clearTimeout(this._syncTimer); + document.removeEventListener("click", this._outsideClickHandler); } - private renderDraftList() { + private renderDropdowns() { if (!this.shadowRoot) return; - const container = this.shadowRoot.querySelector(".draft-list"); - if (!container) return; - container.innerHTML = this._drafts.map((d) => ` - - `).join(""); + // Format dropdown panel + const formatPanel = this.shadowRoot.querySelector('.format-dropdown-panel') as HTMLElement; + if (formatPanel) formatPanel.hidden = !this._formatDropdownOpen; - // Bind click events on draft items - container.querySelectorAll(".draft-item").forEach((btn) => { - btn.addEventListener("click", () => { - const el = btn as HTMLElement; - const docId = el.dataset.docId as DocumentId; - const draftId = el.dataset.draftId!; - if (docId !== this._activeDocId) { - this.switchDraft(docId, draftId); - } - }); - }); + // Drafts dropdown panel + const draftsPanel = this.shadowRoot.querySelector('.drafts-dropdown-panel') as HTMLElement; + if (draftsPanel) draftsPanel.hidden = !this._draftsDropdownOpen; + } + + private getWordCount(): number { + if (!this.shadowRoot) return 0; + const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; + if (!textarea || !textarea.value.trim()) return 0; + return textarea.value.trim().split(/\s+/).length; + } + + private getContent(): string { + if (!this.shadowRoot) return ''; + const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; + return textarea?.value || ''; } private formatTime(ts: number): string { @@ -382,90 +388,92 @@ export class FolkPubsEditor extends HTMLElement { private render() { if (!this.shadowRoot) return; - const hasDrafts = this._drafts.length > 0; + const currentFormat = this._formats.find(f => f.id === this._selectedFormat); + const formatLabel = currentFormat ? currentFormat.name : this._selectedFormat; this.shadowRoot.innerHTML = ` ${this.getStyles()} -
-
-
-
- - -
-
- ${this._syncConnected ? 'Synced' : ''} - - - -
+
+ +
+
+ +
- -
- + + +
+
+ +
+ +
+ +
+ ${this._pdfInfo ? `${this._pdfInfo}` : ''} +
+ + +
+ ${this._view === 'write' ? this.renderWriteStep() : ''} + ${this._view === 'preview' ? this.renderPreviewStep() : ''} + ${this._view === 'publish' ? this.renderPublishStep() : ''}
`; @@ -480,6 +488,58 @@ export class FolkPubsEditor extends HTMLElement { } } + private renderWriteStep(): string { + return ` +
+ +
+
+ + ${this.escapeHtml(this._formats.find(f => f.id === this._selectedFormat)?.name || this._selectedFormat)} +
+ ${this._error ? `
${this.escapeHtml(this._error)}
` : ''} + +
+
+ `; + } + + private renderPreviewStep(): string { + return ` +
+
+ + + Download PDF +
+
+ +
+
+ ${this._pdfInfo || ''} + +
+
+ `; + } + + private renderPublishStep(): string { + return ` +
+ + +
+ `; + } + startTour() { this._tour.start(); } @@ -487,7 +547,99 @@ export class FolkPubsEditor extends HTMLElement { private bindEvents() { if (!this.shadowRoot) return; - this.shadowRoot.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); + // Tour + this.shadowRoot.querySelector(".btn-tour-trigger")?.addEventListener("click", () => this.startTour()); + + // Step navigation + this.shadowRoot.querySelectorAll(".step[data-step]").forEach(btn => { + btn.addEventListener("click", () => { + const step = (btn as HTMLElement).dataset.step as "write" | "preview" | "publish"; + if (step === "write") { + this._view = "write"; + this.render(); + } else if (step === "preview" && this._pdfUrl) { + this._view = "preview"; + this.render(); + } else if (step === "publish" && this._pdfUrl) { + this._view = "publish"; + this.render(); + } + }); + }); + + // Back navigation buttons + this.shadowRoot.querySelector('[data-action="back-to-write"]')?.addEventListener("click", () => { + this._view = "write"; + this.render(); + }); + this.shadowRoot.querySelector('[data-action="back-to-preview"]')?.addEventListener("click", () => { + this._view = "preview"; + this.render(); + }); + this.shadowRoot.querySelector('.btn-publish-next')?.addEventListener("click", () => { + this._view = "publish"; + this.render(); + }); + + // Fullscreen toggle + this.shadowRoot.querySelector('[data-action="fullscreen-toggle"]')?.addEventListener("click", () => { + const flipbook = this.shadowRoot!.querySelector("folk-pubs-flipbook"); + if (!flipbook) return; + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + flipbook.requestFullscreen().catch(() => {}); + } + }); + + // Format dropdown + this.shadowRoot.querySelector('.format-dropdown-btn')?.addEventListener("click", (e) => { + e.stopPropagation(); + this._formatDropdownOpen = !this._formatDropdownOpen; + this._draftsDropdownOpen = false; + this.renderDropdowns(); + }); + this.shadowRoot.querySelectorAll('.format-option').forEach(btn => { + btn.addEventListener("click", () => { + this._selectedFormat = (btn as HTMLElement).dataset.format!; + this._formatDropdownOpen = false; + this.syncMetaToDoc('format', this._selectedFormat); + // Clear previous PDF on format change + if (this._pdfUrl) { + URL.revokeObjectURL(this._pdfUrl); + this._pdfUrl = null; + this._pdfInfo = null; + this._view = "write"; + } + this._error = null; + this.render(); + }); + }); + + // Drafts dropdown + this.shadowRoot.querySelector('.drafts-dropdown-btn')?.addEventListener("click", (e) => { + e.stopPropagation(); + this._draftsDropdownOpen = !this._draftsDropdownOpen; + this._formatDropdownOpen = false; + this.renderDropdowns(); + }); + this.shadowRoot.querySelectorAll('.draft-option:not(.new-draft-option)').forEach(btn => { + btn.addEventListener("click", () => { + const el = btn as HTMLElement; + const docId = el.dataset.docId as DocumentId; + const draftId = el.dataset.draftId!; + this._draftsDropdownOpen = false; + if (docId !== this._activeDocId) { + this.switchDraft(docId, draftId); + } else { + this.renderDropdowns(); + } + }); + }); + this.shadowRoot.querySelector('.new-draft-option')?.addEventListener("click", () => { + this._draftsDropdownOpen = false; + this.createNewDraft(); + }); const textarea = this.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; const titleInput = this.shadowRoot.querySelector(".title-input") as HTMLInputElement; @@ -495,14 +647,19 @@ export class FolkPubsEditor extends HTMLElement { const generateBtn = this.shadowRoot.querySelector(".btn-generate") as HTMLButtonElement; const sampleBtn = this.shadowRoot.querySelector(".btn-sample"); const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement; - const newDraftBtn = this.shadowRoot.querySelector(".btn-new-draft"); - // ── Content sync → Automerge (debounced) ── + // Content sync + word count update textarea?.addEventListener("input", () => { this.syncContentToDoc(textarea.value); + this.updateWordCount(); }); - // ── Title/Author sync → Automerge (immediate) ── + // Update word count on initial render + if (textarea) { + requestAnimationFrame(() => this.updateWordCount()); + } + + // Title/Author sync titleInput?.addEventListener("input", () => { this.syncMetaToDoc('title', titleInput.value); }); @@ -510,58 +667,28 @@ export class FolkPubsEditor extends HTMLElement { this.syncMetaToDoc('author', authorInput.value); }); - // ── New draft button ── - newDraftBtn?.addEventListener("click", () => { - this.createNewDraft(); - }); - - // ── Draft list items ── - this.shadowRoot.querySelectorAll(".draft-item").forEach((btn) => { - btn.addEventListener("click", () => { - const el = btn as HTMLElement; - const docId = el.dataset.docId as DocumentId; - const draftId = el.dataset.draftId!; - if (docId !== this._activeDocId) { - this.switchDraft(docId, draftId); - } - }); - }); - - // Format buttons - this.shadowRoot.querySelectorAll(".format-btn").forEach((btn) => { - btn.addEventListener("click", () => { - this._selectedFormat = (btn as HTMLElement).dataset.format!; - this.syncMetaToDoc('format', this._selectedFormat); - // Clear previous PDF on format change - if (this._pdfUrl) { - URL.revokeObjectURL(this._pdfUrl); - this._pdfUrl = null; - this._pdfInfo = null; - } - this._error = null; - this.render(); - }); - }); - // Sample content sampleBtn?.addEventListener("click", () => { + if (!textarea) return; textarea.value = SAMPLE_CONTENT; - titleInput.value = ""; - authorInput.value = ""; + if (titleInput) titleInput.value = ""; + if (authorInput) authorInput.value = ""; this.syncContentToDoc(SAMPLE_CONTENT); this.syncMetaToDoc('title', ''); this.syncMetaToDoc('author', ''); + this.updateWordCount(); }); // File upload fileInput?.addEventListener("change", () => { const file = fileInput.files?.[0]; - if (file) { + if (file && textarea) { const reader = new FileReader(); reader.onload = () => { const text = reader.result as string; textarea.value = text; this.syncContentToDoc(text); + this.updateWordCount(); }; reader.readAsText(file); } @@ -585,25 +712,15 @@ export class FolkPubsEditor extends HTMLElement { const text = reader.result as string; textarea.value = text; this.syncContentToDoc(text); + this.updateWordCount(); }; reader.readAsText(file); } }); - // Fullscreen toggle for flipbook - this.shadowRoot.querySelector(".btn-fullscreen")?.addEventListener("click", () => { - const flipbook = this.shadowRoot!.querySelector("folk-pubs-flipbook"); - if (!flipbook) return; - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - flipbook.requestFullscreen().catch(() => {}); - } - }); - // Generate PDF generateBtn?.addEventListener("click", async () => { - const content = textarea.value.trim(); + const content = textarea?.value?.trim(); if (!content) { this._error = "Please enter some content first."; this.render(); @@ -622,8 +739,8 @@ export class FolkPubsEditor extends HTMLElement { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content, - title: titleInput.value.trim() || undefined, - author: authorInput.value.trim() || undefined, + title: titleInput?.value?.trim() || undefined, + author: authorInput?.value?.trim() || undefined, format: this._selectedFormat, }), }); @@ -641,6 +758,9 @@ export class FolkPubsEditor extends HTMLElement { this._pdfPageCount = parseInt(pageCount) || 0; this._pdfInfo = `${pageCount} pages \u00B7 ${format?.name || this._selectedFormat}`; this._loading = false; + + // Auto-advance to preview + this._view = "preview"; this.render(); } catch (e: any) { this._loading = false; @@ -650,6 +770,14 @@ export class FolkPubsEditor extends HTMLElement { }); } + private updateWordCount() { + if (!this.shadowRoot) return; + const el = this.shadowRoot.querySelector('.word-count'); + if (!el) return; + const count = this.getWordCount(); + el.textContent = count > 0 ? `${count} words` : ''; + } + private getStyles(): string { return ``; } diff --git a/modules/rpubs/components/folk-pubs-publish-panel.ts b/modules/rpubs/components/folk-pubs-publish-panel.ts index 7ab179c..91323ca 100644 --- a/modules/rpubs/components/folk-pubs-publish-panel.ts +++ b/modules/rpubs/components/folk-pubs-publish-panel.ts @@ -445,7 +445,7 @@ export class FolkPubsPublishPanel extends HTMLElement { private getStyles(): string { return `