diff --git a/lib/folk-calendar.ts b/lib/folk-calendar.ts
index 95f8b0d..60ed8ae 100644
--- a/lib/folk-calendar.ts
+++ b/lib/folk-calendar.ts
@@ -350,7 +350,8 @@ export class FolkCalendar extends FolkShape {
const prevMonthLastDay = new Date(year, month, 0).getDate();
for (let i = startPadding - 1; i >= 0; i--) {
const day = prevMonthLastDay - i;
- html += `
${dayEvents.length === 0 ? `
No events
` :
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => {
@@ -1866,6 +1869,70 @@ class FolkCalendarView extends HTMLElement {
setTimeout(() => document.addEventListener("click", closeHandler), 100);
}
+ private showDayAddForm(dateStr: string) {
+ // Remove any existing add-form
+ this.shadow.querySelector(".dd-add-form")?.remove();
+
+ const friendlyDate = new Date(dateStr + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
+ const form = document.createElement("div");
+ form.className = "dd-add-form";
+ form.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const detail = this.shadow.querySelector(".day-detail");
+ if (!detail) return;
+ detail.appendChild(form);
+
+ const titleInput = form.querySelector(".dd-add-title") as HTMLInputElement;
+ titleInput.focus();
+
+ const createReminder = async (hour: number, minute = 0) => {
+ const title = titleInput.value.trim();
+ if (!title) { titleInput.focus(); titleInput.style.borderColor = "#ef4444"; return; }
+ form.remove();
+ const remindAt = new Date(`${dateStr}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`).getTime();
+ const base = this.getScheduleApiBase();
+ try {
+ await fetch(`${base}/api/reminders`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title, remindAt, allDay: false, syncToCalendar: true }),
+ });
+ if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
+ } catch (err) {
+ console.error("[rCal] Failed to create reminder:", err);
+ }
+ };
+
+ // Quick-pick time buttons
+ form.querySelectorAll
(".dd-add-time[data-hour]").forEach((btn) => {
+ btn.addEventListener("click", () => createReminder(parseInt(btn.dataset.hour!)));
+ });
+
+ // Custom time + submit
+ form.querySelector(".dd-add-submit")?.addEventListener("click", () => {
+ const input = form.querySelector(".dd-add-time-input") as HTMLInputElement;
+ const [h, m] = (input.value || "09:00").split(":").map(Number);
+ createReminder(h, m);
+ });
+
+ // Enter key in title → use 9 AM default
+ titleInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") createReminder(9);
+ });
+ }
+
startTour() { this._tour.start(); }
// ── Attach Listeners ──
@@ -2178,6 +2245,14 @@ class FolkCalendarView extends HTMLElement {
e.stopPropagation(); this.expandedDay = ""; this.render();
});
+ // Add reminder from day detail
+ $("dd-add")?.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const dateStr = (e.target as HTMLElement).dataset.addDate;
+ if (!dateStr) return;
+ this.showDayAddForm(dateStr);
+ });
+
// Modal close
$("modal-overlay")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
@@ -2644,7 +2719,20 @@ class FolkCalendarView extends HTMLElement {
.day-detail { grid-column: 1 / -1; background: var(--rs-bg-surface); border: 1px solid var(--rs-bg-surface-raised); border-radius: 8px; padding: 12px; }
.dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.dd-date { font-size: 14px; font-weight: 600; color: var(--rs-text-primary); }
+ .dd-header-actions { display: flex; gap: 4px; align-items: center; }
+ .dd-add { background: none; border: 1px solid var(--rs-border-strong, #444); color: var(--rs-primary-hover, #818cf8); font-size: 18px; cursor: pointer; padding: 2px 8px; border-radius: 6px; line-height: 1; }
+ .dd-add:hover { background: var(--rs-bg-hover); }
.dd-close { background: none; border: none; color: var(--rs-text-muted); font-size: 18px; cursor: pointer; padding: 4px 8px; }
+ .dd-add-form { margin-top: 8px; padding: 10px; background: var(--rs-bg-surface-raised, #2a2a3e); border-radius: 8px; border: 1px solid var(--rs-border-strong, #444); }
+ .dd-add-title { width: 100%; padding: 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); font-size: 13px; margin-bottom: 8px; box-sizing: border-box; }
+ .dd-add-title:focus { outline: none; border-color: var(--rs-primary-hover, #818cf8); }
+ .dd-add-times { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 8px; }
+ .dd-add-time { padding: 6px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); cursor: pointer; font-size: 12px; text-align: center; }
+ .dd-add-time:hover { border-color: var(--rs-primary-hover, #818cf8); background: var(--rs-bg-hover); }
+ .dd-add-custom { display: flex; gap: 6px; }
+ .dd-add-time-input { flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong, #444); background: var(--rs-bg-surface, #1e1e2e); color: var(--rs-text-primary, #e0e0e0); font-size: 12px; }
+ .dd-add-submit { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--rs-primary-hover, #818cf8); background: var(--rs-primary-hover, #818cf8); color: #fff; cursor: pointer; font-size: 12px; font-weight: 500; }
+ .dd-add-submit:hover { opacity: 0.9; }
.dd-event { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: 6px; margin-bottom: 4px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.dd-event:hover { background: var(--rs-bg-hover); }
.dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; }
diff --git a/modules/rpubs/components/folk-pubs-editor.ts b/modules/rpubs/components/folk-pubs-editor.ts
index 81e0b58..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;
@@ -98,7 +99,7 @@ export class FolkPubsEditor extends HTMLElement {
}
async connectedCallback() {
- this.attachShadow({ mode: "open" });
+ if (!this.shadowRoot) this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadowRoot!,
FolkPubsEditor.TOUR_STEPS,
@@ -441,8 +442,15 @@ export class FolkPubsEditor extends HTMLElement {
${this._pdfUrl ? `
` : `
@@ -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();
@@ -598,7 +617,7 @@ export class FolkPubsEditor extends HTMLElement {
this.render();
try {
- const res = await fetch(`/${this._spaceSlug}/pubs/api/generate`, {
+ const res = await fetch(`/${this._spaceSlug}/rpubs/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -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) => `- ${s}
`).join('')}
+
+
Binding
+
${guide.bindingInstructions.filter((s: string) => s).map((s: string) => `- ${s.replace(/^\s+/, '')}
`).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 b1907e7..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: ``,
}));
});
@@ -350,6 +684,7 @@ export const pubsModule: RSpaceModule = {
description: "Drop in a document, get a pocket book",
scoping: { defaultScope: 'global', userConfigurable: true },
routes,
+ publicWrite: true,
standaloneDomain: "rpubs.online",
landingPage: renderLanding,
feeds: [
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"),
diff --git a/website/canvas.html b/website/canvas.html
index ca6b60a..66e730b 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -2316,17 +2316,17 @@