diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts index 8d5a354..1e6ba79 100644 --- a/modules/rpubs/components/folk-pubs-editor.ts +++ b/modules/rpubs/components/folk-pubs-editor.ts @@ -69,6 +69,7 @@ export class FolkPubsEditor extends HTMLElement { private _error: string | null = null; private _pdfUrl: string | null = null; private _pdfInfo: string | null = null; + private _pdfPageCount = 0; // ── Automerge collaborative state ── private _runtime: any = null; @@ -441,8 +442,15 @@ export class FolkPubsEditor extends HTMLElement { ${this._pdfUrl ? `
${this._pdfInfo || ""}
- - Download PDF + + +
` : `
@@ -582,6 +590,17 @@ export class FolkPubsEditor extends HTMLElement { } }); + // 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(); @@ -619,7 +638,8 @@ export class FolkPubsEditor extends HTMLElement { const format = this._formats.find((f) => f.id === this._selectedFormat); this._pdfUrl = URL.createObjectURL(blob); - this._pdfInfo = `${pageCount} pages · ${format?.name || this._selectedFormat}`; + this._pdfPageCount = parseInt(pageCount) || 0; + this._pdfInfo = `${pageCount} pages \u00B7 ${format?.name || this._selectedFormat}`; this._loading = false; this.render(); } catch (e: any) { @@ -887,26 +907,19 @@ export class FolkPubsEditor extends HTMLElement { text-align: center; } - .pdf-preview { + .btn-fullscreen { + display: block; width: 100%; - height: 300px; + text-align: center; + padding: 0.375rem; border: 1px solid var(--rs-border); border-radius: 0.375rem; - background: #fff; + background: var(--rs-bg-surface); + color: var(--rs-text-secondary); + font-size: 0.75rem; + cursor: pointer; } - - .btn-download { - display: block; - text-align: center; - padding: 0.5rem; - border: 1px solid var(--rs-success); - border-radius: 0.375rem; - color: var(--rs-success); - text-decoration: none; - font-size: 0.85rem; - font-weight: 500; - } - .btn-download:hover { background: rgba(34, 197, 94, 0.1); } + .btn-fullscreen:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); } .placeholder { color: var(--rs-text-muted); diff --git a/modules/rpubs/components/folk-pubs-flipbook.ts b/modules/rpubs/components/folk-pubs-flipbook.ts new file mode 100644 index 0000000..9a2b88b --- /dev/null +++ b/modules/rpubs/components/folk-pubs-flipbook.ts @@ -0,0 +1,288 @@ +/** + * — Interactive page-flip PDF preview using pdf.js + StPageFlip. + * + * Ephemeral preview for generated PDFs — no IndexedDB caching needed. + * Follows the pattern from folk-book-reader.ts. + * + * Attributes: + * pdf-url — Blob URL or HTTP URL to the PDF + */ + +const PDFJS_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.min.mjs"; +const PDFJS_WORKER_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.worker.min.mjs"; +const STPAGEFLIP_CDN = "https://unpkg.com/page-flip@2.0.7/dist/js/page-flip.browser.js"; + +export class FolkPubsFlipbook extends HTMLElement { + private _pdfUrl = ""; + private _pageImages: string[] = []; + private _numPages = 0; + private _currentPage = 0; + private _aspectRatio = 1.414; + private _isLoading = true; + private _loadingProgress = 0; + private _loadingStatus = "Preparing..."; + private _error: string | null = null; + private _flipBook: any = null; + private _keyHandler: ((e: KeyboardEvent) => void) | null = null; + private _resizeTimer: ReturnType | null = null; + + static get observedAttributes() { + return ["pdf-url"]; + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === "pdf-url" && val !== _old) { + this._pdfUrl = val; + if (this.shadowRoot) this.loadPDF(); + } + } + + connectedCallback() { + this._pdfUrl = this.getAttribute("pdf-url") || ""; + if (!this.shadowRoot) this.attachShadow({ mode: "open" }); + this.renderLoading(); + if (this._pdfUrl) this.loadPDF(); + } + + disconnectedCallback() { + this._flipBook?.destroy(); + if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler); + if (this._resizeTimer) clearTimeout(this._resizeTimer); + } + + private async loadPDF() { + this._isLoading = true; + this._error = null; + this._pageImages = []; + this.renderLoading(); + + try { + this._loadingStatus = "Loading PDF.js..."; + this.updateLoadingUI(); + + const pdfjsLib = await import(/* @vite-ignore */ PDFJS_CDN); + pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_CDN; + + this._loadingStatus = "Rendering pages..."; + this.updateLoadingUI(); + + const pdf = await pdfjsLib.getDocument(this._pdfUrl).promise; + this._numPages = pdf.numPages; + + const firstPage = await pdf.getPage(1); + const viewport = firstPage.getViewport({ scale: 1 }); + this._aspectRatio = viewport.width / viewport.height; + + const scale = 2; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + + for (let i = 1; i <= pdf.numPages; i++) { + this._loadingStatus = `Rendering page ${i} of ${pdf.numPages}...`; + this._loadingProgress = Math.round((i / pdf.numPages) * 100); + this.updateLoadingUI(); + + const page = await pdf.getPage(i); + const vp = page.getViewport({ scale }); + canvas.width = vp.width; + canvas.height = vp.height; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + await page.render({ canvasContext: ctx, viewport: vp }).promise; + this._pageImages.push(canvas.toDataURL("image/jpeg", 0.85)); + } + + this._isLoading = false; + this._currentPage = 0; + this.renderReader(); + } catch (e: any) { + this._error = e.message || "Failed to render PDF"; + this._isLoading = false; + this.renderError(); + } + } + + private renderLoading() { + if (!this.shadowRoot) return; + this.shadowRoot.innerHTML = ` + ${this.getStyles()} +
+
+
${this._loadingStatus}
+
+
+
+
+ `; + } + + private updateLoadingUI() { + if (!this.shadowRoot) return; + const status = this.shadowRoot.querySelector(".loading-status"); + const fill = this.shadowRoot.querySelector(".loading-fill") as HTMLElement; + if (status) status.textContent = this._loadingStatus; + if (fill) fill.style.width = `${this._loadingProgress}%`; + } + + private renderError() { + if (!this.shadowRoot) return; + this.shadowRoot.innerHTML = ` + ${this.getStyles()} +
+

Failed to render preview: ${this._error || "Unknown error"}

