feat: add prompt-to-print web app at zine.jeffemmett.com

Full-stack Next.js 15 web application for creating mycro-zines:

Features:
- Text or voice input for concept description
- Style/tone selection (punk-zine, minimal, collage, etc.)
- AI-powered outline generation via Gemini
- 8-page image generation with refinement loop
- Print-ready PNG download (300 DPI)
- Shareable zine URLs (/z/[id])

Tech stack:
- Next.js 15 with App Router
- Tailwind CSS v4 + Radix UI
- Gemini API for text and image generation
- Sharp for print layout assembly
- Docker + Traefik for deployment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-18 17:55:39 -05:00
parent 2aa9dacfc4
commit efc6b8158b
36 changed files with 5337 additions and 1 deletions

11
.gitignore vendored
View File

@ -25,3 +25,14 @@ npm-debug.log*
# Build artifacts # Build artifacts
dist/ dist/
build/ build/
# Next.js
web/.next/
web/out/
web/node_modules/
# Data storage (user zines)
data/zines/
# Docker
.docker/

88
Dockerfile Normal file
View File

@ -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"]

View File

@ -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 | | `retro` | 1970s aesthetic, earth tones, groovy typography, halftone patterns |
| `academic` | Diagram-heavy, annotated illustrations, infographic elements | | `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 ## 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: This library is designed to work with the [Gemini MCP Server](https://github.com/jeffemmett/gemini-mcp) for AI-powered content and image generation:

44
docker-compose.yml Normal file
View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -11,7 +11,15 @@
}, },
"scripts": { "scripts": {
"layout": "node src/layout.mjs", "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": [ "keywords": [
"zine", "zine",

9
web/.env.example Normal file
View File

@ -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

View File

@ -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<string, string> = {
"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<string, string> = {
"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<string> {
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<string> {
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<string> {
// Create a simple placeholder image using sharp
const sharp = (await import("sharp")).default;
const svg = `
<svg width="825" height="1275" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#f0f0f0"/>
<rect x="20" y="20" width="785" height="1235" fill="white" stroke="black" stroke-width="3"/>
<text x="412" y="600" text-anchor="middle" font-family="Courier New" font-size="24" font-weight="bold">
[IMAGE PLACEHOLDER]
</text>
<text x="412" y="650" text-anchor="middle" font-family="Courier New" font-size="14">
${prompt.slice(0, 50)}...
</text>
<text x="412" y="700" text-anchor="middle" font-family="Courier New" font-size="12" fill="#666">
Image generation in progress
</text>
</svg>
`;
const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
return buffer.toString("base64");
}

View File

@ -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 }
);
}
}

View File

@ -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`,
});
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

View File

@ -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 }
);
}
}

618
web/app/create/page.tsx Normal file
View File

@ -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<ZineState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<Loader2 className="w-12 h-12 mx-auto animate-spin mb-4" />
<p className="text-lg punk-text">Generating your outline...</p>
</div>
</div>
);
}
if (error && !state) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<div className="punk-border bg-white p-8 max-w-md text-center">
<h2 className="text-2xl font-bold punk-text mb-4">Error</h2>
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={() => router.push("/")}
className="px-6 py-2 bg-black text-white punk-text hover:bg-green-500 hover:text-black"
>
Try Again
</button>
</div>
</div>
);
}
if (!state) return null;
return (
<div className="min-h-screen p-4 sm:p-8">
{/* Header with Progress */}
<div className="max-w-4xl mx-auto mb-8">
<div className="flex items-center justify-between mb-4">
<button
onClick={() => router.push("/")}
className="flex items-center gap-2 text-gray-600 hover:text-black"
>
<ArrowLeft className="w-4 h-4" />
<span className="punk-text text-sm">Back</span>
</button>
<h1 className="text-xl sm:text-2xl font-bold punk-text truncate max-w-md">
{state.topic}
</h1>
</div>
{/* Progress Steps */}
<div className="flex items-center justify-between">
{STEPS.map((step, i) => (
<div key={step} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold
${
STEPS.indexOf(state.currentStep) >= i
? "bg-black text-white"
: "bg-gray-200 text-gray-500"
}`}
>
{STEPS.indexOf(state.currentStep) > i ? (
<Check className="w-4 h-4" />
) : (
i + 1
)}
</div>
{i < STEPS.length - 1 && (
<div
className={`w-12 sm:w-24 h-1 mx-2 ${
STEPS.indexOf(state.currentStep) > i ? "bg-black" : "bg-gray-200"
}`}
/>
)}
</div>
))}
</div>
<div className="flex justify-between mt-2">
{STEPS.map((step) => (
<span
key={step}
className={`text-xs punk-text ${
state.currentStep === step ? "text-black" : "text-gray-400"
}`}
>
{STEP_LABELS[step]}
</span>
))}
</div>
</div>
{/* Error Banner */}
{error && (
<div className="max-w-4xl mx-auto mb-4 p-4 bg-red-100 border border-red-400 text-red-700 punk-text text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">
Dismiss
</button>
</div>
)}
{/* Step Content */}
<div className="max-w-4xl mx-auto">
{/* Step 1: Outline Review */}
{state.currentStep === "outline" && (
<div className="space-y-4">
<h2 className="text-xl font-bold punk-text mb-4">Your 8-Page Outline</h2>
<div className="grid gap-4">
{state.outline.map((page, i) => (
<div key={i} className="punk-border bg-white p-4">
<div className="flex items-start justify-between">
<div>
<span className="text-xs text-gray-500 punk-text">
Page {page.pageNumber} {page.type}
</span>
<h3 className="font-bold text-lg">{page.title}</h3>
<ul className="mt-2 text-sm text-gray-600">
{page.keyPoints.map((point, j) => (
<li key={j}> {point}</li>
))}
</ul>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end gap-4 mt-6">
<button
onClick={generatePages}
className="px-6 py-3 bg-black text-white punk-text flex items-center gap-2
hover:bg-green-500 hover:text-black transition-colors punk-border"
>
Generate Pages
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Step 2: Page Generation */}
{state.currentStep === "generate" && (
<div className="space-y-6">
<h2 className="text-xl font-bold punk-text">
Generating Pages... {state.pages.filter((p) => p).length}/8
</h2>
<div className="grid grid-cols-4 gap-4">
{state.outline.map((page, i) => (
<div
key={i}
className={`aspect-[3/4] punk-border flex items-center justify-center
${state.pages[i] ? "bg-white" : "bg-gray-100"}`}
>
{state.pages[i] ? (
<img
src={state.pages[i]}
alt={`Page ${i + 1}`}
className="w-full h-full object-cover"
/>
) : state.generatingPage === i + 1 ? (
<div className="text-center p-2">
<Loader2 className="w-8 h-8 mx-auto animate-spin mb-2" />
<span className="text-xs punk-text">Page {i + 1}</span>
</div>
) : (
<span className="text-gray-400 punk-text">P{i + 1}</span>
)}
</div>
))}
</div>
</div>
)}
{/* Step 3: Page Refinement */}
{state.currentStep === "refine" && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold punk-text">
Page {currentPage} of 8
</h2>
<div className="flex gap-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 punk-border disabled:opacity-50"
>
<ArrowLeft className="w-4 h-4" />
</button>
<button
onClick={() => setCurrentPage((p) => Math.min(8, p + 1))}
disabled={currentPage === 8}
className="p-2 punk-border disabled:opacity-50"
>
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
{/* Current Page Preview */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="aspect-[3/4] punk-border bg-white">
{state.generatingPage === currentPage ? (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="w-12 h-12 animate-spin" />
</div>
) : (
<img
src={state.pages[currentPage - 1]}
alt={`Page ${currentPage}`}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="space-y-4">
<div className="punk-border bg-white p-4">
<h3 className="font-bold punk-text">{state.outline[currentPage - 1].title}</h3>
<p className="text-sm text-gray-600 mt-2">
{state.outline[currentPage - 1].keyPoints.join(" • ")}
</p>
</div>
<div className="punk-border bg-white p-4">
<label className="block text-sm font-bold punk-text mb-2">
Feedback for refinement
</label>
<div className="relative">
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Make it more punk... Add more contrast... Change the layout..."
className="w-full h-24 p-3 pr-12 border-2 border-black resize-none punk-text text-sm"
disabled={state.generatingPage !== null}
/>
<button
type="button"
onClick={handleVoiceInput}
className={`absolute right-2 top-2 p-2 rounded-full ${
isListening ? "bg-red-500 text-white" : "bg-gray-100"
}`}
>
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
</button>
</div>
<button
onClick={regeneratePage}
disabled={!feedback.trim() || state.generatingPage !== null}
className="mt-3 w-full py-2 bg-black text-white punk-text flex items-center justify-center gap-2
hover:bg-green-500 hover:text-black disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-4 h-4 ${state.generatingPage ? "animate-spin" : ""}`} />
Regenerate Page
</button>
</div>
</div>
</div>
{/* Thumbnail Strip */}
<div className="flex gap-2 overflow-x-auto pb-2">
{state.pages.map((page, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`flex-shrink-0 w-16 aspect-[3/4] punk-border overflow-hidden
${currentPage === i + 1 ? "ring-2 ring-green-500" : ""}`}
>
<img src={page} alt={`Page ${i + 1}`} className="w-full h-full object-cover" />
</button>
))}
</div>
<div className="flex justify-end">
<button
onClick={createPrintLayout}
className="px-6 py-3 bg-black text-white punk-text flex items-center gap-2
hover:bg-green-500 hover:text-black transition-colors punk-border"
>
Create Print Layout
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Step 4: Download & Share */}
{state.currentStep === "download" && (
<div className="space-y-6">
<h2 className="text-xl font-bold punk-text text-center">Your Zine is Ready!</h2>
{/* Print Layout Preview */}
<div className="punk-border bg-white p-4">
<img
src={state.printLayoutUrl || ""}
alt="Print Layout"
className="w-full"
/>
</div>
{/* Actions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<a
href={state.printLayoutUrl || "#"}
download={`${state.topic.slice(0, 20).replace(/[^a-zA-Z0-9]/g, "_")}_print.png`}
className="punk-border bg-black text-white py-4 px-6 flex items-center justify-center gap-2
hover:bg-green-500 hover:text-black transition-colors punk-text"
>
<Download className="w-5 h-5" />
Download PNG (300 DPI)
</a>
<button
onClick={copyShareLink}
className="punk-border bg-white py-4 px-6 flex items-center justify-center gap-2
hover:bg-gray-100 transition-colors punk-text"
>
{copied ? (
<>
<CheckCircle className="w-5 h-5 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="w-5 h-5" />
Copy Share Link
</>
)}
</button>
</div>
{/* Folding Instructions */}
<div className="punk-border bg-gray-50 p-6">
<h3 className="font-bold punk-text mb-4">How to Fold Your Zine</h3>
<ol className="text-sm space-y-2">
<li>1. Print the layout on 8.5&quot; x 11&quot; paper (landscape)</li>
<li>2. Fold in half along the long edge (hotdog fold)</li>
<li>3. Fold in half again along the short edge</li>
<li>4. Fold once more to create a booklet</li>
<li>5. Unfold completely and lay flat</li>
<li>6. Cut the center slit between pages 3-6 and 4-5</li>
<li>7. Refold hotdog style and push ends together</li>
<li>8. Flatten - pages should now be in order 1-8!</li>
</ol>
</div>
<div className="text-center">
<button
onClick={() => {
sessionStorage.removeItem("zineInput");
router.push("/");
}}
className="text-gray-600 hover:text-black punk-text underline"
>
Create Another Zine
</button>
</div>
</div>
)}
</div>
</div>
);
}

