Merge branch 'dev'
This commit is contained in:
commit
05646eeb4e
|
|
@ -350,7 +350,8 @@ export class FolkCalendar extends FolkShape {
|
||||||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||||
for (let i = startPadding - 1; i >= 0; i--) {
|
for (let i = startPadding - 1; i >= 0; i--) {
|
||||||
const day = prevMonthLastDay - i;
|
const day = prevMonthLastDay - i;
|
||||||
html += `<div class="day other-month" data-date="${year}-${month - 1}-${day}">${day}</div>`;
|
const d = new Date(year, month - 1, day);
|
||||||
|
html += `<div class="day other-month" data-date="${d.getFullYear()}-${d.getMonth()}-${d.getDate()}">${day}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current month days
|
// Current month days
|
||||||
|
|
@ -372,7 +373,8 @@ export class FolkCalendar extends FolkShape {
|
||||||
const totalCells = startPadding + daysInMonth;
|
const totalCells = startPadding + daysInMonth;
|
||||||
const nextPadding = totalCells <= 35 ? 35 - totalCells : 42 - totalCells;
|
const nextPadding = totalCells <= 35 ? 35 - totalCells : 42 - totalCells;
|
||||||
for (let day = 1; day <= nextPadding; day++) {
|
for (let day = 1; day <= nextPadding; day++) {
|
||||||
html += `<div class="day other-month" data-date="${year}-${month + 1}-${day}">${day}</div>`;
|
const d = new Date(year, month + 1, day);
|
||||||
|
html += `<div class="day other-month" data-date="${d.getFullYear()}-${d.getMonth()}-${d.getDate()}">${day}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#daysContainer.innerHTML = html;
|
this.#daysContainer.innerHTML = html;
|
||||||
|
|
@ -451,11 +453,13 @@ export class FolkCalendar extends FolkShape {
|
||||||
return {
|
return {
|
||||||
...super.toJSON(),
|
...super.toJSON(),
|
||||||
type: "folk-calendar",
|
type: "folk-calendar",
|
||||||
selectedDate: this.selectedDate?.toISOString() || null,
|
selectedDate: this.selectedDate && !isNaN(this.selectedDate.getTime()) ? this.selectedDate.toISOString() : null,
|
||||||
events: this.events.map((e) => ({
|
events: this.events
|
||||||
...e,
|
.filter((e) => e.date && !isNaN(e.date.getTime()))
|
||||||
date: e.date.toISOString(),
|
.map((e) => ({
|
||||||
})),
|
...e,
|
||||||
|
date: e.date.toISOString(),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1688,7 +1688,10 @@ class FolkCalendarView extends HTMLElement {
|
||||||
return `<div class="day-detail">
|
return `<div class="day-detail">
|
||||||
<div class="dd-header">
|
<div class="dd-header">
|
||||||
<span class="dd-date">${label}</span>
|
<span class="dd-date">${label}</span>
|
||||||
<button class="dd-close" id="dd-close">\u2715</button>
|
<div class="dd-header-actions">
|
||||||
|
<button class="dd-add" id="dd-add" data-add-date="${dateStr}" title="Add reminder">+</button>
|
||||||
|
<button class="dd-close" id="dd-close">\u2715</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
|
${dayEvents.length === 0 ? `<div class="dd-empty">No events</div>` :
|
||||||
dayEvents.sort((a, b) => a.start_time.localeCompare(b.start_time)).map(e => {
|
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);
|
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 = `
|
||||||
|
<input type="text" class="dd-add-title" placeholder="Reminder title..." autofocus>
|
||||||
|
<div class="dd-add-times">
|
||||||
|
<button class="dd-add-time" data-hour="9">\u{1F305} 9 AM</button>
|
||||||
|
<button class="dd-add-time" data-hour="12">\u2600\uFE0F Noon</button>
|
||||||
|
<button class="dd-add-time" data-hour="17">\u{1F307} 5 PM</button>
|
||||||
|
<button class="dd-add-time" data-hour="21">\u{1F319} 9 PM</button>
|
||||||
|
</div>
|
||||||
|
<div class="dd-add-custom">
|
||||||
|
<input type="time" class="dd-add-time-input" value="09:00">
|
||||||
|
<button class="dd-add-submit">Add</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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<HTMLButtonElement>(".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(); }
|
startTour() { this._tour.start(); }
|
||||||
|
|
||||||
// ── Attach Listeners ──
|
// ── Attach Listeners ──
|
||||||
|
|
@ -2178,6 +2245,14 @@ class FolkCalendarView extends HTMLElement {
|
||||||
e.stopPropagation(); this.expandedDay = ""; this.render();
|
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 close
|
||||||
$("modal-overlay")?.addEventListener("click", (e) => {
|
$("modal-overlay")?.addEventListener("click", (e) => {
|
||||||
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
|
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; }
|
.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-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-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-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 { 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-event:hover { background: var(--rs-bg-hover); }
|
||||||
.dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; }
|
.dd-color { width: 4px; border-radius: 2px; align-self: stretch; flex-shrink: 0; }
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
private _error: string | null = null;
|
private _error: string | null = null;
|
||||||
private _pdfUrl: string | null = null;
|
private _pdfUrl: string | null = null;
|
||||||
private _pdfInfo: string | null = null;
|
private _pdfInfo: string | null = null;
|
||||||
|
private _pdfPageCount = 0;
|
||||||
|
|
||||||
// ── Automerge collaborative state ──
|
// ── Automerge collaborative state ──
|
||||||
private _runtime: any = null;
|
private _runtime: any = null;
|
||||||
|
|
@ -98,7 +99,7 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
this.attachShadow({ mode: "open" });
|
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
||||||
this._tour = new TourEngine(
|
this._tour = new TourEngine(
|
||||||
this.shadowRoot!,
|
this.shadowRoot!,
|
||||||
FolkPubsEditor.TOUR_STEPS,
|
FolkPubsEditor.TOUR_STEPS,
|
||||||
|
|
@ -441,8 +442,15 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
${this._pdfUrl ? `
|
${this._pdfUrl ? `
|
||||||
<div class="result">
|
<div class="result">
|
||||||
<div class="result-info">${this._pdfInfo || ""}</div>
|
<div class="result-info">${this._pdfInfo || ""}</div>
|
||||||
<iframe class="pdf-preview" src="${this._pdfUrl}"></iframe>
|
<folk-pubs-flipbook pdf-url="${this._pdfUrl}"></folk-pubs-flipbook>
|
||||||
<a class="btn-download" href="${this._pdfUrl}" download>Download PDF</a>
|
<button class="btn-fullscreen" title="Toggle fullscreen preview">Fullscreen</button>
|
||||||
|
<folk-pubs-publish-panel
|
||||||
|
pdf-url="${this._pdfUrl}"
|
||||||
|
format-id="${this._selectedFormat}"
|
||||||
|
format-name="${this.escapeHtml(this._formats.find(f => f.id === this._selectedFormat)?.name || this._selectedFormat)}"
|
||||||
|
page-count="${this._pdfPageCount || 0}"
|
||||||
|
space-slug="${this._spaceSlug}"
|
||||||
|
></folk-pubs-publish-panel>
|
||||||
</div>
|
</div>
|
||||||
` : `
|
` : `
|
||||||
<div class="placeholder">
|
<div class="placeholder">
|
||||||
|
|
@ -582,6 +590,17 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fullscreen toggle for flipbook
|
||||||
|
this.shadowRoot.querySelector(".btn-fullscreen")?.addEventListener("click", () => {
|
||||||
|
const flipbook = this.shadowRoot!.querySelector("folk-pubs-flipbook");
|
||||||
|
if (!flipbook) return;
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
flipbook.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Generate PDF
|
// Generate PDF
|
||||||
generateBtn?.addEventListener("click", async () => {
|
generateBtn?.addEventListener("click", async () => {
|
||||||
const content = textarea.value.trim();
|
const content = textarea.value.trim();
|
||||||
|
|
@ -598,7 +617,7 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/${this._spaceSlug}/pubs/api/generate`, {
|
const res = await fetch(`/${this._spaceSlug}/rpubs/api/generate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -619,7 +638,8 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
const format = this._formats.find((f) => f.id === this._selectedFormat);
|
const format = this._formats.find((f) => f.id === this._selectedFormat);
|
||||||
|
|
||||||
this._pdfUrl = URL.createObjectURL(blob);
|
this._pdfUrl = URL.createObjectURL(blob);
|
||||||
this._pdfInfo = `${pageCount} pages · ${format?.name || this._selectedFormat}`;
|
this._pdfPageCount = parseInt(pageCount) || 0;
|
||||||
|
this._pdfInfo = `${pageCount} pages \u00B7 ${format?.name || this._selectedFormat}`;
|
||||||
this._loading = false;
|
this._loading = false;
|
||||||
this.render();
|
this.render();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -887,26 +907,19 @@ export class FolkPubsEditor extends HTMLElement {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-preview {
|
.btn-fullscreen {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
text-align: center;
|
||||||
|
padding: 0.375rem;
|
||||||
border: 1px solid var(--rs-border);
|
border: 1px solid var(--rs-border);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
background: #fff;
|
background: var(--rs-bg-surface);
|
||||||
|
color: var(--rs-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.btn-fullscreen:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
|
||||||
.btn-download {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid var(--rs-success);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: var(--rs-success);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.btn-download:hover { background: rgba(34, 197, 94, 0.1); }
|
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
color: var(--rs-text-muted);
|
color: var(--rs-text-muted);
|
||||||
|
|
|
||||||
|
|
@ -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 { resolve, join } from "node:path";
|
||||||
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
|
import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createTransport, type Transporter } from "nodemailer";
|
||||||
import { parseMarkdown } from "./parse-document";
|
import { parseMarkdown } from "./parse-document";
|
||||||
import { compileDocument } from "./typst-compile";
|
import { compileDocument } from "./typst-compile";
|
||||||
import { getFormat, FORMATS, listFormats } from "./formats";
|
import { getFormat, FORMATS, listFormats } from "./formats";
|
||||||
import type { BookFormat } from "./formats";
|
import type { BookFormat } from "./formats";
|
||||||
|
import { generateImposition } from "./imposition";
|
||||||
|
import { discoverPrinters } from "./printer-discovery";
|
||||||
import { renderShell } from "../../server/shell";
|
import { renderShell } from "../../server/shell";
|
||||||
import { getModuleInfoList } from "../../shared/module";
|
import { getModuleInfoList } from "../../shared/module";
|
||||||
import type { RSpaceModule } from "../../shared/module";
|
import type { RSpaceModule } from "../../shared/module";
|
||||||
|
|
@ -20,6 +23,43 @@ import { renderLanding } from "./landing";
|
||||||
|
|
||||||
const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts";
|
const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts";
|
||||||
|
|
||||||
|
// ── SMTP ──
|
||||||
|
|
||||||
|
let _smtpTransport: Transporter | null = null;
|
||||||
|
|
||||||
|
function getSmtpTransport(): Transporter | null {
|
||||||
|
if (_smtpTransport) return _smtpTransport;
|
||||||
|
if (!process.env.SMTP_PASS) return null;
|
||||||
|
_smtpTransport = createTransport({
|
||||||
|
host: process.env.SMTP_HOST || "mail.rmail.online",
|
||||||
|
port: Number(process.env.SMTP_PORT) || 587,
|
||||||
|
secure: Number(process.env.SMTP_PORT) === 465,
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
return _smtpTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email rate limiter (5/hour per IP) ──
|
||||||
|
|
||||||
|
const emailRateMap = new Map<string, number[]>();
|
||||||
|
|
||||||
|
function checkEmailRate(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const hour = 60 * 60 * 1000;
|
||||||
|
const attempts = (emailRateMap.get(ip) || []).filter((t) => now - t < hour);
|
||||||
|
if (attempts.length >= 5) return false;
|
||||||
|
attempts.push(now);
|
||||||
|
emailRateMap.set(ip, attempts);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// rCart internal URL
|
||||||
|
const RCART_URL = process.env.RCART_URL || "http://localhost:3000";
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
interface ArtifactRequest {
|
interface ArtifactRequest {
|
||||||
|
|
@ -319,6 +359,298 @@ routes.get("/api/artifact/:id/pdf", async (c) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── API: Generate imposition PDF ──
|
||||||
|
routes.post("/api/imposition", async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { content, title, author, format: formatId } = body;
|
||||||
|
|
||||||
|
if (!content || typeof content !== "string" || content.trim().length === 0) {
|
||||||
|
return c.json({ error: "Content is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formatId || !getFormat(formatId)) {
|
||||||
|
return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = parseMarkdown(content, title, author);
|
||||||
|
const result = await compileDocument({ document, formatId });
|
||||||
|
|
||||||
|
const imposition = await generateImposition(result.pdf, formatId);
|
||||||
|
const filename = `${document.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${formatId}-imposition.pdf`;
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(imposition.pdf), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
"X-Sheet-Count": String(imposition.sheetCount),
|
||||||
|
"X-Page-Count": String(imposition.pageCount),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Pubs] Imposition error:", error);
|
||||||
|
return c.json({ error: error instanceof Error ? error.message : "Imposition generation failed" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: Email PDF ──
|
||||||
|
routes.post("/api/email-pdf", async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { content, title, author, format: formatId, email } = body;
|
||||||
|
|
||||||
|
if (!content || typeof content !== "string" || content.trim().length === 0) {
|
||||||
|
return c.json({ error: "Content is required" }, 400);
|
||||||
|
}
|
||||||
|
if (!email || typeof email !== "string" || !email.includes("@")) {
|
||||||
|
return c.json({ error: "Valid email is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const format = getFormat(formatId);
|
||||||
|
if (!formatId || !format) {
|
||||||
|
return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown";
|
||||||
|
if (!checkEmailRate(ip)) {
|
||||||
|
return c.json({ error: "Rate limit exceeded (5 emails/hour). Try again later." }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transport = getSmtpTransport();
|
||||||
|
if (!transport) {
|
||||||
|
return c.json({ error: "Email service not configured" }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = parseMarkdown(content, title, author);
|
||||||
|
const result = await compileDocument({ document, formatId });
|
||||||
|
|
||||||
|
const slug = (title || document.title || "document")
|
||||||
|
.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
|
|
||||||
|
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@rmail.online";
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: `"rPubs Press" <${fromAddr}>`,
|
||||||
|
to: email,
|
||||||
|
subject: `Your publication: ${title || document.title || "Untitled"}`,
|
||||||
|
text: [
|
||||||
|
`Here's your publication from rPubs Pocket Press.`,
|
||||||
|
``,
|
||||||
|
`Title: ${title || document.title || "Untitled"}`,
|
||||||
|
author ? `Author: ${author}` : null,
|
||||||
|
`Format: ${format.name} (${format.widthMm}\u00D7${format.heightMm}mm)`,
|
||||||
|
`Pages: ${result.pageCount}`,
|
||||||
|
``,
|
||||||
|
`---`,
|
||||||
|
`rPubs \u00B7 Community pocket press`,
|
||||||
|
`https://rpubs.online`,
|
||||||
|
].filter(Boolean).join("\n"),
|
||||||
|
html: [
|
||||||
|
`<div style="font-family: system-ui, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">`,
|
||||||
|
`<h2 style="margin: 0 0 8px; font-size: 18px;">Your publication is ready</h2>`,
|
||||||
|
`<p style="color: #64748b; margin: 0 0 16px; font-size: 14px;">`,
|
||||||
|
`<strong>${title || document.title || "Untitled"}</strong>`,
|
||||||
|
author ? ` by ${author}` : "",
|
||||||
|
`</p>`,
|
||||||
|
`<table style="font-size: 13px; color: #475569; margin-bottom: 16px;">`,
|
||||||
|
`<tr><td style="padding: 2px 12px 2px 0; color: #94a3b8;">Format</td><td>${format.name}</td></tr>`,
|
||||||
|
`<tr><td style="padding: 2px 12px 2px 0; color: #94a3b8;">Pages</td><td>${result.pageCount}</td></tr>`,
|
||||||
|
`</table>`,
|
||||||
|
`<p style="font-size: 13px; color: #64748b;">The PDF is attached below.</p>`,
|
||||||
|
`<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 20px 0;" />`,
|
||||||
|
`<p style="font-size: 11px; color: #94a3b8;">rPubs · 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) ──
|
// ── Page: Zine Generator (redirect to canvas with auto-spawn) ──
|
||||||
routes.get("/zine", (c) => {
|
routes.get("/zine", (c) => {
|
||||||
const spaceSlug = c.req.param("space") || "personal";
|
const spaceSlug = c.req.param("space") || "personal";
|
||||||
|
|
@ -336,7 +668,9 @@ routes.get("/", (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
|
body: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
|
||||||
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></script>`,
|
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></script>
|
||||||
|
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js"></script>
|
||||||
|
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
|
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
@ -350,6 +684,7 @@ export const pubsModule: RSpaceModule = {
|
||||||
description: "Drop in a document, get a pocket book",
|
description: "Drop in a document, get a pocket book",
|
||||||
scoping: { defaultScope: 'global', userConfigurable: true },
|
scoping: { defaultScope: 'global', userConfigurable: true },
|
||||||
routes,
|
routes,
|
||||||
|
publicWrite: true,
|
||||||
standaloneDomain: "rpubs.online",
|
standaloneDomain: "rpubs.online",
|
||||||
landingPage: renderLanding,
|
landingPage: renderLanding,
|
||||||
feeds: [
|
feeds: [
|
||||||
|
|
|
||||||
|
|
@ -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/extension-underline": "^3.20.0",
|
||||||
"@tiptap/pm": "^3.20.0",
|
"@tiptap/pm": "^3.20.0",
|
||||||
"@tiptap/starter-kit": "^3.20.0",
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
|
"@tiptap/y-tiptap": "^3.0.2",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"@x402/core": "^2.3.1",
|
"@x402/core": "^2.3.1",
|
||||||
"@x402/evm": "^2.5.0",
|
"@x402/evm": "^2.5.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
|
@ -43,13 +45,18 @@
|
||||||
"mailparser": "^3.7.2",
|
"mailparser": "^3.7.2",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"perfect-arrows": "^0.3.7",
|
"perfect-arrows": "^0.3.7",
|
||||||
"perfect-freehand": "^1.2.2",
|
"perfect-freehand": "^1.2.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"sharp": "^0.33.0",
|
"sharp": "^0.33.0",
|
||||||
|
"turndown": "^7.2.2",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"yaml": "^2.8.2"
|
"y-indexeddb": "^9.0.12",
|
||||||
|
"y-prosemirror": "^1.3.7",
|
||||||
|
"yaml": "^2.8.2",
|
||||||
|
"yjs": "^13.6.30"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
|
@ -1935,6 +1942,12 @@
|
||||||
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mixmark-io/domino": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/@noble/ciphers": {
|
"node_modules/@noble/ciphers": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||||
|
|
@ -2010,6 +2023,24 @@
|
||||||
"axios-retry": "4.5.0"
|
"axios-retry": "4.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
|
@ -3992,6 +4023,26 @@
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/y-tiptap": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.100"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.7.1",
|
||||||
|
"prosemirror-state": "^1.2.3",
|
||||||
|
"prosemirror-view": "^1.9.10",
|
||||||
|
"y-protocols": "^1.0.1",
|
||||||
|
"yjs": "^13.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -4085,6 +4136,12 @@
|
||||||
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
|
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/turndown": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
|
@ -5590,6 +5647,16 @@
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/isomorphic.js": {
|
||||||
|
"version": "0.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||||
|
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isows": {
|
"node_modules/isows": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz",
|
||||||
|
|
@ -5686,6 +5753,27 @@
|
||||||
"url": "https://ko-fi.com/killymxi"
|
"url": "https://ko-fi.com/killymxi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lib0": {
|
||||||
|
"version": "0.2.117",
|
||||||
|
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
|
||||||
|
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"isomorphic.js": "^0.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||||
|
"0gentesthtml": "bin/gentesthtml.js",
|
||||||
|
"0serve": "bin/0serve.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/libbase64": {
|
"node_modules/libbase64": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||||
|
|
@ -6177,6 +6265,24 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pdf-lib/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/peberminta": {
|
"node_modules/peberminta": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||||
|
|
@ -7165,6 +7271,15 @@
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/turndown": {
|
||||||
|
"version": "7.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
|
||||||
|
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mixmark-io/domino": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tweetnacl": {
|
"node_modules/tweetnacl": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
|
@ -7594,6 +7709,71 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y-indexeddb": {
|
||||||
|
"version": "9.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
|
||||||
|
"integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.74"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y-prosemirror": {
|
||||||
|
"version": "1.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz",
|
||||||
|
"integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.109"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"prosemirror-model": "^1.7.1",
|
||||||
|
"prosemirror-state": "^1.2.3",
|
||||||
|
"prosemirror-view": "^1.9.10",
|
||||||
|
"y-protocols": "^1.0.1",
|
||||||
|
"yjs": "^13.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/y-protocols": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.85"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"yjs": "^13.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
|
@ -7691,6 +7871,23 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yjs": {
|
||||||
|
"version": "13.6.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz",
|
||||||
|
"integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lib0": "^0.2.99"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "GitHub Sponsors ❤",
|
||||||
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
"mailparser": "^3.7.2",
|
"mailparser": "^3.7.2",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"perfect-arrows": "^0.3.7",
|
"perfect-arrows": "^0.3.7",
|
||||||
"perfect-freehand": "^1.2.2",
|
"perfect-freehand": "^1.2.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,46 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build pubs flipbook component
|
||||||
|
await wasmBuild({
|
||||||
|
configFile: false,
|
||||||
|
root: resolve(__dirname, "modules/rpubs/components"),
|
||||||
|
build: {
|
||||||
|
emptyOutDir: false,
|
||||||
|
outDir: resolve(__dirname, "dist/modules/rpubs"),
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, "modules/rpubs/components/folk-pubs-flipbook.ts"),
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: () => "folk-pubs-flipbook.js",
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "folk-pubs-flipbook.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build pubs publish panel component
|
||||||
|
await wasmBuild({
|
||||||
|
configFile: false,
|
||||||
|
root: resolve(__dirname, "modules/rpubs/components"),
|
||||||
|
build: {
|
||||||
|
emptyOutDir: false,
|
||||||
|
outDir: resolve(__dirname, "dist/modules/rpubs"),
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, "modules/rpubs/components/folk-pubs-publish-panel.ts"),
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: () => "folk-pubs-publish-panel.js",
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "folk-pubs-publish-panel.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Copy pubs CSS
|
// Copy pubs CSS
|
||||||
mkdirSync(resolve(__dirname, "dist/modules/rpubs"), { recursive: true });
|
mkdirSync(resolve(__dirname, "dist/modules/rpubs"), { recursive: true });
|
||||||
copyFileSync(
|
copyFileSync(
|
||||||
|
|
@ -317,39 +357,6 @@ export default defineConfig({
|
||||||
resolve(__dirname, "dist/modules/rchoices/choices.css"),
|
resolve(__dirname, "dist/modules/rchoices/choices.css"),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build crowdsurf module component (with Automerge WASM for local-first client)
|
|
||||||
await wasmBuild({
|
|
||||||
configFile: false,
|
|
||||||
root: resolve(__dirname, "modules/crowdsurf/components"),
|
|
||||||
plugins: [wasm()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@automerge/automerge': resolve(__dirname, 'node_modules/@automerge/automerge'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
target: "esnext",
|
|
||||||
emptyOutDir: false,
|
|
||||||
outDir: resolve(__dirname, "dist/modules/crowdsurf"),
|
|
||||||
lib: {
|
|
||||||
entry: resolve(__dirname, "modules/crowdsurf/components/folk-crowdsurf-dashboard.ts"),
|
|
||||||
formats: ["es"],
|
|
||||||
fileName: () => "folk-crowdsurf-dashboard.js",
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
entryFileNames: "folk-crowdsurf-dashboard.js",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy crowdsurf CSS
|
|
||||||
mkdirSync(resolve(__dirname, "dist/modules/crowdsurf"), { recursive: true });
|
|
||||||
copyFileSync(
|
|
||||||
resolve(__dirname, "modules/crowdsurf/components/crowdsurf.css"),
|
|
||||||
resolve(__dirname, "dist/modules/crowdsurf/crowdsurf.css"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build flows module components
|
// Build flows module components
|
||||||
const flowsAlias = {
|
const flowsAlias = {
|
||||||
|
|
|
||||||
|
|
@ -2316,17 +2316,17 @@
|
||||||
<!-- Reminder mini-calendar widget -->
|
<!-- Reminder mini-calendar widget -->
|
||||||
<style>
|
<style>
|
||||||
#reminder-widget {
|
#reminder-widget {
|
||||||
position: fixed; top: 108px; right: 16px; z-index: 1001;
|
position: fixed; z-index: 1001;
|
||||||
background: var(--rs-toolbar-bg, #1a1a2e); border: 1px solid #333;
|
background: var(--rs-toolbar-bg, #1a1a2e); border: 1px solid #333;
|
||||||
border-radius: 14px; padding: 14px; width: 260px;
|
border-radius: 14px; padding: 14px; width: 260px;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||||
opacity: 0; transform: translateY(-8px) scale(0.96);
|
opacity: 0; transform: scale(0.3); transform-origin: top left;
|
||||||
pointer-events: none; transition: opacity 0.2s, transform 0.2s;
|
pointer-events: none; transition: opacity 0.2s, transform 0.2s;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
color: var(--rs-text, #e0e0e0);
|
color: var(--rs-text, #e0e0e0);
|
||||||
}
|
}
|
||||||
#reminder-widget.visible {
|
#reminder-widget.visible {
|
||||||
opacity: 1; transform: translateY(0) scale(1); pointer-events: auto;
|
opacity: 1; transform: scale(1); pointer-events: auto;
|
||||||
}
|
}
|
||||||
.rw-header { font-size: 12px; color: #94a3b8; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
.rw-header { font-size: 12px; color: #94a3b8; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
||||||
.rw-header-icon { font-size: 16px; }
|
.rw-header-icon { font-size: 16px; }
|
||||||
|
|
@ -3118,7 +3118,17 @@
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
if (rwWidget.classList.contains("visible")) {
|
if (rwWidget.classList.contains("visible")) {
|
||||||
rwWidget.classList.remove("visible");
|
rwWidget.classList.remove("visible");
|
||||||
|
scheduleIconEl.classList.add("visible");
|
||||||
} else {
|
} else {
|
||||||
|
// Position widget at the icon, then hide the icon
|
||||||
|
const iconRect = scheduleIconEl.getBoundingClientRect();
|
||||||
|
const ww = 260 + 28; // widget width + padding
|
||||||
|
const wh = 340; // approx widget height
|
||||||
|
const left = Math.min(iconRect.left, window.innerWidth - ww);
|
||||||
|
const top = Math.min(Math.max(iconRect.top, 8), window.innerHeight - wh);
|
||||||
|
rwWidget.style.left = left + "px";
|
||||||
|
rwWidget.style.top = top + "px";
|
||||||
|
scheduleIconEl.classList.remove("visible");
|
||||||
updateReminderWidget();
|
updateReminderWidget();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -4415,14 +4425,21 @@
|
||||||
// Auto-spawn shape from ?tool= URL param (e.g. ?tool=folk-zine-gen)
|
// Auto-spawn shape from ?tool= URL param (e.g. ?tool=folk-zine-gen)
|
||||||
const toolParam = urlParams.get("tool");
|
const toolParam = urlParams.get("tool");
|
||||||
if (toolParam && shapeRegistry.has(toolParam)) {
|
if (toolParam && shapeRegistry.has(toolParam)) {
|
||||||
// Wait for sync to initialize, then auto-place the shape
|
const spawnToolShape = () => {
|
||||||
setTimeout(() => {
|
|
||||||
newShape(toolParam);
|
newShape(toolParam);
|
||||||
// Clean up URL param without reload
|
|
||||||
const cleanUrl = new URL(window.location.href);
|
const cleanUrl = new URL(window.location.href);
|
||||||
cleanUrl.searchParams.delete("tool");
|
cleanUrl.searchParams.delete("tool");
|
||||||
history.replaceState(null, "", cleanUrl.pathname + cleanUrl.search);
|
history.replaceState(null, "", cleanUrl.pathname + cleanUrl.search);
|
||||||
}, 800);
|
};
|
||||||
|
// Wait for sync to be ready, or fallback after 2s
|
||||||
|
if (sync._connected) {
|
||||||
|
setTimeout(spawnToolShape, 100);
|
||||||
|
} else {
|
||||||
|
let spawned = false;
|
||||||
|
const onReady = () => { if (!spawned) { spawned = true; setTimeout(spawnToolShape, 100); } };
|
||||||
|
document.addEventListener("community-sync-ready", onReady, { once: true });
|
||||||
|
setTimeout(onReady, 2000); // fallback if sync never fires
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed shape — pull live data from another layer/module
|
// Feed shape — pull live data from another layer/module
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue