/** * — 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 */ import { getModuleApiBase } from "../../../shared/url-helpers"; 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 _epubReflowLoading = false; private _epubFixedLoading = false; private _epubError: string | null = null; private _publishing = false; private _publishResult: { hosted_url: string; pdf_url: string; epub_url: string; epub_fixed_url: string | null } | null = null; private _publishError: string | null = null; private _publishIncludeFixed = false; private _orderStatus: string | null = null; private _batchStatus: any = null; private static readonly ICONS = { download: ``, copy: ``, mail: ``, book: ``, scissors: ``, printer: ``, location: ``, users: ``, check: ``, send: ``, share: ``, info: ``, phone: ``, globe: ``, verified: ``, }; static get observedAttributes() { return ["pdf-url", "format-id", "format-name", "page-count", "space-slug"]; } attributeChangedCallback(name: string, _old: string, val: string) { if (name === "pdf-url") this._pdfUrl = val; else if (name === "format-id") this._formatId = val; else if (name === "format-name") this._formatName = val; else if (name === "page-count") this._pageCount = parseInt(val) || 0; else if (name === "space-slug") this._spaceSlug = val; if (this.shadowRoot) this.render(); } connectedCallback() { this._pdfUrl = this.getAttribute("pdf-url") || ""; this._formatId = this.getAttribute("format-id") || ""; this._formatName = this.getAttribute("format-name") || ""; this._pageCount = parseInt(this.getAttribute("page-count") || "0") || 0; this._spaceSlug = this.getAttribute("space-slug") || "personal"; if (!this.shadowRoot) this.attachShadow({ mode: "open" }); this.render(); } private render() { if (!this.shadowRoot) return; this.shadowRoot.innerHTML = ` ${this.getStyles()}
${this._activeTab === 'share' ? this.renderShareTab() : ''} ${this._activeTab === 'diy' ? this.renderDiyTab() : ''} ${this._activeTab === 'order' ? this.renderOrderTab() : ''}
`; this.bindEvents(); } private renderShareTab(): string { return `
${FolkPubsPublishPanel.ICONS.book}
${this.esc(this._formatName)} ${this._pageCount} pages
${this.renderPublishSection()}
downloads
${FolkPubsPublishPanel.ICONS.download} Download PDF

Reflowable reads on any e-reader. Fixed layout preserves page design.

${this._epubError ? `
${this.esc(this._epubError)}
` : ''}
or send by email
${this._emailSent ? `
${FolkPubsPublishPanel.ICONS.check} PDF sent!
` : ''} ${this._emailError ? `
${this.esc(this._emailError)}
` : ''}
`; } private renderPublishSection(): string { if (this._publishResult) { return `
${FolkPubsPublishPanel.ICONS.check} Published to ${this.esc(this._spaceSlug)}
Open page
`; } return `

Saves PDF + EPUB to your space and returns a public URL anyone can visit.

${this._publishError ? `
${this.esc(this._publishError)}
` : ''} `; } private renderDiyTab(): string { return `
${FolkPubsPublishPanel.ICONS.scissors}
DIY Printing Guide
Print, fold & bind at home

Pre-arranged pages for double-sided printing & folding.

`; } private renderOrderTab(): string { if (this._selectedProvider) { return this.renderProviderDetail(); } return `
25+
Saddle stitch
~$1.20/ea
100+
Perfect bind
~$0.60/ea
${this._printersError ? `
${this.esc(this._printersError)}
` : ''} ${this._printers.length > 0 ? this.renderPrinterList() : ''}
`; } private renderPrinterList(): string { return `
${this._printers.map((p) => ` `).join('')}
`; } private renderProviderDetail(): string { const p = this._selectedProvider; return `
${FolkPubsPublishPanel.ICONS.printer}

${this.esc(p.name)}

${p.address ? `
${FolkPubsPublishPanel.ICONS.location} ${this.esc(p.address)}
` : ''} ${p.phone ? `
${FolkPubsPublishPanel.ICONS.phone} ${this.esc(p.phone)}
` : ''} ${p.website ? `
${FolkPubsPublishPanel.ICONS.globe} ${this.esc(p.website)}
` : ''} ${p.email ? `
${FolkPubsPublishPanel.ICONS.mail} ${this.esc(p.email)}
` : ''}
${p.description ? `
${this.esc(p.description)}
` : ''} ${this._orderStatus ? `
${FolkPubsPublishPanel.ICONS.check} ${this.esc(this._orderStatus)}
` : ''} ${this._batchStatus ? this.renderBatchProgress() : ''}
`; } private renderBatchProgress(): string { const b = this._batchStatus; if (b.action === 'error') return `
${this.esc(b.error)}
`; const participants = b.participants || 1; const threshold = 25; const pct = Math.min(Math.round((participants / threshold) * 100), 100); return `
${FolkPubsPublishPanel.ICONS.users} Group Buy Progress
${participants} / ${threshold} participants
25+ unlocked 50+ ${participants >= 50 ? 'unlocked' : 'locked'}
`; } 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"]')!; const span = btn.querySelector('span'); if (span) span.textContent = "Copied!"; btn.innerHTML = `${FolkPubsPublishPanel.ICONS.check}Copied!`; setTimeout(() => { btn.innerHTML = `${FolkPubsPublishPanel.ICONS.copy}Copy Flipbook Link`; }, 2000); }); }); this.shadowRoot.querySelector('[data-action="email-pdf"]')?.addEventListener("click", () => { this.sendEmailPdf(); }); this.shadowRoot.querySelector('[data-action="download-epub-reflow"]')?.addEventListener("click", () => { this.downloadEpub("reflowable"); }); this.shadowRoot.querySelector('[data-action="download-epub-fixed"]')?.addEventListener("click", () => { this.downloadEpub("fixed"); }); this.shadowRoot.querySelector('[data-action="publish-to-space"]')?.addEventListener("click", () => { this.publishToSpace(); }); this.shadowRoot.querySelector('[data-action="toggle-include-fixed"]')?.addEventListener("change", (e) => { this._publishIncludeFixed = (e.target as HTMLInputElement).checked; }); this.shadowRoot.querySelector('[data-action="copy-published-link"]')?.addEventListener("click", () => { if (!this._publishResult) return; navigator.clipboard.writeText(this._publishResult.hosted_url).then(() => { const btn = this.shadowRoot!.querySelector('[data-action="copy-published-link"] span'); if (btn) { const orig = btn.textContent; btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = orig; }, 1500); } }); }); this.shadowRoot.querySelector('[data-action="publish-again"]')?.addEventListener("click", () => { this._publishResult = null; this._publishError = null; this.render(); }); // 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(`${getModuleApiBase("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 publishToSpace() { this._publishing = true; this._publishError = null; this.render(); try { const res = await fetch(`${getModuleApiBase("rpubs")}/api/publish`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ format: this._formatId, include_fixed_epub: this._publishIncludeFixed, ...this.getEditorContent(), }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as any).error || "Publish failed"); } const data = await res.json(); this._publishResult = { hosted_url: data.hosted_url, pdf_url: data.pdf_url, epub_url: data.epub_url, epub_fixed_url: data.epub_fixed_url, }; } catch (e: any) { this._publishError = e.message; console.error("[rpubs] Publish error:", e); } finally { this._publishing = false; this.render(); } } private async downloadEpub(mode: "reflowable" | "fixed") { this._epubError = null; if (mode === "reflowable") this._epubReflowLoading = true; else this._epubFixedLoading = true; this.render(); try { const res = await fetch(`${getModuleApiBase("rpubs")}/api/generate-epub`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ format: this._formatId, mode, ...this.getEditorContent(), }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err as any).error || "EPUB generation failed"); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const suffix = mode === "fixed" ? `-${this._formatId}-fixed` : ""; a.download = `publication${suffix}.epub`; a.click(); URL.revokeObjectURL(url); } catch (e: any) { this._epubError = e.message; console.error("[rpubs] EPUB error:", e); } finally { if (mode === "reflowable") this._epubReflowLoading = false; else this._epubFixedLoading = false; this.render(); } } private async downloadImposition() { this._impositionLoading = true; this.render(); try { const res = await fetch(`${getModuleApiBase("rpubs")}/api/imposition`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ format: this._formatId, ...this.getEditorContent(), }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Imposition generation failed"); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `imposition-${this._formatId}.pdf`; a.click(); URL.revokeObjectURL(url); } catch (e: any) { console.error("[rpubs] Imposition error:", e); } finally { this._impositionLoading = false; this.render(); } } private async loadGuide(target: HTMLElement) { if (!this._formatId) return; // Dynamically import the guide const { getGuide, recommendedBinding, paddedPageCount } = await import("../print-guides"); const guide = getGuide(this._formatId); if (!guide) { target.innerHTML = '

No guide available for this format.

'; return; } const binding = recommendedBinding(this._formatId, this._pageCount); const padded = paddedPageCount(this._pageCount); const sheets = Math.ceil(padded / guide.pagesPerSheet); target.innerHTML = `
${sheets} Sheets
${guide.parentSheet} Paper
${binding} Binding
${FolkPubsPublishPanel.ICONS.info} ${guide.paperRecommendation}
Tools needed
    ${guide.tools.map((t: string) => `
  • ${t}
  • `).join('')}
Folding
${guide.foldInstructions.map((s: string, i: number) => `
${i + 1} ${s}
`).join('')}
Binding
${guide.bindingInstructions.filter((s: string) => s).map((s: string, i: number) => `
${i + 1} ${s.replace(/^\s+/, '')}
`).join('')}
Tips
    ${guide.tips.map((t: string) => `
  • \u2605 ${t}
  • `).join('')}
`; } private async findPrinters() { this._printersLoading = true; this._printersError = null; this.render(); try { const pos = await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }); }); const { latitude: lat, longitude: lng } = pos.coords; const res = await fetch( `${getModuleApiBase("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(`${getModuleApiBase("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(`${getModuleApiBase("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 — uses cached values since textarea may not be in DOM during publish step */ private getEditorContent(): { content: string; title?: string; author?: string } { const editor = this.closest("folk-pubs-editor") || document.querySelector("folk-pubs-editor"); if (!editor) return { content: "" }; // Try cached content first (always available after PDF generation) const cached = (editor as any).cachedContent; if (cached?.content) { return { content: cached.content, title: cached.title || undefined, author: cached.author || undefined, }; } // Fallback: try reading from shadow DOM (only works during write step) if (!editor.shadowRoot) return { content: "" }; const textarea = editor.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; const titleInput = editor.shadowRoot.querySelector(".title-input") as HTMLInputElement; const authorInput = editor.shadowRoot.querySelector(".author-input") as HTMLInputElement; return { content: textarea?.value || "", title: titleInput?.value?.trim() || undefined, author: authorInput?.value?.trim() || undefined, }; } private esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } private getStyles(): string { return ``; } } customElements.define("folk-pubs-publish-panel", FolkPubsPublishPanel);