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:
parent
77f2e9ae56
commit
f5b455f83c
|
|
@ -69,6 +69,7 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
private _error: string | null = null;
|
||||
private _pdfUrl: string | null = null;
|
||||
private _pdfInfo: string | null = null;
|
||||
private _pdfPageCount = 0;
|
||||
|
||||
// ── Automerge collaborative state ──
|
||||
private _runtime: any = null;
|
||||
|
|
@ -441,8 +442,15 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
${this._pdfUrl ? `
|
||||
<div class="result">
|
||||
<div class="result-info">${this._pdfInfo || ""}</div>
|
||||
<iframe class="pdf-preview" src="${this._pdfUrl}"></iframe>
|
||||
<a class="btn-download" href="${this._pdfUrl}" download>Download PDF</a>
|
||||
<folk-pubs-flipbook pdf-url="${this._pdfUrl}"></folk-pubs-flipbook>
|
||||
<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 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
|
||||
generateBtn?.addEventListener("click", async () => {
|
||||
const content = textarea.value.trim();
|
||||
|
|
@ -619,7 +638,8 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
const format = this._formats.find((f) => f.id === this._selectedFormat);
|
||||
|
||||
this._pdfUrl = URL.createObjectURL(blob);
|
||||
this._pdfInfo = `${pageCount} pages · ${format?.name || this._selectedFormat}`;
|
||||
this._pdfPageCount = parseInt(pageCount) || 0;
|
||||
this._pdfInfo = `${pageCount} pages \u00B7 ${format?.name || this._selectedFormat}`;
|
||||
this._loading = false;
|
||||
this.render();
|
||||
} catch (e: any) {
|
||||
|
|
@ -887,26 +907,19 @@ export class FolkPubsEditor extends HTMLElement {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.pdf-preview {
|
||||
.btn-fullscreen {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
text-align: center;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid var(--rs-border);
|
||||
border-radius: 0.375rem;
|
||||
background: #fff;
|
||||
background: var(--rs-bg-surface);
|
||||
color: var(--rs-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--rs-success);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--rs-success);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-download:hover { background: rgba(34, 197, 94, 0.1); }
|
||||
.btn-fullscreen:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
|
||||
|
||||
.placeholder {
|
||||
color: var(--rs-text-muted);
|
||||
|
|
|
|||
|
|
@ -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">‹</button>
|
||||
<div class="flipbook-container" style="width:${pageW * 2}px; height:${pageH}px;"></div>
|
||||
<button class="nav-btn" data-dir="next" title="Next page">›</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);
|
||||
|
|
@ -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} · ${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 & 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)} · ${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">← 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} · ${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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<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 ──
|
||||
|
||||
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 · Community pocket press · <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) ──
|
||||
routes.get("/zine", (c) => {
|
||||
const spaceSlug = c.req.param("space") || "personal";
|
||||
|
|
@ -336,7 +668,9 @@ routes.get("/", (c) => {
|
|||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
|
||||
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue