feat: add rZine - AI-powered community zine creator

Port mycro-zine (zine.mycofi.earth) to rsocials.online/zine as a community
content generation tool. Full 8-page zine creation pipeline with Gemini AI
for outlines and image generation, Sharp for print layout composition.

- 7 API routes under /api/zine/ (outline, generate-page, regenerate-page,
  print-layout, save, generate-image, [id])
- 4-step creation wizard: topic → outline → page generation → print layout
- 5 visual styles, 4 tones, voice input, page refinement
- 300 DPI print-ready output (3300x2550px)
- Shareable zine viewer at /zine/z/[id] with OG metadata
- Docker: added zine-data volume, Sharp deps, env vars for API keys
- Also includes pre-existing Postiz URL updates (crypto-commons.org)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-23 16:21:27 -08:00
parent eca456deab
commit f767975986
31 changed files with 3030 additions and 18 deletions

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Zine feature - AI generation
GEMINI_API_KEY=your-gemini-api-key-here
RUNPOD_API_KEY=your-runpod-api-key
RUNPOD_GEMINI_ENDPOINT_ID=ntqjz8cdsth42i
# Public URL for share links
NEXT_PUBLIC_APP_URL=https://rsocials.online
# Data directory for zine storage
DATA_DIR=/app/data

View File

@ -3,6 +3,9 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Sharp needs these for native compilation
RUN apk add --no-cache python3 make g++
# Copy package files first for layer caching
COPY package*.json ./
@ -25,6 +28,9 @@ WORKDIR /app
ENV NODE_ENV=production
# Sharp runtime dependencies
RUN apk add --no-cache vips-dev
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@ -34,6 +40,9 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Create data directory for zine storage
RUN mkdir -p /app/data/zines && chown -R nextjs:nodejs /app/data
# Set ownership
RUN chown -R nextjs:nodejs /app
@ -43,5 +52,6 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV DATA_DIR=/app/data
CMD ["node", "server.js"]

View File

@ -5,6 +5,14 @@ services:
dockerfile: Dockerfile
container_name: rsocials
restart: unless-stopped
environment:
- GEMINI_API_KEY=${GEMINI_API_KEY}
- RUNPOD_API_KEY=${RUNPOD_API_KEY}
- RUNPOD_GEMINI_ENDPOINT_ID=${RUNPOD_GEMINI_ENDPOINT_ID:-ntqjz8cdsth42i}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://rsocials.online}
- DATA_DIR=/app/data
volumes:
- zine-data:/app/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.rsocials.rule=Host(`rsocials.online`) || Host(`www.rsocials.online`)"
@ -28,6 +36,9 @@ services:
- /tmp
- /home/nextjs/.npm
volumes:
zine-data:
networks:
traefik-public:
external: true

View File

@ -2,6 +2,20 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["sharp"],
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "rsocials.online",
},
],
},
};
export default nextConfig;

67
package-lock.json generated
View File

@ -8,16 +8,20 @@
"name": "rsocials-online",
"version": "0.1.0",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"nanoid": "^5.1.6",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@ -499,6 +503,15 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@google/generative-ai": {
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -556,7 +569,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@ -4351,7 +4363,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -6531,9 +6542,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
@ -6542,10 +6553,10 @@
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/napi-postinstall": {
@ -6634,6 +6645,24 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -6977,6 +7006,25 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -7450,7 +7498,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@ -7494,7 +7541,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
@ -8395,7 +8441,6 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@ -9,16 +9,20 @@
"lint": "eslint"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"nanoid": "^5.1.6",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@ -4,9 +4,9 @@ services:
container_name: postiz-rsocials
restart: always
environment:
MAIN_URL: 'https://socials.rsocials.online'
FRONTEND_URL: 'https://socials.rsocials.online'
NEXT_PUBLIC_BACKEND_URL: 'https://socials.rsocials.online/api'
MAIN_URL: 'https://socials.crypto-commons.org'
FRONTEND_URL: 'https://socials.crypto-commons.org'
NEXT_PUBLIC_BACKEND_URL: 'https://socials.crypto-commons.org/api'
JWT_SECRET: '${JWT_SECRET}'
DATABASE_URL: 'postgresql://postiz:${POSTGRES_PASSWORD}@postiz-rsocials-postgres:5432/postiz'
REDIS_URL: 'redis://postiz-rsocials-redis:6379'
@ -70,9 +70,17 @@ services:
- postiz-rsocials-uploads:/uploads/
labels:
- "traefik.enable=true"
- "traefik.http.routers.postiz-rsocials.rule=Host(`socials.rsocials.online`)"
# Primary domain → Postiz
- "traefik.http.routers.postiz-rsocials.rule=Host(`socials.crypto-commons.org`)"
- "traefik.http.routers.postiz-rsocials.entrypoints=web"
- "traefik.http.services.postiz-rsocials.loadbalancer.server.port=5000"
# Redirect rsocials.online subdomain → primary domain
- "traefik.http.routers.postiz-rsocials-redirect.rule=Host(`socials.rsocials.online`)"
- "traefik.http.routers.postiz-rsocials-redirect.entrypoints=web"
- "traefik.http.routers.postiz-rsocials-redirect.middlewares=postiz-rsocials-redirect"
- "traefik.http.middlewares.postiz-rsocials-redirect.redirectregex.regex=^https?://socials\\.rsocials\\.online(.*)"
- "traefik.http.middlewares.postiz-rsocials-redirect.redirectregex.replacement=https://socials.crypto-commons.org$${1}"
- "traefik.http.middlewares.postiz-rsocials-redirect.redirectregex.permanent=true"
- "traefik.docker.network=traefik-public"
networks:
- traefik-public

View File

@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { getZine, readFileAsBuffer, getPageImagePath, getPrintLayoutPath } from "@/lib/zine-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,170 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Simple image generation API that proxies to RunPod/Gemini
* Returns base64 image data directly (no file storage)
* Used by canvas-website integration
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { prompt } = body;
if (!prompt) {
return NextResponse.json(
{ error: "Missing required field: prompt" },
{ status: 400 }
);
}
// Generate image via RunPod proxy
const imageBase64 = await generateImageWithRunPod(prompt);
if (!imageBase64) {
return NextResponse.json(
{ error: "Image generation failed" },
{ status: 500 }
);
}
return NextResponse.json(
{
success: true,
imageData: imageBase64,
mimeType: "image/png",
},
{
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
}
);
} catch (error) {
console.error("Image generation error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to generate image" },
{ status: 500 }
);
}
}
async function generateImageWithRunPod(prompt: string): Promise<string | null> {
const apiKey = process.env.GEMINI_API_KEY;
const runpodApiKey = process.env.RUNPOD_API_KEY;
const runpodEndpointId = process.env.RUNPOD_GEMINI_ENDPOINT_ID || "ntqjz8cdsth42i";
if (!apiKey) {
console.error("GEMINI_API_KEY not configured");
return null;
}
if (!runpodApiKey) {
console.error("RUNPOD_API_KEY not configured, trying direct API");
return generateDirectGeminiImage(prompt, apiKey);
}
const runpodUrl = `https://api.runpod.ai/v2/${runpodEndpointId}/runsync`;
try {
const response = await fetch(runpodUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${runpodApiKey}`,
},
body: JSON.stringify({
input: {
api_key: apiKey,
model: "gemini-2.0-flash-exp",
contents: [
{
parts: [
{
text: `Generate an image: ${prompt}`,
},
],
},
],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
},
},
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error("RunPod API error:", response.status, errorText);
return null;
}
const result = await response.json();
const data = result.output || result;
if (data.error) {
console.error("Gemini API error via RunPod:", data.error);
return null;
}
// Extract image from response
const parts = data.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if (part.inlineData?.mimeType?.startsWith("image/")) {
return part.inlineData.data;
}
}
console.error("No image in response");
return null;
} catch (error) {
console.error("RunPod request error:", error);
return null;
}
}
async function generateDirectGeminiImage(prompt: string, apiKey: string): Promise<string | null> {
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`;
try {
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) {
console.error("Direct Gemini API error:", response.status);
return null;
}
const data = await response.json();
const parts = data.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if (part.inlineData?.mimeType?.startsWith("image/")) {
return part.inlineData.data;
}
}
return null;
} catch (error) {
console.error("Direct Gemini error:", error);
return null;
}
}
// Allow CORS for canvas-website
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}

View File

