feat: add MycoZine - AI-powered mini-zine generator

- Add /zine landing page with topic input, style & tone selectors
- Add /zine/create page with full generation flow
- Add /zine/z/[id] shareable zine viewer
- Add API routes for outline generation, page creation, print layout
- Add Gemini AI integration for content generation
- Add Sharp for image processing and print layout creation
- Add punk zine styles (borders, text effects, xerox texture)
- Custom MycoFi styles: Mycelial style, Regenerative tone

🤖 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-21 01:16:53 -05:00
parent 0844ae2ead
commit 0743f6080d
16 changed files with 6312 additions and 0 deletions

105
app/api/zine/[id]/route.ts Normal file
View File

@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { getZine, readFileAsBuffer, getPageImagePath, getPrintLayoutPath } from "@/lib/storage";
interface RouteContext {
params: Promise<{ id: string }>;
}
// GET /api/zine/[id] - Get zine metadata
// GET /api/zine/[id]?image=p1 - Get page image
// GET /api/zine/[id]?print=true - Get print layout
export async function GET(request: NextRequest, context: RouteContext) {
try {
const { id } = await context.params;
const url = new URL(request.url);
const imageParam = url.searchParams.get("image");
const printParam = url.searchParams.get("print");
// Serve page image
if (imageParam) {
const pageMatch = imageParam.match(/^p(\d)$/);
if (!pageMatch) {
return NextResponse.json(
{ error: "Invalid image parameter. Use p1-p8." },
{ status: 400 }
);
}
const pageNumber = parseInt(pageMatch[1], 10);
if (pageNumber < 1 || pageNumber > 8) {
return NextResponse.json(
{ error: "Page number must be between 1 and 8" },
{ status: 400 }
);
}
const imagePath = await getPageImagePath(id, pageNumber);
if (!imagePath) {
return NextResponse.json(
{ error: "Page image not found" },
{ status: 404 }
);
}
const imageBuffer = await readFileAsBuffer(imagePath);
return new NextResponse(new Uint8Array(imageBuffer), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
// Serve print layout
if (printParam === "true") {
const printPath = await getPrintLayoutPath(id);
if (!printPath) {
return NextResponse.json(
{ error: "Print layout not found. Generate it first." },
{ status: 404 }
);
}
const printBuffer = await readFileAsBuffer(printPath);
const downloadParam = url.searchParams.get("download");
const headers: Record<string, string> = {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
};
// Only add Content-Disposition for explicit downloads
if (downloadParam === "true") {
headers["Content-Disposition"] = `attachment; filename="${id}_print.png"`;
}
return new NextResponse(new Uint8Array(printBuffer), { headers });
}
// Return zine metadata
const zine = await getZine(id);
if (!zine) {
return NextResponse.json(
{ error: "Zine not found" },
{ status: 404 }
);
}
// Build response with image URLs
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "";
const response = {
...zine,
pageUrls: Array.from({ length: 8 }, (_, i) => `${baseUrl}/api/zine/${id}?image=p${i + 1}`),
printLayoutUrl: zine.printLayout ? `${baseUrl}/api/zine/${id}?print=true` : null,
shareUrl: `${baseUrl}/zine/z/${id}`,
};
return NextResponse.json(response);
} catch (error) {
console.error("Get zine error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to get zine" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,280 @@
import { NextRequest, NextResponse } from "next/server";
import { getZine, saveZine, savePageImage } from "@/lib/storage";
import type { PageOutline } from "@/lib/gemini";
// Style-specific image generation prompts
const STYLE_PROMPTS: Record<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",
"mycelial": "organic mycelial network patterns, spore prints, earth tones with green accents, fungal textures, underground root systems, natural decomposition aesthetic, interconnected web designs",
"minimal": "clean minimalist design, lots of white space, modern sans-serif typography, simple geometric shapes, subtle gradients, elegant composition",
"collage": "layered mixed media collage, vintage photographs, torn paper edges, overlapping textures, eclectic composition, found imagery",
"retro": "1970s aesthetic, earth tones, groovy psychedelic typography, halftone dot patterns, vintage illustration style, warm colors",
};
const TONE_PROMPTS: Record<string, string> = {
"rebellious": "defiant anti-establishment energy, provocative bold statements, raw and unfiltered, urgent",
"regenerative": "hopeful and healing, nature-inspired wisdom, interconnected systems thinking, restoration and renewal, growth from decay",
"playful": "whimsical fun light-hearted energy, humor and wit, bright positive vibes, joyful",
"informative": "educational and factual, clear explanations, structured information, accessible",
"poetic": "lyrical and metaphorical, evocative imagery, emotional depth, contemplative",
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { zineId, pageNumber, outline, style, tone } = body;
if (!zineId || !pageNumber || !outline) {
return NextResponse.json(
{ error: "Missing required fields: zineId, pageNumber, outline" },
{ status: 400 }
);
}
// Verify zine exists
const zine = await getZine(zineId);
if (!zine) {
return NextResponse.json(
{ error: "Zine not found" },
{ status: 404 }
);
}
const pageOutline = outline as PageOutline;
const stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS["mycelial"];
const tonePrompt = TONE_PROMPTS[tone] || TONE_PROMPTS["regenerative"];
// Build the full image generation prompt
const fullPrompt = buildImagePrompt(pageOutline, stylePrompt, tonePrompt);
// Generate image using Gemini Imagen API
// Pass outline and style for styled fallback
const imageBase64 = await generateImageWithGemini(fullPrompt, pageOutline, style);
// Save the page image
const imagePath = await savePageImage(zineId, pageNumber, imageBase64);
// Update zine metadata
zine.pages[pageNumber - 1] = imagePath;
zine.updatedAt = new Date().toISOString();
await saveZine(zine);
return NextResponse.json({
pageNumber,
imageUrl: `/api/zine/${zineId}?image=p${pageNumber}`,
success: true,
});
} catch (error) {
console.error("Page generation error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to generate page" },
{ status: 500 }
);
}
}
function buildImagePrompt(outline: PageOutline, stylePrompt: string, tonePrompt: string): string {
return `Create a single zine page image (portrait orientation, 825x1275 pixels aspect ratio).
PAGE ${outline.pageNumber}: "${outline.title}"
Type: ${outline.type}
Content to visualize:
${outline.keyPoints.map((p, i) => `${i + 1}. ${p}`).join("\n")}
Visual Style: ${stylePrompt}
Mood/Tone: ${tonePrompt}
Detailed requirements:
${outline.imagePrompt}
IMPORTANT:
- This is a SINGLE page that will be printed
- Include any text/typography as part of the graphic design
- Fill the entire page - no blank margins
- Make it visually striking and cohesive
- The design should work in print (high contrast, clear details)`;
}
async function generateImageWithGemini(
prompt: string,
outline: PageOutline,
style: string
): Promise<string> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY not configured");
}
// Try Gemini 2.0 Flash with image generation (Nano Banana)
// Uses responseModalities: ["TEXT", "IMAGE"] for native image generation
try {
const result = await generateWithGemini2FlashImage(prompt, apiKey);
if (result) {
console.log("Generated image with Gemini 2.0 Flash");
return result;
}
} catch (error) {
console.error("Gemini 2.0 Flash image generation error:", error);
}
// Fallback: Create styled placeholder with actual content
console.log("Using styled placeholder image for page", outline.pageNumber);
return createStyledPlaceholder(outline, style);
}
// Gemini 2.0 Flash with native image generation
async function generateWithGemini2FlashImage(prompt: string, apiKey: string): Promise<string | null> {
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`;
const response = await fetch(geminiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: `Generate an image: ${prompt}` }] }],
generationConfig: { responseModalities: ["TEXT", "IMAGE"] },
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error("Gemini API error:", response.status, errorText);
return null;
}
const data = await response.json();
if (data.error) {
console.error("Gemini API error:", data.error);
return null;
}
const parts = data.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if (part.inlineData?.mimeType?.startsWith("image/")) {
return part.inlineData.data;
}
}
return null;
}
// Create styled placeholder images with actual page content
async function createStyledPlaceholder(
outline: PageOutline,
style: string
): Promise<string> {
const sharp = (await import("sharp")).default;
// Escape XML special characters
const escapeXml = (str: string) =>
str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const title = escapeXml(outline.title.slice(0, 40));
const keyPoints = outline.keyPoints.slice(0, 3).map((p) => escapeXml(p.slice(0, 50)));
// Style-specific colors and patterns
const styles: Record<string, { bg: string; fg: string; accent: string; pattern: string }> = {
"punk-zine": {
bg: "#ffffff",
fg: "#000000",
accent: "#ff0066",
pattern: `<pattern id="dots" patternUnits="userSpaceOnUse" width="20" height="20">
<circle cx="10" cy="10" r="2" fill="#000" opacity="0.3"/>
</pattern>`,
},
"mycelial": {
bg: "#f5f0e8",
fg: "#2d3a2d",
accent: "#4a7c4f",
pattern: `<pattern id="mycelium" patternUnits="userSpaceOnUse" width="60" height="60">
<path d="M10 30 Q30 10 50 30 Q70 50 50 50" fill="none" stroke="#4a7c4f" stroke-width="1" opacity="0.2"/>
<path d="M0 50 Q20 30 40 50" fill="none" stroke="#4a7c4f" stroke-width="1" opacity="0.15"/>
</pattern>`,
},
minimal: {
bg: "#fafafa",
fg: "#333333",
accent: "#0066ff",
pattern: "",
},
collage: {
bg: "#f5e6d3",
fg: "#2d2d2d",
accent: "#8b4513",
pattern: `<pattern id="paper" patternUnits="userSpaceOnUse" width="100" height="100">
<rect width="100" height="100" fill="#f5e6d3"/>
<rect x="0" y="0" width="50" height="50" fill="#ebe0d0" opacity="0.5"/>
</pattern>`,
},
retro: {
bg: "#fff8dc",
fg: "#8b4513",
accent: "#ff6347",
pattern: `<pattern id="halftone" patternUnits="userSpaceOnUse" width="8" height="8">
<circle cx="4" cy="4" r="1.5" fill="#8b4513" opacity="0.2"/>
</pattern>`,
},
};
const s = styles[style] || styles["mycelial"];
const pageNum = outline.pageNumber;
const pageType = escapeXml(outline.type.toUpperCase());
const svg = `
<svg width="825" height="1275" xmlns="http://www.w3.org/2000/svg">
<defs>
${s.pattern}
<style>
.title { font-family: 'Courier New', monospace; font-weight: bold; }
.body { font-family: 'Courier New', monospace; }
.accent { font-family: 'Courier New', monospace; font-weight: bold; }
</style>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="${s.bg}"/>
${s.pattern ? `<rect width="100%" height="100%" fill="url(#${s.pattern.match(/id="(\w+)"/)?.[1] || "dots"})"/>` : ""}
<!-- Border -->
<rect x="30" y="30" width="765" height="1215" fill="none" stroke="${s.fg}" stroke-width="4"/>
<rect x="40" y="40" width="745" height="1195" fill="none" stroke="${s.fg}" stroke-width="2"/>
<!-- Page number badge -->
<rect x="60" y="60" width="80" height="40" fill="${s.fg}"/>
<text x="100" y="88" text-anchor="middle" class="accent" font-size="24" fill="${s.bg}">P${pageNum}</text>
<!-- Page type -->
<text x="765" y="90" text-anchor="end" class="body" font-size="16" fill="${s.accent}">${pageType}</text>
<!-- Title -->
<text x="412" y="200" text-anchor="middle" class="title" font-size="48" fill="${s.fg}">${title}</text>
<!-- Decorative line -->
<line x1="150" y1="240" x2="675" y2="240" stroke="${s.accent}" stroke-width="3"/>
<!-- Key points -->
${keyPoints
.map(
(point, i) => `
<rect x="100" y="${350 + i * 120}" width="625" height="80" fill="${s.bg}" stroke="${s.fg}" stroke-width="2" rx="5"/>
<text x="120" y="${400 + i * 120}" class="body" font-size="20" fill="${s.fg}">${point}${point.length >= 50 ? "..." : ""}</text>
`
)
.join("")}
<!-- Generation notice -->
<rect x="150" y="1050" width="525" height="100" fill="${s.accent}" opacity="0.1" rx="10"/>
<text x="412" y="1090" text-anchor="middle" class="body" font-size="18" fill="${s.fg}">MycoZine - AI Generated</text>
<text x="412" y="1120" text-anchor="middle" class="body" font-size="14" fill="${s.fg}" opacity="0.7">Styled placeholder - image gen pending</text>
<!-- Corner decorations -->
<path d="M 30 130 L 30 30 L 130 30" fill="none" stroke="${s.accent}" stroke-width="4"/>
<path d="M 795 130 L 795 30 L 695 30" fill="none" stroke="${s.accent}" stroke-width="4"/>
<path d="M 30 1145 L 30 1245 L 130 1245" fill="none" stroke="${s.accent}" stroke-width="4"/>
<path d="M 795 1145 L 795 1245 L 695 1245" fill="none" stroke="${s.accent}" stroke-width="4"/>
</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 = "mycelial", tone = "regenerative" } = body;
if (!topic || typeof topic !== "string" || topic.trim().length === 0) {
return NextResponse.json(
{ error: "Topic is required" },
{ status: 400 }
);
}
// Generate the 8-page outline using Gemini
const pages = await generateOutline(topic.trim(), style, tone);
if (!pages || pages.length !== 8) {
return NextResponse.json(
{ error: "Failed to generate complete outline" },
{ status: 500 }
);
}
// Create a new zine ID
const id = generateZineId();
const now = new Date().toISOString();
// Save initial zine metadata
const zine: StoredZine = {
id,
topic: topic.trim(),
style,
tone,
outline: pages,
pages: [], // Will be populated as images are generated
createdAt: now,
updatedAt: now,
};
await saveZine(zine);
return NextResponse.json({
id,
topic: topic.trim(),
style,
tone,
outline: pages,
});
} catch (error) {
console.error("Outline generation error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to generate outline" },
{ status: 500 }
);
}
}

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=true`,
filename: `${zineName || "mycozine"}_print.png`,
});
} catch (error) {
console.error("Print layout error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to create print layout" },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const zineId = url.searchParams.get("zineId");
if (!zineId) {
return NextResponse.json(
{ error: "Missing zineId" },
{ status: 400 }
);
}
const layoutPath = await getPrintLayoutPath(zineId);
if (!layoutPath) {
return NextResponse.json(
{ error: "Print layout not found. Generate it first." },
{ status: 404 }
);
}
return NextResponse.json({
exists: true,
printLayoutUrl: `/api/zine/${zineId}?print=true`,
});
}

View File

@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from "next/server";
import { GoogleGenerativeAI } from "@google/generative-ai";
import { getZine, saveZine } from "@/lib/storage";
import type { PageOutline } from "@/lib/gemini";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { zineId, pageNumber, currentOutline, feedback, style, tone } = body;
if (!zineId || !pageNumber || !currentOutline || !feedback) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "GEMINI_API_KEY not configured" },
{ status: 500 }
);
}
const genAI = new GoogleGenerativeAI(apiKey);
// Verify zine exists
const zine = await getZine(zineId);
if (!zine) {
return NextResponse.json(
{ error: "Zine not found" },
{ status: 404 }
);
}
// Update outline based on feedback using Gemini
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
const prompt = `You are refining a zine page based on user feedback.
Current page outline:
- Page Number: ${currentOutline.pageNumber}
- Type: ${currentOutline.type}
- Title: ${currentOutline.title}
- Key Points: ${currentOutline.keyPoints.join(", ")}
- Image Prompt: ${currentOutline.imagePrompt}
User feedback: "${feedback}"
Style: ${style}
Tone: ${tone}
Update the page outline to incorporate this feedback. Keep the same page number and type.
Return ONLY valid JSON (no markdown, no code blocks):
{
"pageNumber": ${currentOutline.pageNumber},
"type": "${currentOutline.type}",
"title": "Updated title if needed",
"keyPoints": ["Updated point 1", "Updated point 2"],
"imagePrompt": "Updated detailed image prompt incorporating the feedback"
}`;
const result = await model.generateContent(prompt);
const response = result.response.text();
// Parse the updated outline
let jsonStr = response;
if (response.includes("```")) {
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
jsonStr = match[1];
}
}
const updatedOutline = JSON.parse(jsonStr.trim()) as PageOutline;
// Generate new image with updated outline
// Forward to generate-page endpoint logic
const generateResponse = await fetch(
new URL("/api/zine/generate-page", request.url),
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
zineId,
pageNumber,
outline: updatedOutline,
style,
tone,
}),
}
);
if (!generateResponse.ok) {
throw new Error("Failed to regenerate image");
}
const generateResult = await generateResponse.json();
// Update the zine outline
zine.outline[pageNumber - 1] = updatedOutline;
zine.updatedAt = new Date().toISOString();
await saveZine(zine);
return NextResponse.json({
pageNumber,
updatedOutline,
imageUrl: generateResult.imageUrl,
success: true,
});
} catch (error) {
console.error("Page regeneration error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to regenerate page" },
{ status: 500 }
);
}
}

View File

@ -131,3 +131,44 @@
scroll-behavior: smooth; scroll-behavior: smooth;
} }
} }
/* Punk zine styling utilities */
.punk-border {
border: 3px solid black;
box-shadow: 4px 4px 0 black;
}
.punk-text {
font-family: 'Courier New', Courier, monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Loading animation for generation */
@keyframes pulse-punk {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse-punk {
animation: pulse-punk 1.5s ease-in-out infinite;
}
/* Xerox texture overlay */
.xerox-texture {
position: relative;
}
.xerox-texture::after {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
mix-blend-mode: multiply;
}

681
app/zine/create/page.tsx Normal file
View File

@ -0,0 +1,681 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
ArrowRight,
Check,
Download,
Loader2,
Mic,
MicOff,
RefreshCw,
Copy,
CheckCircle,
} from "lucide-react";
interface PageOutline {
pageNumber: number;
type: string;
title: string;
keyPoints: string[];
imagePrompt: string;
}
interface ZineState {
id: string;
topic: string;
style: string;
tone: string;
outline: PageOutline[];
pages: string[];
currentStep: "outline" | "generate" | "refine" | "download";
generatingPage: number | null;
printLayoutUrl: string | null;
}
const STEPS = ["outline", "generate", "refine", "download"] as const;
const STEP_LABELS = {
outline: "Review Outline",
generate: "Generate Pages",
refine: "Refine Pages",
download: "Download & Share",
};
export default function CreatePage() {
const router = useRouter();
const [state, setState] = useState<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("/zine");
return;
}
const { topic, style, tone } = JSON.parse(input);
generateOutline(topic, style, tone);
}, [router]);
const generateOutline = async (topic: string, style: string, tone: string) => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/zine/outline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic, style, tone }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to generate outline");
}
const data = await response.json();
setState({
id: data.id,
topic,
style,
tone,
outline: data.outline,
pages: new Array(8).fill(""),
currentStep: "outline",
generatingPage: null,
printLayoutUrl: null,
});
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
};
const generatePages = async () => {
if (!state) return;
setState((s) => (s ? { ...s, currentStep: "generate" } : s));
for (let i = 1; i <= 8; i++) {
if (state.pages[i - 1]) continue; // Skip already generated pages
setState((s) => (s ? { ...s, generatingPage: i } : s));
try {
const response = await fetch("/api/zine/generate-page", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zineId: state.id,
pageNumber: i,
outline: state.outline[i - 1],
style: state.style,
tone: state.tone,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || `Failed to generate page ${i}`);
}
const data = await response.json();
// Add cache-busting timestamp to force image reload
const imageUrlWithTimestamp = `${data.imageUrl}&t=${Date.now()}`;
setState((s) => {
if (!s) return s;
const newPages = [...s.pages];
newPages[i - 1] = imageUrlWithTimestamp;
return { ...s, pages: newPages };
});
} catch (err) {
console.error(`Error generating page ${i}:`, err);
setError(`Failed to generate page ${i}`);
return;
}
}
setState((s) => (s ? { ...s, generatingPage: null, currentStep: "refine" } : s));
};
const regeneratePage = async () => {
if (!state || !feedback.trim()) return;
setState((s) => (s ? { ...s, generatingPage: currentPage } : s));
try {
const response = await fetch("/api/zine/regenerate-page", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zineId: state.id,
pageNumber: currentPage,
currentOutline: state.outline[currentPage - 1],
feedback: feedback.trim(),
style: state.style,
tone: state.tone,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to regenerate page");
}
const data = await response.json();
setState((s) => {
if (!s) return s;
const newPages = [...s.pages];
newPages[currentPage - 1] = data.imageUrl;
const newOutline = [...s.outline];
newOutline[currentPage - 1] = data.updatedOutline;
return { ...s, pages: newPages, outline: newOutline, generatingPage: null };
});
setFeedback("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to regenerate");
setState((s) => (s ? { ...s, generatingPage: null } : s));
}
};
const createPrintLayout = async () => {
if (!state) return;
setLoading(true);
try {
const response = await fetch("/api/zine/print-layout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zineId: state.id,
zineName: state.topic.slice(0, 20).replace(/[^a-zA-Z0-9]/g, "_"),
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to create print layout");
}
const data = await response.json();
setState((s) =>
s ? { ...s, printLayoutUrl: data.printLayoutUrl, currentStep: "download" } : s
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create print layout");
} finally {
setLoading(false);
}
};
const handleVoiceInput = useCallback(() => {
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
alert("Voice input not supported");
return;
}
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = false;
recognition.onstart = () => setIsListening(true);
recognition.onend = () => setIsListening(false);
recognition.onerror = () => setIsListening(false);
recognition.onresult = (event: any) => {
const transcript = event.results[0][0].transcript;
setFeedback((prev) => (prev ? prev + " " + transcript : transcript));
};
if (isListening) {
recognition.stop();
} else {
recognition.start();
}
}, [isListening]);
const copyShareLink = async () => {
if (!state) return;
const shareUrl = `${window.location.origin}/zine/z/${state.id}`;
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (loading && !state) {
return (
<div className="flex items-center justify-center min-h-screen bg-white">
<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 bg-white">
<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("/zine")}
className="px-6 py-2 bg-black text-white punk-text hover:bg-green-600"
>
Try Again
</button>
</div>
</div>
);
}
if (!state) return null;
return (
<div className="min-h-screen p-4 sm:p-8 bg-white">
{/* 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("/zine")}
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-600 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">
<div className="text-center">
<h2 className="text-xl font-bold punk-text mb-2">
Generating Your Zine
</h2>
<p className="text-gray-600 text-sm">
Page {state.generatingPage || state.pages.filter((p) => p).length} of 8
</p>
</div>
{/* Overall Progress Bar */}
<div className="punk-border bg-white p-4">
<div className="flex justify-between text-sm punk-text mb-2">
<span>Progress</span>
<span>{Math.round((state.pages.filter((p) => p).length / 8) * 100)}%</span>
</div>
<div className="h-4 bg-gray-200 punk-border overflow-hidden">
<div
className="h-full bg-green-600 transition-all duration-500 ease-out"
style={{ width: `${(state.pages.filter((p) => p).length / 8) * 100}%` }}
/>
</div>
<div className="flex justify-between mt-2">
{[1, 2, 3, 4, 5, 6, 7, 8].map((num) => (
<div
key={num}
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold
${state.pages[num - 1] ? "bg-green-600 text-white" :
state.generatingPage === num ? "bg-black text-white animate-pulse" :
"bg-gray-200 text-gray-500"}`}
>
{state.pages[num - 1] ? <Check className="w-3 h-3" /> : num}
</div>
))}
</div>
</div>
{/* Current Page Being Generated */}
{state.generatingPage && (
<div className="punk-border bg-gray-50 p-4">
<div className="flex items-center gap-4">
<div className="w-16 h-20 bg-white punk-border flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
<div className="flex-1">
<h3 className="font-bold punk-text">
{state.outline[state.generatingPage - 1]?.title}
</h3>
<p className="text-sm text-gray-600">
{state.outline[state.generatingPage - 1]?.type} Page {state.generatingPage}
</p>
<div className="mt-2 h-2 bg-gray-200 rounded overflow-hidden">
<div className="h-full bg-green-600 animate-pulse" style={{ width: "60%" }} />
</div>
</div>
</div>
</div>
)}
{/* Thumbnail Grid */}
<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 overflow-hidden
${state.pages[i] ? "bg-white" : "bg-gray-100"}
${state.generatingPage === i + 1 ? "ring-2 ring-green-600 ring-offset-2" : ""}`}
>
{state.pages[i] ? (
<img
src={state.pages[i]}
alt={`Page ${i + 1}`}
className="w-full h-full object-cover"
onLoad={() => console.log(`Page ${i + 1} image loaded`)}
/>
) : state.generatingPage === i + 1 ? (
<div className="text-center p-2">
<Loader2 className="w-8 h-8 mx-auto animate-spin mb-2 text-green-600" />
<span className="text-xs punk-text font-bold">Generating...</span>
</div>
) : (
<div className="text-center">
<span className="text-2xl text-gray-300 font-bold">P{i + 1}</span>
<p className="text-xs text-gray-400 mt-1">Pending</p>
</div>
)}
</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 mycelial... 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-600 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-600" : ""}`}
>
<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-600 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 ? `${state.printLayoutUrl}&download=true` : "#"}
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-600 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-600" />
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("/zine");
}}
className="text-gray-600 hover:text-black punk-text underline"
>
Create Another Zine
</button>
</div>
</div>
)}
</div>
</div>
);
}

214
app/zine/page.tsx Normal file
View File

@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Mic, MicOff, Sparkles, BookOpen, Printer, ArrowLeft } from "lucide-react";
const STYLES = [
{ value: "punk-zine", label: "Punk Zine", description: "Xerox texture, high contrast, DIY collage" },
{ value: "mycelial", label: "Mycelial", description: "Organic networks, spore patterns, earth tones" },
{ 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" },
];
const TONES = [
{ value: "rebellious", label: "Rebellious", description: "Defiant, punk attitude" },
{ value: "regenerative", label: "Regenerative", description: "Hopeful, nature-inspired, healing" },
{ 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 ZinePage() {
const router = useRouter();
const [topic, setTopic] = useState("");
const [style, setStyle] = useState("mycelial");
const [tone, setTone] = useState("regenerative");
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("/zine/create");
};
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 sm:p-8 bg-white">
{/* Back to MycoFi */}
<div className="absolute top-4 left-4">
<Link
href="/"
className="flex items-center gap-2 text-gray-600 hover:text-black punk-text text-sm"
>
<ArrowLeft className="w-4 h-4" />
MycoFi
</Link>
</div>
{/* 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">
MYCO<span className="text-green-600">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-600 hover:text-white 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>
<p className="mt-2">
Powered by <Link href="/" className="underline hover:text-gray-600">MycoFi</Link>
</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Download, Plus, 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="/zine"
className="text-gray-600 hover:text-black punk-text text-sm flex items-center gap-1"
>
<ArrowLeft className="w-4 h-4" />
MycoZine
</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-600" />
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-600 hover:text-white 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-600 hover:text-white 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-600" : "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=true`}
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-600 transition-colors punk-text"
>
<Download className="w-5 h-5" />
Download Print Layout
</a>
)}
<Link
href="/zine"
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="/zine" className="underline hover:text-gray-600">
MycoZine
</Link>
{" by "}
<Link href="/" className="underline hover:text-gray-600">
MycoFi
</Link>
</p>
<p className="mt-1">
{new Date(zine.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
</div>
</div>
);
}

58
app/zine/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 - MycoZine",
};
}
return {
title: `${zine.topic} - MycoZine`,
description: `An 8-page mini-zine about ${zine.topic}. Created with MycoZine by MycoFi.`,
openGraph: {
title: `${zine.topic} - MycoZine`,
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} - MycoZine`,
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} />;
}

205
lib/gemini.ts Normal file
View File

@ -0,0 +1,205 @@
import { GoogleGenerativeAI } from "@google/generative-ai";
// Lazy initialization to ensure runtime env var is used (not build-time)
let _genAI: GoogleGenerativeAI | null = null;
function getGenAI(): GoogleGenerativeAI {
if (!_genAI) {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY environment variable is not set");
}
_genAI = new GoogleGenerativeAI(apiKey);
}
return _genAI;
}
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",
"mycelial": "organic mycelial network patterns, spore prints, earth tones with green accents, fungal textures, underground root systems, natural decomposition aesthetic",
"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",
};
const TONE_PROMPTS: Record<string, string> = {
"rebellious": "defiant anti-establishment energy, provocative bold statements, raw and unfiltered",
"regenerative": "hopeful and healing, nature-inspired wisdom, interconnected systems thinking, restoration and renewal",
"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 = getGenAI().getGenerativeModel({ model: "gemini-2.0-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["mycelial"]}
Tone: ${tone} - ${TONE_PROMPTS[tone] || TONE_PROMPTS["regenerative"]}
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 = getGenAI().getGenerativeModel({ model: "gemini-2.0-flash-exp" });
const styleDesc = STYLE_PROMPTS[style] || STYLE_PROMPTS["mycelial"];
const toneDesc = TONE_PROMPTS[tone] || TONE_PROMPTS["regenerative"];
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 = getGenAI().getGenerativeModel({ model: "gemini-2.0-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
lib/storage.ts Normal file
View File

@ -0,0 +1,150 @@
import fs from "fs/promises";
import path from "path";
import type { 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`;
}

View File

@ -4,3 +4,12 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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;
}

86
lib/zine.ts Normal file
View File

@ -0,0 +1,86 @@
import sharp from "sharp";
import { getAllPagePaths, readFileAsBuffer, savePrintLayout } from "./storage";
export interface PrintLayoutOptions {
zineId: string;
zineName?: string;
background?: string;
}
// Create print layout directly with Sharp
export async function createZinePrintLayout(
zineId: string,
zineName: string = "mycozine"
): 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 };
}

4026
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.21.0",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4", "@radix-ui/react-alert-dialog": "1.1.4",
@ -55,6 +56,7 @@
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "2.15.4", "recharts": "2.15.4",
"sharp": "^0.34.1",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",