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:
parent
0844ae2ead
commit
0743f6080d
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
const title = escapeXml(outline.title.slice(0, 40));
|
||||
const keyPoints = outline.keyPoints.slice(0, 3).map((p) => escapeXml(p.slice(0, 50)));
|
||||
|
||||
// Style-specific colors and patterns
|
||||
const styles: Record<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");
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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`,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -131,3 +131,44 @@
|
|||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
/* Punk zine styling utilities */
|
||||
.punk-border {
|
||||
border: 3px solid black;
|
||||
box-shadow: 4px 4px 0 black;
|
||||
}
|
||||
|
||||
.punk-text {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Loading animation for generation */
|
||||
@keyframes pulse-punk {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-punk {
|
||||
animation: pulse-punk 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Xerox texture overlay */
|
||||
.xerox-texture {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xerox-texture::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
opacity: 0.03;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" x 11" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">⚙</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" x 11" 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -4,3 +4,12 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,6 +9,7 @@
|
|||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
|
|
@ -55,6 +56,7 @@
|
|||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
"sharp": "^0.34.1",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
|
|
|||
Loading…
Reference in New Issue