/** * — 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; 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
${FolkPubsPublishPanel.ICONS.download} Download PDF
or send by email
${this._emailSent ? `
${FolkPubsPublishPanel.ICONS.check} PDF sent!
` : ''} ${this._emailError ? `
${this.esc(this._emailError)}
` : ''}
`; } 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(); }); // DIY tab this.shadowRoot.querySelector('[data-action="download-imposition"]')?.addEventListener("click", () => { this.downloadImposition(); }); // Load guide content const guideTarget = this.shadowRoot.querySelector('[data-guide-target]'); if (guideTarget && this._activeTab === 'diy') { this.loadGuide(guideTarget as HTMLElement); } // Order tab this.shadowRoot.querySelector('[data-action="find-printers"]')?.addEventListener("click", () => { this.findPrinters(); }); this.shadowRoot.querySelectorAll(".printer-card").forEach((card) => { card.addEventListener("click", () => { const id = (card as HTMLElement).dataset.providerId; this._selectedProvider = this._printers.find((p) => p.id === id) || null; this.render(); }); }); this.shadowRoot.querySelector('[data-action="back-to-list"]')?.addEventListener("click", () => { this._selectedProvider = null; this._orderStatus = null; this._batchStatus = null; this.render(); }); this.shadowRoot.querySelector('[data-action="place-order"]')?.addEventListener("click", () => { this.placeOrder(); }); this.shadowRoot.querySelector('[data-action="join-batch"]')?.addEventListener("click", () => { this.joinBatch(); }); } private async sendEmailPdf() { const input = this.shadowRoot?.querySelector(".email-input") as HTMLInputElement; const email = input?.value?.trim(); if (!email || !email.includes("@")) { this._emailError = "Enter a valid email address"; this.render(); return; } this._emailSending = true; this._emailError = null; this._emailSent = false; this.render(); try { const res = await fetch(`/${this._spaceSlug}/rpubs/api/email-pdf`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, format: this._formatId, // Content will be re-read from the editor via a custom event ...this.getEditorContent(), }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Failed to send email"); } this._emailSent = true; } catch (e: any) { this._emailError = e.message; } finally { this._emailSending = false; this.render(); } } private async downloadImposition() { this._impositionLoading = true; this.render(); try { const res = await fetch(`/${this._spaceSlug}/rpubs/api/imposition`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ format: this._formatId, ...this.getEditorContent(), }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Imposition generation failed"); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `imposition-${this._formatId}.pdf`; a.click(); URL.revokeObjectURL(url); } catch (e: any) { console.error("[rpubs] Imposition error:", e); } finally { this._impositionLoading = false; this.render(); } } private async loadGuide(target: HTMLElement) { if (!this._formatId) return; // Dynamically import the guide const { getGuide, recommendedBinding, paddedPageCount } = await import("../print-guides"); const guide = getGuide(this._formatId); if (!guide) { target.innerHTML = '

No guide available for this format.

'; return; } const binding = recommendedBinding(this._formatId, this._pageCount); const padded = paddedPageCount(this._pageCount); const sheets = Math.ceil(padded / guide.pagesPerSheet); target.innerHTML = `
${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( `/${this._spaceSlug}/rpubs/api/printers?lat=${lat}&lng=${lng}&radius=100&format=${this._formatId}`, ); if (!res.ok) throw new Error("Failed to search printers"); const data = await res.json(); this._printers = data.providers || []; } catch (e: any) { if (e.code === 1) { this._printersError = "Location access denied. Enable location to find nearby printers."; } else { this._printersError = e.message || "Search failed"; } } finally { this._printersLoading = false; this.render(); } } private async placeOrder() { if (!this._selectedProvider) return; try { const res = await fetch(`/${this._spaceSlug}/rpubs/api/order`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider_id: this._selectedProvider.id, provider_name: this._selectedProvider.name, provider_distance_km: this._selectedProvider.distance_km, total_price: 0, currency: "USD", format: this._formatId, ...this.getEditorContent(), }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Order failed"); } const order = await res.json(); this._orderStatus = `Order created: ${order.id || 'confirmed'}`; this.render(); } catch (e: any) { this._orderStatus = `Error: ${e.message}`; this.render(); } } private async joinBatch() { if (!this._selectedProvider) return; try { const res = await fetch(`/${this._spaceSlug}/rpubs/api/batch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider_id: this._selectedProvider.id, provider_name: this._selectedProvider.name, format: this._formatId, ...this.getEditorContent(), }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Batch operation failed"); } this._batchStatus = await res.json(); this.render(); } catch (e: any) { this._batchStatus = { action: "error", error: e.message }; this.render(); } } /** Get content from the parent editor by reading its textarea */ private getEditorContent(): { content: string; title?: string; author?: string } { const editor = this.closest("folk-pubs-editor") || document.querySelector("folk-pubs-editor"); if (!editor?.shadowRoot) return { content: "" }; const textarea = editor.shadowRoot.querySelector(".content-area") as HTMLTextAreaElement; const titleInput = editor.shadowRoot.querySelector(".title-input") as HTMLInputElement; const authorInput = editor.shadowRoot.querySelector(".author-input") as HTMLInputElement; return { content: textarea?.value || "", title: titleInput?.value?.trim() || undefined, author: authorInput?.value?.trim() || undefined, }; } private esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } private getStyles(): string { return ``; } } customElements.define("folk-pubs-publish-panel", FolkPubsPublishPanel);