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:
parent
9b815a90d6
commit
3262786269
|
|
@ -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}
|
||||
|
|
@ -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"])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>© 2026 Mycopunk. Build tools, not empires.</p>
|
||||
<p>© 2026 Mycopunk. Build infrastructure, not empires.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue