import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const styles = css` :host { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 480px; min-height: 560px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: linear-gradient(135deg, #f59e0b, #ef4444); color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .content { display: flex; flex-direction: column; height: calc(100% - 36px); overflow: hidden; } /* ── Phase: Ideation ── */ .ideation { padding: 16px; display: flex; flex-direction: column; gap: 12px; } .ideation h3 { margin: 0; font-size: 14px; color: #1e293b; } .topic-input { width: 100%; padding: 10px 12px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 13px; resize: none; outline: none; font-family: inherit; } .topic-input:focus { border-color: #f59e0b; } .options-row { display: flex; gap: 8px; } select { padding: 6px 10px; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 12px; background: white; cursor: pointer; flex: 1; } .generate-btn { padding: 10px 20px; background: linear-gradient(135deg, #f59e0b, #ef4444); color: white; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; } .generate-btn:hover { opacity: 0.9; } .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; } /* ── Phase: Drafts / Feedback ── */ .pages-view { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .page-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid #e2e8f0; font-size: 12px; color: #64748b; } .page-nav button { padding: 4px 10px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer; font-size: 12px; } .page-nav button:hover { background: #f1f5f9; } .page-nav button:disabled { opacity: 0.3; cursor: not-allowed; } .page-dots { display: flex; gap: 4px; } .page-dot { width: 8px; height: 8px; border-radius: 50%; background: #cbd5e1; cursor: pointer; border: none; padding: 0; } .page-dot.active { background: #f59e0b; } .page-dot.generated { background: #22c55e; } .page-dot.generating { background: #f59e0b; animation: pulse 1s infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } .page-content { flex: 1; overflow-y: auto; padding: 12px; } /* ── Section rendering ── */ .section { position: relative; margin-bottom: 12px; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; transition: border-color 0.2s; } .section:hover { border-color: #f59e0b; } .section-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; font-size: 10px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; } .section-actions { display: flex; gap: 2px; } .section-actions button { padding: 2px 6px; border: none; background: transparent; cursor: pointer; font-size: 11px; border-radius: 3px; color: #64748b; } .section-actions button:hover { background: #e2e8f0; color: #1e293b; } .section-body { padding: 8px 10px; } .section-text { width: 100%; border: none; outline: none; font-family: inherit; font-size: 13px; line-height: 1.5; color: #1e293b; background: transparent; resize: none; min-height: 24px; overflow: hidden; } .section-text.headline { font-size: 18px; font-weight: 700; } .section-text.subhead { font-size: 14px; font-weight: 500; color: #475569; } .section-text.pullquote { font-style: italic; border-left: 3px solid #f59e0b; padding-left: 10px; color: #64748b; } .section-image { width: 100%; border-radius: 4px; display: block; } .section-image-placeholder { display: flex; align-items: center; justify-content: center; height: 120px; background: #f1f5f9; border-radius: 4px; color: #94a3b8; font-size: 12px; } /* ── Feedback input ── */ .feedback-row { display: flex; gap: 6px; padding: 4px 8px 8px; background: #fffbeb; border-top: 1px solid #fde68a; } .feedback-input { flex: 1; padding: 6px 8px; border: 1px solid #fde68a; border-radius: 4px; font-size: 11px; outline: none; font-family: inherit; } .feedback-input:focus { border-color: #f59e0b; } .feedback-btn { padding: 6px 10px; background: #f59e0b; color: white; border: none; border-radius: 4px; font-size: 11px; cursor: pointer; white-space: nowrap; } .feedback-btn:disabled { opacity: 0.5; cursor: not-allowed; } /* ── Bottom bar ── */ .bottom-bar { padding: 8px 12px; border-top: 1px solid #e2e8f0; display: flex; gap: 8px; justify-content: space-between; align-items: center; } .status { font-size: 11px; color: #64748b; } /* ── Loading ── */ .loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 12px; flex: 1; } .spinner { width: 32px; height: 32px; border: 3px solid #e2e8f0; border-top-color: #f59e0b; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .error { color: #ef4444; padding: 12px; background: #fef2f2; border-radius: 6px; font-size: 13px; margin: 12px; } .placeholder { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; text-align: center; gap: 8px; padding: 24px; } .placeholder-icon { font-size: 48px; opacity: 0.5; } /* ── Progress bar ── */ .progress-bar { height: 3px; background: #e2e8f0; border-radius: 2px; overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, #f59e0b, #ef4444); transition: width 0.5s ease; } `; // ── Types ── interface ZineSection { id: string; type: "text" | "image"; content?: string; imagePrompt?: string; imageUrl?: string; } interface ZinePage { pageNumber: number; type: "cover" | "content" | "cta"; title: string; sections: ZineSection[]; hashtags: string[]; } type ZinePhase = "ideation" | "generating" | "editing" | "complete"; declare global { interface HTMLElementTagNameMap { "folk-zine-gen": FolkZineGen; } } export class FolkZineGen extends FolkShape { static override tagName = "folk-zine-gen"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) .map((r) => r.cssText) .join("\n"); const childRules = Array.from(styles.cssRules) .map((r) => r.cssText) .join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #phase: ZinePhase = "ideation"; #pages: ZinePage[] = []; #currentPage = 0; #isLoading = false; #error: string | null = null; #style = "punk-zine"; #tone = "informative"; #topic = ""; #generatingPage = 0; // 0 = not generating #regeneratingSection: string | null = null; // DOM refs #contentEl: HTMLElement | null = null; override createRenderRoot() { const root = super.createRenderRoot(); const wrapper = document.createElement("div"); wrapper.innerHTML = html`
📰 Zine Generator
`; const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) { containerDiv.replaceWith(wrapper); } this.#contentEl = wrapper.querySelector(".content"); const resetBtn = wrapper.querySelector(".reset-btn") as HTMLButtonElement; const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; resetBtn.addEventListener("click", (e) => { e.stopPropagation(); this.#reset(); }); closeBtn.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); this.#render(); return root; } #reset() { this.#phase = "ideation"; this.#pages = []; this.#currentPage = 0; this.#isLoading = false; this.#error = null; this.#generatingPage = 0; this.#regeneratingSection = null; this.#render(); } #render() { if (!this.#contentEl) return; switch (this.#phase) { case "ideation": this.#renderIdeation(); break; case "generating": this.#renderGenerating(); break; case "editing": case "complete": this.#renderEditing(); break; } } // ── Ideation Phase ── #renderIdeation() { if (!this.#contentEl) return; this.#contentEl.innerHTML = `

