From 32627862691793ae8b6bf0a486d356e95242344d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 30 Jan 2026 11:00:07 +0000 Subject: [PATCH] feat: add Design Swag feature with AI generation - Add /api/design/generate endpoint for AI image generation - Add /api/design/{slug}/activate to publish designs - Add /design page with form and preview - Add 'Design Swag' button to navigation - Update footer tagline to 'Build infrastructure, not empires' --- backend/app/api/design_generator.py | 219 ++++++++++++++++++ backend/app/main.py | 3 +- frontend/app/design/page.tsx | 334 ++++++++++++++++++++++++++++ frontend/app/layout.tsx | 5 +- 4 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/design_generator.py create mode 100644 frontend/app/design/page.tsx diff --git a/backend/app/api/design_generator.py b/backend/app/api/design_generator.py new file mode 100644 index 0000000..f7dd15e --- /dev/null +++ b/backend/app/api/design_generator.py @@ -0,0 +1,219 @@ +"""AI design generation API.""" + +import os +import re +import uuid +from datetime import date +from pathlib import Path + +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.config import get_settings + +router = APIRouter() +settings = get_settings() + + +class DesignRequest(BaseModel): + """Request to generate a new design.""" + concept: str + name: str + tags: list[str] = [] + product_type: str = "sticker" + + +class DesignResponse(BaseModel): + """Response with generated design info.""" + slug: str + name: str + image_url: str + status: str + + +def slugify(text: str) -> str: + """Convert text to URL-friendly slug.""" + text = text.lower().strip() + text = re.sub(r'[^\w\s-]', '', text) + text = re.sub(r'[\s_-]+', '-', text) + text = re.sub(r'^-+|-+$', '', text) + return text + + +@router.post("/generate", response_model=DesignResponse) +async def generate_design(request: DesignRequest): + """Generate a new design using AI.""" + + gemini_api_key = os.environ.get("GEMINI_API_KEY", "") + if not gemini_api_key: + raise HTTPException( + status_code=503, + detail="AI generation not configured. Set GEMINI_API_KEY." + ) + + # Create slug from name + slug = slugify(request.name) + if not slug: + slug = f"design-{uuid.uuid4().hex[:8]}" + + # Check if design already exists + design_dir = settings.designs_dir / "stickers" / slug + if design_dir.exists(): + raise HTTPException( + status_code=409, + detail=f"Design '{slug}' already exists" + ) + + # Build the image generation prompt + style_prompt = f"""A striking sticker design for "{request.name}". +{request.concept} +The design should have a mycopunk aesthetic with mycelium network patterns, +punk/DIY vibes, bold typography if text is needed. +Colors: deep purples, electric blues, golden yellows on dark background. +High contrast, suitable for vinyl sticker printing. +Square format, clean edges for die-cut sticker.""" + + # Call Gemini API for image generation + try: + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key={gemini_api_key}", + json={ + "contents": [{ + "parts": [{ + "text": f"Generate an image: {style_prompt}" + }] + }], + "generationConfig": { + "responseModalities": ["image", "text"], + "responseMimeType": "image/png" + } + }, + headers={"Content-Type": "application/json"} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=502, + detail=f"AI generation failed: {response.text}" + ) + + result = response.json() + + # Extract image data from response + image_data = None + for candidate in result.get("candidates", []): + for part in candidate.get("content", {}).get("parts", []): + if "inlineData" in part: + image_data = part["inlineData"]["data"] + break + if image_data: + break + + if not image_data: + raise HTTPException( + status_code=502, + detail="AI did not return an image" + ) + + except httpx.TimeoutException: + raise HTTPException( + status_code=504, + detail="AI generation timed out" + ) + except Exception as e: + raise HTTPException( + status_code=502, + detail=f"AI generation error: {str(e)}" + ) + + # Create design directory + design_dir.mkdir(parents=True, exist_ok=True) + + # Save image + import base64 + image_path = design_dir / f"{slug}.png" + image_bytes = base64.b64decode(image_data) + image_path.write_bytes(image_bytes) + + # Create metadata.yaml + tags_str = ", ".join(request.tags) if request.tags else "mycopunk, sticker, ai-generated" + metadata_content = f"""name: "{request.name}" +slug: {slug} +description: "{request.concept}" +tags: [{tags_str}] +created: {date.today().isoformat()} +author: ai-generated + +source: + file: {slug}.png + format: png + dimensions: + width: 1024 + height: 1024 + dpi: 300 + color_profile: sRGB + +products: + - type: sticker + provider: prodigi + sku: GLOBAL-STI-KIS-3X3 + variants: [matte, gloss] + retail_price: 3.50 + +status: draft +""" + + metadata_path = design_dir / "metadata.yaml" + metadata_path.write_text(metadata_content) + + return DesignResponse( + slug=slug, + name=request.name, + image_url=f"/api/designs/{slug}/image", + status="draft" + ) + + +@router.post("/{slug}/activate") +async def activate_design(slug: str): + """Activate a draft design to make it visible in the store.""" + + design_dir = settings.designs_dir / "stickers" / slug + metadata_path = design_dir / "metadata.yaml" + + if not metadata_path.exists(): + raise HTTPException(status_code=404, detail="Design not found") + + # Read and update metadata + content = metadata_path.read_text() + content = content.replace("status: draft", "status: active") + metadata_path.write_text(content) + + return {"status": "activated", "slug": slug} + + +@router.delete("/{slug}") +async def delete_design(slug: str): + """Delete a design (only drafts can be deleted).""" + import shutil + + design_dir = settings.designs_dir / "stickers" / slug + metadata_path = design_dir / "metadata.yaml" + + if not metadata_path.exists(): + raise HTTPException(status_code=404, detail="Design not found") + + # Check if draft + content = metadata_path.read_text() + if "status: active" in content: + raise HTTPException( + status_code=400, + detail="Cannot delete active designs. Set to draft first." + ) + + # Delete directory + shutil.rmtree(design_dir) + + return {"status": "deleted", "slug": slug} diff --git a/backend/app/main.py b/backend/app/main.py index 4f7c92e..8b14c98 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import get_settings -from app.api import designs, products, cart, checkout, orders, webhooks, health +from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator from app.api.admin import router as admin_router settings = get_settings() @@ -48,6 +48,7 @@ app.include_router(cart.router, prefix="/api/cart", tags=["cart"]) app.include_router(checkout.router, prefix="/api/checkout", tags=["checkout"]) app.include_router(orders.router, prefix="/api/orders", tags=["orders"]) app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"]) +app.include_router(design_generator.router, prefix="/api/design", tags=["design-generator"]) app.include_router(admin_router, prefix="/api/admin", tags=["admin"]) diff --git a/frontend/app/design/page.tsx b/frontend/app/design/page.tsx new file mode 100644 index 0000000..355facf --- /dev/null +++ b/frontend/app/design/page.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"; + +interface GeneratedDesign { + slug: string; + name: string; + image_url: string; + status: string; +} + +export default function DesignPage() { + const [name, setName] = useState(""); + const [concept, setConcept] = useState(""); + const [tags, setTags] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + const [generatedDesign, setGeneratedDesign] = useState(null); + const [isActivating, setIsActivating] = useState(false); + + const handleGenerate = async (e: React.FormEvent) => { + e.preventDefault(); + setIsGenerating(true); + setError(null); + setGeneratedDesign(null); + + try { + const response = await fetch(`${API_URL}/design/generate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + concept, + tags: tags.split(",").map((t) => t.trim()).filter(Boolean), + product_type: "sticker", + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || "Failed to generate design"); + } + + const design = await response.json(); + setGeneratedDesign(design); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsGenerating(false); + } + }; + + const handleActivate = async () => { + if (!generatedDesign) return; + + setIsActivating(true); + setError(null); + + try { + const response = await fetch( + `${API_URL}/design/${generatedDesign.slug}/activate`, + { + method: "POST", + } + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || "Failed to activate design"); + } + + setGeneratedDesign({ ...generatedDesign, status: "active" }); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsActivating(false); + } + }; + + const handleDelete = async () => { + if (!generatedDesign) return; + + try { + const response = await fetch( + `${API_URL}/design/${generatedDesign.slug}`, + { + method: "DELETE", + } + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || "Failed to delete design"); + } + + setGeneratedDesign(null); + setName(""); + setConcept(""); + setTags(""); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } + }; + + return ( +
+
+

Design Swag

+

+ Create custom mycopunk merchandise using AI. Describe your vision and + we'll generate a unique design. +

+ +
+ {/* Form */} +
+
+
+ + setName(e.target.value)} + placeholder="e.g., Mycelial Revolution" + className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + required + disabled={isGenerating} + /> +
+ +
+ +