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 = ` + + + ${s.pattern} + + + + + + ${s.pattern ? `` : ""} + + + + + + + + P${pageNum} + + + ${pageType} + + + ${title} + + + + + + ${keyPoints + .map( + (point, i) => ` + + ${point}${point.length >= 50 ? "..." : ""} + ` + ) + .join("")} + + + + AI Image Generation + Styled placeholder - image gen geo-restricted + + + + + + + + `; + + 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() {

+ + +
+ ); + } + + if (!state) return null; + + const stepIndex = STEPS.indexOf(state.currentStep); + const completedPages = state.pages.filter((p) => p).length; + + return ( +
+ {/* Header with Progress */} +
+
+ +

+ {state.topic} +

+
+ + {/* Progress Steps */} +
+ {STEPS.map((step, i) => ( +
+
= i + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground" + }`} + > + {stepIndex > i ? : i + 1} +
+ {i < STEPS.length - 1 && ( +
i ? "bg-primary" : "bg-muted" + }`} + /> + )} +
+ ))} +
+
+ {STEPS.map((step) => ( + + {STEP_LABELS[step]} + + ))} +
+
+ + {/* Step Content */} +
+ {/* Step 1: Outline Review */} + {state.currentStep === "outline" && ( +
+

Your 8-Page Outline

+
+ {state.outline.map((page, i) => ( + + +
+ + Page {page.pageNumber} · {page.type} + +

{page.title}

+
    + {page.keyPoints.map((point, j) => ( +
  • • {point}
  • + ))} +
+
+
+
+ ))} +
+
+ +
+
+ )} + + {/* Step 2: Page Generation */} + {state.currentStep === "generate" && ( +
+
+

Generating Your Zine

+

+ Page {state.generatingPage || completedPages} of 8 +

+
+ + {/* Overall Progress Bar */} + + +
+ Progress + {Math.round((completedPages / 8) * 100)}% +
+ +
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((num) => ( +
+ {state.pages[num - 1] ? : num} +
+ ))} +
+
+
+ + {/* Current Page Being Generated */} + {state.generatingPage && ( + + +
+
+ +
+
+

+ {state.outline[state.generatingPage - 1]?.title} +

+

+ {state.outline[state.generatingPage - 1]?.type} · Page {state.generatingPage} +

+ +
+
+
+
+ )} + + {/* Thumbnail Grid */} +
+ {state.outline.map((page, i) => ( +
+ {state.pages[i] ? ( + {`Page + ) : state.generatingPage === i + 1 ? ( +
+ + Generating... +
+ ) : ( +
+ P{i + 1} +

Pending

+
+ )} +
+ ))} +
+
+ )} + + {/* Step 3: Page Refinement */} + {state.currentStep === "refine" && ( +
+
+

+ Page {currentPage} of 8 +

+
+ + +
+
+ + {/* Current Page Preview */} +
+
+ {state.generatingPage === currentPage ? ( +
+ +
+ ) : ( + {`Page + )} +
+ +
+ + +

{state.outline[currentPage - 1].title}

+

+ {state.outline[currentPage - 1].keyPoints.join(" · ")} +

+
+
+ + + + +
+