rspace-online/lib/folk-zine-gen.ts

910 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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`
<div class="header">
<span class="header-title">
<span>📰</span>
<span>Zine Generator</span>
</span>
<div class="header-actions">
<button class="reset-btn" title="Start over">↺</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content"></div>
`;
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 = `
<div class="ideation">
<h3>Create a MycroZine</h3>
<textarea class="topic-input" placeholder="What's your zine about? e.g. 'The future of regenerative economics' or 'A guide to urban foraging'" rows="3">${this.#escapeHtml(this.#topic)}</textarea>
<div class="options-row">
<select class="style-select">
<option value="punk-zine"${this.#style === "punk-zine" ? " selected" : ""}>Punk Zine</option>
<option value="mycelial"${this.#style === "mycelial" ? " selected" : ""}>Mycelial</option>
<option value="minimal"${this.#style === "minimal" ? " selected" : ""}>Minimal</option>
<option value="collage"${this.#style === "collage" ? " selected" : ""}>Collage</option>
<option value="retro"${this.#style === "retro" ? " selected" : ""}>Retro</option>
<option value="academic"${this.#style === "academic" ? " selected" : ""}>Academic</option>
</select>
<select class="tone-select">
<option value="informative"${this.#tone === "informative" ? " selected" : ""}>Informative</option>
<option value="rebellious"${this.#tone === "rebellious" ? " selected" : ""}>Rebellious</option>
<option value="regenerative"${this.#tone === "regenerative" ? " selected" : ""}>Regenerative</option>
<option value="playful"${this.#tone === "playful" ? " selected" : ""}>Playful</option>
<option value="poetic"${this.#tone === "poetic" ? " selected" : ""}>Poetic</option>
</select>
</div>
<button class="generate-btn"${this.#isLoading ? " disabled" : ""}>
${this.#isLoading ? "Generating outline..." : "Generate Zine Outline"}
</button>
${this.#error ? `<div class="error">${this.#escapeHtml(this.#error)}</div>` : ""}
</div>
`;
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 = `
<div class="loading">
<div class="spinner"></div>
<span>Generating page ${this.#generatingPage} of ${total}...</span>
<div class="progress-bar" style="width: 200px;">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
<span style="font-size: 11px; color: #94a3b8;">${this.#pages[this.#generatingPage - 1]?.title || ""}</span>
</div>
`;
}
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 `<button class="page-dot ${cls}" data-page="${i}" title="Page ${i + 1}: ${this.#escapeHtml(p.title)}"></button>`;
}).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 += `<div class="section" data-section-id="${section.id}">`;
sectionsHtml += `<div class="section-header">
<span>${this.#escapeHtml(sectionLabel)}</span>
<div class="section-actions">
<button class="regen-section-btn" data-section-id="${section.id}" title="Regenerate this section"${isRegenerating ? " disabled" : ""}>
${isRegenerating ? "..." : "↻"}
</button>
</div>
</div>`;
if (section.type === "text") {
const textClass = section.id === "headline" ? "headline" :
section.id === "subhead" ? "subhead" :
section.id === "pullquote" ? "pullquote" : "";
sectionsHtml += `<div class="section-body">
<textarea class="section-text ${textClass}" data-section-id="${section.id}">${this.#escapeHtml(section.content || "")}</textarea>
</div>`;
} else if (section.type === "image") {
sectionsHtml += `<div class="section-body">`;
if (section.imageUrl) {
sectionsHtml += `<img class="section-image" src="${this.#escapeHtml(section.imageUrl)}" alt="${this.#escapeHtml(section.imagePrompt || "")}" loading="lazy" />`;
} else {
sectionsHtml += `<div class="section-image-placeholder">${isRegenerating ? '<div class="spinner" style="width:24px;height:24px;"></div>' : "No image yet — click ↻ to generate"}</div>`;
}
sectionsHtml += `</div>`;
}
// Feedback row for regeneration
sectionsHtml += `<div class="feedback-row" style="display:none" data-feedback-for="${section.id}">
<input class="feedback-input" data-section-id="${section.id}" placeholder="Describe changes..." />
<button class="feedback-btn" data-section-id="${section.id}">Regen</button>
</div>`;
sectionsHtml += `</div>`;
}
this.#contentEl.innerHTML = `
<div class="page-nav">
<button class="prev-btn"${this.#currentPage === 0 ? " disabled" : ""}>← Prev</button>
<div class="page-dots">${dots}</div>
<button class="next-btn"${this.#currentPage >= total - 1 ? " disabled" : ""}>Next →</button>
</div>
<div class="page-content">
<div style="font-size:11px; color:#94a3b8; margin-bottom:8px;">
Page ${page.pageNumber} of ${total} — <strong>${this.#escapeHtml(page.title)}</strong>
${page.hashtags?.length ? `<span style="margin-left:8px;">${page.hashtags.map(t => this.#escapeHtml(t)).join(" ")}</span>` : ""}
</div>
${sectionsHtml}
</div>
<div class="bottom-bar">
<span class="status">${this.#phase === "complete" ? "Complete" : "Editing — click text to edit, ↻ to regenerate sections"}</span>
<button class="generate-btn" style="padding:6px 12px; font-size:11px;">Download All</button>
</div>
${this.#error ? `<div class="error">${this.#escapeHtml(this.#error)}</div>` : ""}
`;
// ── 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<HTMLTextAreaElement>) {
// 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<HTMLButtonElement>) {
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<HTMLElement>) {
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<HTMLButtonElement>) {
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<HTMLInputElement>) {
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 || "")}</${tag}>`;
}
if (s.type === "image" && s.imageUrl) {
return `<img src="${this.#escapeHtml(s.imageUrl)}" style="max-width:100%;border-radius:8px;" />`;
}
return "";
}).join("\n");
return `<div class="zine-page" style="page-break-after:always;padding:32px;max-width:600px;margin:0 auto;">
${sectionsHtml}
<div style="font-size:10px;color:#999;margin-top:16px;">${page.hashtags?.join(" ") || ""}</div>
</div>`;
}).join("\n");
const doc = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>MycroZine: ${this.#escapeHtml(this.#topic)}</title>
<style>body{font-family:sans-serif;margin:0}h1{font-size:28px}h2{font-size:18px;color:#666}blockquote{border-left:4px solid #f59e0b;padding-left:16px;font-style:italic;color:#666}p{line-height:1.6;font-size:14px}@media print{.zine-page{page-break-after:always}}</style>
</head><body>${pagesHtml}</body></html>`;
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,
};
}
}