+
+ `; + } + + private renderReader() { + if (!this.shadowRoot) return; + + const maxW = Math.min((this.parentElement?.clientWidth || window.innerWidth) - 40, 700); + const maxH = 500; + let pageW = maxW / 2; + let pageH = pageW / this._aspectRatio; + if (pageH > maxH) { + pageH = maxH; + pageW = pageH * this._aspectRatio; + } + + this.shadowRoot.innerHTML = ` + ${this.getStyles()} +
+
+ +
+ +
+
+ Page ${this._currentPage + 1} of ${this._numPages} +
+
+ `; + + this.initFlipbook(pageW, pageH); + this.bindEvents(); + } + + private async initFlipbook(pageW: number, pageH: number) { + if (!this.shadowRoot) return; + const container = this.shadowRoot.querySelector(".flipbook-container") as HTMLElement; + if (!container) return; + + await this.loadStPageFlip(); + const PageFlip = (window as any).St?.PageFlip; + if (!PageFlip) 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, + }); + + 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); + } + + this._flipBook.loadFromHTML(pages); + this._flipBook.on("flip", (e: any) => { + this._currentPage = e.data; + const cur = this.shadowRoot?.querySelector(".cur"); + if (cur) cur.textContent = String(this._currentPage + 1); + }); + } + + private loadStPageFlip(): Promise { + return new Promise((resolve, reject) => { + if ((window as any).St?.PageFlip) { resolve(); return; } + const script = document.createElement("script"); + script.src = STPAGEFLIP_CDN; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load StPageFlip")); + document.head.appendChild(script); + }); + } + + private bindEvents() { + if (!this.shadowRoot) return; + + this.shadowRoot.querySelectorAll(".nav-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const dir = (btn as HTMLElement).dataset.dir; + if (dir === "prev") this._flipBook?.flipPrev(); + else this._flipBook?.flipNext(); + }); + }); + + if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler); + this._keyHandler = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft") this._flipBook?.flipPrev(); + else if (e.key === "ArrowRight") this._flipBook?.flipNext(); + }; + document.addEventListener("keydown", this._keyHandler); + + window.addEventListener("resize", () => { + if (this._resizeTimer) clearTimeout(this._resizeTimer); + this._resizeTimer = setTimeout(() => this.renderReader(), 250); + }); + } + + private getStyles(): string { + return ``; + } +} + +customElements.define("folk-pubs-flipbook", FolkPubsFlipbook); diff --git a/modules/rpubs/components/folk-pubs-publish-panel.ts b/modules/rpubs/components/folk-pubs-publish-panel.ts new file mode 100644 index 0000000..7ab179c --- /dev/null +++ b/modules/rpubs/components/folk-pubs-publish-panel.ts @@ -0,0 +1,562 @@ +/** + * — Unified sidebar for sharing, DIY printing, and ordering. + * + * Three tabs: Share, DIY Print, Order. + * Replaces the old bare "Download PDF" link. + * + * Attributes: + * pdf-url — Blob URL of the generated PDF + * format-id — Selected format (a7, a6, quarter-letter, digest) + * format-name — Display name of the format + * page-count — Number of pages in the generated PDF + * space-slug — Current space slug for API calls + */ + +export class FolkPubsPublishPanel extends HTMLElement { + private _pdfUrl = ""; + private _formatId = ""; + private _formatName = ""; + private _pageCount = 0; + private _spaceSlug = "personal"; + private _activeTab: "share" | "diy" | "order" = "share"; + private _printers: any[] = []; + private _printersLoading = false; + private _printersError: string | null = null; + private _selectedProvider: any = null; + private _emailSending = false; + private _emailSent = false; + private _emailError: string | null = null; + private _impositionLoading = false; + private _orderStatus: string | null = null; + private _batchStatus: any = null; + + static get observedAttributes() { + return ["pdf-url", "format-id", "format-name", "page-count", "space-slug"]; + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === "pdf-url") this._pdfUrl = val; + else if (name === "format-id") this._formatId = val; + else if (name === "format-name") this._formatName = val; + else if (name === "page-count") this._pageCount = parseInt(val) || 0; + else if (name === "space-slug") this._spaceSlug = val; + if (this.shadowRoot) this.render(); + } + + connectedCallback() { + this._pdfUrl = this.getAttribute("pdf-url") || ""; + this._formatId = this.getAttribute("format-id") || ""; + this._formatName = this.getAttribute("format-name") || ""; + this._pageCount = parseInt(this.getAttribute("page-count") || "0") || 0; + this._spaceSlug = this.getAttribute("space-slug") || "personal"; + if (!this.shadowRoot) this.attachShadow({ mode: "open" }); + this.render(); + } + + private render() { + if (!this.shadowRoot) return; + this.shadowRoot.innerHTML = ` + ${this.getStyles()} +
+
+ + + +
+
+ ${this._activeTab === 'share' ? this.renderShareTab() : ''} + ${this._activeTab === 'diy' ? this.renderDiyTab() : ''} + ${this._activeTab === 'order' ? this.renderOrderTab() : ''} +
+
+ `; + this.bindEvents(); + } + + private renderShareTab(): string { + return ` +
+ Download PDF + + + ${this._emailSent ? '
PDF sent!
' : ''} + ${this._emailError ? `
${this.esc(this._emailError)}
` : ''} +
+
+ ${this._formatName} · ${this._pageCount} pages +
+ `; + } + + private renderDiyTab(): string { + return ` +
+ +

Pre-arranged pages for double-sided printing & folding.

+
+
+ `; + } + + private renderOrderTab(): string { + if (this._selectedProvider) { + return this.renderProviderDetail(); + } + + return ` +
+ + ${this._printersError ? `
${this.esc(this._printersError)}
` : ''} + ${this._printers.length > 0 ? this.renderPrinterList() : ''} +
+ `; + } + + private renderPrinterList(): string { + return ` +
+ ${this._printers.map((p) => ` + + `).join('')} +
+ `; + } + + private renderProviderDetail(): string { + const p = this._selectedProvider; + return ` +
+ +
+

${this.esc(p.name)}

+
+ ${p.address ? `
${this.esc(p.address)}
` : ''} + ${p.website ? `` : ''} + ${p.email ? `
${this.esc(p.email)}
` : ''} + ${p.phone ? `
${this.esc(p.phone)}
` : ''} + ${p.description ? `
${this.esc(p.description)}
` : ''} +
+ + + ${this._orderStatus ? `
${this.esc(this._orderStatus)}
` : ''} + ${this._batchStatus ? `
Batch: ${this._batchStatus.action} · ${this._batchStatus.participants || '?'} participants
` : ''} +
+
+ `; + } + + private bindEvents() { + if (!this.shadowRoot) return; + + // Tab switching + this.shadowRoot.querySelectorAll(".tab").forEach((btn) => { + btn.addEventListener("click", () => { + this._activeTab = (btn as HTMLElement).dataset.tab as any; + this.render(); + }); + }); + + // Share tab actions + this.shadowRoot.querySelector('[data-action="copy-link"]')?.addEventListener("click", () => { + const url = `${window.location.origin}${window.location.pathname}`; + navigator.clipboard.writeText(url).then(() => { + const btn = this.shadowRoot!.querySelector('[data-action="copy-link"]')!; + btn.textContent = "Copied!"; + setTimeout(() => { btn.textContent = "Copy Flipbook Link"; }, 2000); + }); + }); + + this.shadowRoot.querySelector('[data-action="email-pdf"]')?.addEventListener("click", () => { + this.sendEmailPdf(); + }); + + // DIY tab + this.shadowRoot.querySelector('[data-action="download-imposition"]')?.addEventListener("click", () => { + this.downloadImposition(); + }); + + // Load guide content + const guideTarget = this.shadowRoot.querySelector('[data-guide-target]'); + if (guideTarget && this._activeTab === 'diy') { + this.loadGuide(guideTarget as HTMLElement); + } + + // Order tab + this.shadowRoot.querySelector('[data-action="find-printers"]')?.addEventListener("click", () => { + this.findPrinters(); + }); + + this.shadowRoot.querySelectorAll(".printer-card").forEach((card) => { + card.addEventListener("click", () => { + const id = (card as HTMLElement).dataset.providerId; + this._selectedProvider = this._printers.find((p) => p.id === id) || null; + this.render(); + }); + }); + + this.shadowRoot.querySelector('[data-action="back-to-list"]')?.addEventListener("click", () => { + this._selectedProvider = null; + this._orderStatus = null; + this._batchStatus = null; + this.render(); + }); + + this.shadowRoot.querySelector('[data-action="place-order"]')?.addEventListener("click", () => { + this.placeOrder(); + }); + + this.shadowRoot.querySelector('[data-action="join-batch"]')?.addEventListener("click", () => { + this.joinBatch(); + }); + } + + private async sendEmailPdf() { + const input = this.shadowRoot?.querySelector(".email-input") as HTMLInputElement; + const email = input?.value?.trim(); + if (!email || !email.includes("@")) { + this._emailError = "Enter a valid email address"; + this.render(); + return; + } + + this._emailSending = true; + this._emailError = null; + this._emailSent = false; + this.render(); + + try { + const res = await fetch(`/${this._spaceSlug}/rpubs/api/email-pdf`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + format: this._formatId, + // Content will be re-read from the editor via a custom event + ...this.getEditorContent(), + }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to send email"); + } + + this._emailSent = true; + } catch (e: any) { + this._emailError = e.message; + } finally { + this._emailSending = false; + this.render(); + } + } + + private async downloadImposition() { + this._impositionLoading = true; + this.render(); + + try { + const res = await fetch(`/${this._spaceSlug}/rpubs/api/imposition`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + format: this._formatId, + ...this.getEditorContent(), + }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Imposition generation failed"); + } + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `imposition-${this._formatId}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch (e: any) { + console.error("[rpubs] Imposition error:", e); + } finally { + this._impositionLoading = false; + this.render(); + } + } + + private async loadGuide(target: HTMLElement) { + if (!this._formatId) return; + + // Dynamically import the guide + const { getGuide, recommendedBinding, paddedPageCount } = await import("../print-guides"); + const guide = getGuide(this._formatId); + if (!guide) { target.innerHTML = '

No guide available for this format.

'; return; } + + const binding = recommendedBinding(this._formatId, this._pageCount); + const padded = paddedPageCount(this._pageCount); + const sheets = Math.ceil(padded / guide.pagesPerSheet); + + target.innerHTML = ` +
+

${guide.formatName} — DIY Guide

+
Sheets needed: ${sheets} (${guide.parentSheet})
+
Binding: ${binding}
+
Paper: ${guide.paperRecommendation}
+ +
Tools
+
    ${guide.tools.map((t: string) => `
  • ${t}
  • `).join('')}
+ +
Folding
+
    ${guide.foldInstructions.map((s: string) => `
  1. ${s}
  2. `).join('')}
+ +
Binding
+
    ${guide.bindingInstructions.filter((s: string) => s).map((s: string) => `
  1. ${s.replace(/^\s+/, '')}
  2. `).join('')}
+ +
Tips
+
    ${guide.tips.map((t: string) => `
  • ${t}
  • `).join('')}
+
+ `; + } + + private async findPrinters() { + this._printersLoading = true; + this._printersError = null; + this.render(); + + try { + const pos = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }); + }); + + const { latitude: lat, longitude: lng } = pos.coords; + const res = await fetch( + `/${this._spaceSlug}/rpubs/api/printers?lat=${lat}&lng=${lng}&radius=100&format=${this._formatId}`, + ); + + if (!res.ok) throw new Error("Failed to search printers"); + const data = await res.json(); + this._printers = data.providers || []; + } catch (e: any) { + if (e.code === 1) { + this._printersError = "Location access denied. Enable location to find nearby printers."; + } else { + this._printersError = e.message || "Search failed"; + } + } finally { + this._printersLoading = false; + this.render(); + } + } + + private async placeOrder() { + if (!this._selectedProvider) return; + try { + const res = await fetch(`/${this._spaceSlug}/rpubs/api/order`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider_id: this._selectedProvider.id, + provider_name: this._selectedProvider.name, + provider_distance_km: this._selectedProvider.distance_km, + total_price: 0, + currency: "USD", + format: this._formatId, + ...this.getEditorContent(), + }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Order failed"); + } + + const order = await res.json(); + this._orderStatus = `Order created: ${order.id || 'confirmed'}`; + this.render(); + } catch (e: any) { + this._orderStatus = `Error: ${e.message}`; + this.render(); + } + } + + private async joinBatch() { + if (!this._selectedProvider) return; + try { + const res = await fetch(`/${this._spaceSlug}/rpubs/api/batch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider_id: this._selectedProvider.id, + provider_name: this._selectedProvider.name, + format: this._formatId, + ...this.getEditorContent(), + }), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Batch operation failed"); + } + + this._batchStatus = await res.json(); + this.render(); + } catch (e: any) { + this._batchStatus = { action: "error", error: e.message }; + this.render(); + } + } + + /** Get content from the parent editor by reading its textarea */ + 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: "" }; + + 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; + + return { + content: textarea?.value || "", + title: titleInput?.value?.trim() || undefined, + author: authorInput?.value?.trim() || undefined, + }; + } + + private esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + private getStyles(): string { + return ``; + } +} + +customElements.define("folk-pubs-publish-panel", FolkPubsPublishPanel); diff --git a/modules/rpubs/curated-shops.json b/modules/rpubs/curated-shops.json new file mode 100644 index 0000000..5195559 --- /dev/null +++ b/modules/rpubs/curated-shops.json @@ -0,0 +1,162 @@ +[ + { + "name": "Radix Media", + "lat": 40.6863, + "lng": -73.9738, + "city": "Brooklyn, NY", + "country": "US", + "address": "1115 Flushing Ave, Brooklyn, NY 11237", + "website": "https://radixmedia.org", + "email": "info@radixmedia.org", + "capabilities": ["saddle-stitch", "perfect-bind", "laser-print", "risograph"], + "tags": ["worker-owned", "union", "eco-friendly"], + "description": "Worker-owned, union print shop specializing in publications for social justice organizations, artists, and independent publishers.", + "formats": ["a6", "quarter-letter", "digest"] + }, + { + "name": "Eberhardt Press", + "lat": 45.5231, + "lng": -122.6765, + "city": "Portland, OR", + "country": "US", + "address": "2427 SE Belmont St, Portland, OR 97214", + "website": "https://eberhardtpress.org", + "capabilities": ["saddle-stitch", "perfect-bind", "laser-print"], + "tags": ["cooperative", "community-owned", "eco-friendly"], + "description": "Community-oriented print shop creating zines, books, pamphlets, and posters for grassroots organizations.", + "formats": ["a6", "quarter-letter", "digest"] + }, + { + "name": "Collective Copies", + "lat": 42.3751, + "lng": -72.5199, + "city": "Amherst, MA", + "country": "US", + "address": "71 S Pleasant St, Amherst, MA 01002", + "website": "https://collectivecopies.com", + "capabilities": ["saddle-stitch", "laser-print", "fold"], + "tags": ["worker-owned", "cooperative"], + "description": "Worker-owned cooperative print shop serving the Pioneer Valley since 1983.", + "formats": ["a6", "quarter-letter", "digest"] + }, + { + "name": "Community Printers", + "lat": 36.9741, + "lng": -122.0308, + "city": "Santa Cruz, CA", + "country": "US", + "address": "1515 Pacific Ave, Santa Cruz, CA 95060", + "website": "https://communityprinters.com", + "capabilities": ["saddle-stitch", "perfect-bind", "laser-print"], + "tags": ["community-owned", "eco-friendly"], + "description": "Community-focused print shop producing publications, zines, and books for local organizations.", + "formats": ["a6", "quarter-letter", "digest"] + }, + { + "name": "Repetitor Press", + "lat": 43.6532, + "lng": -79.3832, + "city": "Toronto, ON", + "country": "CA", + "address": "Toronto, Ontario", + "website": "https://repetitorpress.ca", + "capabilities": ["risograph", "saddle-stitch", "fold"], + "tags": ["cooperative", "eco-friendly"], + "description": "Risograph and zine-focused print collective in Toronto.", + "formats": ["a7", "a6", "quarter-letter"] + }, + { + "name": "Calverts", + "lat": 51.5284, + "lng": -0.0739, + "city": "London", + "country": "UK", + "address": "31-39 Redchurch St, London E2 7DJ", + "website": "https://calverts.coop", + "email": "enquiries@calverts.coop", + "capabilities": ["saddle-stitch", "perfect-bind", "laser-print"], + "tags": ["cooperative", "worker-owned", "eco-friendly"], + "description": "Worker-owned cooperative operating since 1977. Design, print, and binding for ethical organizations.", + "formats": ["a7", "a6", "quarter-letter", "digest"] + }, + { + "name": "Footprint Workers Co-op", + "lat": 53.7996, + "lng": -1.5491, + "city": "Leeds", + "country": "UK", + "address": "Chapeltown Enterprise Centre, Leeds LS7 3LA", + "website": "https://footprinters.co.uk", + "email": "info@footprinters.co.uk", + "capabilities": ["saddle-stitch", "perfect-bind", "laser-print"], + "tags": ["worker-owned", "cooperative", "eco-friendly"], + "description": "Worker co-op using recycled paper and vegetable-based inks.", + "formats": ["a6", "quarter-letter", "digest"] + }, + { + "name": "Aldgate Press", + "lat": 51.5139, + "lng": -0.0686, + "city": "London", + "country": "UK", + "address": "7 Gunthorpe St, London E1 7RQ", + "website": "https://aldgatepress.co.uk", + "email": "info@aldgatepress.co.uk", + "capabilities": ["saddle-stitch", "perfect-bind", "laser-print", "letterpress"], + "tags": ["cooperative", "community-owned"], + "description": "Community print cooperative in Whitechapel.", + "formats": ["a7", "a6", "quarter-letter", "digest"] + }, + { + "name": "Letterpress Collective", + "lat": 51.4545, + "lng": -2.5879, + "city": "Bristol", + "country": "UK", + "address": "Bristol, UK", + "website": "https://thelemontree.xyz", + "capabilities": ["letterpress", "risograph", "saddle-stitch"], + "tags": ["cooperative", "eco-friendly"], + "description": "Letterpress and risograph collective producing small-run publications and artist books.", + "formats": ["a7", "a6", "quarter-letter"] + }, + { + "name": "Druckerei Thieme", + "lat": 51.3397, + "lng": 12.3731, + "city": "Leipzig", + "country": "DE", + "address": "Leipzig, Germany", + "website": "https://www.thieme-druck.de", + "capabilities": ["perfect-bind", "saddle-stitch", "laser-print"], + "tags": ["eco-friendly"], + "description": "Leipzig-based printer with strong environmental focus. FSC-certified paper, climate-neutral printing.", + "formats": ["a6", "quarter-letter", "digest"] + }, + { + "name": "Drukkerij Raddraaier", + "lat": 52.3676, + "lng": 4.9041, + "city": "Amsterdam", + "country": "NL", + "address": "Amsterdam, Netherlands", + "website": "https://raddraaier.nl", + "capabilities": ["risograph", "saddle-stitch", "fold"], + "tags": ["cooperative", "eco-friendly"], + "description": "Cooperative risograph print studio and publisher in Amsterdam.", + "formats": ["a7", "a6", "quarter-letter"] + }, + { + "name": "Sticky Institute", + "lat": -37.8136, + "lng": 144.9631, + "city": "Melbourne", + "country": "AU", + "address": "Shop 10, Campbell Arcade, Melbourne VIC 3000", + "website": "https://stickyinstitute.com", + "capabilities": ["saddle-stitch", "fold", "risograph"], + "tags": ["community-owned", "cooperative"], + "description": "Melbourne's non-profit zine shop and print space. Self-service printing and binding.", + "formats": ["a7", "a6", "quarter-letter"] + } +] diff --git a/modules/rpubs/imposition.ts b/modules/rpubs/imposition.ts new file mode 100644 index 0000000..59f1a69 --- /dev/null +++ b/modules/rpubs/imposition.ts @@ -0,0 +1,132 @@ +/** + * Saddle-stitch imposition generator using pdf-lib. + * Reorders PDF pages for 2-up printing on A4/Letter sheets with fold marks. + */ + +import { PDFDocument, rgb, LineCapStyle } from "pdf-lib"; + +const PARENT_SHEETS = { + A4: { width: 595.28, height: 841.89 }, + "US Letter": { width: 612, height: 792 }, +}; + +const FORMAT_LAYOUT: Record = { + a7: { parent: "A4", pagesPerSide: 2 }, + a6: { parent: "A4", pagesPerSide: 2 }, + "quarter-letter": { parent: "US Letter", pagesPerSide: 2 }, + digest: { parent: "US Letter", pagesPerSide: 2 }, +}; + +/** + * Generate saddle-stitch signature page order. + * For N pages (must be multiple of 4): + * Sheet 1 front: [N-1, 0], back: [1, N-2] + * Sheet 2 front: [N-3, 2], back: [3, N-4] etc. + * Returns [leftPage, rightPage] pairs, alternating front/back. 0-indexed. + */ +function saddleStitchOrder(totalPages: number): [number, number][] { + const pairs: [number, number][] = []; + const sheets = totalPages / 4; + + for (let i = 0; i < sheets; i++) { + const frontLeft = totalPages - 1 - 2 * i; + const frontRight = 2 * i; + pairs.push([frontLeft, frontRight]); + + const backLeft = 2 * i + 1; + const backRight = totalPages - 2 - 2 * i; + pairs.push([backLeft, backRight]); + } + + return pairs; +} + +function drawFoldMarks( + page: ReturnType, + parentWidth: number, + parentHeight: number, +) { + const markLen = 15; + const markColor = rgb(0.7, 0.7, 0.7); + const markWidth = 0.5; + const cx = parentWidth / 2; + + page.drawLine({ + start: { x: cx, y: parentHeight }, + end: { x: cx, y: parentHeight - markLen }, + thickness: markWidth, + color: markColor, + lineCap: LineCapStyle.Round, + dashArray: [3, 3], + }); + page.drawLine({ + start: { x: cx, y: 0 }, + end: { x: cx, y: markLen }, + thickness: markWidth, + color: markColor, + lineCap: LineCapStyle.Round, + dashArray: [3, 3], + }); +} + +export async function generateImposition( + pdfBuffer: Buffer | Uint8Array, + formatId: string, +): Promise<{ pdf: Uint8Array; sheetCount: number; pageCount: number }> { + const layout = FORMAT_LAYOUT[formatId]; + if (!layout) throw new Error(`Imposition not supported for format: ${formatId}`); + + const srcDoc = await PDFDocument.load(pdfBuffer); + const srcPages = srcDoc.getPages(); + const srcPageCount = srcPages.length; + const padded = Math.ceil(srcPageCount / 4) * 4; + + const impDoc = await PDFDocument.create(); + const parent = PARENT_SHEETS[layout.parent]; + const pairs = saddleStitchOrder(padded); + + for (const [leftIdx, rightIdx] of pairs) { + const page = impDoc.addPage([parent.width, parent.height]); + const bookPageWidth = parent.width / 2; + const bookPageHeight = parent.height; + + if (leftIdx >= 0 && leftIdx < srcPageCount) { + const [embedded] = await impDoc.embedPages([srcDoc.getPage(leftIdx)]); + const srcW = srcPages[leftIdx].getWidth(); + const srcH = srcPages[leftIdx].getHeight(); + const scale = Math.min(bookPageWidth / srcW, bookPageHeight / srcH); + const scaledW = srcW * scale; + const scaledH = srcH * scale; + page.drawPage(embedded, { + x: (bookPageWidth - scaledW) / 2, + y: (bookPageHeight - scaledH) / 2, + width: scaledW, + height: scaledH, + }); + } + + if (rightIdx >= 0 && rightIdx < srcPageCount) { + const [embedded] = await impDoc.embedPages([srcDoc.getPage(rightIdx)]); + const srcW = srcPages[rightIdx].getWidth(); + const srcH = srcPages[rightIdx].getHeight(); + const scale = Math.min(bookPageWidth / srcW, bookPageHeight / srcH); + const scaledW = srcW * scale; + const scaledH = srcH * scale; + page.drawPage(embedded, { + x: bookPageWidth + (bookPageWidth - scaledW) / 2, + y: (bookPageHeight - scaledH) / 2, + width: scaledW, + height: scaledH, + }); + } + + drawFoldMarks(page, parent.width, parent.height); + } + + const impBytes = await impDoc.save(); + return { + pdf: impBytes, + sheetCount: pairs.length / 2, + pageCount: srcPageCount, + }; +} diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts index ff78a84..6a15baf 100644 --- a/modules/rpubs/mod.ts +++ b/modules/rpubs/mod.ts @@ -9,10 +9,13 @@ import { Hono } from "hono"; import { resolve, join } from "node:path"; import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises"; import { randomUUID } from "node:crypto"; +import { createTransport, type Transporter } from "nodemailer"; import { parseMarkdown } from "./parse-document"; import { compileDocument } from "./typst-compile"; import { getFormat, FORMATS, listFormats } from "./formats"; import type { BookFormat } from "./formats"; +import { generateImposition } from "./imposition"; +import { discoverPrinters } from "./printer-discovery"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; @@ -20,6 +23,43 @@ import { renderLanding } from "./landing"; const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts"; +// ── SMTP ── + +let _smtpTransport: Transporter | null = null; + +function getSmtpTransport(): Transporter | null { + if (_smtpTransport) return _smtpTransport; + if (!process.env.SMTP_PASS) return null; + _smtpTransport = createTransport({ + host: process.env.SMTP_HOST || "mail.rmail.online", + port: Number(process.env.SMTP_PORT) || 587, + secure: Number(process.env.SMTP_PORT) === 465, + auth: { + user: process.env.SMTP_USER || "noreply@rmail.online", + pass: process.env.SMTP_PASS, + }, + tls: { rejectUnauthorized: false }, + }); + return _smtpTransport; +} + +// ── Email rate limiter (5/hour per IP) ── + +const emailRateMap = new Map(); + +function checkEmailRate(ip: string): boolean { + const now = Date.now(); + const hour = 60 * 60 * 1000; + const attempts = (emailRateMap.get(ip) || []).filter((t) => now - t < hour); + if (attempts.length >= 5) return false; + attempts.push(now); + emailRateMap.set(ip, attempts); + return true; +} + +// rCart internal URL +const RCART_URL = process.env.RCART_URL || "http://localhost:3000"; + // ── Types ── interface ArtifactRequest { @@ -319,6 +359,298 @@ routes.get("/api/artifact/:id/pdf", async (c) => { }); }); +// ── API: Generate imposition PDF ── +routes.post("/api/imposition", async (c) => { + try { + const body = await c.req.json(); + const { content, title, author, format: formatId } = body; + + if (!content || typeof content !== "string" || content.trim().length === 0) { + return c.json({ error: "Content is required" }, 400); + } + + if (!formatId || !getFormat(formatId)) { + return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); + } + + const document = parseMarkdown(content, title, author); + const result = await compileDocument({ document, formatId }); + + const imposition = await generateImposition(result.pdf, formatId); + const filename = `${document.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${formatId}-imposition.pdf`; + + return new Response(new Uint8Array(imposition.pdf), { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${filename}"`, + "X-Sheet-Count": String(imposition.sheetCount), + "X-Page-Count": String(imposition.pageCount), + }, + }); + } catch (error) { + console.error("[Pubs] Imposition error:", error); + return c.json({ error: error instanceof Error ? error.message : "Imposition generation failed" }, 500); + } +}); + +// ── API: Email PDF ── +routes.post("/api/email-pdf", async (c) => { + try { + const body = await c.req.json(); + const { content, title, author, format: formatId, email } = body; + + if (!content || typeof content !== "string" || content.trim().length === 0) { + return c.json({ error: "Content is required" }, 400); + } + if (!email || typeof email !== "string" || !email.includes("@")) { + return c.json({ error: "Valid email is required" }, 400); + } + + const format = getFormat(formatId); + if (!formatId || !format) { + return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); + } + + const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown"; + if (!checkEmailRate(ip)) { + return c.json({ error: "Rate limit exceeded (5 emails/hour). Try again later." }, 429); + } + + const transport = getSmtpTransport(); + if (!transport) { + return c.json({ error: "Email service not configured" }, 503); + } + + const document = parseMarkdown(content, title, author); + const result = await compileDocument({ document, formatId }); + + const slug = (title || document.title || "document") + .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + + const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@rmail.online"; + + await transport.sendMail({ + from: `"rPubs Press" <${fromAddr}>`, + to: email, + subject: `Your publication: ${title || document.title || "Untitled"}`, + text: [ + `Here's your publication from rPubs Pocket Press.`, + ``, + `Title: ${title || document.title || "Untitled"}`, + author ? `Author: ${author}` : null, + `Format: ${format.name} (${format.widthMm}\u00D7${format.heightMm}mm)`, + `Pages: ${result.pageCount}`, + ``, + `---`, + `rPubs \u00B7 Community pocket press`, + `https://rpubs.online`, + ].filter(Boolean).join("\n"), + html: [ + `
`, + `

