From f7679759863eb653bb3d559c3f70d82272c8afab Mon Sep 17 00:00:00 2001
From: Jeff Emmett
Date: Mon, 23 Feb 2026 16:21:27 -0800
Subject: [PATCH] feat: add rZine - AI-powered community zine creator
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.env.example | 10 +
Dockerfile | 10 +
docker-compose.yml | 11 +
next.config.ts | 14 +
package-lock.json | 67 ++-
package.json | 6 +-
postiz/docker-compose.yml | 16 +-
src/app/api/zine/[id]/route.ts | 105 ++++
src/app/api/zine/generate-image/route.ts | 170 ++++++
src/app/api/zine/generate-page/route.ts | 350 ++++++++++++
src/app/api/zine/outline/route.ts | 60 ++
src/app/api/zine/print-layout/route.ts | 83 +++
src/app/api/zine/regenerate-page/route.ts | 113 ++++
src/app/api/zine/save/route.ts | 45 ++
src/app/globals.css | 9 +
src/app/layout.tsx | 3 +
src/app/page.tsx | 4 +-
src/app/zine/create/page.tsx | 659 ++++++++++++++++++++++
src/app/zine/page.tsx | 209 +++++++
src/app/zine/z/[id]/ZineViewer.tsx | 186 ++++++
src/app/zine/z/[id]/page.tsx | 59 ++
src/components/Navbar.tsx | 9 +
src/components/ui/dialog.tsx | 158 ++++++
src/components/ui/progress.tsx | 31 +
src/components/ui/select.tsx | 190 +++++++
src/components/ui/tabs.tsx | 91 +++
src/components/ui/textarea.tsx | 18 +
src/lib/gemini.ts | 142 +++++
src/lib/utils.ts | 5 +
src/lib/zine-layout.ts | 79 +++
src/lib/zine-storage.ts | 136 +++++
31 files changed, 3030 insertions(+), 18 deletions(-)
create mode 100644 .env.example
create mode 100644 src/app/api/zine/[id]/route.ts
create mode 100644 src/app/api/zine/generate-image/route.ts
create mode 100644 src/app/api/zine/generate-page/route.ts
create mode 100644 src/app/api/zine/outline/route.ts
create mode 100644 src/app/api/zine/print-layout/route.ts
create mode 100644 src/app/api/zine/regenerate-page/route.ts
create mode 100644 src/app/api/zine/save/route.ts
create mode 100644 src/app/zine/create/page.tsx
create mode 100644 src/app/zine/page.tsx
create mode 100644 src/app/zine/z/[id]/ZineViewer.tsx
create mode 100644 src/app/zine/z/[id]/page.tsx
create mode 100644 src/components/ui/dialog.tsx
create mode 100644 src/components/ui/progress.tsx
create mode 100644 src/components/ui/select.tsx
create mode 100644 src/components/ui/tabs.tsx
create mode 100644 src/components/ui/textarea.tsx
create mode 100644 src/lib/gemini.ts
create mode 100644 src/lib/zine-layout.ts
create mode 100644 src/lib/zine-storage.ts
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..cbe490e
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/Dockerfile b/Dockerfile
index d624df6..fbf620c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 9a57bf6..05106da 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/next.config.ts b/next.config.ts
index 68a6c64..6bf71b7 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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;
diff --git a/package-lock.json b/package-lock.json
index 4b7bb49..baf63c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
diff --git a/package.json b/package.json
index efe752e..97fbb01 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/postiz/docker-compose.yml b/postiz/docker-compose.yml
index 30295df..599c216 100644
--- a/postiz/docker-compose.yml
+++ b/postiz/docker-compose.yml
@@ -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
diff --git a/src/app/api/zine/[id]/route.ts b/src/app/api/zine/[id]/route.ts
new file mode 100644
index 0000000..295b2ad
--- /dev/null
+++ b/src/app/api/zine/[id]/route.ts
@@ -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 = {
+ "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 }
+ );
+ }
+}
diff --git a/src/app/api/zine/generate-image/route.ts b/src/app/api/zine/generate-image/route.ts
new file mode 100644
index 0000000..7a59036
--- /dev/null
+++ b/src/app/api/zine/generate-image/route.ts
@@ -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 {
+ 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 {
+ 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",
+ },
+ });
+}
diff --git a/src/app/api/zine/generate-page/route.ts b/src/app/api/zine/generate-page/route.ts
new file mode 100644
index 0000000..5ec9416
--- /dev/null
+++ b/src/app/api/zine/generate-page/route.ts
@@ -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 = {
+ "punk-zine": "xerox-style high contrast black and white, DIY cut-and-paste collage aesthetic, hand-drawn typography, punk rock zine style, grainy texture, photocopied look, bold graphic elements",
+ "minimal": "clean minimalist design, lots of white space, modern sans-serif typography, simple geometric shapes, subtle gradients, elegant composition",
+ "collage": "layered mixed media collage, vintage photographs, torn paper edges, overlapping textures, eclectic composition, found imagery",
+ "retro": "1970s aesthetic, earth tones, groovy psychedelic typography, halftone dot patterns, vintage illustration style, warm colors",
+ "academic": "clean infographic style, annotated diagrams, data visualization, technical illustration, educational layout, clear hierarchy",
+};
+
+const TONE_PROMPTS: Record = {
+ "rebellious": "defiant anti-establishment energy, provocative bold statements, raw and unfiltered, urgent",
+ "playful": "whimsical fun light-hearted energy, humor and wit, bright positive vibes, joyful",
+ "informative": "educational and factual, clear explanations, structured information, accessible",
+ "poetic": "lyrical and metaphorical, evocative imagery, emotional depth, contemplative",
+};
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+ const { zineId, pageNumber, outline, style, tone } = body;
+
+ if (!zineId || !pageNumber || !outline) {
+ return NextResponse.json(
+ { error: "Missing required fields: zineId, pageNumber, outline" },
+ { status: 400 }
+ );
+ }
+
+ // Verify zine exists
+ const zine = await getZine(zineId);
+ if (!zine) {
+ return NextResponse.json(
+ { error: "Zine not found" },
+ { status: 404 }
+ );
+ }
+
+ const pageOutline = outline as PageOutline;
+ const stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS["punk-zine"];
+ const tonePrompt = TONE_PROMPTS[tone] || TONE_PROMPTS["rebellious"];
+
+ // Build the full image generation prompt
+ const fullPrompt = buildImagePrompt(pageOutline, stylePrompt, tonePrompt);
+
+ // Generate image using Gemini 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 {
+ 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 {
+ 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 {
+ 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 {
+ const sharp = (await import("sharp")).default;
+
+ // Escape XML special characters
+ const escapeXml = (str: string) =>
+ str.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 = {
+ "punk-zine": {
+ bg: "#ffffff",
+ fg: "#000000",
+ accent: "#ff0066",
+ pattern: `
+
+ `,
+ },
+ minimal: {
+ bg: "#fafafa",
+ fg: "#333333",
+ accent: "#0066ff",
+ pattern: "",
+ },
+ collage: {
+ bg: "#f5e6d3",
+ fg: "#2d2d2d",
+ accent: "#8b4513",
+ pattern: `
+
+
+ `,
+ },
+ retro: {
+ bg: "#fff8dc",
+ fg: "#8b4513",
+ accent: "#ff6347",
+ pattern: `
+
+ `,
+ },
+ academic: {
+ bg: "#ffffff",
+ fg: "#1a1a1a",
+ accent: "#0055aa",
+ pattern: `
+
+ `,
+ },
+ };
+
+ const s = styles[style] || styles["punk-zine"];
+ const pageNum = outline.pageNumber;
+ const pageType = escapeXml(outline.type.toUpperCase());
+
+ const svg = `
+
+ `;
+
+ const buffer = await sharp(Buffer.from(svg)).png().toBuffer();
+ return buffer.toString("base64");
+}
diff --git a/src/app/api/zine/outline/route.ts b/src/app/api/zine/outline/route.ts
new file mode 100644
index 0000000..a977c08
--- /dev/null
+++ b/src/app/api/zine/outline/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/zine/print-layout/route.ts b/src/app/api/zine/print-layout/route.ts
new file mode 100644
index 0000000..20ff3d5
--- /dev/null
+++ b/src/app/api/zine/print-layout/route.ts
@@ -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`,
+ });
+}
diff --git a/src/app/api/zine/regenerate-page/route.ts b/src/app/api/zine/regenerate-page/route.ts
new file mode 100644
index 0000000..b35204e
--- /dev/null
+++ b/src/app/api/zine/regenerate-page/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/zine/save/route.ts b/src/app/api/zine/save/route.ts
new file mode 100644
index 0000000..16279ad
--- /dev/null
+++ b/src/app/api/zine/save/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 77a77c0..e731d9a 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 1c219ef..207a72b 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -27,6 +27,9 @@ export const metadata: Metadata = {
"management",
"rSpace",
"open source",
+ "zine",
+ "content creation",
+ "print",
],
};
diff --git a/src/app/page.tsx b/src/app/page.tsx
index ccb6c4a..272996c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -39,7 +39,7 @@ export default function HomePage() {
+
+ rZine
+
GitHub
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..80d7ad6
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -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) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..bca13fe
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -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) {
+ return (
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..fd01b74
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -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) {
+ return
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..7f73dcd
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+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 &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts
new file mode 100644
index 0000000..7340f9f
--- /dev/null
+++ b/src/lib/gemini.ts
@@ -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 = {
+ "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 = {
+ "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 {
+ 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 };
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index bd0c391..70365ab 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -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)
+}
diff --git a/src/lib/zine-layout.ts b/src/lib/zine-layout.ts
new file mode 100644
index 0000000..347aec0
--- /dev/null
+++ b/src/lib/zine-layout.ts
@@ -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 };
+}
diff --git a/src/lib/zine-storage.ts b/src/lib/zine-storage.ts
new file mode 100644
index 0000000..d79cf10
--- /dev/null
+++ b/src/lib/zine-storage.ts
@@ -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 {
+ try {
+ await fs.access(dir);
+ } catch {
+ await fs.mkdir(dir, { recursive: true });
+ }
+}
+
+export async function saveZine(zine: StoredZine): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ return fs.readFile(filepath);
+}
+
+export async function listAllZines(): Promise {
+ 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 {
+ const zineDir = path.join(ZINES_DIR, id);
+ try {
+ await fs.rm(zineDir, { recursive: true });
+ } catch {
+ // Ignore if doesn't exist
+ }
+}