diff --git a/backend/app/api/design_generator.py b/backend/app/api/design_generator.py index 23550c8..e8304e4 100644 --- a/backend/app/api/design_generator.py +++ b/backend/app/api/design_generator.py @@ -183,16 +183,27 @@ status: draft ) +def find_design_dir(slug: str) -> Path | None: + """Find a design directory by slug, searching all categories.""" + for category_dir in settings.designs_dir.iterdir(): + if not category_dir.is_dir(): + continue + design_dir = category_dir / slug + if design_dir.exists() and (design_dir / "metadata.yaml").exists(): + return design_dir + return None + + @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(): + design_dir = find_design_dir(slug) + if not design_dir: raise HTTPException(status_code=404, detail="Design not found") + metadata_path = design_dir / "metadata.yaml" + # Read and update metadata content = metadata_path.read_text() content = content.replace("status: draft", "status: active") @@ -209,12 +220,12 @@ 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(): + design_dir = find_design_dir(slug) + if not design_dir: raise HTTPException(status_code=404, detail="Design not found") + metadata_path = design_dir / "metadata.yaml" + # Check if draft content = metadata_path.read_text() if "status: active" in content: diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..b233eb7 --- /dev/null +++ b/backend/app/api/upload.py @@ -0,0 +1,145 @@ +"""Design upload API — users upload their own artwork.""" + +import io +import re +import uuid +from datetime import date +from pathlib import Path + +from fastapi import APIRouter, Form, HTTPException, UploadFile +from PIL import Image +from pydantic import BaseModel + +from app.config import get_settings +from app.api.designs import design_service + +router = APIRouter() +settings = get_settings() + +ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"} +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB +MIN_DIMENSION = 500 + + +class UploadResponse(BaseModel): + slug: str + name: str + image_url: str + status: str + products: list[dict] + + +def slugify(text: str) -> str: + 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("/upload", response_model=UploadResponse) +async def upload_design( + file: UploadFile, + name: str = Form(...), + space: str = Form("default"), + tags: str = Form(""), +): + """Upload a custom design image.""" + + # Validate content type + if file.content_type not in ALLOWED_TYPES: + raise HTTPException(400, "Only PNG, JPEG, and WebP files are accepted") + + # Read file and check size + contents = await file.read() + if len(contents) > MAX_FILE_SIZE: + raise HTTPException(400, "File size must be under 10 MB") + + # Open with Pillow and validate dimensions + try: + img = Image.open(io.BytesIO(contents)) + except Exception: + raise HTTPException(400, "Could not read image file") + + if img.width < MIN_DIMENSION or img.height < MIN_DIMENSION: + raise HTTPException(400, f"Image must be at least {MIN_DIMENSION}x{MIN_DIMENSION} pixels") + + # Create slug + slug = slugify(name) + if not slug: + slug = f"upload-{uuid.uuid4().hex[:8]}" + + # Check for existing design + design_dir = settings.designs_dir / "uploads" / slug + if design_dir.exists(): + slug = f"{slug}-{uuid.uuid4().hex[:6]}" + design_dir = settings.designs_dir / "uploads" / slug + + # Save image as PNG + design_dir.mkdir(parents=True, exist_ok=True) + img = img.convert("RGBA") + image_path = design_dir / f"{slug}.png" + img.save(str(image_path), "PNG") + + # Build metadata + safe_name = name.replace('"', '\\"') + tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else ["custom", "upload"] + tags_str = ", ".join(tag_list) + space_field = space if space != "default" else "all" + + metadata_content = f"""name: "{safe_name}" +slug: {slug} +description: "Custom uploaded design" +tags: [{tags_str}] +space: {space_field} +category: uploads +created: "{date.today().isoformat()}" +author: user-upload + +source: + file: {slug}.png + format: png + dimensions: + width: {img.width} + height: {img.height} + dpi: 300 + color_profile: sRGB + +products: + - type: sticker + provider: prodigi + sku: GLOBAL-STI-KIS-3X3 + variants: [matte, gloss] + retail_price: 3.50 + - type: shirt + provider: printful + sku: "71" + variants: [S, M, L, XL, 2XL] + retail_price: 29.99 + - type: print + provider: prodigi + sku: GLOBAL-FAP-A4 + variants: [matte, lustre] + retail_price: 12.99 + +status: draft +""" + metadata_path = design_dir / "metadata.yaml" + metadata_path.write_text(metadata_content) + + # Clear design cache so the new upload is discoverable + design_service.clear_cache() + + products = [ + {"type": "sticker", "price": 3.50}, + {"type": "shirt", "price": 29.99}, + {"type": "print", "price": 12.99}, + ] + + return UploadResponse( + slug=slug, + name=name, + image_url=f"/api/designs/{slug}/image", + status="draft", + products=products, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 79b2e02..ba4529c 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, design_generator, spaces +from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator, upload, spaces from app.api.admin import router as admin_router settings = get_settings() @@ -50,6 +50,7 @@ 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(upload.router, prefix="/api/design", tags=["upload"]) app.include_router(spaces.router, prefix="/api/spaces", tags=["spaces"]) app.include_router(admin_router, prefix="/api/admin", tags=["admin"]) diff --git a/designs/uploads/.gitkeep b/designs/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 2000083..f30897a 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -72,6 +72,9 @@ export default async function RootLayout({ Design Swag + + Upload Swag + Cart diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 04c3d0c..d167286 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -74,6 +74,12 @@ export default async function HomePage() { > Design Your Own + + Upload Your Own + diff --git a/frontend/app/upload/page.tsx b/frontend/app/upload/page.tsx new file mode 100644 index 0000000..1459133 --- /dev/null +++ b/frontend/app/upload/page.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import Link from "next/link"; +import { getSpaceIdFromCookie } from "@/lib/spaces"; +import { MOCKUP_CONFIGS, generateMockup } from "@/lib/mockups"; +import type { SpaceConfig } from "@/lib/spaces"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api"; + +interface UploadedDesign { + slug: string; + name: string; + image_url: string; + status: string; + products: { type: string; price: number }[]; +} + +export default function UploadPage() { + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [name, setName] = useState(""); + const [tags, setTags] = useState(""); + const [isDragging, setIsDragging] = useState(false); + const [mockups, setMockups] = useState>({}); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + const [uploadedDesign, setUploadedDesign] = useState(null); + const [isActivating, setIsActivating] = useState(false); + const [spaceConfig, setSpaceConfig] = useState(null); + const fileInputRef = useRef(null); + + useEffect(() => { + const spaceId = getSpaceIdFromCookie(); + fetch(`${API_URL}/spaces/${spaceId}`) + .then((res) => (res.ok ? res.json() : null)) + .then(setSpaceConfig) + .catch(() => {}); + }, []); + + // Generate mockups when preview changes + useEffect(() => { + if (!preview) { + setMockups({}); + return; + } + MOCKUP_CONFIGS.forEach(async (config) => { + try { + const result = await generateMockup(preview, config); + setMockups((prev) => ({ ...prev, [config.productType]: result })); + } catch { + // Template load failure — fallback handled in render + } + }); + }, [preview]); + + const handleFile = useCallback((f: File) => { + if (!f.type.startsWith("image/")) { + setError("Please select an image file (PNG, JPEG, or WebP)"); + return; + } + if (f.size > 10 * 1024 * 1024) { + setError("File must be under 10 MB"); + return; + } + setError(null); + setFile(f); + setPreview(URL.createObjectURL(f)); + if (!name) { + // Auto-fill name from filename + const baseName = f.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " "); + setName(baseName.charAt(0).toUpperCase() + baseName.slice(1)); + } + }, [name]); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) handleFile(droppedFile); + }, + [handleFile] + ); + + const handleUpload = async (e: React.FormEvent) => { + e.preventDefault(); + if (!file || !name) return; + + setIsUploading(true); + setError(null); + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("name", name); + formData.append("space", getSpaceIdFromCookie()); + if (tags) formData.append("tags", tags); + + const response = await fetch(`${API_URL}/design/upload`, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || "Upload failed"); + } + + const design = await response.json(); + setUploadedDesign(design); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsUploading(false); + } + }; + + const handleActivate = async () => { + if (!uploadedDesign) return; + setIsActivating(true); + setError(null); + + try { + const response = await fetch( + `${API_URL}/design/${uploadedDesign.slug}/activate`, + { method: "POST" } + ); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || "Failed to activate design"); + } + setUploadedDesign({ ...uploadedDesign, status: "active" }); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsActivating(false); + } + }; + + const handleDelete = async () => { + if (!uploadedDesign) return; + try { + const response = await fetch( + `${API_URL}/design/${uploadedDesign.slug}`, + { method: "DELETE" } + ); + if (!response.ok) { + const data = await response.json(); + throw new Error(data.detail || "Failed to delete design"); + } + resetForm(); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } + }; + + const resetForm = () => { + setFile(null); + setPreview(null); + setName(""); + setTags(""); + setMockups({}); + setUploadedDesign(null); + setError(null); + }; + + return ( +
+
+

