diff --git a/app/api/zine/[id]/route.ts b/app/api/zine/[id]/route.ts new file mode 100644 index 0000000..efc1b7d --- /dev/null +++ b/app/api/zine/[id]/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getZine, readFileAsBuffer, getPageImagePath, getPrintLayoutPath } from "@/lib/storage"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +// GET /api/zine/[id] - Get zine metadata +// GET /api/zine/[id]?image=p1 - Get page image +// GET /api/zine/[id]?print=true - Get print layout +export async function GET(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params; + const url = new URL(request.url); + const imageParam = url.searchParams.get("image"); + const printParam = url.searchParams.get("print"); + + // Serve page image + if (imageParam) { + const pageMatch = imageParam.match(/^p(\d)$/); + if (!pageMatch) { + return NextResponse.json( + { error: "Invalid image parameter. Use p1-p8." }, + { status: 400 } + ); + } + + const pageNumber = parseInt(pageMatch[1], 10); + if (pageNumber < 1 || pageNumber > 8) { + return NextResponse.json( + { error: "Page number must be between 1 and 8" }, + { status: 400 } + ); + } + + const imagePath = await getPageImagePath(id, pageNumber); + if (!imagePath) { + return NextResponse.json( + { error: "Page image not found" }, + { status: 404 } + ); + } + + const imageBuffer = await readFileAsBuffer(imagePath); + return new NextResponse(new Uint8Array(imageBuffer), { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } + + // Serve print layout + if (printParam === "true") { + const printPath = await getPrintLayoutPath(id); + if (!printPath) { + return NextResponse.json( + { error: "Print layout not found. Generate it first." }, + { status: 404 } + ); + } + + const printBuffer = await readFileAsBuffer(printPath); + const downloadParam = url.searchParams.get("download"); + + const headers: Record = { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=31536000, immutable", + }; + + // Only add Content-Disposition for explicit downloads + if (downloadParam === "true") { + headers["Content-Disposition"] = `attachment; filename="${id}_print.png"`; + } + + return new NextResponse(new Uint8Array(printBuffer), { headers }); + } + + // Return zine metadata + const zine = await getZine(id); + if (!zine) { + return NextResponse.json( + { error: "Zine not found" }, + { status: 404 } + ); + } + + // Build response with image URLs + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || ""; + const response = { + ...zine, + pageUrls: Array.from({ length: 8 }, (_, i) => `${baseUrl}/api/zine/${id}?image=p${i + 1}`), + printLayoutUrl: zine.printLayout ? `${baseUrl}/api/zine/${id}?print=true` : null, + shareUrl: `${baseUrl}/zine/z/${id}`, + }; + + return NextResponse.json(response); + } catch (error) { + console.error("Get zine error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to get zine" }, + { status: 500 } + ); + } +} diff --git a/app/api/zine/generate-page/route.ts b/app/api/zine/generate-page/route.ts new file mode 100644 index 0000000..678bdec --- /dev/null +++ b/app/api/zine/generate-page/route.ts @@ -0,0 +1,280 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getZine, saveZine, savePageImage } from "@/lib/storage"; +import type { PageOutline } from "@/lib/gemini"; + +// Style-specific image generation prompts +const STYLE_PROMPTS: Record = { + "punk-zine": "xerox-style high contrast black and white, DIY cut-and-paste collage aesthetic, hand-drawn typography, punk rock zine style, grainy texture, photocopied look, bold graphic elements", + "mycelial": "organic mycelial network patterns, spore prints, earth tones with green accents, fungal textures, underground root systems, natural decomposition aesthetic, interconnected web designs", + "minimal": "clean minimalist design, lots of white space, modern sans-serif typography, simple geometric shapes, subtle gradients, elegant composition", + "collage": "layered mixed media collage, vintage photographs, torn paper edges, overlapping textures, eclectic composition, found imagery", + "retro": "1970s aesthetic, earth tones, groovy psychedelic typography, halftone dot patterns, vintage illustration style, warm colors", +}; + +const TONE_PROMPTS: Record = { + "rebellious": "defiant anti-establishment energy, provocative bold statements, raw and unfiltered, urgent", + "regenerative": "hopeful and healing, nature-inspired wisdom, interconnected systems thinking, restoration and renewal, growth from decay", + "playful": "whimsical fun light-hearted energy, humor and wit, bright positive vibes, joyful", + "informative": "educational and factual, clear explanations, structured information, accessible", + "poetic": "lyrical and metaphorical, evocative imagery, emotional depth, contemplative", +}; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { zineId, pageNumber, outline, style, tone } = body; + + if (!zineId || !pageNumber || !outline) { + return NextResponse.json( + { error: "Missing required fields: zineId, pageNumber, outline" }, + { status: 400 } + ); + } + + // Verify zine exists + const zine = await getZine(zineId); + if (!zine) { + return NextResponse.json( + { error: "Zine not found" }, + { status: 404 } + ); + } + + const pageOutline = outline as PageOutline; + const stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS["mycelial"]; + const tonePrompt = TONE_PROMPTS[tone] || TONE_PROMPTS["regenerative"]; + + // Build the full image generation prompt + const fullPrompt = buildImagePrompt(pageOutline, stylePrompt, tonePrompt); + + // Generate image using Gemini Imagen API + // Pass outline and style for styled fallback + const imageBase64 = await generateImageWithGemini(fullPrompt, pageOutline, style); + + // Save the page image + const imagePath = await savePageImage(zineId, pageNumber, imageBase64); + + // Update zine metadata + zine.pages[pageNumber - 1] = imagePath; + zine.updatedAt = new Date().toISOString(); + await saveZine(zine); + + return NextResponse.json({ + pageNumber, + imageUrl: `/api/zine/${zineId}?image=p${pageNumber}`, + success: true, + }); + } catch (error) { + console.error("Page generation error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to generate page" }, + { status: 500 } + ); + } +} + +function buildImagePrompt(outline: PageOutline, stylePrompt: string, tonePrompt: string): string { + return `Create a single zine page image (portrait orientation, 825x1275 pixels aspect ratio). + +PAGE ${outline.pageNumber}: "${outline.title}" +Type: ${outline.type} + +Content to visualize: +${outline.keyPoints.map((p, i) => `${i + 1}. ${p}`).join("\n")} + +Visual Style: ${stylePrompt} +Mood/Tone: ${tonePrompt} + +Detailed requirements: +${outline.imagePrompt} + +IMPORTANT: +- This is a SINGLE page that will be printed +- Include any text/typography as part of the graphic design +- Fill the entire page - no blank margins +- Make it visually striking and cohesive +- The design should work in print (high contrast, clear details)`; +} + +async function generateImageWithGemini( + prompt: string, + outline: PageOutline, + style: string +): Promise { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error("GEMINI_API_KEY not configured"); + } + + // Try Gemini 2.0 Flash with image generation (Nano Banana) + // Uses responseModalities: ["TEXT", "IMAGE"] for native image generation + try { + const result = await generateWithGemini2FlashImage(prompt, apiKey); + if (result) { + console.log("Generated image with Gemini 2.0 Flash"); + return result; + } + } catch (error) { + console.error("Gemini 2.0 Flash image generation error:", error); + } + + // Fallback: Create styled placeholder with actual content + console.log("Using styled placeholder image for page", outline.pageNumber); + return createStyledPlaceholder(outline, style); +} + +// Gemini 2.0 Flash with native image generation +async function generateWithGemini2FlashImage(prompt: string, apiKey: string): Promise { + const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`; + + const response = await fetch(geminiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: `Generate an image: ${prompt}` }] }], + generationConfig: { responseModalities: ["TEXT", "IMAGE"] }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Gemini API error:", response.status, errorText); + return null; + } + + const data = await response.json(); + if (data.error) { + console.error("Gemini API error:", data.error); + return null; + } + + const parts = data.candidates?.[0]?.content?.parts || []; + for (const part of parts) { + if (part.inlineData?.mimeType?.startsWith("image/")) { + return part.inlineData.data; + } + } + + return null; +} + +// Create styled placeholder images with actual page content +async function createStyledPlaceholder( + outline: PageOutline, + style: string +): Promise { + const sharp = (await import("sharp")).default; + + // Escape XML special characters + const escapeXml = (str: string) => + str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + + const title = escapeXml(outline.title.slice(0, 40)); + const keyPoints = outline.keyPoints.slice(0, 3).map((p) => escapeXml(p.slice(0, 50))); + + // Style-specific colors and patterns + const styles: Record = { + "punk-zine": { + bg: "#ffffff", + fg: "#000000", + accent: "#ff0066", + pattern: ` + + `, + }, + "mycelial": { + bg: "#f5f0e8", + fg: "#2d3a2d", + accent: "#4a7c4f", + pattern: ` + + + `, + }, + minimal: { + bg: "#fafafa", + fg: "#333333", + accent: "#0066ff", + pattern: "", + }, + collage: { + bg: "#f5e6d3", + fg: "#2d2d2d", + accent: "#8b4513", + pattern: ` + + + `, + }, + retro: { + bg: "#fff8dc", + fg: "#8b4513", + accent: "#ff6347", + pattern: ` + + `, + }, + }; + + const s = styles[style] || styles["mycelial"]; + const pageNum = outline.pageNumber; + const pageType = escapeXml(outline.type.toUpperCase()); + + const svg = ` + + + ${s.pattern} + + + + + + ${s.pattern ? `` : ""} + + + + + + + + P${pageNum} + + + ${pageType} + + + ${title} + + + + + + ${keyPoints + .map( + (point, i) => ` + + ${point}${point.length >= 50 ? "..." : ""} + ` + ) + .join("")} + + + + MycoZine - AI Generated + Styled placeholder - image gen pending + + + + + + + + `; + + const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); + return buffer.toString("base64"); +} diff --git a/app/api/zine/outline/route.ts b/app/api/zine/outline/route.ts new file mode 100644 index 0000000..4e22135 --- /dev/null +++ b/app/api/zine/outline/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateOutline } from "@/lib/gemini"; +import { saveZine, type StoredZine } from "@/lib/storage"; +import { generateZineId } from "@/lib/utils"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { topic, style = "mycelial", tone = "regenerative" } = body; + + if (!topic || typeof topic !== "string" || topic.trim().length === 0) { + return NextResponse.json( + { error: "Topic is required" }, + { status: 400 } + ); + } + + // Generate the 8-page outline using Gemini + const pages = await generateOutline(topic.trim(), style, tone); + + if (!pages || pages.length !== 8) { + return NextResponse.json( + { error: "Failed to generate complete outline" }, + { status: 500 } + ); + } + + // Create a new zine ID + const id = generateZineId(); + const now = new Date().toISOString(); + + // Save initial zine metadata + const zine: StoredZine = { + id, + topic: topic.trim(), + style, + tone, + outline: pages, + pages: [], // Will be populated as images are generated + createdAt: now, + updatedAt: now, + }; + + await saveZine(zine); + + return NextResponse.json({ + id, + topic: topic.trim(), + style, + tone, + outline: pages, + }); + } catch (error) { + console.error("Outline generation error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to generate outline" }, + { status: 500 } + ); + } +} diff --git a/app/api/zine/print-layout/route.ts b/app/api/zine/print-layout/route.ts new file mode 100644 index 0000000..48ecc08 --- /dev/null +++ b/app/api/zine/print-layout/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getZine, saveZine, getPrintLayoutPath } from "@/lib/storage"; +import { createZinePrintLayout } from "@/lib/zine"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { zineId, zineName } = body; + + if (!zineId) { + return NextResponse.json( + { error: "Missing zineId" }, + { status: 400 } + ); + } + + // Verify zine exists and has all pages + const zine = await getZine(zineId); + if (!zine) { + return NextResponse.json( + { error: "Zine not found" }, + { status: 404 } + ); + } + + // Check that all 8 pages exist + const validPages = zine.pages.filter((p) => p && p.length > 0); + if (validPages.length !== 8) { + return NextResponse.json( + { error: `Expected 8 pages, found ${validPages.length}. Please generate all pages first.` }, + { status: 400 } + ); + } + + // Create the print layout + const { filepath, buffer } = await createZinePrintLayout( + zineId, + zineName || zine.topic.slice(0, 20).replace(/[^a-zA-Z0-9]/g, "_") + ); + + // Update zine metadata + zine.printLayout = filepath; + zine.updatedAt = new Date().toISOString(); + await saveZine(zine); + + return NextResponse.json({ + success: true, + printLayoutUrl: `/api/zine/${zineId}?print=true`, + filename: `${zineName || "mycozine"}_print.png`, + }); + } catch (error) { + console.error("Print layout error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to create print layout" }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const zineId = url.searchParams.get("zineId"); + + if (!zineId) { + return NextResponse.json( + { error: "Missing zineId" }, + { status: 400 } + ); + } + + const layoutPath = await getPrintLayoutPath(zineId); + if (!layoutPath) { + return NextResponse.json( + { error: "Print layout not found. Generate it first." }, + { status: 404 } + ); + } + + return NextResponse.json({ + exists: true, + printLayoutUrl: `/api/zine/${zineId}?print=true`, + }); +} diff --git a/app/api/zine/regenerate-page/route.ts b/app/api/zine/regenerate-page/route.ts new file mode 100644 index 0000000..3d47b90 --- /dev/null +++ b/app/api/zine/regenerate-page/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { GoogleGenerativeAI } from "@google/generative-ai"; +import { getZine, saveZine } from "@/lib/storage"; +import type { PageOutline } from "@/lib/gemini"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { zineId, pageNumber, currentOutline, feedback, style, tone } = body; + + if (!zineId || !pageNumber || !currentOutline || !feedback) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + return NextResponse.json( + { error: "GEMINI_API_KEY not configured" }, + { status: 500 } + ); + } + + const genAI = new GoogleGenerativeAI(apiKey); + + // Verify zine exists + const zine = await getZine(zineId); + if (!zine) { + return NextResponse.json( + { error: "Zine not found" }, + { status: 404 } + ); + } + + // Update outline based on feedback using Gemini + const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" }); + + const prompt = `You are refining a zine page based on user feedback. + +Current page outline: +- Page Number: ${currentOutline.pageNumber} +- Type: ${currentOutline.type} +- Title: ${currentOutline.title} +- Key Points: ${currentOutline.keyPoints.join(", ")} +- Image Prompt: ${currentOutline.imagePrompt} + +User feedback: "${feedback}" + +Style: ${style} +Tone: ${tone} + +Update the page outline to incorporate this feedback. Keep the same page number and type. + +Return ONLY valid JSON (no markdown, no code blocks): +{ + "pageNumber": ${currentOutline.pageNumber}, + "type": "${currentOutline.type}", + "title": "Updated title if needed", + "keyPoints": ["Updated point 1", "Updated point 2"], + "imagePrompt": "Updated detailed image prompt incorporating the feedback" +}`; + + const result = await model.generateContent(prompt); + const response = result.response.text(); + + // Parse the updated outline + let jsonStr = response; + if (response.includes("```")) { + const match = response.match(/```(?:json)?\s*([\s\S]*?)```/); + if (match) { + jsonStr = match[1]; + } + } + + const updatedOutline = JSON.parse(jsonStr.trim()) as PageOutline; + + // Generate new image with updated outline + // Forward to generate-page endpoint logic + const generateResponse = await fetch( + new URL("/api/zine/generate-page", request.url), + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + zineId, + pageNumber, + outline: updatedOutline, + style, + tone, + }), + } + ); + + if (!generateResponse.ok) { + throw new Error("Failed to regenerate image"); + } + + const generateResult = await generateResponse.json(); + + // Update the zine outline + zine.outline[pageNumber - 1] = updatedOutline; + zine.updatedAt = new Date().toISOString(); + await saveZine(zine); + + return NextResponse.json({ + pageNumber, + updatedOutline, + imageUrl: generateResult.imageUrl, + success: true, + }); + } catch (error) { + console.error("Page regeneration error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to regenerate page" }, + { status: 500 } + ); + } +} diff --git a/app/globals.css b/app/globals.css index ed5c9ae..35187d2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -131,3 +131,44 @@ scroll-behavior: smooth; } } + +/* Punk zine styling utilities */ +.punk-border { + border: 3px solid black; + box-shadow: 4px 4px 0 black; +} + +.punk-text { + font-family: 'Courier New', Courier, monospace; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Loading animation for generation */ +@keyframes pulse-punk { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-pulse-punk { + animation: pulse-punk 1.5s ease-in-out infinite; +} + +/* Xerox texture overlay */ +.xerox-texture { + position: relative; +} + +.xerox-texture::after { + content: ''; + position: absolute; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); + opacity: 0.03; + pointer-events: none; + mix-blend-mode: multiply; +} diff --git a/app/zine/create/page.tsx b/app/zine/create/page.tsx new file mode 100644 index 0000000..ab7a6f2 --- /dev/null +++ b/app/zine/create/page.tsx @@ -0,0 +1,681 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + ArrowLeft, + ArrowRight, + Check, + Download, + Loader2, + Mic, + MicOff, + RefreshCw, + Copy, + CheckCircle, +} from "lucide-react"; + +interface PageOutline { + pageNumber: number; + type: string; + title: string; + keyPoints: string[]; + imagePrompt: string; +} + +interface ZineState { + id: string; + topic: string; + style: string; + tone: string; + outline: PageOutline[]; + pages: string[]; + currentStep: "outline" | "generate" | "refine" | "download"; + generatingPage: number | null; + printLayoutUrl: string | null; +} + +const STEPS = ["outline", "generate", "refine", "download"] as const; +const STEP_LABELS = { + outline: "Review Outline", + generate: "Generate Pages", + refine: "Refine Pages", + download: "Download & Share", +}; + +export default function CreatePage() { + const router = useRouter(); + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [feedback, setFeedback] = useState(""); + const [isListening, setIsListening] = useState(false); + const [copied, setCopied] = useState(false); + + // Initialize from session storage + useEffect(() => { + const input = sessionStorage.getItem("zineInput"); + if (!input) { + router.push("/zine"); + return; + } + + const { topic, style, tone } = JSON.parse(input); + generateOutline(topic, style, tone); + }, [router]); + + const generateOutline = async (topic: string, style: string, tone: string) => { + setLoading(true); + setError(null); + + try { + const response = await fetch("/api/zine/outline", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic, style, tone }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to generate outline"); + } + + const data = await response.json(); + + setState({ + id: data.id, + topic, + style, + tone, + outline: data.outline, + pages: new Array(8).fill(""), + currentStep: "outline", + generatingPage: null, + printLayoutUrl: null, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setLoading(false); + } + }; + + const generatePages = async () => { + if (!state) return; + + setState((s) => (s ? { ...s, currentStep: "generate" } : s)); + + for (let i = 1; i <= 8; i++) { + if (state.pages[i - 1]) continue; // Skip already generated pages + + setState((s) => (s ? { ...s, generatingPage: i } : s)); + + try { + const response = await fetch("/api/zine/generate-page", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + zineId: state.id, + pageNumber: i, + outline: state.outline[i - 1], + style: state.style, + tone: state.tone, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || `Failed to generate page ${i}`); + } + + const data = await response.json(); + + // Add cache-busting timestamp to force image reload + const imageUrlWithTimestamp = `${data.imageUrl}&t=${Date.now()}`; + + setState((s) => { + if (!s) return s; + const newPages = [...s.pages]; + newPages[i - 1] = imageUrlWithTimestamp; + return { ...s, pages: newPages }; + }); + } catch (err) { + console.error(`Error generating page ${i}:`, err); + setError(`Failed to generate page ${i}`); + return; + } + } + + setState((s) => (s ? { ...s, generatingPage: null, currentStep: "refine" } : s)); + }; + + const regeneratePage = async () => { + if (!state || !feedback.trim()) return; + + setState((s) => (s ? { ...s, generatingPage: currentPage } : s)); + + try { + const response = await fetch("/api/zine/regenerate-page", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + zineId: state.id, + pageNumber: currentPage, + currentOutline: state.outline[currentPage - 1], + feedback: feedback.trim(), + style: state.style, + tone: state.tone, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to regenerate page"); + } + + const data = await response.json(); + + setState((s) => { + if (!s) return s; + const newPages = [...s.pages]; + newPages[currentPage - 1] = data.imageUrl; + const newOutline = [...s.outline]; + newOutline[currentPage - 1] = data.updatedOutline; + return { ...s, pages: newPages, outline: newOutline, generatingPage: null }; + }); + + setFeedback(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to regenerate"); + setState((s) => (s ? { ...s, generatingPage: null } : s)); + } + }; + + const createPrintLayout = async () => { + if (!state) return; + + setLoading(true); + + try { + const response = await fetch("/api/zine/print-layout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + zineId: state.id, + zineName: state.topic.slice(0, 20).replace(/[^a-zA-Z0-9]/g, "_"), + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to create print layout"); + } + + const data = await response.json(); + + setState((s) => + s ? { ...s, printLayoutUrl: data.printLayoutUrl, currentStep: "download" } : s + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create print layout"); + } finally { + setLoading(false); + } + }; + + const handleVoiceInput = useCallback(() => { + if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) { + alert("Voice input not supported"); + return; + } + + const SpeechRecognition = + (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + const recognition = new SpeechRecognition(); + recognition.continuous = false; + recognition.interimResults = false; + + recognition.onstart = () => setIsListening(true); + recognition.onend = () => setIsListening(false); + recognition.onerror = () => setIsListening(false); + + recognition.onresult = (event: any) => { + const transcript = event.results[0][0].transcript; + setFeedback((prev) => (prev ? prev + " " + transcript : transcript)); + }; + + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [isListening]); + + const copyShareLink = async () => { + if (!state) return; + const shareUrl = `${window.location.origin}/zine/z/${state.id}`; + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (loading && !state) { + return ( +
+
+ +

Generating your outline...

+
+
+ ); + } + + if (error && !state) { + return ( +
+
+

Error

+

{error}

+ +
+
+ ); + } + + if (!state) return null; + + return ( +
+ {/* Header with Progress */} +
+
+ +

+ {state.topic} +

+
+ + {/* Progress Steps */} +
+ {STEPS.map((step, i) => ( +
+
= i + ? "bg-black text-white" + : "bg-gray-200 text-gray-500" + }`} + > + {STEPS.indexOf(state.currentStep) > i ? ( + + ) : ( + i + 1 + )} +
+ {i < STEPS.length - 1 && ( +
i ? "bg-black" : "bg-gray-200" + }`} + /> + )} +
+ ))} +
+
+ {STEPS.map((step) => ( + + {STEP_LABELS[step]} + + ))} +
+
+ + {/* Error Banner */} + {error && ( +
+ {error} + +
+ )} + + {/* Step Content */} +
+ {/* Step 1: Outline Review */} + {state.currentStep === "outline" && ( +
+

Your 8-Page Outline

+
+ {state.outline.map((page, i) => ( +
+
+
+ + Page {page.pageNumber} • {page.type} + +

{page.title}

+
    + {page.keyPoints.map((point, j) => ( +
  • • {point}
  • + ))} +
+
+
+
+ ))} +
+
+ +
+
+ )} + + {/* Step 2: Page Generation */} + {state.currentStep === "generate" && ( +
+
+

+ Generating Your Zine +

+

+ Page {state.generatingPage || state.pages.filter((p) => p).length} of 8 +

+
+ + {/* Overall Progress Bar */} +
+
+ Progress + {Math.round((state.pages.filter((p) => p).length / 8) * 100)}% +
+
+
p).length / 8) * 100}%` }} + /> +
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((num) => ( +
+ {state.pages[num - 1] ? : num} +
+ ))} +
+
+ + {/* Current Page Being Generated */} + {state.generatingPage && ( +
+
+
+ +
+
+

+ {state.outline[state.generatingPage - 1]?.title} +

+

+ {state.outline[state.generatingPage - 1]?.type} • Page {state.generatingPage} +

+
+
+
+
+
+
+ )} + + {/* Thumbnail Grid */} +
+ {state.outline.map((page, i) => ( +
+ {state.pages[i] ? ( + {`Page console.log(`Page ${i + 1} image loaded`)} + /> + ) : state.generatingPage === i + 1 ? ( +
+ + Generating... +
+ ) : ( +
+ P{i + 1} +

Pending

+
+ )} +
+ ))} +
+
+ )} + + {/* Step 3: Page Refinement */} + {state.currentStep === "refine" && ( +
+
+

+ Page {currentPage} of 8 +

+
+ + +
+
+ + {/* Current Page Preview */} +
+
+ {state.generatingPage === currentPage ? ( +
+ +
+ ) : ( + {`Page + )} +
+ +
+
+

{state.outline[currentPage - 1].title}

+

+ {state.outline[currentPage - 1].keyPoints.join(" • ")} +

+
+ +
+ +
+