feat(rpubs): port full feature parity from rpubs-online

Flipbook preview (pdf.js + StPageFlip), saddle-stitch imposition
(pdf-lib), DIY print guides, email PDF, printer discovery (curated
+ OSM Overpass), rCart order/batch integration, publish panel with
Share/DIY/Order tabs. 5 new API routes, 6 new files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 22:55:10 -07:00
parent 77f2e9ae56
commit f5b455f83c
11 changed files with 2144 additions and 55 deletions

View File

@ -69,6 +69,7 @@ export class FolkPubsEditor extends HTMLElement {
private _error: string | null = null; private _error: string | null = null;
private _pdfUrl: string | null = null; private _pdfUrl: string | null = null;
private _pdfInfo: string | null = null; private _pdfInfo: string | null = null;
private _pdfPageCount = 0;
// ── Automerge collaborative state ── // ── Automerge collaborative state ──
private _runtime: any = null; private _runtime: any = null;
@ -441,8 +442,15 @@ export class FolkPubsEditor extends HTMLElement {
${this._pdfUrl ? ` ${this._pdfUrl ? `
<div class="result"> <div class="result">
<div class="result-info">${this._pdfInfo || ""}</div> <div class="result-info">${this._pdfInfo || ""}</div>
<iframe class="pdf-preview" src="${this._pdfUrl}"></iframe> <folk-pubs-flipbook pdf-url="${this._pdfUrl}"></folk-pubs-flipbook>
<a class="btn-download" href="${this._pdfUrl}" download>Download PDF</a> <button class="btn-fullscreen" title="Toggle fullscreen preview">Fullscreen</button>
<folk-pubs-publish-panel
pdf-url="${this._pdfUrl}"
format-id="${this._selectedFormat}"
format-name="${this.escapeHtml(this._formats.find(f => f.id === this._selectedFormat)?.name || this._selectedFormat)}"
page-count="${this._pdfPageCount || 0}"
space-slug="${this._spaceSlug}"
></folk-pubs-publish-panel>
</div> </div>
` : ` ` : `
<div class="placeholder"> <div class="placeholder">
@ -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 // Generate PDF
generateBtn?.addEventListener("click", async () => { generateBtn?.addEventListener("click", async () => {
const content = textarea.value.trim(); const content = textarea.value.trim();
@ -619,7 +638,8 @@ export class FolkPubsEditor extends HTMLElement {
const format = this._formats.find((f) => f.id === this._selectedFormat); const format = this._formats.find((f) => f.id === this._selectedFormat);
this._pdfUrl = URL.createObjectURL(blob); 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._loading = false;
this.render(); this.render();
} catch (e: any) { } catch (e: any) {
@ -887,26 +907,19 @@ export class FolkPubsEditor extends HTMLElement {
text-align: center; text-align: center;
} }
.pdf-preview { .btn-fullscreen {
display: block;
width: 100%; width: 100%;
height: 300px; text-align: center;
padding: 0.375rem;
border: 1px solid var(--rs-border); border: 1px solid var(--rs-border);
border-radius: 0.375rem; border-radius: 0.375rem;
background: #fff; background: var(--rs-bg-surface);
color: var(--rs-text-secondary);
font-size: 0.75rem;
cursor: pointer;
} }
.btn-fullscreen:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
.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); }
.placeholder { .placeholder {
color: var(--rs-text-muted); color: var(--rs-text-muted);

View File

@ -0,0 +1,288 @@
/**
* <folk-pubs-flipbook> 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<typeof setTimeout> | 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()}
<div class="loading">
<div class="loading-spinner"></div>
<div class="loading-status">${this._loadingStatus}</div>
<div class="loading-bar">
<div class="loading-fill" style="width:${this._loadingProgress}%"></div>
</div>
</div>
`;
}
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()}
<div class="error">
<p>Failed to render preview: ${this._error || "Unknown error"}</p>
</div>
`;
}
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()}
<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>
<button class="nav-btn" data-dir="next" title="Next page">&#8250;</button>
</div>
<div class="page-info">
Page <span class="cur">${this._currentPage + 1}</span> of ${this._numPages}
</div>
</div>
`;
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<void> {
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 `<style>
:host { display: block; }
.loading {
display: flex; flex-direction: column; align-items: center;
justify-content: center; padding: 2rem; gap: 0.75rem;
}
.loading-spinner {
width: 32px; height: 32px;
border: 3px solid var(--rs-border-strong, #444);
border-top-color: #60a5fa; border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-status { color: var(--rs-text-secondary, #aaa); font-size: 0.8rem; }
.loading-bar { width: 160px; height: 3px; background: var(--rs-bg-surface, #333); border-radius: 2px; overflow: hidden; }
.loading-fill { height: 100%; background: #60a5fa; transition: width 0.3s; border-radius: 2px; }
.error { padding: 1rem; color: #f87171; font-size: 0.8rem; text-align: center; }
.reader { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
.flipbook-row { display: flex; align-items: center; gap: 0.375rem; }
.flipbook-container {
overflow: hidden; border-radius: 3px;
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
}
.nav-btn {
width: 32px; height: 60px;
border: 1px solid var(--rs-border-strong, #555);
border-radius: 0.375rem;
background: var(--rs-bg-surface, #2a2a2a);
color: var(--rs-text-primary, #eee);
font-size: 1.25rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.nav-btn:hover { background: var(--rs-border-strong, #555); }
.page-info {
font-size: 0.75rem; color: var(--rs-text-secondary, #aaa);
}
</style>`;
}
}
customElements.define("folk-pubs-flipbook", FolkPubsFlipbook);

View File

@ -0,0 +1,562 @@
/**
* <folk-pubs-publish-panel> 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()}
<div class="panel">
<div class="tabs">
<button class="tab ${this._activeTab === 'share' ? 'active' : ''}" data-tab="share">Share</button>
<button class="tab ${this._activeTab === 'diy' ? 'active' : ''}" data-tab="diy">DIY Print</button>
<button class="tab ${this._activeTab === 'order' ? 'active' : ''}" data-tab="order">Order</button>
</div>
<div class="tab-content">
${this._activeTab === 'share' ? this.renderShareTab() : ''}
${this._activeTab === 'diy' ? this.renderDiyTab() : ''}
${this._activeTab === 'order' ? this.renderOrderTab() : ''}
</div>
</div>
`;
this.bindEvents();
}
private renderShareTab(): string {
return `
<div class="section">
<a class="action-btn primary" href="${this._pdfUrl}" download>Download PDF</a>
<button class="action-btn" data-action="copy-link">Copy Flipbook Link</button>
<div class="email-row">
<input type="email" class="email-input" placeholder="Email address" />
<button class="action-btn small" data-action="email-pdf" ${this._emailSending ? 'disabled' : ''}>
${this._emailSending ? 'Sending...' : 'Send'}
</button>
</div>
${this._emailSent ? '<div class="msg success">PDF sent!</div>' : ''}
${this._emailError ? `<div class="msg error">${this.esc(this._emailError)}</div>` : ''}
</div>
<div class="meta">
${this._formatName} &middot; ${this._pageCount} pages
</div>
`;
}
private renderDiyTab(): string {
return `
<div class="section">
<button class="action-btn primary" data-action="download-imposition" ${this._impositionLoading ? 'disabled' : ''}>
${this._impositionLoading ? 'Generating...' : 'Download Imposition PDF'}
</button>
<p class="hint">Pre-arranged pages for double-sided printing &amp; folding.</p>
<div class="guide-placeholder" data-guide-target></div>
</div>
`;
}
private renderOrderTab(): string {
if (this._selectedProvider) {
return this.renderProviderDetail();
}
return `
<div class="section">
<button class="action-btn primary" data-action="find-printers" ${this._printersLoading ? 'disabled' : ''}>
${this._printersLoading ? 'Searching...' : 'Find Nearby Printers'}
</button>
${this._printersError ? `<div class="msg error">${this.esc(this._printersError)}</div>` : ''}
${this._printers.length > 0 ? this.renderPrinterList() : ''}
</div>
`;
}
private renderPrinterList(): string {
return `
<div class="printer-list">
${this._printers.map((p) => `
<button class="printer-card" data-provider-id="${this.esc(p.id)}">
<div class="printer-name">${this.esc(p.name)}</div>
<div class="printer-meta">
${this.esc(p.city)} &middot; ${p.distance_km} km
${p.source === 'curated' ? '<span class="badge">curated</span>' : ''}
</div>
${p.tags?.length ? `<div class="printer-tags">${p.tags.map((t: string) => `<span class="tag">${this.esc(t)}</span>`).join('')}</div>` : ''}
${p.capabilities?.length ? `<div class="printer-caps">${p.capabilities.join(', ')}</div>` : ''}
</button>
`).join('')}
</div>
`;
}
private renderProviderDetail(): string {
const p = this._selectedProvider;
return `
<div class="section">
<button class="back-btn" data-action="back-to-list">&larr; Back to results</button>
<div class="provider-detail">
<h4>${this.esc(p.name)}</h4>
<div class="provider-info">
${p.address ? `<div>${this.esc(p.address)}</div>` : ''}
${p.website ? `<div><a href="${this.esc(p.website)}" target="_blank" rel="noopener">${this.esc(p.website)}</a></div>` : ''}
${p.email ? `<div>${this.esc(p.email)}</div>` : ''}
${p.phone ? `<div>${this.esc(p.phone)}</div>` : ''}
${p.description ? `<div class="provider-desc">${this.esc(p.description)}</div>` : ''}
</div>
<button class="action-btn primary" data-action="place-order">Place Order</button>
<button class="action-btn" data-action="join-batch">Join Group Buy</button>
${this._orderStatus ? `<div class="msg success">${this.esc(this._orderStatus)}</div>` : ''}
${this._batchStatus ? `<div class="msg info">Batch: ${this._batchStatus.action} &middot; ${this._batchStatus.participants || '?'} participants</div>` : ''}
</div>
</div>
`;
}
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 = '<p class="hint">No guide available for this format.</p>'; return; }
const binding = recommendedBinding(this._formatId, this._pageCount);
const padded = paddedPageCount(this._pageCount);
const sheets = Math.ceil(padded / guide.pagesPerSheet);
target.innerHTML = `
<div class="guide">
<h4>${guide.formatName} DIY Guide</h4>
<div class="guide-stat">Sheets needed: ${sheets} (${guide.parentSheet})</div>
<div class="guide-stat">Binding: ${binding}</div>
<div class="guide-stat">Paper: ${guide.paperRecommendation}</div>
<h5>Tools</h5>
<ul>${guide.tools.map((t: string) => `<li>${t}</li>`).join('')}</ul>
<h5>Folding</h5>
<ol>${guide.foldInstructions.map((s: string) => `<li>${s}</li>`).join('')}</ol>
<h5>Binding</h5>
<ol>${guide.bindingInstructions.filter((s: string) => s).map((s: string) => `<li>${s.replace(/^\s+/, '')}</li>`).join('')}</ol>
<h5>Tips</h5>
<ul>${guide.tips.map((t: string) => `<li>${t}</li>`).join('')}</ul>
</div>
`;
}
private async findPrinters() {
this._printersLoading = true;
this._printersError = null;
this.render();
try {
const pos = await new Promise<GeolocationPosition>((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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
private getStyles(): string {
return `<style>
:host { display: block; }
.panel { display: flex; flex-direction: column; gap: 0.5rem; }
.tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--rs-border-subtle, #333);
}
.tab {
flex: 1; padding: 0.4rem 0.5rem;
border: none; border-bottom: 2px solid transparent;
background: transparent;
color: var(--rs-text-secondary, #aaa);
font-size: 0.75rem; font-weight: 500; cursor: pointer;
transition: all 0.15s;
}
.tab:hover { color: var(--rs-text-primary, #eee); }
.tab.active {
color: var(--rs-primary, #3b82f6);
border-bottom-color: var(--rs-primary, #3b82f6);
}
.tab-content { padding: 0.5rem 0; }
.section { display: flex; flex-direction: column; gap: 0.5rem; }
.action-btn {
display: block; width: 100%; text-align: center;
padding: 0.5rem; border-radius: 0.375rem;
border: 1px solid var(--rs-border, #444);
background: var(--rs-bg-surface, #2a2a2a);
color: var(--rs-text-primary, #eee);
font-size: 0.8rem; cursor: pointer;
text-decoration: none;
transition: all 0.15s;
}
.action-btn:hover { border-color: var(--rs-primary, #3b82f6); }
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.action-btn.primary {
background: var(--rs-primary, #3b82f6);
border-color: var(--rs-primary, #3b82f6);
color: #fff; font-weight: 600;
}
.action-btn.primary:hover { opacity: 0.9; }
.action-btn.small { width: auto; flex-shrink: 0; padding: 0.4rem 0.75rem; }
.email-row { display: flex; gap: 0.375rem; }
.email-input {
flex: 1; padding: 0.4rem 0.5rem;
border: 1px solid var(--rs-input-border, #444);
border-radius: 0.375rem;
background: var(--rs-input-bg, #1a1a2e);
color: var(--rs-input-text, #eee);
font-size: 0.8rem;
}
.email-input:focus { outline: none; border-color: var(--rs-primary, #3b82f6); }
.email-input::placeholder { color: var(--rs-text-muted, #666); }
.msg { font-size: 0.75rem; padding: 0.375rem 0.5rem; border-radius: 0.25rem; }
.msg.success { background: rgba(34, 197, 94, 0.15); color: var(--rs-success, #22c55e); }
.msg.error { background: rgba(248, 113, 113, 0.1); color: #f87171; }
.msg.info { background: rgba(59, 130, 246, 0.1); color: #60a5fa; }
.hint { font-size: 0.7rem; color: var(--rs-text-muted, #666); margin: 0; }
.meta { font-size: 0.7rem; color: var(--rs-text-secondary, #aaa); text-align: center; }
/* DIY guide */
.guide h4 { margin: 0.5rem 0 0.25rem; font-size: 0.85rem; color: var(--rs-text-primary, #eee); }
.guide h5 { margin: 0.75rem 0 0.25rem; font-size: 0.75rem; color: var(--rs-text-secondary, #aaa); text-transform: uppercase; letter-spacing: 0.05em; }
.guide ul, .guide ol { margin: 0; padding-left: 1.25rem; font-size: 0.75rem; color: var(--rs-text-primary, #ddd); line-height: 1.5; }
.guide li { margin-bottom: 0.25rem; }
.guide-stat { font-size: 0.75rem; color: var(--rs-text-secondary, #aaa); }
/* Printer list */
.printer-list { display: flex; flex-direction: column; gap: 0.375rem; max-height: 300px; overflow-y: auto; }
.printer-card {
text-align: left; padding: 0.5rem;
border: 1px solid var(--rs-border, #444);
border-radius: 0.375rem;
background: var(--rs-bg-surface, #2a2a2a);
color: var(--rs-text-primary, #eee);
cursor: pointer; transition: border-color 0.15s;
}
.printer-card:hover { border-color: var(--rs-primary, #3b82f6); }
.printer-name { font-size: 0.8rem; font-weight: 600; }
.printer-meta { font-size: 0.7rem; color: var(--rs-text-secondary, #aaa); }
.printer-tags { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.25rem; }
.tag {
font-size: 0.6rem; padding: 0.1rem 0.375rem;
border-radius: 1rem;
background: rgba(34, 197, 94, 0.15);
color: var(--rs-success, #22c55e);
}
.badge {
font-size: 0.6rem; padding: 0.1rem 0.375rem;
border-radius: 1rem;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
}
.printer-caps { font-size: 0.65rem; color: var(--rs-text-muted, #666); margin-top: 0.2rem; }
/* Provider detail */
.back-btn {
background: none; border: none;
color: var(--rs-text-secondary, #aaa);
font-size: 0.75rem; cursor: pointer;
padding: 0; text-align: left;
}
.back-btn:hover { color: var(--rs-text-primary, #eee); }
.provider-detail h4 { margin: 0.5rem 0 0.375rem; font-size: 0.9rem; }
.provider-info { font-size: 0.75rem; color: var(--rs-text-secondary, #aaa); display: flex; flex-direction: column; gap: 0.2rem; margin-bottom: 0.5rem; }
.provider-info a { color: var(--rs-primary, #3b82f6); }
.provider-desc { font-size: 0.7rem; color: var(--rs-text-muted, #666); margin-top: 0.25rem; }
</style>`;
}
}
customElements.define("folk-pubs-publish-panel", FolkPubsPublishPanel);

View File

@ -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"]
}
]

132
modules/rpubs/imposition.ts Normal file
View File

@ -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<string, { parent: "A4" | "US Letter"; pagesPerSide: 2 }> = {
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<PDFDocument["addPage"]>,
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,
};
}

View File

@ -9,10 +9,13 @@ import { Hono } from "hono";
import { resolve, join } from "node:path"; import { resolve, join } from "node:path";
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises"; import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { createTransport, type Transporter } from "nodemailer";
import { parseMarkdown } from "./parse-document"; import { parseMarkdown } from "./parse-document";
import { compileDocument } from "./typst-compile"; import { compileDocument } from "./typst-compile";
import { getFormat, FORMATS, listFormats } from "./formats"; import { getFormat, FORMATS, listFormats } from "./formats";
import type { BookFormat } from "./formats"; import type { BookFormat } from "./formats";
import { generateImposition } from "./imposition";
import { discoverPrinters } from "./printer-discovery";
import { renderShell } from "../../server/shell"; import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } 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"; 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<string, number[]>();
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 ── // ── Types ──
interface ArtifactRequest { 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: [
`<div style="font-family: system-ui, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">`,
`<h2 style="margin: 0 0 8px; font-size: 18px;">Your publication is ready</h2>`,
`<p style="color: #64748b; margin: 0 0 16px; font-size: 14px;">`,
`<strong>${title || document.title || "Untitled"}</strong>`,
author ? ` by ${author}` : "",
`</p>`,
`<table style="font-size: 13px; color: #475569; margin-bottom: 16px;">`,
`<tr><td style="padding: 2px 12px 2px 0; color: #94a3b8;">Format</td><td>${format.name}</td></tr>`,
`<tr><td style="padding: 2px 12px 2px 0; color: #94a3b8;">Pages</td><td>${result.pageCount}</td></tr>`,
`</table>`,
`<p style="font-size: 13px; color: #64748b;">The PDF is attached below.</p>`,
`<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 20px 0;" />`,
`<p style="font-size: 11px; color: #94a3b8;">rPubs &middot; Community pocket press &middot; <a href="https://rpubs.online" style="color: #5a9a7a;">rpubs.online</a></p>`,
`</div>`,
].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) ── // ── Page: Zine Generator (redirect to canvas with auto-spawn) ──
routes.get("/zine", (c) => { routes.get("/zine", (c) => {
const spaceSlug = c.req.param("space") || "personal"; const spaceSlug = c.req.param("space") || "personal";
@ -336,7 +668,9 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`, body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></script>`, scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></script>
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`, styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
})); }));
}); });

View File

@ -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<string, DiyGuide> = {
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";
}

View File

@ -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<DiscoveredProvider[]> {
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<string, string>;
}> = data.elements || [];
const seen = new Set<string>();
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<DiscoveredProvider[]> {
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;
}

199
package-lock.json generated
View File

@ -28,7 +28,9 @@
"@tiptap/extension-underline": "^3.20.0", "@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0", "@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0", "@tiptap/starter-kit": "^3.20.0",
"@tiptap/y-tiptap": "^3.0.2",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/turndown": "^5.0.6",
"@x402/core": "^2.3.1", "@x402/core": "^2.3.1",
"@x402/evm": "^2.5.0", "@x402/evm": "^2.5.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
@ -43,13 +45,18 @@
"mailparser": "^3.7.2", "mailparser": "^3.7.2",
"marked": "^17.0.3", "marked": "^17.0.3",
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0",
"pdf-lib": "^1.17.1",
"perfect-arrows": "^0.3.7", "perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2", "perfect-freehand": "^1.2.2",
"postgres": "^3.4.5", "postgres": "^3.4.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sharp": "^0.33.0", "sharp": "^0.33.0",
"turndown": "^7.2.2",
"web-push": "^3.6.7", "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": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
@ -1935,6 +1942,12 @@
"@lit-labs/ssr-dom-shim": "^1.5.0" "@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": { "node_modules/@noble/ciphers": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
@ -2010,6 +2023,24 @@
"axios-retry": "4.5.0" "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": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@ -3992,6 +4023,26 @@
"url": "https://github.com/sponsors/ueberdosis" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -4085,6 +4136,12 @@
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
"license": "MIT" "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": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@ -5590,6 +5647,16 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC" "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": { "node_modules/isows": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
@ -5686,6 +5753,27 @@
"url": "https://ko-fi.com/killymxi" "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": { "node_modules/libbase64": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
@ -6177,6 +6265,24 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/peberminta": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
@ -7165,6 +7271,15 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/tweetnacl": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", "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": { "node_modules/y18n": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
@ -7691,6 +7871,23 @@
"node": ">=8" "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": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@ -55,6 +55,7 @@
"mailparser": "^3.7.2", "mailparser": "^3.7.2",
"marked": "^17.0.3", "marked": "^17.0.3",
"nodemailer": "^6.9.0", "nodemailer": "^6.9.0",
"pdf-lib": "^1.17.1",
"perfect-arrows": "^0.3.7", "perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2", "perfect-freehand": "^1.2.2",
"postgres": "^3.4.5", "postgres": "^3.4.5",

View File

@ -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 // Copy pubs CSS
mkdirSync(resolve(__dirname, "dist/modules/rpubs"), { recursive: true }); mkdirSync(resolve(__dirname, "dist/modules/rpubs"), { recursive: true });
copyFileSync( copyFileSync(
@ -317,39 +357,6 @@ export default defineConfig({
resolve(__dirname, "dist/modules/rchoices/choices.css"), 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 // Build flows module components
const flowsAlias = { const flowsAlias = {