Upload Swag

+

+ Upload your own design and preview it on{" "} + {spaceConfig?.name || "rSwag"} merchandise. See how it looks on + shirts, stickers, and prints before ordering. +

+ +
+ {/* Left: Upload Form */} +
+
+
+ + setName(e.target.value)} + placeholder="e.g., My Custom Logo" + className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + required + disabled={isUploading || !!uploadedDesign} + /> +
+ + {/* Drag & Drop Zone */} +
+ +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onClick={() => + !uploadedDesign && fileInputRef.current?.click() + } + className={`relative border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${ + isDragging + ? "border-primary bg-primary/5" + : preview + ? "border-primary/50" + : "border-muted-foreground/30 hover:border-primary/50" + } ${uploadedDesign ? "pointer-events-none opacity-60" : ""}`} + > + { + const f = e.target.files?.[0]; + if (f) handleFile(f); + }} + className="hidden" + /> + {preview ? ( +
+ Preview +

+ {file?.name} ( + {((file?.size || 0) / 1024 / 1024).toFixed(1)} MB) +

+ {!uploadedDesign && ( + + )} +
+ ) : ( +
+ + + +

+ Drag & drop your design here, or{" "} + browse +

+

+ PNG, JPEG, or WebP. Max 10 MB. Min 500x500px. +

+
+ )} +
+
+ +
+ + setTags(e.target.value)} + placeholder="logo, custom, brand" + className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + disabled={isUploading || !!uploadedDesign} + /> +
+ + {error && ( +
+ {error} +
+ )} + + {!uploadedDesign && ( + + )} +
+ + {/* Post-upload actions */} + {uploadedDesign && ( +
+
+

{uploadedDesign.name}

+

+ Status:{" "} + + {uploadedDesign.status} + +

+
+ +
+ {uploadedDesign.status === "draft" ? ( + <> + + + + ) : ( + <> + + View in Store + + + + )} +
+
+ )} +
+ + {/* Right: Mockup Previews */} +
+

Product Previews

+ {preview ? ( +
+ {MOCKUP_CONFIGS.map((config) => ( +
+
+ {mockups[config.productType] ? ( + {`${config.label} + ) : ( +
+
+
+ )} +
+
+ {config.label} + + from ${config.price.toFixed(2)} + +
+
+ ))} +
+ ) : ( +
+

+ Upload a design to see product previews +

+
+ )} +
+
+ + {/* Tips */} +
+

Upload Tips

+
    +
  • + • Use a high-resolution image (at least 2000x2000px for best + print quality) +
  • +
  • + • PNG with transparency works best for stickers and shirts +
  • +
  • + • Keep important elements centered — edges may be cropped on + some products +
  • +
  • + • Designs start as drafts — preview before adding to the + store +
  • +
+
+
+
+ ); +} diff --git a/frontend/lib/mockups.ts b/frontend/lib/mockups.ts new file mode 100644 index 0000000..f84ae4a --- /dev/null +++ b/frontend/lib/mockups.ts @@ -0,0 +1,88 @@ +/** Client-side Canvas mockup compositing for design previews. */ + +export interface MockupConfig { + template: string; + designArea: { x: number; y: number; width: number; height: number }; + label: string; + productType: string; + price: number; +} + +export const MOCKUP_CONFIGS: MockupConfig[] = [ + { + template: "/mockups/shirt-template.png", + designArea: { x: 275, y: 300, width: 250, height: 250 }, + label: "T-Shirt", + productType: "shirt", + price: 29.99, + }, + { + template: "/mockups/sticker-template.png", + designArea: { x: 130, y: 130, width: 540, height: 540 }, + label: "Sticker", + productType: "sticker", + price: 3.50, + }, + { + template: "/mockups/print-template.png", + designArea: { x: 160, y: 160, width: 480, height: 480 }, + label: "Art Print", + productType: "print", + price: 12.99, + }, +]; + +/** + * Composite a design image onto a product template using Canvas API. + * Draws the design into the bounding box first, then overlays the template + * so transparent regions in the template show the design through. + */ +export function generateMockup( + designDataUrl: string, + config: MockupConfig +): Promise { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + canvas.width = 800; + canvas.height = 800; + const ctx = canvas.getContext("2d"); + if (!ctx) return reject(new Error("Canvas not supported")); + + const templateImg = new window.Image(); + const designImg = new window.Image(); + + templateImg.crossOrigin = "anonymous"; + designImg.crossOrigin = "anonymous"; + + let loaded = 0; + const onBothLoaded = () => { + loaded++; + if (loaded < 2) return; + + // Draw design first (underneath template) + const { x, y, width, height } = config.designArea; + + // Maintain aspect ratio within the bounding box + const scale = Math.min(width / designImg.width, height / designImg.height); + const dw = designImg.width * scale; + const dh = designImg.height * scale; + const dx = x + (width - dw) / 2; + const dy = y + (height - dh) / 2; + + ctx.drawImage(designImg, dx, dy, dw, dh); + + // Draw template on top (transparent areas show design through) + ctx.drawImage(templateImg, 0, 0, 800, 800); + + resolve(canvas.toDataURL("image/png")); + }; + + templateImg.onload = onBothLoaded; + designImg.onload = onBothLoaded; + templateImg.onerror = () => reject(new Error(`Failed to load template: ${config.template}`)); + designImg.onerror = () => reject(new Error("Failed to load design image")); + + templateImg.src = config.template; + designImg.src = designDataUrl; + }); +} diff --git a/frontend/public/mockups/print-template.png b/frontend/public/mockups/print-template.png new file mode 100644 index 0000000..1c5dc6b Binary files /dev/null and b/frontend/public/mockups/print-template.png differ diff --git a/frontend/public/mockups/shirt-template.png b/frontend/public/mockups/shirt-template.png new file mode 100644 index 0000000..9141640 Binary files /dev/null and b/frontend/public/mockups/shirt-template.png differ diff --git a/frontend/public/mockups/sticker-template.png b/frontend/public/mockups/sticker-template.png new file mode 100644 index 0000000..331961a Binary files /dev/null and b/frontend/public/mockups/sticker-template.png differ