@ -0,0 +1,350 @@
import { NextRequest, NextResponse } from "next/server";
import { getZine, saveZine, savePageImage } from "@/lib/zine-storage";
import type { PageOutline } from "@/lib/gemini";
// Style-specific image generation prompts
const STYLE_PROMPTS: Record<string, string> = {
"punk-zine": "xerox-style high contrast black and white, DIY cut-and-paste collage aesthetic, hand-drawn typography, punk rock zine style, grainy texture, photocopied look, bold graphic elements",
"minimal": "clean minimalist design, lots of white space, modern sans-serif typography, simple geometric shapes, subtle gradients, elegant composition",
"collage": "layered mixed media collage, vintage photographs, torn paper edges, overlapping textures, eclectic composition, found imagery",
"retro": "1970s aesthetic, earth tones, groovy psychedelic typography, halftone dot patterns, vintage illustration style, warm colors",
"academic": "clean infographic style, annotated diagrams, data visualization, technical illustration, educational layout, clear hierarchy",
};
const TONE_PROMPTS: Record<string, string> = {
"rebellious": "defiant anti-establishment energy, provocative bold statements, raw and unfiltered, urgent",
"playful": "whimsical fun light-hearted energy, humor and wit, bright positive vibes, joyful",
"informative": "educational and factual, clear explanations, structured information, accessible",
"poetic": "lyrical and metaphorical, evocative imagery, emotional depth, contemplative",
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { zineId, pageNumber, outline, style, tone } = body;
if (!zineId || !pageNumber || !outline) {
return NextResponse.json(
{ error: "Missing required fields: zineId, pageNumber, outline" },
{ status: 400 }
);
}
// Verify zine exists
const zine = await getZine(zineId);
if (!zine) {
return NextResponse.json(
{ error: "Zine not found" },
{ status: 404 }
);
}
const pageOutline = outline as PageOutline;
const stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS["punk-zine"];
const tonePrompt = TONE_PROMPTS[tone] || TONE_PROMPTS["rebellious"];
// Build the full image generation prompt
const fullPrompt = buildImagePrompt(pageOutline, stylePrompt, tonePrompt);
// Generate image using Gemini API
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 via RunPod proxy
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
// Uses RunPod serverless proxy (US-based) to bypass geo-restrictions
async function generateWithGemini2FlashImage(prompt: string, apiKey: string): Promise<string | null> {
const runpodEndpointId = process.env.RUNPOD_GEMINI_ENDPOINT_ID || "ntqjz8cdsth42i";
const runpodApiKey = process.env.RUNPOD_API_KEY;
if (!runpodApiKey) {
console.error("RUNPOD_API_KEY not configured, falling back to direct API");
return generateDirectGeminiImage(prompt, apiKey);
}
const runpodUrl = `https://api.runpod.ai/v2/${runpodEndpointId}/runsync`;
try {
const response = await fetch(runpodUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${runpodApiKey}`,
},
body: JSON.stringify({
input: {
api_key: apiKey,
model: "gemini-2.0-flash-exp",
contents: [
{
parts: [
{
text: `Generate an image: ${prompt}`,
},
],
},
],
generationConfig: {
responseModalities: ["TEXT", "IMAGE"],
},
},
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error("RunPod API error:", response.status, errorText);
return null;
}
const result = await response.json();
// RunPod wraps the response in { output: ... }
const data = result.output || result;
// Check for errors
if (data.error) {
console.error("Gemini API error via RunPod:", data.error);
return null;
}
// Extract image from Gemini response
const parts = data.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if (part.inlineData?.mimeType?.startsWith("image/")) {
console.log("Generated image via RunPod proxy");
return part.inlineData.data;
}
}
console.error("No image in Gemini response via RunPod");
return null;
} catch (error) {
console.error("RunPod request error:", error);
return null;
}
}
// Fallback: Try direct Gemini API (will fail in geo-restricted regions)
async function generateDirectGeminiImage(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("Direct 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>`,
},
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>`,
},
academic: {
bg: "#ffffff",
fg: "#1a1a1a",
accent: "#0055aa",
pattern: `<pattern id="grid" patternUnits="userSpaceOnUse" width="40" height="40">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#ddd" stroke-width="1"/>
</pattern>`,
},
};
const s = styles[style] || styles["punk-zine"];
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}">AI Image Generation</text>
<text x="412" y="1120" text-anchor="middle" class="body" font-size="14" fill="${s.fg}" opacity="0.7">Styled placeholder - image gen geo-restricted</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/zine-storage";
import { generateZineId } from "@/lib/utils";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { topic, style = "punk-zine", tone = "rebellious" } = body;
if (!topic || typeof topic !== "string" || topic.trim().length === 0) {
return NextResponse.json(
{ error: "Topic is required" },
{ status: 400 }
);
}
// Generate the 8-page outline using Gemini
const pages = await generateOutline(topic.trim(), style, tone);
if (!pages || pages.length !== 8) {
return NextResponse.json(
{ error: "Failed to generate complete outline" },
{ status: 500 }
);
}
// Create a new zine ID
const id = generateZineId();
const now = new Date().toISOString();
// Save initial zine metadata
const zine: StoredZine = {
id,
topic: topic.trim(),
style,
tone,
outline: pages,
pages: [], // Will be populated as images are generated
createdAt: now,
updatedAt: now,
};
await saveZine(zine);
return NextResponse.json({
id,
topic: topic.trim(),
style,
tone,
outline: pages,
});
} catch (error) {
console.error("Outline generation error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to generate outline" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { getZine, saveZine, getPrintLayoutPath } from "@/lib/zine-storage";
import { createZinePrintLayout } from "@/lib/zine-layout";
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 } = 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 || "rzine"}_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,113 @@
import { NextRequest, NextResponse } from "next/server";
import { GoogleGenerativeAI } from "@google/generative-ai";
import { getZine, saveZine } from "@/lib/zine-storage";
import type { PageOutline } from "@/lib/gemini";
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || "");
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { zineId, pageNumber, currentOutline, feedback, style, tone } = body;
if (!zineId || !pageNumber || !currentOutline || !feedback) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// Verify zine exists
const zine = await getZine(zineId);
if (!zine) {
return NextResponse.json(
{ error: "Zine not found" },
{ status: 404 }
);
}
// Update outline based on feedback using Gemini
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash-exp" });
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
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

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { getZine, saveZine } from "@/lib/zine-storage";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { zineId } = body;
if (!zineId) {
return NextResponse.json(
{ error: "Missing zineId" },
{ status: 400 }
);
}
// Get existing zine
const zine = await getZine(zineId);
if (!zine) {
return NextResponse.json(
{ error: "Zine not found" },
{ status: 404 }
);
}
// Update the timestamp to mark it as "saved"
zine.updatedAt = new Date().toISOString();
await saveZine(zine);
// Return the shareable URL
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://rsocials.online";
const shareUrl = `${baseUrl}/zine/z/${zineId}`;
return NextResponse.json({
success: true,
id: zineId,
shareUrl,
});
} catch (error) {
console.error("Save zine error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to save zine" },
{ status: 500 }
);
}
}

View File

@ -123,3 +123,12 @@
@apply bg-background text-foreground;
}
}
@keyframes pulse-generate {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.animate-pulse-generate {
animation: pulse-generate 1.5s ease-in-out infinite;
}

View File

@ -27,6 +27,9 @@ export const metadata: Metadata = {
"management",
"rSpace",
"open source",
"zine",
"content creation",
"print",
],
};

View File

@ -39,7 +39,7 @@ export default function HomePage() {
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4 pt-4">
<Button asChild size="lg" className="text-lg px-8 bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70">
<a href="https://socials.rsocials.online">
<a href="https://socials.crypto-commons.org">
Launch Postiz
<ArrowRight className="ml-2 h-5 w-5" />
</a>
@ -324,7 +324,7 @@ export default function HomePage() {
</div>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Button asChild size="lg" className="text-lg px-8 bg-gradient-to-r from-primary to-accent hover:opacity-90">
<a href="https://socials.rsocials.online">
<a href="https://socials.crypto-commons.org">
Try the Live Instance
<ArrowRight className="ml-2 h-5 w-5" />
</a>

View File

@ -0,0 +1,659 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
ArrowRight,
Check,
Download,
Loader2,
Mic,
MicOff,
RefreshCw,
Copy,
CheckCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Progress } from "@/components/ui/progress";
import { toast } from "sonner";
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;
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();
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);
toast.error(`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("");
toast.success("Page regenerated");
} catch (err) {
toast.error(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) {
toast.error(err instanceof Error ? err.message : "Failed to create print layout");
} finally {
setLoading(false);
}
};
const handleVoiceInput = useCallback(() => {
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
toast.error("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);
toast.success("Share link copied!");
setTimeout(() => setCopied(false), 2000);
};
if (loading && !state) {
return (
<div className="flex items-center justify-center min-h-[80vh]">
<div className="text-center">
<Loader2 className="w-12 h-12 mx-auto animate-spin text-primary mb-4" />
<p className="text-lg text-muted-foreground">Generating your outline...</p>
</div>
</div>
);
}
if (error && !state) {
return (
<div className="flex items-center justify-center min-h-[80vh]">
<Card className="max-w-md">
<CardContent className="p-8 text-center">
<h2 className="text-2xl font-bold mb-4">Error</h2>
<p className="text-destructive mb-4">{error}</p>
<Button onClick={() => router.push("/zine")}>Try Again</Button>
</CardContent>
</Card>
</div>
);
}
if (!state) return null;
const stepIndex = STEPS.indexOf(state.currentStep);
const completedPages = state.pages.filter((p) => p).length;
return (
<div className="min-h-[80vh] py-4">
{/* Header with Progress */}
<div className="max-w-4xl mx-auto mb-8">
<div className="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" onClick={() => router.push("/zine")}>
<ArrowLeft className="w-4 h-4 mr-1" />
Back
</Button>
<h1 className="text-xl sm:text-2xl font-bold 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 transition-colors
${stepIndex >= i
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{stepIndex > 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 rounded transition-colors ${
stepIndex > i ? "bg-primary" : "bg-muted"
}`}
/>
)}
</div>
))}
</div>
<div className="flex justify-between mt-2">
{STEPS.map((step) => (
<span
key={step}
className={`text-xs ${
state.currentStep === step ? "text-foreground font-medium" : "text-muted-foreground"
}`}
>
{STEP_LABELS[step]}
</span>
))}
</div>
</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 mb-4">Your 8-Page Outline</h2>
<div className="grid gap-4">
{state.outline.map((page, i) => (
<Card key={i}>
<CardContent className="p-4">
<div>
<span className="text-xs text-muted-foreground">
Page {page.pageNumber} &middot; {page.type}
</span>
<h3 className="font-bold text-lg">{page.title}</h3>
<ul className="mt-2 text-sm text-muted-foreground">
{page.keyPoints.map((point, j) => (
<li key={j}>&bull; {point}</li>
))}
</ul>
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex justify-end gap-4 mt-6">
<Button onClick={generatePages} size="lg">
Generate Pages
<ArrowRight className="w-4 h-4 ml-2" />
</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 mb-2">Generating Your Zine</h2>
<p className="text-muted-foreground text-sm">
Page {state.generatingPage || completedPages} of 8
</p>
</div>
{/* Overall Progress Bar */}
<Card>
<CardContent className="p-4">
<div className="flex justify-between text-sm mb-2">
<span>Progress</span>
<span>{Math.round((completedPages / 8) * 100)}%</span>
</div>
<Progress value={(completedPages / 8) * 100} />
<div className="flex justify-between mt-3">
{[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 transition-colors
${state.pages[num - 1] ? "bg-primary text-primary-foreground" :
state.generatingPage === num ? "bg-primary text-primary-foreground animate-pulse" :
"bg-muted text-muted-foreground"}`}
>
{state.pages[num - 1] ? <Check className="w-3 h-3" /> : num}
</div>
))}
</div>
</CardContent>
</Card>
{/* Current Page Being Generated */}
{state.generatingPage && (
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-center gap-4">
<div className="w-16 h-20 bg-background rounded border flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
<div className="flex-1">
<h3 className="font-bold">
{state.outline[state.generatingPage - 1]?.title}
</h3>
<p className="text-sm text-muted-foreground">
{state.outline[state.generatingPage - 1]?.type} &middot; Page {state.generatingPage}
</p>
<Progress value={60} className="mt-2 h-2" />
</div>
</div>
</CardContent>
</Card>
)}
{/* Thumbnail Grid */}
<div className="grid grid-cols-4 gap-4">
{state.outline.map((page, i) => (
<div
key={i}
className={`aspect-[3/4] rounded-lg border overflow-hidden flex items-center justify-center
${state.pages[i] ? "bg-background" : "bg-muted"}
${state.generatingPage === i + 1 ? "ring-2 ring-primary ring-offset-2" : ""}`}
>
{state.pages[i] ? (
<img
src={state.pages[i]}
alt={`Page ${i + 1}`}
className="w-full h-full object-cover"
/>
) : state.generatingPage === i + 1 ? (
<div className="text-center p-2">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary mb-2" />
<span className="text-xs font-medium">Generating...</span>
</div>
) : (
<div className="text-center">
<span className="text-2xl text-muted-foreground/30 font-bold">P{i + 1}</span>
<p className="text-xs text-muted-foreground 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">
Page {currentPage} of 8
</h2>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
<ArrowLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setCurrentPage((p) => Math.min(8, p + 1))}
disabled={currentPage === 8}
>
<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] rounded-lg border bg-background overflow-hidden">
{state.generatingPage === currentPage ? (
<div className="w-full h-full flex items-center justify-center">
<Loader2 className="w-12 h-12 animate-spin text-primary" />
</div>
) : (
<img
src={state.pages[currentPage - 1]}
alt={`Page ${currentPage}`}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="space-y-4">
<Card>
<CardContent className="p-4">
<h3 className="font-bold">{state.outline[currentPage - 1].title}</h3>
<p className="text-sm text-muted-foreground mt-2">
{state.outline[currentPage - 1].keyPoints.join(" &middot; ")}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<label className="block text-sm font-semibold mb-2">
Feedback for refinement
</label>
<div className="relative">
<Textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Make it more vibrant... Add more contrast... Change the layout..."
className="min-h-24 pr-12 resize-none"
disabled={state.generatingPage !== null}
/>
<button
type="button"
onClick={handleVoiceInput}
className={`absolute right-2 top-2 p-2 rounded-full transition-colors ${
isListening ? "bg-destructive text-white" : "bg-muted"
}`}
>
{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"
>
<RefreshCw className={`w-4 h-4 mr-2 ${state.generatingPage ? "animate-spin" : ""}`} />
Regenerate Page
</Button>
</CardContent>
</Card>
</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] rounded border overflow-hidden transition-all
${currentPage === i + 1 ? "ring-2 ring-primary" : "opacity-60 hover:opacity-100"}`}
>
<img src={page} alt={`Page ${i + 1}`} className="w-full h-full object-cover" />
</button>
))}
</div>
<div className="flex justify-end">
<Button onClick={createPrintLayout} size="lg" disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Create Print Layout
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
)}
{/* Step 4: Download & Share */}
{state.currentStep === "download" && (
<div className="space-y-6">
<h2 className="text-xl font-bold text-center">Your Zine is Ready!</h2>
{/* Print Layout Preview */}
<Card>
<CardContent className="p-4">
<img
src={state.printLayoutUrl || ""}
alt="Print Layout"
className="w-full rounded"
/>
</CardContent>
</Card>
{/* Actions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button size="lg" className="py-6" asChild>
<a
href={state.printLayoutUrl ? `${state.printLayoutUrl}&download=true` : "#"}
download={`${state.topic.slice(0, 20).replace(/[^a-zA-Z0-9]/g, "_")}_print.png`}
>
<Download className="w-5 h-5 mr-2" />
Download PNG (300 DPI)
</a>
</Button>
<Button variant="outline" size="lg" className="py-6" onClick={copyShareLink}>
{copied ? (
<>
<CheckCircle className="w-5 h-5 mr-2 text-primary" />
Copied!
</>
) : (
<>
<Copy className="w-5 h-5 mr-2" />
Copy Share Link
</>
)}
</Button>
</div>
{/* Folding Instructions */}
<Card className="bg-muted/50">
<CardContent className="p-6">
<h3 className="font-bold mb-4">How to Fold Your Zine</h3>
<ol className="text-sm space-y-2 text-muted-foreground">
<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 &mdash; pages should now be in order 1-8!</li>
</ol>
</CardContent>
</Card>
<div className="text-center">
<Button
variant="link"
onClick={() => {
sessionStorage.removeItem("zineInput");
router.push("/zine");
}}
>
Create Another Zine
</Button>
</div>
</div>
)}
</div>
</div>
);
}

209
src/app/zine/page.tsx Normal file
View File

@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Mic, MicOff, Sparkles, BookOpen, Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
const STYLES = [
{ value: "punk-zine", label: "Punk Zine", description: "Xerox texture, high contrast, DIY collage" },
{ value: "minimal", label: "Minimal", description: "Clean lines, white space, modern" },
{ value: "collage", label: "Collage", description: "Layered imagery, mixed media" },
{ value: "retro", label: "Retro", description: "1970s aesthetic, earth tones" },
{ value: "academic", label: "Academic", description: "Diagrams, annotations, infographic" },
];
const TONES = [
{ value: "rebellious", label: "Rebellious", description: "Defiant, punk attitude" },
{ value: "playful", label: "Playful", description: "Whimsical, fun, light-hearted" },
{ value: "informative", label: "Informative", description: "Educational, factual" },
{ value: "poetic", label: "Poetic", description: "Lyrical, metaphorical" },
];
export default function ZinePage() {
const router = useRouter();
const [topic, setTopic] = useState("");
const [style, setStyle] = useState("punk-zine");
const [tone, setTone] = useState("rebellious");
const [isListening, setIsListening] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleVoiceInput = () => {
if (!("webkitSpeechRecognition" in window) && !("SpeechRecognition" in window)) {
toast.error("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-[80vh] py-8">
{/* Header */}
<div className="text-center mb-8 sm:mb-12">
<h1 className="text-4xl sm:text-6xl font-bold tracking-tight mb-4">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent">rZine</span>
</h1>
<p className="text-lg sm:text-xl text-muted-foreground max-w-xl mx-auto">
Transform your ideas into printable 8-page mini-zines with AI.
Create community content your members can print, fold, and share.
</p>
</div>
{/* Features */}
<div className="flex flex-wrap justify-center gap-4 mb-8 sm:mb-12">
<Badge variant="secondary" className="gap-1.5 px-3 py-1">
<Sparkles className="w-3.5 h-3.5" />
AI-Generated
</Badge>
<Badge variant="secondary" className="gap-1.5 px-3 py-1">
<BookOpen className="w-3.5 h-3.5" />
8 Pages
</Badge>
<Badge variant="secondary" className="gap-1.5 px-3 py-1">
<Printer className="w-3.5 h-3.5" />
Print Ready
</Badge>
</div>
{/* Main Form */}
<form onSubmit={handleSubmit} className="w-full max-w-2xl space-y-6">
{/* Topic Input */}
<Card>
<CardContent className="p-4">
<div className="relative">
<Textarea
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="What's your zine about? Describe your concept..."
className="min-h-32 pr-12 text-lg resize-none border-0 focus-visible:ring-0 shadow-none"
disabled={isLoading}
/>
<button
type="button"
onClick={handleVoiceInput}
className={`absolute right-3 top-3 p-2 rounded-full transition-colors ${
isListening
? "bg-destructive text-white animate-pulse"
: "bg-muted hover:bg-muted/80 text-muted-foreground"
}`}
title={isListening ? "Stop listening" : "Voice input"}
>
{isListening ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5" />}
</button>
</div>
</CardContent>
</Card>
{/* Style & Tone Selectors */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Style Select */}
<Card>
<CardContent className="p-4">
<label className="block text-sm font-semibold mb-2">Style</label>
<div className="grid grid-cols-1 gap-2">
{STYLES.map((s) => (
<button
key={s.value}
type="button"
onClick={() => setStyle(s.value)}
disabled={isLoading}
className={`text-left px-3 py-2 rounded-lg border transition-colors ${
style === s.value
? "border-primary bg-primary/5 text-foreground"
: "border-transparent hover:bg-muted text-muted-foreground"
}`}
>
<span className="text-sm font-medium">{s.label}</span>
<span className="text-xs text-muted-foreground ml-2">{s.description}</span>
</button>
))}
</div>
</CardContent>
</Card>
{/* Tone Select */}
<Card>
<CardContent className="p-4">
<label className="block text-sm font-semibold mb-2">Tone</label>
<div className="grid grid-cols-1 gap-2">
{TONES.map((t) => (
<button
key={t.value}
type="button"
onClick={() => setTone(t.value)}
disabled={isLoading}
className={`text-left px-3 py-2 rounded-lg border transition-colors ${
tone === t.value
? "border-primary bg-primary/5 text-foreground"
: "border-transparent hover:bg-muted text-muted-foreground"
}`}
>
<span className="text-sm font-medium">{t.label}</span>
<span className="text-xs text-muted-foreground ml-2">{t.description}</span>
</button>
))}
</div>
</CardContent>
</Card>
</div>
{/* Submit Button */}
<Button
type="submit"
size="lg"
disabled={!topic.trim() || isLoading}
className="w-full py-6 text-lg"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin">&#9881;</span>
Generating...
</span>
) : (
"Generate Outline →"
)}
</Button>
</form>
{/* Footer note */}
<p className="mt-8 text-center text-sm text-muted-foreground">
Folds into a single 8.5&quot; x 11&quot; sheet
</p>
</div>
);
}

View File

@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ArrowLeft, ArrowRight, Download, Plus, Copy, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
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);
toast.success("Share link copied!");
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-[80vh] py-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/zine">
<ArrowLeft className="w-4 h-4 mr-1" />
rZine
</Link>
</Button>
<Button variant="outline" size="sm" onClick={copyShareLink}>
{copied ? (
<>
<CheckCircle className="w-4 h-4 mr-1 text-primary" />
Copied
</>
) : (
<>
<Copy className="w-4 h-4 mr-1" />
Share
</>
)}
</Button>
</div>
{/* Title */}
<h1 className="text-2xl sm:text-3xl font-bold text-center mb-2">
{zine.topic}
</h1>
<p className="text-center text-muted-foreground text-sm mb-6">
{zine.style} &middot; {zine.tone}
</p>
{/* Main Viewer */}
<div className="relative">
{/* Page Display */}
<Card className="aspect-[3/4] max-w-md mx-auto overflow-hidden">
<CardContent className="p-0 h-full">
{zine.pageUrls[currentPage] && (
<img
src={zine.pageUrls[currentPage]}
alt={`Page ${currentPage + 1}`}
className="w-full h-full object-cover"
/>
)}
</CardContent>
</Card>
{/* Navigation Arrows */}
<Button
variant="outline"
size="icon"
onClick={handlePrevPage}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 sm:-translate-x-12"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleNextPage}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 sm:translate-x-12"
>
<ArrowRight className="w-5 h-5" />
</Button>
</div>
{/* Page Info */}
<div className="text-center mt-4">
<p className="text-sm font-medium">
Page {currentPage + 1} of {zine.pageUrls.length}
</p>
{zine.outline[currentPage] && (
<p className="text-muted-foreground 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] rounded border overflow-hidden transition-all
${currentPage === i ? "ring-2 ring-primary" : "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 && (
<Button size="lg" asChild>
<a
href={`${zine.printLayoutUrl}&download=true`}
download={`${zine.topic.slice(0, 20).replace(/[^a-zA-Z0-9]/g, "_")}_print.png`}
>
<Download className="w-5 h-5 mr-2" />
Download Print Layout
</a>
</Button>
)}
<Button variant="outline" size="lg" asChild>
<Link href="/zine">
<Plus className="w-5 h-5 mr-2" />
Create Your Own
</Link>
</Button>
</div>
{/* Footer Info */}
<div className="text-center mt-8 text-xs text-muted-foreground">
<p>
Created with{" "}
<Link href="/zine" className="underline hover:text-foreground">
rZine
</Link>
{" "}on rSocials
</p>
<p className="mt-1">
{new Date(zine.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
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 - rZine",
};
}
return {
title: `${zine.topic} - rZine | rSocials`,
description: `An 8-page community zine about ${zine.topic}. Created with rZine on rSocials.`,
openGraph: {
title: `${zine.topic} - rZine`,
description: `An 8-page community zine about ${zine.topic}`,
type: "article",
siteName: "rSocials rZine",
images: zine.pageUrls?.[0] ? [{ url: zine.pageUrls[0] }] : [],
},
twitter: {
card: "summary_large_image",
title: `${zine.topic} - rZine`,
description: `An 8-page community 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} />;
}

View File

@ -31,10 +31,19 @@ export function Navbar() {
>
Deploy
</Link>
<Link
href="/zine"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
rZine
</Link>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild className="md:hidden">
<Link href="/zine">rZine</Link>
</Button>
<Button variant="ghost" asChild>
<a href="https://github.com/gitroomhq/postiz-app" target="_blank" rel="noopener noreferrer">
GitHub

View File

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

142
src/lib/gemini.ts Normal file
View File

@ -0,0 +1,142 @@
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",
"minimal": "clean minimalist design, lots of white space, modern sans-serif typography, simple geometric shapes, subtle gradients",
"collage": "layered mixed media collage, vintage photographs, torn paper edges, overlapping textures, eclectic composition",
"retro": "1970s aesthetic, earth tones, groovy psychedelic typography, halftone dot patterns, vintage illustration style",
"academic": "clean infographic style, annotated diagrams, data visualization, technical illustration, educational layout",
};
const TONE_PROMPTS: Record<string, string> = {
"rebellious": "defiant anti-establishment energy, provocative bold statements, raw and unfiltered",
"playful": "whimsical fun light-hearted energy, humor and wit, bright positive vibes",
"informative": "educational and factual, clear explanations, structured information",
"poetic": "lyrical and metaphorical, evocative imagery, emotional depth",
};
export async function generateOutline(
topic: string,
style: string,
tone: string
): Promise<PageOutline[]> {
const model = getGenAI().getGenerativeModel({ model: "gemini-2.0-flash" });
const prompt = `You are creating an 8-page community zine (mini DIY zine that folds from a single sheet of paper).
Topic: ${topic}
Visual Style: ${style} - ${STYLE_PROMPTS[style] || STYLE_PROMPTS["punk-zine"]}
Tone: ${tone} - ${TONE_PROMPTS[tone] || TONE_PROMPTS["rebellious"]}
Create a detailed outline for all 8 pages. Each page should have a distinct purpose:
- Page 1: Cover (eye-catching title and central image)
- Page 2: Introduction (hook the reader, set the stage)
- Pages 3-6: Main content (key concepts, stories, visuals)
- Page 7: Resources or deeper dive
- Page 8: Call to action (what reader should do next)
Return ONLY valid JSON in this exact format (no markdown, no code blocks):
{
"pages": [
{
"pageNumber": 1,
"type": "cover",
"title": "Short punchy title",
"keyPoints": ["Main visual concept", "Tagline or subtitle"],
"imagePrompt": "Detailed prompt for generating the page image including style elements"
}
]
}
Make each imagePrompt detailed and specific to the ${style} visual style. Include concrete visual elements, composition details, and mood descriptors.`;
const result = await model.generateContent(prompt);
const response = result.response.text();
// Parse JSON from response (handle potential markdown code blocks)
let jsonStr = response;
if (response.includes("```")) {
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
jsonStr = match[1];
}
}
const parsed = JSON.parse(jsonStr.trim());
return parsed.pages;
}
export async function regeneratePageWithFeedback(
currentOutline: PageOutline,
feedback: string,
style: string,
tone: string
): Promise<{ updatedOutline: PageOutline }> {
const model = getGenAI().getGenerativeModel({ model: "gemini-2.0-flash" });
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;
return { updatedOutline };
}

View File

@ -1,6 +1,11 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { nanoid } from "nanoid"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function generateZineId(): string {
return nanoid(8)
}

79
src/lib/zine-layout.ts Normal file
View File

@ -0,0 +1,79 @@
import sharp from "sharp";
import { getAllPagePaths, readFileAsBuffer, savePrintLayout } from "./zine-storage";
export async function createZinePrintLayout(
zineId: string,
zineName: string = "rzine"
): 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 as const },
{ page: 8, col: 1, row: 0, rotate: 180 as const },
{ page: 7, col: 2, row: 0, rotate: 180 as const },
{ page: 6, col: 3, row: 0, rotate: 180 as const },
// Bottom row
{ page: 2, col: 0, row: 1, rotate: 0 as const },
{ page: 3, col: 1, row: 1, rotate: 0 as const },
{ page: 4, col: 2, row: 1, rotate: 0 as const },
{ page: 5, col: 3, row: 1, rotate: 0 as const },
];
// 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 };
}

136
src/lib/zine-storage.ts Normal file
View File

@ -0,0 +1,136 @@
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 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
}
}