103
web/app/globals.css Normal file
View File

@ -0,0 +1,103 @@
@import "tailwindcss";
/* Custom CSS Variables for theming */
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
/* Punk zine accent colors */
--punk-green: 120 100% 50%;
--punk-pink: 330 100% 50%;
--punk-yellow: 60 100% 50%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings: "rlig" 1, "calt" 1;
}
/* 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;
}

30
web/app/layout.tsx Normal file
View File

@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "MycroZine - Create Your Own Mini-Zine",
description: "Transform your ideas into printable 8-page mini-zines with AI. Input a concept, generate pages, refine, and print!",
openGraph: {
title: "MycroZine - Create Your Own Mini-Zine",
description: "Transform your ideas into printable 8-page mini-zines with AI",
url: "https://zine.jeffemmett.com",
siteName: "MycroZine",
type: "website",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="min-h-screen bg-white antialiased">
<main className="flex min-h-screen flex-col">
{children}
</main>
</body>
</html>
);
}

198
web/app/page.tsx Normal file
View File

@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Mic, MicOff, Sparkles, BookOpen, Printer } from "lucide-react";
const STYLES = [
{ value: "punk-zine", label: "Punk Zine", description: "Xerox texture, high contrast, DIY collage" },
{ value: "minimal", label: "Minimal", description: "Clean lines, white space, modern" },
{ value: "collage", label: "Collage", description: "Layered imagery, mixed media" },
{ value: "retro", label: "Retro", description: "1970s aesthetic, earth tones" },
{ value: "academic", label: "Academic", description: "Diagrams, annotations, infographic" },
];
const TONES = [
{ value: "rebellious", label: "Rebellious", description: "Defiant, punk attitude" },
{ value: "playful", label: "Playful", description: "Whimsical, fun, light-hearted" },
{ value: "informative", label: "Informative", description: "Educational, factual" },
{ value: "poetic", label: "Poetic", description: "Lyrical, metaphorical" },
];
export default function Home() {
const router = useRouter();
const [topic, setTopic] = useState("");
const [style, setStyle] = useState("punk-zine");
const [tone, setTone] = useState("rebellious");
const [isListening, setIsListening] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleVoiceInput = () => {
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
alert("Voice input is not supported in this browser. Try Chrome or Edge.");
return;
}
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = false;
recognition.lang = "en-US";
recognition.onstart = () => setIsListening(true);
recognition.onend = () => setIsListening(false);
recognition.onerror = () => setIsListening(false);
recognition.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
setTopic((prev) => (prev ? prev + " " + transcript : transcript));
};
if (isListening) {
recognition.stop();
} else {
recognition.start();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!topic.trim()) return;
setIsLoading(true);
// Store the input in sessionStorage and navigate to create page
sessionStorage.setItem("zineInput", JSON.stringify({ topic, style, tone }));
router.push("/create");
};
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 sm:p-8">
{/* Header */}
<div className="text-center mb-8 sm:mb-12">
<h1 className="text-4xl sm:text-6xl font-bold tracking-tight punk-text mb-4">
MYCRO<span className="text-green-500">ZINE</span>
</h1>
<p className="text-lg sm:text-xl text-gray-600 max-w-xl mx-auto">
Transform your ideas into printable 8-page mini-zines with AI
</p>
</div>
{/* Features */}
<div className="flex flex-wrap justify-center gap-6 mb-8 sm:mb-12">
<div className="flex items-center gap-2 text-sm text-gray-500">
<Sparkles className="w-4 h-4" />
<span>AI-Generated</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<BookOpen className="w-4 h-4" />
<span>8 Pages</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Printer className="w-4 h-4" />
<span>Print Ready</span>
</div>
</div>
{/* Main Form */}
<form onSubmit={handleSubmit} className="w-full max-w-2xl space-y-6">
{/* Topic Input */}
<div className="punk-border bg-white p-1">
<div className="relative">
<textarea
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="What's your zine about? Describe your concept..."
className="w-full h-32 p-4 pr-12 text-lg resize-none border-0 focus:outline-none focus:ring-0 punk-text"
disabled={isLoading}
/>
<button
type="button"
onClick={handleVoiceInput}
className={`absolute right-3 top-3 p-2 rounded-full transition-colors ${
isListening
? "bg-red-500 text-white animate-pulse"
: "bg-gray-100 hover:bg-gray-200 text-gray-600"
}`}
title={isListening ? "Stop listening" : "Voice input"}
>
{isListening ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
</button>
</div>
</div>
{/* Style & Tone Selectors */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Style Select */}
<div className="punk-border bg-white p-4">
<label className="block text-sm font-bold punk-text mb-2">Style</label>
<select
value={style}
onChange={(e) => setStyle(e.target.value)}
className="w-full p-2 border-2 border-black bg-white punk-text focus:outline-none"
disabled={isLoading}
>
{STYLES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
<p className="mt-2 text-xs text-gray-500">
{STYLES.find((s) => s.value === style)?.description}
</p>
</div>
{/* Tone Select */}
<div className="punk-border bg-white p-4">
<label className="block text-sm font-bold punk-text mb-2">Tone</label>
<select
value={tone}
onChange={(e) => setTone(e.target.value)}
className="w-full p-2 border-2 border-black bg-white punk-text focus:outline-none"
disabled={isLoading}
>
{TONES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<p className="mt-2 text-xs text-gray-500">
{TONES.find((t) => t.value === tone)?.description}
</p>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={!topic.trim() || isLoading}
className="w-full py-4 px-6 bg-black text-white text-lg font-bold punk-text
hover:bg-green-500 hover:text-black transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
punk-border hover:shadow-[6px_6px_0_black]"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin">&#9881;</span>
Generating...
</span>
) : (
"Generate Outline →"
)}
</button>
</form>
{/* Footer */}
<footer className="mt-12 text-center text-sm text-gray-400">
<p>
Folds into a single 8.5&quot; x 11&quot; sheet {" "}
<a href="#how-it-works" className="underline hover:text-gray-600">
How to fold
</a>
</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Download, Plus, Share2, Copy, CheckCircle } from "lucide-react";
interface PageOutline {
pageNumber: number;
type: string;
title: string;
keyPoints: string[];
imagePrompt: string;
}
interface ZineData {
id: string;
topic: string;
style: string;
tone: string;
outline: PageOutline[];
pageUrls: string[];
printLayoutUrl: string | null;
shareUrl: string;
createdAt: string;
}
interface ZineViewerProps {
zine: ZineData;
}
export default function ZineViewer({ zine }: ZineViewerProps) {
const [currentPage, setCurrentPage] = useState(0);
const [copied, setCopied] = useState(false);
const copyShareLink = async () => {
await navigator.clipboard.writeText(window.location.href);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handlePrevPage = () => {
setCurrentPage((p) => (p > 0 ? p - 1 : zine.pageUrls.length - 1));
};
const handleNextPage = () => {
setCurrentPage((p) => (p < zine.pageUrls.length - 1 ? p + 1 : 0));
};
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<Link
href="/"
className="text-gray-600 hover:text-black punk-text text-sm flex items-center gap-1"
>
<ArrowLeft className="w-4 h-4" />
MycroZine
</Link>
<div className="flex gap-2">
<button
onClick={copyShareLink}
className="px-3 py-1 punk-border bg-white text-sm punk-text flex items-center gap-1
hover:bg-gray-100"
>
{copied ? (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
Copied
</>
) : (
<>
<Copy className="w-4 h-4" />
Share
</>
)}
</button>
</div>
</div>
{/* Title */}
<h1 className="text-2xl sm:text-3xl font-bold punk-text text-center mb-2">
{zine.topic}
</h1>
<p className="text-center text-gray-500 text-sm mb-6">
{zine.style} {zine.tone}
</p>
{/* Main Viewer */}
<div className="relative">
{/* Page Display */}
<div className="punk-border bg-white aspect-[3/4] max-w-md mx-auto overflow-hidden">
{zine.pageUrls[currentPage] && (
<img
src={zine.pageUrls[currentPage]}
alt={`Page ${currentPage + 1}`}
className="w-full h-full object-cover"
/>
)}
</div>
{/* Navigation Arrows */}
<button
onClick={handlePrevPage}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 sm:-translate-x-12
p-2 sm:p-3 punk-border bg-white hover:bg-green-500 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<button
onClick={handleNextPage}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 sm:translate-x-12
p-2 sm:p-3 punk-border bg-white hover:bg-green-500 transition-colors"
>
<ArrowRight className="w-5 h-5" />
</button>
</div>
{/* Page Info */}
<div className="text-center mt-4">
<p className="punk-text text-sm">
Page {currentPage + 1} of {zine.pageUrls.length}
</p>
{zine.outline[currentPage] && (
<p className="text-gray-600 text-sm mt-1">{zine.outline[currentPage].title}</p>
)}
</div>
{/* Thumbnail Strip */}
<div className="flex justify-center gap-2 mt-6 overflow-x-auto pb-2">
{zine.pageUrls.map((url, i) => (
<button
key={i}
onClick={() => setCurrentPage(i)}
className={`flex-shrink-0 w-12 sm:w-16 aspect-[3/4] punk-border overflow-hidden
${currentPage === i ? "ring-2 ring-green-500" : "opacity-60 hover:opacity-100"}`}
>
<img src={url} alt={`Page ${i + 1}`} className="w-full h-full object-cover" />
</button>
))}
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row justify-center gap-4 mt-8">
{zine.printLayoutUrl && (
<a
href={zine.printLayoutUrl}
download={`${zine.topic.slice(0, 20).replace(/[^a-zA-Z0-9]/g, "_")}_print.png`}
className="punk-border bg-black text-white py-3 px-6 flex items-center justify-center gap-2
hover:bg-green-500 hover:text-black transition-colors punk-text"
>
<Download className="w-5 h-5" />
Download Print Layout
</a>
)}
<Link
href="/"
className="punk-border bg-white py-3 px-6 flex items-center justify-center gap-2
hover:bg-gray-100 transition-colors punk-text"
>
<Plus className="w-5 h-5" />
Create Your Own
</Link>
</div>
{/* Footer Info */}
<div className="text-center mt-8 text-xs text-gray-400">
<p>
Created with{" "}
<Link href="/" className="underline hover:text-gray-600">
MycroZine
</Link>
</p>
<p className="mt-1">
{new Date(zine.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
</div>
</div>
);
}

58
web/app/z/[id]/page.tsx Normal file
View File

@ -0,0 +1,58 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import ZineViewer from "./ZineViewer";
interface PageProps {
params: Promise<{ id: string }>;
}
async function getZine(id: string) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const response = await fetch(`${baseUrl}/api/zine/${id}`, {
cache: "no-store",
});
if (!response.ok) {
return null;
}
return response.json();
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const zine = await getZine(id);
if (!zine) {
return {
title: "Zine Not Found - MycroZine",
};
}
return {
title: `${zine.topic} - MycroZine`,
description: `An 8-page mini-zine about ${zine.topic}. Created with MycroZine.`,
openGraph: {
title: `${zine.topic} - MycroZine`,
description: `An 8-page mini-zine about ${zine.topic}`,
type: "article",
images: zine.pageUrls?.[0] ? [{ url: zine.pageUrls[0] }] : [],
},
twitter: {
card: "summary_large_image",
title: `${zine.topic} - MycroZine`,
description: `An 8-page mini-zine about ${zine.topic}`,
},
};
}
export default async function SharedZinePage({ params }: PageProps) {
const { id } = await params;
const zine = await getZine(id);
if (!zine) {
notFound();
}
return <ZineViewer zine={zine} />;
}

192
web/lib/gemini.ts Normal file
View File

@ -0,0 +1,192 @@
import { GoogleGenerativeAI } from "@google/generative-ai";
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || "");
export interface PageOutline {
pageNumber: number;
type: string;
title: string;
keyPoints: string[];
imagePrompt: string;
}
export interface ZineOutline {
id: string;
topic: string;
style: string;
tone: string;
pages: PageOutline[];
createdAt: string;
}
const STYLE_PROMPTS: Record<string, string> = {
"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",
"minimal": "clean minimalist design, lots of white space, modern sans-serif typography, simple geometric shapes, subtle gradients",
"collage": "layered mixed media collage, vintage photographs, torn paper edges, overlapping textures, eclectic composition",
"retro": "1970s aesthetic, earth tones, groovy psychedelic typography, halftone dot patterns, vintage illustration style",
"academic": "clean infographic style, annotated diagrams, data visualization, technical illustration, educational layout",
};
const TONE_PROMPTS: Record<string, string> = {
"rebellious": "defiant anti-establishment energy, provocative bold statements, raw and unfiltered",
"playful": "whimsical fun light-hearted energy, humor and wit, bright positive vibes",
"informative": "educational and factual, clear explanations, structured information",
"poetic": "lyrical and metaphorical, evocative imagery, emotional depth",
};
export async function generateOutline(
topic: string,
style: string,
tone: string
): Promise<PageOutline[]> {
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
const prompt = `You are creating an 8-page mycro-zine (mini DIY zine that folds from a single sheet of paper).
Topic: ${topic}
Visual Style: ${style} - ${STYLE_PROMPTS[style] || STYLE_PROMPTS["punk-zine"]}
Tone: ${tone} - ${TONE_PROMPTS[tone] || TONE_PROMPTS["rebellious"]}
Create a detailed outline for all 8 pages. Each page should have a distinct purpose:
- Page 1: Cover (eye-catching title and central image)
- Page 2: Introduction (hook the reader, set the stage)
- Pages 3-6: Main content (key concepts, stories, visuals)
- Page 7: Resources or deeper dive
- Page 8: Call to action (what reader should do next)
Return ONLY valid JSON in this exact format (no markdown, no code blocks):
{
"pages": [
{
"pageNumber": 1,
"type": "cover",
"title": "Short punchy title",
"keyPoints": ["Main visual concept", "Tagline or subtitle"],
"imagePrompt": "Detailed prompt for generating the page image including style elements"
}
]
}
Make each imagePrompt detailed and specific to the ${style} visual style. Include concrete visual elements, composition details, and mood descriptors.`;
const result = await model.generateContent(prompt);
const response = result.response.text();
// Parse JSON from response (handle potential markdown code blocks)
let jsonStr = response;
if (response.includes("```")) {
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
jsonStr = match[1];
}
}
const parsed = JSON.parse(jsonStr.trim());
return parsed.pages;
}
export async function generatePageImage(
pageOutline: PageOutline,
style: string,
tone: string,
feedback?: string
): Promise<string> {
// Use Gemini's image generation (Imagen 3 via Gemini API)
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash-exp" });
const styleDesc = STYLE_PROMPTS[style] || STYLE_PROMPTS["punk-zine"];
const toneDesc = TONE_PROMPTS[tone] || TONE_PROMPTS["rebellious"];
let imagePrompt = `Create a single page for a mini-zine (approximately 825x1275 pixels aspect ratio, portrait orientation).
Page ${pageOutline.pageNumber}: ${pageOutline.title}
Type: ${pageOutline.type}
Key elements: ${pageOutline.keyPoints.join(", ")}
Visual style: ${styleDesc}
Mood/tone: ${toneDesc}
Specific requirements:
${pageOutline.imagePrompt}
The image should be a complete, self-contained page that could be printed. Include any text as part of the design in a ${style} typography style.`;
if (feedback) {
imagePrompt += `\n\nUser feedback for refinement: ${feedback}`;
}
// For now, return a placeholder - we'll integrate actual image generation
// The actual implementation will use either Gemini's native image gen or RunPod
// Generate with Gemini 2.0 Flash which supports image generation
try {
const result = await model.generateContent({
contents: [{
role: "user",
parts: [{ text: `Generate an image: ${imagePrompt}` }]
}],
generationConfig: {
// Note: Image generation config would go here when available
}
});
// Check if response contains image data
const response = result.response;
// For text model fallback, return a description
// In production, this would use imagen or other image gen API
return `data:text/plain;base64,${Buffer.from(response.text()).toString('base64')}`;
} catch (error) {
console.error("Image generation error:", error);
throw new Error("Failed to generate page image");
}
}
export async function regeneratePageWithFeedback(
currentOutline: PageOutline,
feedback: string,
style: string,
tone: string
): Promise<{ updatedOutline: PageOutline; imageUrl: string }> {
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
// First, update the outline based on feedback
const prompt = `You are refining a zine page based on user feedback.
Current page outline:
${JSON.stringify(currentOutline, null, 2)}
User feedback: "${feedback}"
Style: ${style}
Tone: ${tone}
Update the page outline to incorporate this feedback. Keep the same page number and type, but update title, keyPoints, and imagePrompt as needed.
Return ONLY valid JSON (no markdown, no code blocks):
{
"pageNumber": ${currentOutline.pageNumber},
"type": "${currentOutline.type}",
"title": "Updated title",
"keyPoints": ["Updated point 1", "Updated point 2"],
"imagePrompt": "Updated detailed image prompt"
}`;
const result = await model.generateContent(prompt);
const response = result.response.text();
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
const imageUrl = await generatePageImage(updatedOutline, style, tone, feedback);
return { updatedOutline, imageUrl };
}

150
web/lib/storage.ts Normal file
View File

@ -0,0 +1,150 @@
import fs from "fs/promises";
import path from "path";
import type { ZineOutline, PageOutline } from "./gemini";
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), "..", "data");
const ZINES_DIR = path.join(DATA_DIR, "zines");
export interface StoredZine {
id: string;
topic: string;
style: string;
tone: string;
outline: PageOutline[];
pages: string[]; // Paths to page images (p1.png - p8.png)
printLayout?: string; // Path to final print layout
createdAt: string;
updatedAt: string;
}
async function ensureDir(dir: string): Promise<void> {
try {
await fs.access(dir);
} catch {
await fs.mkdir(dir, { recursive: true });
}
}
export async function saveZine(zine: StoredZine): Promise<void> {
const zineDir = path.join(ZINES_DIR, zine.id);
await ensureDir(zineDir);
const metadataPath = path.join(zineDir, "metadata.json");
await fs.writeFile(metadataPath, JSON.stringify(zine, null, 2));
}
export async function getZine(id: string): Promise<StoredZine | null> {
try {
const metadataPath = path.join(ZINES_DIR, id, "metadata.json");
const data = await fs.readFile(metadataPath, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
}
export async function savePageImage(
zineId: string,
pageNumber: number,
imageData: Buffer | string
): Promise<string> {
const zineDir = path.join(ZINES_DIR, zineId);
const pagesDir = path.join(zineDir, "pages");
await ensureDir(pagesDir);
const filename = `p${pageNumber}.png`;
const filepath = path.join(pagesDir, filename);
if (typeof imageData === "string") {
// Handle base64 data URL
if (imageData.startsWith("data:")) {
const base64Data = imageData.split(",")[1];
await fs.writeFile(filepath, Buffer.from(base64Data, "base64"));
} else {
// Assume it's already base64
await fs.writeFile(filepath, Buffer.from(imageData, "base64"));
}
} else {
await fs.writeFile(filepath, imageData);
}
return filepath;
}
export async function getPageImagePath(zineId: string, pageNumber: number): Promise<string | null> {
const filepath = path.join(ZINES_DIR, zineId, "pages", `p${pageNumber}.png`);
try {
await fs.access(filepath);
return filepath;
} catch {
return null;
}
}
export async function getAllPagePaths(zineId: string): Promise<string[]> {
const paths: string[] = [];
for (let i = 1; i <= 8; i++) {
const pagePath = await getPageImagePath(zineId, i);
if (pagePath) {
paths.push(pagePath);
}
}
return paths;
}
export async function savePrintLayout(zineId: string, imageData: Buffer): Promise<string> {
const zineDir = path.join(ZINES_DIR, zineId);
await ensureDir(zineDir);
const filepath = path.join(zineDir, "print.png");
await fs.writeFile(filepath, imageData);
return filepath;
}
export async function getPrintLayoutPath(zineId: string): Promise<string | null> {
const filepath = path.join(ZINES_DIR, zineId, "print.png");
try {
await fs.access(filepath);
return filepath;
} catch {
return null;
}
}
export async function readFileAsBuffer(filepath: string): Promise<Buffer> {
return fs.readFile(filepath);
}
export async function readFileAsBase64(filepath: string): Promise<string> {
const buffer = await fs.readFile(filepath);
return buffer.toString("base64");
}
export async function listAllZines(): Promise<string[]> {
try {
await ensureDir(ZINES_DIR);
const entries = await fs.readdir(ZINES_DIR, { withFileTypes: true });
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
return [];
}
}
export async function deleteZine(id: string): Promise<void> {
const zineDir = path.join(ZINES_DIR, id);
try {
await fs.rm(zineDir, { recursive: true });
} catch {
// Ignore if doesn't exist
}
}
// Get URL-safe path for serving images
export function getPublicImageUrl(zineId: string, filename: string): string {
return `/api/zine/${zineId}/image/${filename}`;
}
export function getPrintLayoutUrl(zineId: string): string {
return `/api/zine/${zineId}/print`;
}

15
web/lib/utils.ts Normal file
View File

@ -0,0 +1,15 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function generateZineId(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}

137
web/lib/zine.ts Normal file
View File

@ -0,0 +1,137 @@
import path from "path";
import { getAllPagePaths, readFileAsBuffer, savePrintLayout } from "./storage";
// Dynamic import of the ES module mycro-zine library
async function importMycroZine() {
// The mycro-zine library is in the parent directory
const libPath = path.resolve(process.cwd(), "..", "src", "layout.mjs");
try {
const module = await import(libPath);
return module;
} catch (error) {
console.error("Failed to import mycro-zine library:", error);
throw new Error("Could not load mycro-zine library");
}
}
export interface PrintLayoutOptions {
zineId: string;
zineName?: string;
background?: string;
}
export async function createPrintLayoutForZine(
options: PrintLayoutOptions
): Promise<{ filepath: string; buffer: Buffer }> {
const { zineId, zineName = "mycrozine", background = "#ffffff" } = options;
// Get all page image paths
const pagePaths = await getAllPagePaths(zineId);
if (pagePaths.length !== 8) {
throw new Error(`Expected 8 pages, got ${pagePaths.length}`);
}
// Read all page images as buffers
const pageBuffers = await Promise.all(pagePaths.map((p) => readFileAsBuffer(p)));
// Import the layout module
const layoutModule = await importMycroZine();
// Create the print layout using the existing library
// The library expects either file paths or buffers
const outputBuffer = await layoutModule.createPrintLayout({
pages: pageBuffers,
zineName,
background,
returnBuffer: true, // We'll need to add this option to the library
});
// Save the print layout
const filepath = await savePrintLayout(zineId, outputBuffer);
return { filepath, buffer: outputBuffer };
}
// Alternative: Create print layout directly with Sharp if library doesn't support buffer return
import sharp from "sharp";
export async function createPrintLayoutDirect(
zineId: string,
zineName: string = "mycrozine"
): Promise<{ filepath: string; buffer: Buffer }> {
const pagePaths = await getAllPagePaths(zineId);
if (pagePaths.length !== 8) {
throw new Error(`Expected 8 pages, got ${pagePaths.length}`);
}
// Print layout dimensions (300 DPI, 11" x 8.5")
const PRINT_WIDTH = 3300;
const PRINT_HEIGHT = 2550;
const PANEL_WIDTH = 825;
const PANEL_HEIGHT = 1275;
// Page arrangement for proper folding:
// Top row (rotated 180°): P1, P8, P7, P6
// Bottom row (normal): P2, P3, P4, P5
const pageArrangement = [
// Top row
{ page: 1, col: 0, row: 0, rotate: 180 },
{ page: 8, col: 1, row: 0, rotate: 180 },
{ page: 7, col: 2, row: 0, rotate: 180 },
{ page: 6, col: 3, row: 0, rotate: 180 },
// Bottom row
{ page: 2, col: 0, row: 1, rotate: 0 },
{ page: 3, col: 1, row: 1, rotate: 0 },
{ page: 4, col: 2, row: 1, rotate: 0 },
{ page: 5, col: 3, row: 1, rotate: 0 },
];
// Create base canvas
const canvas = sharp({
create: {
width: PRINT_WIDTH,
height: PRINT_HEIGHT,
channels: 4,
background: { r: 255, g: 255, b: 255, alpha: 1 },
},
});
// Prepare composites
const composites: sharp.OverlayOptions[] = [];
for (const { page, col, row, rotate } of pageArrangement) {
const pageBuffer = await readFileAsBuffer(pagePaths[page - 1]);
// Resize page to panel size, maintaining aspect ratio
let processedPage = sharp(pageBuffer).resize(PANEL_WIDTH, PANEL_HEIGHT, {
fit: "cover",
position: "center",
});
// Rotate if needed
if (rotate !== 0) {
processedPage = processedPage.rotate(rotate);
}
const pageData = await processedPage.toBuffer();
composites.push({
input: pageData,
left: col * PANEL_WIDTH,
top: row * PANEL_HEIGHT,
});
}
// Composite all pages
const outputBuffer = await canvas.composite(composites).png().toBuffer();
// Save the print layout
const filepath = await savePrintLayout(zineId, outputBuffer);
return { filepath, buffer: outputBuffer };
}
export { createPrintLayoutDirect as createZinePrintLayout };

6
web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

19
web/next.config.js Normal file
View File

@ -0,0 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'zine.jeffemmett.com',
},
],
},
};
module.exports = nextConfig;

2719
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
web/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "mycrozine-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@google/generative-ai": "^0.21.0",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"nanoid": "^5.0.9",
"sharp": "^0.34.1",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"typescript": "^5.7.2"
}
}

5
web/postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
},
};

28
web/tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
},
"baseUrl": "."
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}