Your publication is ready

`, + `

`, + `${title || document.title || "Untitled"}`, + author ? ` by ${author}` : "", + `

`, + ``, + ``, + ``, + `
Format${format.name}
Pages${result.pageCount}
`, + `

The PDF is attached below.

`, + `
`, + `

rPubs · Community pocket press · rpubs.online

`, + `
`, + ].join("\n"), + attachments: [{ + filename: `${slug}-${formatId}.pdf`, + content: Buffer.from(result.pdf), + contentType: "application/pdf", + }], + }); + + return c.json({ ok: true, message: `PDF sent to ${email}` }); + } catch (error) { + console.error("[Pubs] Email error:", error); + return c.json({ error: error instanceof Error ? error.message : "Failed to send email" }, 500); + } +}); + +// ── API: Discover printers ── +routes.get("/api/printers", async (c) => { + try { + const lat = parseFloat(c.req.query("lat") || ""); + const lng = parseFloat(c.req.query("lng") || ""); + + if (isNaN(lat) || isNaN(lng)) { + return c.json({ error: "lat and lng are required" }, 400); + } + + const radiusKm = parseFloat(c.req.query("radius") || "100"); + const formatId = c.req.query("format") || undefined; + + const providers = await discoverPrinters({ lat, lng, radiusKm, formatId }); + return c.json({ providers }); + } catch (error) { + console.error("[Pubs] Printer discovery error:", error); + return c.json({ error: error instanceof Error ? error.message : "Discovery failed" }, 500); + } +}); + +// ── API: Place order (forward to rCart) ── +routes.post("/api/order", async (c) => { + try { + const body = await c.req.json(); + const { provider_id, total_price } = body; + + if (!provider_id || total_price === undefined) { + return c.json({ error: "provider_id and total_price are required" }, 400); + } + + // Generate artifact first if content is provided + let artifactId = body.artifact_id; + if (!artifactId && body.content) { + const document = parseMarkdown(body.content, body.title, body.author); + const formatId = body.format || "digest"; + const result = await compileDocument({ document, formatId }); + + artifactId = randomUUID(); + const artifactDir = join(ARTIFACTS_DIR, artifactId); + await mkdir(artifactDir, { recursive: true }); + await writeFile(join(artifactDir, `${formatId}.pdf`), result.pdf); + await writeFile(join(artifactDir, "source.md"), body.content); + } + + const orderRes = await fetch(`${RCART_URL}/api/orders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + catalog_entry_id: body.catalog_entry_id, + artifact_id: artifactId, + provider_id, + provider_name: body.provider_name, + provider_distance_km: body.provider_distance_km, + quantity: body.quantity || 1, + production_cost: body.production_cost, + creator_payout: body.creator_payout, + community_payout: body.community_payout, + total_price, + currency: body.currency || "USD", + payment_method: "manual", + buyer_contact: body.buyer_contact, + buyer_location: body.buyer_location, + }), + }); + + if (!orderRes.ok) { + const err = await orderRes.json().catch(() => ({})); + console.error("[Pubs] rCart order failed:", err); + return c.json({ error: "Failed to create order" }, 502 as any); + } + + const order = await orderRes.json(); + return c.json(order, 201); + } catch (error) { + console.error("[Pubs] Order error:", error); + return c.json({ error: error instanceof Error ? error.message : "Order creation failed" }, 500); + } +}); + +// ── API: Batch / group buy ── +routes.post("/api/batch", async (c) => { + try { + const body = await c.req.json(); + const { artifact_id, catalog_entry_id, provider_id, provider_name, buyer_contact, buyer_location, quantity = 1 } = body; + + if (!artifact_id && !catalog_entry_id) { + return c.json({ error: "artifact_id or catalog_entry_id required" }, 400); + } + if (!provider_id) { + return c.json({ error: "provider_id required" }, 400); + } + + // Check for existing open batch + const searchParams = new URLSearchParams({ + artifact_id: artifact_id || catalog_entry_id, + status: "open", + ...(provider_id && { provider_id }), + }); + + const existingRes = await fetch(`${RCART_URL}/api/batches?${searchParams}`); + const existingData = await existingRes.json(); + const openBatches = (existingData.batches || []).filter( + (b: { provider_id: string }) => b.provider_id === provider_id + ); + + if (openBatches.length > 0) { + const batch = openBatches[0]; + const joinRes = await fetch(`${RCART_URL}/api/batches/${batch.id}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ buyer_contact, buyer_location, quantity }), + }); + + if (!joinRes.ok) { + const err = await joinRes.json().catch(() => ({})); + return c.json({ error: (err as any).error || "Failed to join batch" }, 502 as any); + } + + const result = await joinRes.json(); + return c.json({ action: "joined", ...result }); + } + + // Create new batch + const createRes = await fetch(`${RCART_URL}/api/batches`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ catalog_entry_id, artifact_id, provider_id, provider_name }), + }); + + if (!createRes.ok) { + const err = await createRes.json().catch(() => ({})); + return c.json({ error: (err as any).error || "Failed to create batch" }, 502 as any); + } + + const batch = await createRes.json(); + const joinRes = await fetch(`${RCART_URL}/api/batches/${batch.id}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ buyer_contact, buyer_location, quantity }), + }); + + if (!joinRes.ok) { + return c.json({ action: "created", batch, member: null }, 201); + } + + const joinResult = await joinRes.json(); + return c.json({ action: "created", ...joinResult }, 201); + } catch (error) { + console.error("[Pubs] Batch error:", error); + return c.json({ error: error instanceof Error ? error.message : "Batch operation failed" }, 500); + } +}); + +routes.get("/api/batch", async (c) => { + const artifactId = c.req.query("artifact_id"); + const providerId = c.req.query("provider_id"); + + if (!artifactId) { + return c.json({ error: "artifact_id required" }, 400); + } + + const params = new URLSearchParams({ artifact_id: artifactId, status: "open" }); + if (providerId) params.set("provider_id", providerId); + + try { + const res = await fetch(`${RCART_URL}/api/batches?${params}`); + if (!res.ok) return c.json({ batches: [] }); + const data = await res.json(); + return c.json(data); + } catch { + return c.json({ batches: [] }); + } +}); + // ── Page: Zine Generator (redirect to canvas with auto-spawn) ── routes.get("/zine", (c) => { const spaceSlug = c.req.param("space") || "personal"; @@ -336,7 +668,9 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ` + + `, styles: ``, })); }); diff --git a/modules/rpubs/print-guides.ts b/modules/rpubs/print-guides.ts new file mode 100644 index 0000000..82d440e --- /dev/null +++ b/modules/rpubs/print-guides.ts @@ -0,0 +1,185 @@ +/** + * DIY print & bind guides for each pocket-book format. + * Ported from rPubs-online/src/lib/diy-guides.ts. + */ + +export interface DiyGuide { + formatId: string; + formatName: string; + parentSheet: "A4" | "US Letter"; + pagesPerSheet: number; + foldType: "half" | "quarters"; + bindingType: "saddle-stitch" | "perfect-bind"; + tools: string[]; + paperRecommendation: string; + foldInstructions: string[]; + bindingInstructions: string[]; + tips: string[]; +} + +export function paddedPageCount(pageCount: number): number { + return Math.ceil(pageCount / 4) * 4; +} + +const GUIDES: Record = { + a7: { + formatId: "a7", + formatName: "A7 Pocket", + parentSheet: "A4", + pagesPerSheet: 4, + foldType: "quarters", + bindingType: "saddle-stitch", + tools: [ + "Printer (A4 paper, double-sided)", + "Bone folder or ruler edge", + "Stapler (long-reach) or needle + thread", + "Craft knife and cutting mat (optional, for trimming)", + ], + paperRecommendation: + "Standard 80gsm A4 paper works well. For a nicer feel, try 100gsm recycled paper. Use a heavier sheet (160gsm) for the cover wrap.", + foldInstructions: [ + "Print the imposition PDF double-sided, flipping on the short edge.", + "Take each printed sheet and fold it in half widthwise (hamburger fold).", + "Fold in half again, bringing the top down to the bottom — you now have a small A7 signature.", + "Crease firmly along each fold with a bone folder or ruler edge.", + "Nest all signatures inside each other in page order.", + ], + bindingInstructions: [ + "Align all nested signatures so the spine edges are flush.", + "Open the booklet flat to the center spread.", + "Mark two staple points on the spine fold, each about 1/4 from the top and bottom.", + "Staple through from the outside of the spine, or sew a pamphlet stitch.", + "Close the booklet and press firmly along the spine.", + ], + tips: [ + "A7 is tiny — test your printer alignment with a single sheet first.", + "If your printer can't do double-sided, print odd pages, flip the stack, then print even pages.", + "Trim the outer edges with a craft knife for a clean finish.", + ], + }, + a6: { + formatId: "a6", + formatName: "A6 Booklet", + parentSheet: "A4", + pagesPerSheet: 4, + foldType: "half", + bindingType: "saddle-stitch", + tools: [ + "Printer (A4 paper, double-sided)", + "Bone folder or ruler edge", + "Stapler (long-reach) or needle + thread", + "Craft knife and cutting mat (optional)", + ], + paperRecommendation: + "Standard 80-100gsm A4 paper. Use 120-160gsm card stock for a separate cover if desired.", + foldInstructions: [ + "Print the imposition PDF double-sided, flipping on the short edge.", + "Fold each printed A4 sheet in half widthwise — the fold becomes the spine.", + "Crease firmly with a bone folder.", + "Nest all folded sheets inside each other in page order.", + ], + bindingInstructions: [ + "Align all nested sheets so the spine fold is flush.", + "Open the booklet flat to the center spread.", + "Mark 2 or 3 staple/stitch points evenly along the spine fold.", + "Staple through from outside, or sew a pamphlet stitch.", + "Close and press the spine flat.", + ], + tips: [ + "A6 from A4 is the most natural zine format — minimal waste.", + "For thicker booklets (>12 sheets), consider making 2-3 separate signatures and sewing them together.", + "A rubber band around the finished booklet while drying helps keep it flat.", + ], + }, + "quarter-letter": { + formatId: "quarter-letter", + formatName: "Quarter Letter", + parentSheet: "US Letter", + pagesPerSheet: 4, + foldType: "quarters", + bindingType: "saddle-stitch", + tools: [ + "Printer (US Letter paper, double-sided)", + "Bone folder or ruler edge", + "Stapler (long-reach) or needle + thread", + "Craft knife and cutting mat (optional)", + ], + paperRecommendation: + 'Standard 20lb (75gsm) US Letter paper. For a sturdier feel, use 24lb (90gsm). Card stock (65lb / 176gsm) makes a good separate cover.', + foldInstructions: [ + "Print the imposition PDF double-sided, flipping on the short edge.", + 'Fold each sheet in half widthwise — bringing the 11" edges together.', + 'Fold in half again — you now have a quarter-letter booklet (4.25" x 5.5").', + "Crease all folds firmly.", + "Nest folded signatures inside each other in order.", + ], + bindingInstructions: [ + "Align nested signatures with spine edges flush.", + "Open to the center spread.", + "Mark 2 staple points on the spine, 1/4 from top and bottom.", + "Staple or stitch through the spine at each mark.", + "Close and press flat.", + ], + tips: [ + "Quarter Letter is the classic American zine size — easy to photocopy and distribute.", + "If you don't have a long-reach stapler, open it flat and push staples through from inside the fold onto cardboard, then bend the legs flat.", + "Trim the open edges for a professional finish.", + ], + }, + digest: { + formatId: "digest", + formatName: 'Digest (5.5" x 8.5")', + parentSheet: "US Letter", + pagesPerSheet: 2, + foldType: "half", + bindingType: "saddle-stitch", + tools: [ + "Printer (US Letter paper, double-sided)", + "Bone folder or ruler edge", + "Stapler (long-reach) or needle + thread + awl", + "Binder clips", + "PVA glue + brush (for perfect binding, if >48 pages)", + ], + paperRecommendation: + 'Standard 20lb US Letter paper for the interior. For perfect binding, use 24lb paper and a separate cover on card stock.', + foldInstructions: [ + "Print the imposition PDF double-sided, flipping on the short edge.", + 'Fold each US Letter sheet in half along the 11" edge.', + "Crease firmly with a bone folder.", + "Nest folded sheets inside each other in page order.", + ], + bindingInstructions: [ + "For saddle-stitch (up to ~48 pages / 12 sheets):", + " Align all nested sheets with spine flush.", + " Open to center spread, mark 3 stitch points along the spine.", + " Staple or sew through at each point.", + "", + "For perfect binding (thicker books, 48+ pages):", + " Stack all folded signatures in order (don't nest — stack).", + " Clamp the spine edge with binder clips, leaving 3mm exposed.", + " Score the spine with shallow cuts every 3mm to help glue grip.", + " Apply PVA glue thinly. Let dry 5 min, apply a second coat.", + " Wrap a cover sheet around the glued spine.", + " Clamp and let dry for 1-2 hours.", + " Trim the three open edges.", + ], + tips: [ + "Digest is the most common POD size — your home print will match professional prints.", + "For saddle-stitch, keep it under 48 pages (12 folded sheets) or it won't fold flat.", + "For perfect binding, work in a well-ventilated area.", + "A paper cutter gives cleaner edges than a craft knife for the final trim.", + ], + }, +}; + +export function getGuide(formatId: string): DiyGuide | undefined { + return GUIDES[formatId]; +} + +export function recommendedBinding( + formatId: string, + pageCount: number, +): "saddle-stitch" | "perfect-bind" { + if (formatId === "digest" && pageCount > 48) return "perfect-bind"; + return "saddle-stitch"; +} diff --git a/modules/rpubs/printer-discovery.ts b/modules/rpubs/printer-discovery.ts new file mode 100644 index 0000000..52102ab --- /dev/null +++ b/modules/rpubs/printer-discovery.ts @@ -0,0 +1,208 @@ +/** + * Multi-source printer discovery: curated ethical shops + OpenStreetMap. + * Ported from rPubs-online/src/lib/discover-printers.ts. + */ + +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const curatedShopsData = JSON.parse(readFileSync(join(__dirname, "curated-shops.json"), "utf-8")); + +export type ProviderSource = "curated" | "discovered"; + +export interface DiscoveredProvider { + id: string; + name: string; + source: ProviderSource; + distance_km: number; + lat: number; + lng: number; + city: string; + address?: string; + website?: string; + phone?: string; + email?: string; + capabilities?: string[]; + tags?: string[]; + description?: string; +} + +interface CuratedShop { + name: string; + lat: number; + lng: number; + city: string; + country: string; + address: string; + website: string; + email?: string; + phone?: string; + capabilities: string[]; + tags: string[]; + description: string; + formats?: string[]; +} + +const CURATED_SHOPS: CuratedShop[] = curatedShopsData as CuratedShop[]; + +function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function searchCurated( + lat: number, + lng: number, + radiusKm: number, + formatId?: string, +): DiscoveredProvider[] { + return CURATED_SHOPS.filter((shop) => { + const dist = haversineKm(lat, lng, shop.lat, shop.lng); + if (dist > radiusKm) return false; + if (formatId && shop.formats && !shop.formats.includes(formatId)) return false; + return true; + }).map((shop) => ({ + id: `curated-${shop.name.toLowerCase().replace(/\s+/g, "-")}`, + name: shop.name, + source: "curated" as const, + distance_km: Math.round(haversineKm(lat, lng, shop.lat, shop.lng) * 10) / 10, + lat: shop.lat, + lng: shop.lng, + city: `${shop.city}, ${shop.country}`, + address: shop.address, + website: shop.website, + email: shop.email, + phone: shop.phone, + capabilities: shop.capabilities, + tags: shop.tags, + description: shop.description, + })); +} + +const OVERPASS_API = "https://overpass-api.de/api/interpreter"; + +async function searchOSM( + lat: number, + lng: number, + radiusMeters: number, +): Promise { + const query = ` +[out:json][timeout:10]; +( + nwr["shop"="copyshop"](around:${radiusMeters},${lat},${lng}); + nwr["shop"="printing"](around:${radiusMeters},${lat},${lng}); + nwr["craft"="printer"](around:${radiusMeters},${lat},${lng}); + nwr["office"="printing"](around:${radiusMeters},${lat},${lng}); + nwr["amenity"="copyshop"](around:${radiusMeters},${lat},${lng}); + nwr["shop"="stationery"]["printing"="yes"](around:${radiusMeters},${lat},${lng}); +); +out center tags; +`; + + const res = await fetch(OVERPASS_API, { + method: "POST", + body: `data=${encodeURIComponent(query)}`, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "rPubs/1.0 (rspace.online)", + }, + signal: AbortSignal.timeout(12000), + }); + + if (!res.ok) return []; + + const data = await res.json(); + const elements: Array<{ + id: number; + type: string; + lat?: number; + lon?: number; + center?: { lat: number; lon: number }; + tags?: Record; + }> = data.elements || []; + + const seen = new Set(); + + return elements + .filter((el) => { + const name = el.tags?.name; + if (!name) return false; + if (seen.has(name.toLowerCase())) return false; + seen.add(name.toLowerCase()); + return true; + }) + .map((el) => { + const elLat = el.lat ?? el.center?.lat ?? lat; + const elLng = el.lon ?? el.center?.lon ?? lng; + const tags = el.tags || {}; + + const city = tags["addr:city"] || tags["addr:suburb"] || tags["addr:town"] || ""; + const street = tags["addr:street"] || ""; + const housenumber = tags["addr:housenumber"] || ""; + const address = [housenumber, street, city].filter(Boolean).join(" ").trim(); + + const capabilities: string[] = []; + if (tags["service:copy"] === "yes" || tags.shop === "copyshop") capabilities.push("laser-print"); + if (tags["service:binding"] === "yes") capabilities.push("saddle-stitch", "perfect-bind"); + if (tags["service:print"] === "yes" || tags.shop === "printing") capabilities.push("laser-print"); + + return { + id: `osm-${el.type}-${el.id}`, + name: tags.name!, + source: "discovered" as const, + distance_km: Math.round(haversineKm(lat, lng, elLat, elLng) * 10) / 10, + lat: elLat, + lng: elLng, + city: city || "Nearby", + address: address || undefined, + website: tags.website || tags["contact:website"] || undefined, + phone: tags.phone || tags["contact:phone"] || undefined, + email: tags.email || tags["contact:email"] || undefined, + capabilities: capabilities.length > 0 ? capabilities : undefined, + description: tags.description || undefined, + }; + }) + .sort((a, b) => a.distance_km - b.distance_km); +} + +export interface DiscoverOptions { + lat: number; + lng: number; + radiusKm?: number; + formatId?: string; +} + +export async function discoverPrinters(opts: DiscoverOptions): Promise { + const { lat, lng, radiusKm = 100, formatId } = opts; + const radiusMeters = radiusKm * 1000; + + const [curated, osm] = await Promise.all([ + Promise.resolve(searchCurated(lat, lng, radiusKm, formatId)), + searchOSM(lat, lng, radiusMeters).catch((err) => { + console.error("[rpubs] OSM search failed:", err); + return [] as DiscoveredProvider[]; + }), + ]); + + const curatedNames = new Set(curated.map((p) => p.name.toLowerCase())); + const filteredOsm = osm.filter((p) => !curatedNames.has(p.name.toLowerCase())); + + let allCurated = curated; + if (curated.length === 0 && filteredOsm.length < 3) { + allCurated = searchCurated(lat, lng, 20000, formatId).slice(0, 5); + } + + const combined = [...allCurated, ...filteredOsm]; + combined.sort((a, b) => a.distance_km - b.distance_km); + + return combined; +} diff --git a/package-lock.json b/package-lock.json index 47dcd7e..1f8fecc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,9 @@ "@tiptap/extension-underline": "^3.20.0", "@tiptap/pm": "^3.20.0", "@tiptap/starter-kit": "^3.20.0", + "@tiptap/y-tiptap": "^3.0.2", "@types/qrcode": "^1.5.6", + "@types/turndown": "^5.0.6", "@x402/core": "^2.3.1", "@x402/evm": "^2.5.0", "@xterm/addon-fit": "^0.11.0", @@ -43,13 +45,18 @@ "mailparser": "^3.7.2", "marked": "^17.0.3", "nodemailer": "^6.9.0", + "pdf-lib": "^1.17.1", "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.2.2", "postgres": "^3.4.5", "qrcode": "^1.5.4", "sharp": "^0.33.0", + "turndown": "^7.2.2", "web-push": "^3.6.7", - "yaml": "^2.8.2" + "y-indexeddb": "^9.0.12", + "y-prosemirror": "^1.3.7", + "yaml": "^2.8.2", + "yjs": "^13.6.30" }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -1935,6 +1942,12 @@ "@lit-labs/ssr-dom-shim": "^1.5.0" } }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -2010,6 +2023,24 @@ "axios-retry": "4.5.0" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -3992,6 +4023,26 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/y-tiptap": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.2.tgz", + "integrity": "sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.100" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.7.1", + "prosemirror-state": "^1.2.3", + "prosemirror-view": "^1.9.10", + "y-protocols": "^1.0.1", + "yjs": "^13.5.38" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4085,6 +4136,12 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "license": "MIT" }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5590,6 +5647,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -5686,6 +5753,27 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/libbase64": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", @@ -6177,6 +6265,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -7165,6 +7271,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/turndown": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -7594,6 +7709,71 @@ } } }, + "node_modules/y-indexeddb": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", + "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-prosemirror": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz", + "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.109" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "prosemirror-model": "^1.7.1", + "prosemirror-state": "^1.2.3", + "prosemirror-view": "^1.9.10", + "y-protocols": "^1.0.1", + "yjs": "^13.5.38" + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -7691,6 +7871,23 @@ "node": ">=8" } }, + "node_modules/yjs": { + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 73b5ede..f89b735 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "mailparser": "^3.7.2", "marked": "^17.0.3", "nodemailer": "^6.9.0", + "pdf-lib": "^1.17.1", "perfect-arrows": "^0.3.7", "perfect-freehand": "^1.2.2", "postgres": "^3.4.5", diff --git a/vite.config.ts b/vite.config.ts index cc35b96..1fe9549 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -149,6 +149,46 @@ export default defineConfig({ }, }); + // Build pubs flipbook component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rpubs/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rpubs"), + lib: { + entry: resolve(__dirname, "modules/rpubs/components/folk-pubs-flipbook.ts"), + formats: ["es"], + fileName: () => "folk-pubs-flipbook.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-pubs-flipbook.js", + }, + }, + }, + }); + + // Build pubs publish panel component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rpubs/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rpubs"), + lib: { + entry: resolve(__dirname, "modules/rpubs/components/folk-pubs-publish-panel.ts"), + formats: ["es"], + fileName: () => "folk-pubs-publish-panel.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-pubs-publish-panel.js", + }, + }, + }, + }); + // Copy pubs CSS mkdirSync(resolve(__dirname, "dist/modules/rpubs"), { recursive: true }); copyFileSync( @@ -317,40 +357,7 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rchoices/choices.css"), ); - // Build crowdsurf module component (with Automerge WASM for local-first client) - await wasmBuild({ - configFile: false, - root: resolve(__dirname, "modules/crowdsurf/components"), - plugins: [wasm()], - resolve: { - alias: { - '@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'), - }, - }, - build: { - target: "esnext", - emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/crowdsurf"), - lib: { - entry: resolve(__dirname, "modules/crowdsurf/components/folk-crowdsurf-dashboard.ts"), - formats: ["es"], - fileName: () => "folk-crowdsurf-dashboard.js", - }, - rollupOptions: { - output: { - entryFileNames: "folk-crowdsurf-dashboard.js", - }, - }, - }, - }); - - // Copy crowdsurf CSS - mkdirSync(resolve(__dirname, "dist/modules/crowdsurf"), { recursive: true }); - copyFileSync( - resolve(__dirname, "modules/crowdsurf/components/crowdsurf.css"), - resolve(__dirname, "dist/modules/crowdsurf/crowdsurf.css"), - ); - + // Build flows module components const flowsAlias = { "../lib/types": resolve(__dirname, "modules/rflows/lib/types.ts"),