rspace-online/modules/rpubs/components/folk-pubs-publish-panel.ts

563 lines
20 KiB
TypeScript

/**
* <folk-pubs-publish-panel> — Unified sidebar for sharing, DIY printing, and ordering.
*
* Three tabs: Share, DIY Print, Order.
* Replaces the old bare "Download PDF" link.
*
* Attributes:
* pdf-url — Blob URL of the generated PDF
* format-id — Selected format (a7, a6, quarter-letter, digest)
* format-name — Display name of the format
* page-count — Number of pages in the generated PDF
* space-slug — Current space slug for API calls
*/
export class FolkPubsPublishPanel extends HTMLElement {
private _pdfUrl = "";
private _formatId = "";
private _formatName = "";
private _pageCount = 0;
private _spaceSlug = "personal";
private _activeTab: "share" | "diy" | "order" = "share";
private _printers: any[] = [];
private _printersLoading = false;
private _printersError: string | null = null;
private _selectedProvider: any = null;
private _emailSending = false;
private _emailSent = false;
private _emailError: string | null = null;
private _impositionLoading = false;
private _orderStatus: string | null = null;
private _batchStatus: any = null;
static get observedAttributes() {
return ["pdf-url", "format-id", "format-name", "page-count", "space-slug"];
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === "pdf-url") this._pdfUrl = val;
else if (name === "format-id") this._formatId = val;
else if (name === "format-name") this._formatName = val;
else if (name === "page-count") this._pageCount = parseInt(val) || 0;
else if (name === "space-slug") this._spaceSlug = val;
if (this.shadowRoot) this.render();
}
connectedCallback() {
this._pdfUrl = this.getAttribute("pdf-url") || "";
this._formatId = this.getAttribute("format-id") || "";
this._formatName = this.getAttribute("format-name") || "";
this._pageCount = parseInt(this.getAttribute("page-count") || "0") || 0;
this._spaceSlug = this.getAttribute("space-slug") || "personal";
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
this.render();
}
private render() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="panel">
<div class="tabs">
<button class="tab ${this._activeTab === 'share' ? 'active' : ''}" data-tab="share">Share</button>
<button class="tab ${this._activeTab === 'diy' ? 'active' : ''}" data-tab="diy">DIY Print</button>
<button class="tab ${this._activeTab === 'order' ? 'active' : ''}" data-tab="order">Order</button>
</div>
<div class="tab-content">
${this._activeTab === 'share' ? this.renderShareTab() : ''}
${this._activeTab === 'diy' ? this.renderDiyTab() : ''}
${this._activeTab === 'order' ? this.renderOrderTab() : ''}
</div>
</div>
`;
this.bindEvents();
}
private renderShareTab(): string {
return `
<div class="section">
<a class="action-btn primary" href="${this._pdfUrl}" download>Download PDF</a>
<button class="action-btn" data-action="copy-link">Copy Flipbook Link</button>
<div class="email-row">
<input type="email" class="email-input" placeholder="Email address" />
<button class="action-btn small" data-action="email-pdf" ${this._emailSending ? 'disabled' : ''}>
${this._emailSending ? 'Sending...' : 'Send'}
</button>
</div>
${this._emailSent ? '<div class="msg success">PDF sent!</div>' : ''}
${this._emailError ? `<div class="msg error">${this.esc(this._emailError)}</div>` : ''}
</div>
<div class="meta">
${this._formatName} &middot; ${this._pageCount} pages
</div>
`;
}
private renderDiyTab(): string {
return `
<div class="section">
<button class="action-btn primary" data-action="download-imposition" ${this._impositionLoading ? 'disabled' : ''}>
${this._impositionLoading ? 'Generating...' : 'Download Imposition PDF'}
</button>
<p class="hint">Pre-arranged pages for double-sided printing &amp; folding.</p>
<div class="guide-placeholder" data-guide-target></div>
</div>
`;
}
private renderOrderTab(): string {
if (this._selectedProvider) {
return this.renderProviderDetail();
}
return `
<div class="section">
<button class="action-btn primary" data-action="find-printers" ${this._printersLoading ? 'disabled' : ''}>
${this._printersLoading ? 'Searching...' : 'Find Nearby Printers'}
</button>
${this._printersError ? `<div class="msg error">${this.esc(this._printersError)}</div>` : ''}
${this._printers.length > 0 ? this.renderPrinterList() : ''}
</div>
`;
}
private renderPrinterList(): string {
return `
<div class="printer-list">
${this._printers.map((p) => `
<button class="printer-card" data-provider-id="${this.esc(p.id)}">
<div class="printer-name">${this.esc(p.name)}</div>
<div class="printer-meta">
${this.esc(p.city)} &middot; ${p.distance_km} km
${p.source === 'curated' ? '<span class="badge">curated</span>' : ''}
</div>
${p.tags?.length ? `<div class="printer-tags">${p.tags.map((t: string) => `<span class="tag">${this.esc(t)}</span>`).join('')}</div>` : ''}
${p.capabilities?.length ? `<div class="printer-caps">${p.capabilities.join(', ')}</div>` : ''}
</button>
`).join('')}
</div>
`;
}
private renderProviderDetail(): string {
const p = this._selectedProvider;
return `
<div class="section">
<button class="back-btn" data-action="back-to-list">&larr; Back to results</button>
<div class="provider-detail">
<h4>${this.esc(p.name)}</h4>
<div class="provider-info">
${p.address ? `<div>${this.esc(p.address)}</div>` : ''}
${p.website ? `<div><a href="${this.esc(p.website)}" target="_blank" rel="noopener">${this.esc(p.website)}</a></div>` : ''}
${p.email ? `<div>${this.esc(p.email)}</div>` : ''}
${p.phone ? `<div>${this.esc(p.phone)}</div>` : ''}
${p.description ? `<div class="provider-desc">${this.esc(p.description)}</div>` : ''}
</div>
<button class="action-btn primary" data-action="place-order">Place Order</button>
<button class="action-btn" data-action="join-batch">Join Group Buy</button>
${this._orderStatus ? `<div class="msg success">${this.esc(this._orderStatus)}</div>` : ''}
${this._batchStatus ? `<div class="msg info">Batch: ${this._batchStatus.action} &middot; ${this._batchStatus.participants || '?'} participants</div>` : ''}
</div>
</div>
`;
}
private bindEvents() {
if (!this.shadowRoot) return;
// Tab switching
this.shadowRoot.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
this._activeTab = (btn as HTMLElement).dataset.tab as any;
this.render();
});
});
// Share tab actions
this.shadowRoot.querySelector('[data-action="copy-link"]')?.addEventListener("click", () => {
const url = `${window.location.origin}${window.location.pathname}`;
navigator.clipboard.writeText(url).then(() => {
const btn = this.shadowRoot!.querySelector('[data-action="copy-link"]')!;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = "Copy Flipbook Link"; }, 2000);
});
});
this.shadowRoot.querySelector('[data-action="email-pdf"]')?.addEventListener("click", () => {
this.sendEmailPdf();
});
// DIY tab
this.shadowRoot.querySelector('[data-action="download-imposition"]')?.addEventListener("click", () => {
this.downloadImposition();
});
// Load guide content
const guideTarget = this.shadowRoot.querySelector('[data-guide-target]');
if (guideTarget && this._activeTab === 'diy') {
this.loadGuide(guideTarget as HTMLElement);
}
// Order tab
this.shadowRoot.querySelector('[data-action="find-printers"]')?.addEventListener("click", () => {
this.findPrinters();
});
this.shadowRoot.querySelectorAll(".printer-card").forEach((card) => {
card.addEventListener("click", () => {
const id = (card as HTMLElement).dataset.providerId;
this._selectedProvider = this._printers.find((p) => p.id === id) || null;
this.render();
});
});
this.shadowRoot.querySelector('[data-action="back-to-list"]')?.addEventListener("click", () => {
this._selectedProvider = null;
this._orderStatus = null;
this._batchStatus = null;
this.render();
});
this.shadowRoot.querySelector('[data-action="place-order"]')?.addEventListener("click", () => {
this.placeOrder();
});
this.shadowRoot.querySelector('[data-action="join-batch"]')?.addEventListener("click", () => {
this.joinBatch();
});
}
private async sendEmailPdf() {
const input = this.shadowRoot?.querySelector(".email-input") as HTMLInputElement;
const email = input?.value?.trim();
if (!email || !email.includes("@")) {
this._emailError = "Enter a valid email address";
this.render();
return;
}
this._emailSending = true;
this._emailError = null;
this._emailSent = false;
this.render();
try {
const res = await fetch(`/${this._spaceSlug}/rpubs/api/email-pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
format: this._formatId,
// Content will be re-read from the editor via a custom event
...this.getEditorContent(),
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to send email");
}
this._emailSent = true;
} catch (e: any) {
this._emailError = e.message;
} finally {
this._emailSending = false;
this.render();
}
}
private async downloadImposition() {
this._impositionLoading = true;
this.render();
try {
const res = await fetch(`/${this._spaceSlug}/rpubs/api/imposition`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
format: this._formatId,
...this.getEditorContent(),
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Imposition generation failed");
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `imposition-${this._formatId}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e: any) {
console.error("[rpubs] Imposition error:", e);
} finally {
this._impositionLoading = false;
this.render();
}
}
private async loadGuide(target: HTMLElement) {
if (!this._formatId) return;
// Dynamically import the guide
const { getGuide, recommendedBinding, paddedPageCount } = await import("../print-guides");
const guide = getGuide(this._formatId);
if (!guide) { target.innerHTML = '<p class="hint">No guide available for this format.</p>'; return; }
const binding = recommendedBinding(this._formatId, this._pageCount);
const padded = paddedPageCount(this._pageCount);
const sheets = Math.ceil(padded / guide.pagesPerSheet);
target.innerHTML = `
<div class="guide">
<h4>${guide.formatName} — DIY Guide</h4>
<div class="guide-stat">Sheets needed: ${sheets} (${guide.parentSheet})</div>
<div class="guide-stat">Binding: ${binding}</div>
<div class="guide-stat">Paper: ${guide.paperRecommendation}</div>
<h5>Tools</h5>
<ul>${guide.tools.map((t: string) => `<li>${t}</li>`).join('')}</ul>
<h5>Folding</h5>
<ol>${guide.foldInstructions.map((s: string) => `<li>${s}</li>`).join('')}</ol>
<h5>Binding</h5>
<ol>${guide.bindingInstructions.filter((s: string) => s).map((s: string) => `<li>${s.replace(/^\s+/, '')}</li>`).join('')}</ol>
<h5>Tips</h5>
<ul>${guide.tips.map((t: string) => `<li>${t}</li>`).join('')}</ul>
</div>
`;
}
private async findPrinters() {
this._printersLoading = true;
this._printersError = null;
this.render();
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 });
});
const { latitude: lat, longitude: lng } = pos.coords;
const res = await fetch(
`/${this._spaceSlug}/rpubs/api/printers?lat=${lat}&lng=${lng}&radius=100&format=${this._formatId}`,
);
if (!res.ok) throw new Error("Failed to search printers");
const data = await res.json();
this._printers = data.providers || [];
} catch (e: any) {
if (e.code === 1) {
this._printersError = "Location access denied. Enable location to find nearby printers.";
} else {
this._printersError = e.message || "Search failed";
}
} finally {
this._printersLoading = false;
this.render();
}
}
private async placeOrder() {
if (!this._selectedProvider) return;
try {
const res = await fetch(`/${this._spaceSlug}/rpubs/api/order`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_id: this._selectedProvider.id,
provider_name: this._selectedProvider.name,
provider_distance_km: this._selectedProvider.distance_km,
total_price: 0,
currency: "USD",
format: this._formatId,
...this.getEditorContent(),
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Order failed");
}
const order = await res.json();
this._orderStatus = `Order created: ${order.id || 'confirmed'}`;
this.render();
} catch (e: any) {
this._orderStatus = `Error: ${e.message}`;
this.render();
}
}
private async joinBatch() {
if (!this._selectedProvider) return;
try {
const res = await fetch(`/${this._spaceSlug}/rpubs/api/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_id: this._selectedProvider.id,
provider_name: this._selectedProvider.name,
format: this._formatId,
...this.getEditorContent(),
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Batch operation failed");
}
this._batchStatus = await res.json();
this.render();
} catch (e: any) {
this._batchStatus = { action: "error", error: e.message };
this.render();
}
}
/** Get content from the parent editor by reading its textarea */
private getEditorContent(): { content: string; title?: string; author?: string } {
const editor = this.closest("folk-pubs-editor") || document.querySelector("folk-pubs-editor");
if (!editor?.shadowRoot) return { content: "" };
const textarea = editor.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement;
const titleInput = editor.shadowRoot.querySelector(".title-input") as HTMLInputElement;
const authorInput = editor.shadowRoot.querySelector(".author-input") as HTMLInputElement;
return {
content: textarea?.value || "",
title: titleInput?.value?.trim() || undefined,
author: authorInput?.value?.trim() || undefined,
};
}
private esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
private getStyles(): string {
return `<style>
:host { display: block; max-width: 32rem; margin: 0 auto; }
.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);