Create a MycroZine

${this.#error ? `
${this.#escapeHtml(this.#error)}
` : ""}
`; const topicInput = this.#contentEl.querySelector(".topic-input") as HTMLTextAreaElement; const styleSelect = this.#contentEl.querySelector(".style-select") as HTMLSelectElement; const toneSelect = this.#contentEl.querySelector(".tone-select") as HTMLSelectElement; const genBtn = this.#contentEl.querySelector(".generate-btn") as HTMLButtonElement; topicInput?.addEventListener("input", () => { this.#topic = topicInput.value; }); topicInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); styleSelect?.addEventListener("change", () => { this.#style = styleSelect.value; }); toneSelect?.addEventListener("change", () => { this.#tone = toneSelect.value; }); genBtn?.addEventListener("click", (e) => { e.stopPropagation(); this.#generateOutline(); }); } async #generateOutline() { if (!this.#topic.trim() || this.#isLoading) return; this.#isLoading = true; this.#error = null; this.#render(); try { const res = await fetch("/api/zine/outline", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topic: this.#topic, style: this.#style, tone: this.#tone, }), }); if (!res.ok) throw new Error(`Outline generation failed: ${res.statusText}`); const data = await res.json(); this.#pages = data.pages || []; this.#currentPage = 0; this.#phase = "generating"; this.#isLoading = false; this.#render(); // Start generating page images sequentially await this.#generateAllPages(); } catch (e: any) { this.#error = e.message; this.#isLoading = false; this.#render(); } } // ── Generating Phase ── #renderGenerating() { if (!this.#contentEl) return; const total = this.#pages.length; const progress = this.#generatingPage > 0 ? ((this.#generatingPage - 1) / total) * 100 : 0; this.#contentEl.innerHTML = `
Generating page ${this.#generatingPage} of ${total}...
${this.#pages[this.#generatingPage - 1]?.title || ""}
`; } async #generateAllPages() { for (let i = 0; i < this.#pages.length; i++) { this.#generatingPage = i + 1; this.#render(); try { const res = await fetch("/api/zine/page", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ outline: this.#pages[i], style: this.#style, tone: this.#tone, }), }); if (res.ok) { const data = await res.json(); // Update image URLs in sections if (data.images) { for (const section of this.#pages[i].sections) { if (section.type === "image" && data.images[section.id]) { section.imageUrl = data.images[section.id]; } } } } } catch (e: any) { console.error(`[zine-gen] Page ${i + 1} generation failed:`, e.message); } } this.#generatingPage = 0; this.#phase = "editing"; this.#render(); } // ── Editing Phase (with editable text + per-section regeneration) ── #renderEditing() { if (!this.#contentEl) return; const page = this.#pages[this.#currentPage]; if (!page) return; const total = this.#pages.length; const dots = this.#pages.map((p, i) => { const cls = i === this.#currentPage ? "active" : (p.sections.some(s => s.type === "image" && s.imageUrl) ? "generated" : ""); return ``; }).join(""); let sectionsHtml = ""; for (const section of page.sections) { const isRegenerating = this.#regeneratingSection === section.id; const sectionLabel = section.id.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()); sectionsHtml += `
`; sectionsHtml += `
${this.#escapeHtml(sectionLabel)}
`; if (section.type === "text") { const textClass = section.id === "headline" ? "headline" : section.id === "subhead" ? "subhead" : section.id === "pullquote" ? "pullquote" : ""; sectionsHtml += `
`; } else if (section.type === "image") { sectionsHtml += `
`; if (section.imageUrl) { sectionsHtml += `${this.#escapeHtml(section.imagePrompt || `; } else { sectionsHtml += `
${isRegenerating ? '
' : "No image yet — click ↻ to generate"}
`; } sectionsHtml += `
`; } // Feedback row for regeneration sectionsHtml += ``; sectionsHtml += `
`; } this.#contentEl.innerHTML = `
Page ${page.pageNumber} of ${total} — ${this.#escapeHtml(page.title)} ${page.hashtags?.length ? `${page.hashtags.map(t => this.#escapeHtml(t)).join(" ")}` : ""}
${sectionsHtml}
${this.#phase === "complete" ? "Complete" : "Editing — click text to edit, ↻ to regenerate sections"}
${this.#error ? `
${this.#escapeHtml(this.#error)}
` : ""} `; // ── Wire up event listeners ── // Page navigation this.#contentEl.querySelector(".prev-btn")?.addEventListener("click", (e) => { e.stopPropagation(); if (this.#currentPage > 0) { this.#currentPage--; this.#render(); } }); this.#contentEl.querySelector(".next-btn")?.addEventListener("click", (e) => { e.stopPropagation(); if (this.#currentPage < this.#pages.length - 1) { this.#currentPage++; this.#render(); } }); // Page dots for (const dot of this.#contentEl.querySelectorAll(".page-dot")) { dot.addEventListener("click", (e) => { e.stopPropagation(); this.#currentPage = parseInt((dot as HTMLElement).dataset.page || "0"); this.#render(); }); } // Editable text areas — auto-resize and save for (const textarea of this.#contentEl.querySelectorAll(".section-text") as NodeListOf) { // Auto-resize const autoResize = () => { textarea.style.height = "auto"; textarea.style.height = textarea.scrollHeight + "px"; }; autoResize(); textarea.addEventListener("input", () => { autoResize(); const sectionId = textarea.dataset.sectionId; if (sectionId) { const section = page.sections.find(s => s.id === sectionId); if (section) section.content = textarea.value; } }); textarea.addEventListener("pointerdown", (e) => e.stopPropagation()); } // Regenerate section buttons — show feedback row for (const btn of this.#contentEl.querySelectorAll(".regen-section-btn") as NodeListOf) { btn.addEventListener("click", (e) => { e.stopPropagation(); const sectionId = btn.dataset.sectionId!; const feedbackRow = this.#contentEl!.querySelector(`[data-feedback-for="${sectionId}"]`) as HTMLElement; if (feedbackRow) { const isVisible = feedbackRow.style.display !== "none"; // Hide all feedback rows first for (const row of this.#contentEl!.querySelectorAll(".feedback-row") as NodeListOf) { row.style.display = "none"; } feedbackRow.style.display = isVisible ? "none" : "flex"; if (!isVisible) { const input = feedbackRow.querySelector(".feedback-input") as HTMLInputElement; input?.focus(); } } }); } // Feedback submit buttons for (const btn of this.#contentEl.querySelectorAll(".feedback-btn") as NodeListOf) { btn.addEventListener("click", (e) => { e.stopPropagation(); const sectionId = btn.dataset.sectionId!; const input = this.#contentEl!.querySelector(`.feedback-input[data-section-id="${sectionId}"]`) as HTMLInputElement; const feedback = input?.value.trim() || ""; this.#regenerateSection(sectionId, feedback); }); } // Feedback input enter key for (const input of this.#contentEl.querySelectorAll(".feedback-input") as NodeListOf) { input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); const sectionId = input.dataset.sectionId!; this.#regenerateSection(sectionId, input.value.trim()); } }); input.addEventListener("pointerdown", (e) => e.stopPropagation()); } // Download button this.#contentEl.querySelector(".bottom-bar .generate-btn")?.addEventListener("click", (e) => { e.stopPropagation(); this.#downloadZine(); }); } async #regenerateSection(sectionId: string, feedback: string) { const page = this.#pages[this.#currentPage]; const section = page?.sections.find(s => s.id === sectionId); if (!section) return; this.#regeneratingSection = sectionId; this.#error = null; this.#render(); try { const res = await fetch("/api/zine/regenerate-section", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ section, pageTitle: page.title, style: this.#style, tone: this.#tone, feedback, }), }); if (!res.ok) throw new Error(`Regeneration failed: ${res.statusText}`); const data = await res.json(); if (data.type === "text" && data.content) { section.content = data.content; } else if (data.type === "image" && data.url) { section.imageUrl = data.url; } } catch (e: any) { this.#error = e.message; } finally { this.#regeneratingSection = null; this.#render(); } } #downloadZine() { // Create a simple HTML document with all pages for printing const pagesHtml = this.#pages.map(page => { const sectionsHtml = page.sections.map(s => { if (s.type === "text") { const tag = s.id === "headline" ? "h1" : s.id === "subhead" ? "h2" : s.id === "pullquote" ? "blockquote" : "p"; return `<${tag}>${this.#escapeHtml(s.content || "")}`; } if (s.type === "image" && s.imageUrl) { return ``; } return ""; }).join("\n"); return `
${sectionsHtml}
${page.hashtags?.join(" ") || ""}
`; }).join("\n"); const doc = ` MycroZine: ${this.#escapeHtml(this.#topic)} ${pagesHtml}`; const blob = new Blob([doc], { type: "text/html" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `mycrozine-${Date.now()}.html`; a.click(); URL.revokeObjectURL(url); } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-zine-gen", phase: this.#phase, topic: this.#topic, style: this.#style, tone: this.#tone, pages: this.#pages, currentPage: this.#currentPage, }; } }