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'
This commit is contained in:
Jeff Emmett 2026-01-30 11:00:07 +00:00
parent 9b815a90d6
commit 3262786269
4 changed files with 559 additions and 2 deletions

View File

@ -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}

View File

@ -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"])

View File

@ -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<string | null>(null);
const [generatedDesign, setGeneratedDesign] = useState<GeneratedDesign | null>(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 (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-2">Design Swag</h1>
<p className="text-muted-foreground mb-8">
Create custom mycopunk merchandise using AI. Describe your vision and
we&apos;ll generate a unique design.
</p>
<div className="grid md:grid-cols-2 gap-8">
{/* Form */}
<div>
<form onSubmit={handleGenerate} className="space-y-6">
<div>
<label
htmlFor="name"
className="block text-sm font-medium mb-2"
>
Design Name
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => 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}
/>
</div>
<div>
<label
htmlFor="concept"
className="block text-sm font-medium mb-2"
>
Design Concept
</label>
<textarea
id="concept"
value={concept}
onChange={(e) => setConcept(e.target.value)}
placeholder="Describe your design idea... e.g., A mushroom growing through cracked concrete, symbolizing nature reclaiming urban spaces. Include the phrase 'NATURE WINS' in bold letters."
rows={4}
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary resize-none"
required
disabled={isGenerating}
/>
</div>
<div>
<label
htmlFor="tags"
className="block text-sm font-medium mb-2"
>
Tags (comma-separated)
</label>
<input
type="text"
id="tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="mycopunk, nature, urban, punk"
className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isGenerating}
/>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-red-700">
{error}
</div>
)}
<button
type="submit"
disabled={isGenerating || !name || !concept}
className="w-full px-6 py-3 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Generating Design...
</span>
) : (
"Generate Design"
)}
</button>
</form>
</div>
{/* Preview */}
<div>
<h2 className="text-lg font-semibold mb-4">Preview</h2>
<div className="aspect-square border-2 border-dashed rounded-lg flex items-center justify-center bg-muted/30">
{generatedDesign ? (
<img
src={`${API_URL.replace("/api", "")}${generatedDesign.image_url}`}
alt={generatedDesign.name}
className="w-full h-full object-contain rounded-lg"
/>
) : isGenerating ? (
<div className="text-center text-muted-foreground">
<svg
className="animate-spin h-12 w-12 mx-auto mb-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p>Creating your design...</p>
<p className="text-sm">This may take a moment</p>
</div>
) : (
<p className="text-muted-foreground">
Your design will appear here
</p>
)}
</div>
{generatedDesign && (
<div className="mt-4 space-y-4">
<div className="p-4 bg-muted/50 rounded-lg">
<p className="font-medium">{generatedDesign.name}</p>
<p className="text-sm text-muted-foreground">
Status:{" "}
<span
className={
generatedDesign.status === "active"
? "text-green-600"
: "text-yellow-600"
}
>
{generatedDesign.status}
</span>
</p>
</div>
<div className="flex gap-2">
{generatedDesign.status === "draft" ? (
<>
<button
onClick={handleActivate}
disabled={isActivating}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-md font-medium hover:bg-green-700 transition-colors disabled:opacity-50"
>
{isActivating ? "Activating..." : "Add to Store"}
</button>
<button
onClick={handleDelete}
className="px-4 py-2 border border-red-300 text-red-600 rounded-md font-medium hover:bg-red-50 transition-colors"
>
Discard
</button>
</>
) : (
<Link
href="/products"
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors text-center"
>
View in Store
</Link>
)}
</div>
</div>
)}
</div>
</div>
{/* Tips */}
<div className="mt-12 p-6 bg-muted/30 rounded-lg">
<h3 className="font-semibold mb-3">Design Tips</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
Be specific about text you want included - the AI will try to
render it in the design
</li>
<li>
Mention colors, mood, and style preferences in your concept
</li>
<li>
Mycopunk themes work great: mycelium networks, mushrooms, punk
aesthetics, decentralization
</li>
<li>
Generated designs start as drafts - preview before adding to the
store
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@ -27,6 +27,9 @@ export default function RootLayout({
<a href="/products" className="hover:text-primary">
Products
</a>
<a href="/design" className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors">
Design Swag
</a>
<a href="/cart" className="hover:text-primary">
Cart
</a>
@ -36,7 +39,7 @@ export default function RootLayout({
<main className="flex-1">{children}</main>
<footer className="border-t py-6">
<div className="container mx-auto px-4 text-center text-muted-foreground">
<p>&copy; 2026 Mycopunk. Build tools, not empires.</p>
<p>&copy; 2026 Mycopunk. Build infrastructure, not empires.</p>
</div>
</footer>
</div>