diff --git a/.gitignore b/.gitignore index a142f7d..de4b1b7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,14 @@ npm-debug.log* # Build artifacts dist/ build/ + +# Next.js +web/.next/ +web/out/ +web/node_modules/ + +# Data storage (user zines) +data/zines/ + +# Docker +.docker/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1935812 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,88 @@ +# Multi-stage Dockerfile for MycroZine Web App +# Based on decolonize-time-website pattern + +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Dependencies stage +FROM base AS deps +WORKDIR /app + +# Copy package files from web directory +COPY web/package.json web/pnpm-lock.yaml* web/package-lock.json* web/yarn.lock* ./ + +# Install dependencies +RUN \ + if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + else npm install; \ + fi + +# Builder stage +FROM base AS builder +WORKDIR /app + +# Copy dependencies +COPY --from=deps /app/node_modules ./node_modules + +# Copy web app source +COPY web/ ./ + +# Copy the mycro-zine library source (needed for layout generation) +COPY src/ ../src/ + +# Set build-time environment variables +ARG GEMINI_API_KEY +ARG NEXT_PUBLIC_APP_URL=https://zine.jeffemmett.com + +ENV GEMINI_API_KEY=$GEMINI_API_KEY +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_TELEMETRY_DISABLED=1 + +# Build the application +RUN npm run build + +# Production runner stage +FROM base AS runner +WORKDIR /app + +# Set runtime environment +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Create non-root user for security +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy built application +COPY --from=builder /app/public ./public + +# Set permissions for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Copy standalone build +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Copy the mycro-zine library for runtime +COPY --chown=nextjs:nodejs src/ ../src/ + +# Create data directory for zine storage +RUN mkdir -p /app/data/zines && chown -R nextjs:nodejs /app/data + +# Switch to non-root user +USER nextjs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +# Start the application +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 425d666..a7022b1 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,62 @@ See the `examples/undernet/` directory for a complete 8-page zine about The Unde | `retro` | 1970s aesthetic, earth tones, groovy typography, halftone patterns | | `academic` | Diagram-heavy, annotated illustrations, infographic elements | +## Web App + +MycroZine includes a full web application at `zine.jeffemmett.com` that allows anyone to create zines through a browser interface. + +### Features + +- **Text or voice input** - Describe your zine concept naturally +- **AI-powered generation** - Gemini generates outlines and page images +- **Interactive refinement** - Adjust any page with feedback +- **Shareable links** - Share your zine with a unique URL +- **Print-ready download** - 300 DPI PNG for home printing + +### Local Development + +```bash +# Install web dependencies +npm run web:install + +# Create .env.local in web/ directory +cp web/.env.example web/.env.local +# Edit web/.env.local and add your GEMINI_API_KEY + +# Start development server +npm run web:dev +``` + +Visit `http://localhost:3000` to use the app locally. + +### Docker Deployment + +```bash +# Build and start the container +GEMINI_API_KEY=your-key docker compose up -d --build + +# View logs +docker compose logs -f + +# Stop +docker compose down +``` + +The docker-compose.yml includes Traefik labels for automatic HTTPS routing. + +### Deployment to Netcup + +1. Push to Gitea: `git push origin main` +2. SSH to Netcup: `ssh netcup` +3. Pull and deploy: + ```bash + cd /opt/websites/mycro-zine + git pull + export GEMINI_API_KEY=$(cat ~/.gemini_credentials) + docker compose up -d --build + ``` +4. Add to Cloudflare tunnel if not already configured + ## Integration with Gemini MCP This library is designed to work with the [Gemini MCP Server](https://github.com/jeffemmett/gemini-mcp) for AI-powered content and image generation: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f02269 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.8' + +services: + mycrozine-web: + build: + context: . + dockerfile: Dockerfile + args: + - GEMINI_API_KEY=${GEMINI_API_KEY} + - NEXT_PUBLIC_APP_URL=https://zine.jeffemmett.com + container_name: mycrozine-web + restart: unless-stopped + environment: + - NODE_ENV=production + - GEMINI_API_KEY=${GEMINI_API_KEY} + - NEXT_PUBLIC_APP_URL=https://zine.jeffemmett.com + - DATA_DIR=/app/data + volumes: + - zine-data:/app/data + labels: + - "traefik.enable=true" + - "traefik.http.routers.mycrozine.rule=Host(`zine.jeffemmett.com`)" + - "traefik.http.routers.mycrozine.entrypoints=web" + - "traefik.http.services.mycrozine.loadbalancer.server.port=3000" + # HTTPS redirect (if using HTTPS entrypoint) + - "traefik.http.routers.mycrozine-secure.rule=Host(`zine.jeffemmett.com`)" + - "traefik.http.routers.mycrozine-secure.entrypoints=websecure" + - "traefik.http.routers.mycrozine-secure.tls=true" + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + zine-data: + driver: local + +networks: + traefik-public: + external: true diff --git a/examples/undernet_v2/undernet_v2_p1_cover.png b/examples/undernet_v2/undernet_v2_p1_cover.png new file mode 100644 index 0000000..d731c4d Binary files /dev/null and b/examples/undernet_v2/undernet_v2_p1_cover.png differ diff --git a/examples/undernet_v2/undernet_v2_p2_what.png b/examples/undernet_v2/undernet_v2_p2_what.png new file mode 100644 index 0000000..9f5ae41 Binary files /dev/null and b/examples/undernet_v2/undernet_v2_p2_what.png differ diff --git a/examples/undernet_v2/undernet_v2_p3_metacelium.png b/examples/undernet_v2/undernet_v2_p3_metacelium.png new file mode 100644 index 0000000..f9bd5c4 Binary files /dev/null and b/examples/undernet_v2/undernet_v2_p3_metacelium.png differ diff --git a/examples/undernet_v2/undernet_v2_p4_privacy.png b/examples/undernet_v2/undernet_v2_p4_privacy.png new file mode 100644 index 0000000..6cc6c04 Binary files /dev/null and b/examples/undernet_v2/undernet_v2_p4_privacy.png differ diff --git a/examples/undernet_v2/undernet_v2_p5_threepunks.png b/examples/undernet_v2/undernet_v2_p5_threepunks.png new file mode 100644 index 0000000..efc562c Binary files /dev/null and b/examples/undernet_v2/undernet_v2_p5_threepunks.png differ diff --git a/examples/undernet_v2/undernet_v2_p6_techstack.png b/examples/undernet_v2/undernet_v2_p6_techstack.png new file mode 100644 index 0000000..5655ac3 Binary files /dev/null and b/examples/undernet_v2/undernet_v2_p6_techstack.png differ diff --git a/examples/undernet_v2/undernet_v2_p7_philosophy.png b/examples/undernet_v2/undernet_v2_p7_philosophy.png new file mode 100644 index 0000000..0f914da Binary files /dev/null and b/examples/undernet_v2/undernet_v2_p7_philosophy.png differ diff --git a/examples/undernet_v2/undernet_v2_p8_cta.png b/examples/undernet_v2/undernet_v2_p8_cta.png new file mode 100644 index 0000000..e7b534d Binary files /dev/null and b/examples/undernet_v2/undernet_v2_p8_cta.png differ diff --git a/package.json b/package.json index b6ac06f..acdad6a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,15 @@ }, "scripts": { "layout": "node src/layout.mjs", - "example": "node src/layout.mjs examples/undernet/undernet_zine_p1_cover.png examples/undernet/undernet_zine_p2_what.png examples/undernet/undernet_zine_p3_metacelium.png examples/undernet/undernet_zine_p4_privacy.png examples/undernet/undernet_zine_p5_threepunks.png examples/undernet/undernet_zine_p6_techstack.png examples/undernet/undernet_zine_p7_philosophy.png examples/undernet/undernet_zine_p8_cta.png" + "example": "node src/layout.mjs examples/undernet/undernet_zine_p1_cover.png examples/undernet/undernet_zine_p2_what.png examples/undernet/undernet_zine_p3_metacelium.png examples/undernet/undernet_zine_p4_privacy.png examples/undernet/undernet_zine_p5_threepunks.png examples/undernet/undernet_zine_p6_techstack.png examples/undernet/undernet_zine_p7_philosophy.png examples/undernet/undernet_zine_p8_cta.png", + "web:dev": "cd web && npm run dev", + "web:build": "cd web && npm run build", + "web:start": "cd web && npm run start", + "web:install": "cd web && npm install", + "docker:build": "docker compose build", + "docker:up": "docker compose up -d", + "docker:down": "docker compose down", + "docker:logs": "docker compose logs -f" }, "keywords": [ "zine", diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..0cdf298 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,9 @@ +# Gemini API Key (required for AI generation) +# Get one at: https://aistudio.google.com/app/apikey +GEMINI_API_KEY=your-gemini-api-key-here + +# Public URL for the app (used for share links) +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Data directory for storing zines (optional, defaults to ../data) +# DATA_DIR=/app/data diff --git a/web/app/api/generate-page/route.ts b/web/app/api/generate-page/route.ts new file mode 100644 index 0000000..551a624 --- /dev/null +++ b/web/app/api/generate-page/route.ts @@ -0,0 +1,215 @@ +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", + "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", + "academic": "clean infographic style, annotated diagrams, data visualization, technical illustration, educational layout, clear hierarchy", +}; + +const TONE_PROMPTS: Record = { + "rebellious": "defiant anti-establishment energy, provocative bold statements, raw and unfiltered, urgent", + "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["punk-zine"]; + const tonePrompt = TONE_PROMPTS[tone] || TONE_PROMPTS["rebellious"]; + + // Build the full image generation prompt + const fullPrompt = buildImagePrompt(pageOutline, stylePrompt, tonePrompt); + + // Generate image using Gemini Imagen API + // Note: This uses the MCP-style generation - in production, we'd call the Gemini API directly + const imageBase64 = await generateImageWithGemini(fullPrompt); + + // 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}.png`, + 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): Promise { + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error("GEMINI_API_KEY not configured"); + } + + // Use Gemini's Imagen 3 API for image generation + // API endpoint for image generation + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/imagen-3.0-generate-002:predict?key=${apiKey}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + instances: [{ prompt }], + parameters: { + sampleCount: 1, + aspectRatio: "3:4", // Portrait for zine pages + safetyFilterLevel: "BLOCK_ONLY_HIGH", + personGeneration: "ALLOW_ADULT", + }, + }), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Imagen API error:", errorText); + + // Fallback to Gemini 2.0 Flash experimental image generation + return await generateImageWithGemini2(prompt); + } + + const data = await response.json(); + + if (data.predictions && data.predictions[0]?.bytesBase64Encoded) { + return data.predictions[0].bytesBase64Encoded; + } + + throw new Error("No image data in response"); +} + +async function generateImageWithGemini2(prompt: string): Promise { + const apiKey = process.env.GEMINI_API_KEY; + + // Try Gemini 2.0 Flash with image generation capability + const response = await fetch( + `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { + text: `Generate an image: ${prompt}`, + }, + ], + }, + ], + generationConfig: { + responseModalities: ["IMAGE", "TEXT"], + responseMimeType: "image/png", + }, + }), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Gemini 2.0 image error:", errorText); + + // Return a placeholder for development + return createPlaceholderImage(prompt); + } + + const data = await response.json(); + + // Extract image from response + const parts = data.candidates?.[0]?.content?.parts || []; + for (const part of parts) { + if (part.inlineData?.mimeType?.startsWith("image/")) { + return part.inlineData.data; + } + } + + // If no image, create placeholder + return createPlaceholderImage(prompt); +} + +async function createPlaceholderImage(prompt: string): Promise { + // Create a simple placeholder image using sharp + const sharp = (await import("sharp")).default; + + const svg = ` + + + + + [IMAGE PLACEHOLDER] + + + ${prompt.slice(0, 50)}... + + + Image generation in progress + + + `; + + const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); + return buffer.toString("base64"); +} diff --git a/web/app/api/outline/route.ts b/web/app/api/outline/route.ts new file mode 100644 index 0000000..6d6380b --- /dev/null +++ b/web/app/api/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 = "punk-zine", tone = "rebellious" } = 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/web/app/api/print-layout/route.ts b/web/app/api/print-layout/route.ts new file mode 100644 index 0000000..0f16fe4 --- /dev/null +++ b/web/app/api/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`, + filename: `${zineName || "mycrozine"}_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`, + }); +} diff --git a/web/app/api/regenerate-page/route.ts b/web/app/api/regenerate-page/route.ts new file mode 100644 index 0000000..4c2209e --- /dev/null +++ b/web/app/api/regenerate-page/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { GoogleGenerativeAI } from "@google/generative-ai"; +import { getZine, saveZine, savePageImage } from "@/lib/storage"; +import type { PageOutline } from "@/lib/gemini"; + +const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || ""); + +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 } + ); + } + + // 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-1.5-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/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/web/app/api/zine/[id]/route.ts b/web/app/api/zine/[id]/route.ts new file mode 100644 index 0000000..cf2d221 --- /dev/null +++ b/web/app/api/zine/[id]/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getZine, readFileAsBuffer, getPageImagePath, getPrintLayoutPath } from "@/lib/storage"; +import path from "path"; + +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); + return new NextResponse(new Uint8Array(printBuffer), { + headers: { + "Content-Type": "image/png", + "Content-Disposition": `attachment; filename="${id}_print.png"`, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } + + // 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}/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/web/app/api/zine/save/route.ts b/web/app/api/zine/save/route.ts new file mode 100644 index 0000000..ee03b94 --- /dev/null +++ b/web/app/api/zine/save/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getZine, saveZine } from "@/lib/storage"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { zineId } = body; + + if (!zineId) { + return NextResponse.json( + { error: "Missing zineId" }, + { status: 400 } + ); + } + + // Get existing zine + const zine = await getZine(zineId); + if (!zine) { + return NextResponse.json( + { error: "Zine not found" }, + { status: 404 } + ); + } + + // Update the timestamp to mark it as "saved" + zine.updatedAt = new Date().toISOString(); + await saveZine(zine); + + // Return the shareable URL + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://zine.jeffemmett.com"; + const shareUrl = `${baseUrl}/z/${zineId}`; + + return NextResponse.json({ + success: true, + id: zineId, + shareUrl, + }); + } catch (error) { + console.error("Save zine error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to save zine" }, + { status: 500 } + ); + } +} diff --git a/web/app/create/page.tsx b/web/app/create/page.tsx new file mode 100644 index 0000000..1963fe5 --- /dev/null +++ b/web/app/create/page.tsx @@ -0,0 +1,618 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { + ArrowLeft, + ArrowRight, + Check, + Download, + Edit2, + Loader2, + Mic, + MicOff, + RefreshCw, + Share2, + 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("/"); + 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/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/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(); + + setState((s) => { + if (!s) return s; + const newPages = [...s.pages]; + newPages[i - 1] = data.imageUrl; + 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/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/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}/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 Pages... {state.pages.filter((p) => p).length}/8 +

+
+ {state.outline.map((page, i) => ( +
+ {state.pages[i] ? ( + {`Page + ) : state.generatingPage === i + 1 ? ( +
+ + Page {i + 1} +
+ ) : ( + P{i + 1} + )} +
+ ))} +
+
+ )} + + {/* 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(" • ")} +

+
+ +
+ +
+