1006 lines
33 KiB
TypeScript
1006 lines
33 KiB
TypeScript
/**
|
|
* Swag module — design tool for print-ready swag (stickers, posters, tees).
|
|
*
|
|
* Ported from /opt/apps/swag-designer/ (Next.js → Hono).
|
|
* Uses Sharp for image processing. No database needed (filesystem storage).
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdir, writeFile, readFile, readdir, stat, rm } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import sharp from "sharp";
|
|
import { getProduct, PRODUCTS, getPodProducts, type StorefrontProduct } from "./products";
|
|
import { processImage } from "./process-image";
|
|
import { ditherDesign, generateColorSeparations, ALGORITHMS, type DitherAlgorithm } from "./dither";
|
|
import { generateMockup } from "./mockup";
|
|
import { submitFulfillment, parsePrintfulWebhook, parseProdigiWebhook, getFulfillmentStatus, getTrackingInfo, type FulfillmentRequest } from "./fulfillment";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import { renderLanding } from "./landing";
|
|
|
|
const routes = new Hono();
|
|
|
|
const ARTIFACTS_DIR = process.env.SWAG_ARTIFACTS_DIR || "/tmp/swag-artifacts";
|
|
|
|
// ── GET /api/products — List available product templates ──
|
|
routes.get("/api/products", (c) => {
|
|
const products = Object.values(PRODUCTS).map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
description: p.description,
|
|
printArea: p.printArea,
|
|
dpi: p.dpi,
|
|
bleedMm: p.bleedMm,
|
|
widthPx: p.widthPx,
|
|
heightPx: p.heightPx,
|
|
productType: p.productType,
|
|
substrates: p.substrates,
|
|
requiredCapabilities: p.requiredCapabilities,
|
|
finish: p.finish,
|
|
...(p.printful ? { printful: p.printful } : {}),
|
|
}));
|
|
return c.json({ products });
|
|
});
|
|
|
|
// ── POST /api/generate — Preview without persistence ──
|
|
routes.post("/api/generate", async (c) => {
|
|
try {
|
|
const formData = await c.req.formData();
|
|
const imageFile = formData.get("image") as File | null;
|
|
const productId = formData.get("product") as string | null;
|
|
|
|
if (!imageFile) return c.json({ error: "image file is required" }, 400);
|
|
if (!productId) return c.json({ error: "product is required (sticker, poster, tee)" }, 400);
|
|
|
|
const product = getProduct(productId);
|
|
if (!product) return c.json({ error: `Unknown product: ${productId}` }, 400);
|
|
|
|
const inputBuffer = Buffer.from(await imageFile.arrayBuffer());
|
|
const result = await processImage(inputBuffer, product);
|
|
|
|
return new Response(new Uint8Array(result.buffer), {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "image/png",
|
|
"Content-Disposition": `attachment; filename="${productId}-print-ready.png"`,
|
|
"Content-Length": String(result.sizeBytes),
|
|
"X-Width-Px": String(result.widthPx),
|
|
"X-Height-Px": String(result.heightPx),
|
|
"X-DPI": String(result.dpi),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("Generate error:", error);
|
|
return c.json({ error: error instanceof Error ? error.message : "Processing failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── POST /api/artifact — Create artifact with persistence ──
|
|
routes.post("/api/artifact", async (c) => {
|
|
try {
|
|
const formData = await c.req.formData();
|
|
const imageFile = formData.get("image") as File | null;
|
|
const productId = formData.get("product") as string | null;
|
|
|
|
const title = (formData.get("title") as string) || "Untitled Design";
|
|
const description = formData.get("description") as string | null;
|
|
const creatorId = formData.get("creator_id") as string | null;
|
|
const creatorName = formData.get("creator_name") as string | null;
|
|
const creatorWallet = formData.get("creator_wallet") as string | null;
|
|
const sourceSpace = formData.get("source_space") as string | null;
|
|
const license = formData.get("license") as string | null;
|
|
const tagsRaw = formData.get("tags") as string | null;
|
|
const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()) : [];
|
|
const creatorSharePct = parseFloat((formData.get("creator_share_pct") as string) || "25");
|
|
const communitySharePct = parseFloat((formData.get("community_share_pct") as string) || "10");
|
|
|
|
if (!imageFile) return c.json({ error: "image file is required" }, 400);
|
|
if (!productId || !getProduct(productId)) {
|
|
return c.json({ error: `product is required. Available: ${Object.keys(PRODUCTS).join(", ")}` }, 400);
|
|
}
|
|
|
|
const product = getProduct(productId)!;
|
|
const inputBuffer = Buffer.from(await imageFile.arrayBuffer());
|
|
const result = await processImage(inputBuffer, product);
|
|
|
|
// Store artifact
|
|
const artifactId = randomUUID();
|
|
const artifactDir = join(ARTIFACTS_DIR, artifactId);
|
|
await mkdir(artifactDir, { recursive: true });
|
|
|
|
const filename = `${productId}-print-ready.png`;
|
|
await writeFile(join(artifactDir, filename), result.buffer);
|
|
await writeFile(join(artifactDir, `original-${imageFile.name}`), inputBuffer);
|
|
|
|
// Build artifact envelope
|
|
const proto = c.req.header("x-forwarded-proto") || "https";
|
|
const host = c.req.header("host") || "rswag.online";
|
|
const baseUrl = `${proto}://${host}`;
|
|
const renderTargetKey = `${product.id}-${result.format}`;
|
|
|
|
// Build the URL — in unified mode, we're mounted under /:space/swag
|
|
const path = c.req.path.replace("/api/artifact", "");
|
|
const mountBase = c.req.url.includes("/swag/") ? c.req.url.split("/swag/")[0] + "/swag" : "";
|
|
const fileUrl = mountBase ? `${baseUrl}${mountBase}/api/artifact/${artifactId}/file` : `${baseUrl}/api/artifact/${artifactId}/file`;
|
|
|
|
const artifact = {
|
|
id: artifactId,
|
|
schema_version: "1.0",
|
|
type: "print-ready",
|
|
origin: "rswag.online",
|
|
source_space: sourceSpace || null,
|
|
creator: {
|
|
id: creatorId || "anonymous",
|
|
...(creatorName ? { name: creatorName } : {}),
|
|
...(creatorWallet ? { wallet: creatorWallet } : {}),
|
|
},
|
|
created_at: new Date().toISOString(),
|
|
...(license ? { license } : {}),
|
|
payload: {
|
|
title,
|
|
...(description ? { description } : {
|
|
description: `${product.name} design — ${result.widthPx}x${result.heightPx}px at ${result.dpi}dpi`,
|
|
}),
|
|
...(tags.length > 0 ? { tags } : {}),
|
|
},
|
|
spec: {
|
|
product_type: product.productType,
|
|
dimensions: {
|
|
width_mm: product.printArea.widthMm,
|
|
height_mm: product.printArea.heightMm,
|
|
bleed_mm: product.bleedMm,
|
|
},
|
|
color_space: "CMYK",
|
|
dpi: product.dpi,
|
|
binding: "none",
|
|
finish: product.finish,
|
|
substrates: product.substrates,
|
|
required_capabilities: product.requiredCapabilities,
|
|
},
|
|
render_targets: {
|
|
[renderTargetKey]: {
|
|
url: fileUrl,
|
|
format: result.format,
|
|
dpi: result.dpi,
|
|
file_size_bytes: result.sizeBytes,
|
|
notes: `${product.name} print-ready file. ${result.widthPx}x${result.heightPx}px.`,
|
|
},
|
|
},
|
|
pricing: {
|
|
creator_share_pct: creatorSharePct,
|
|
community_share_pct: communitySharePct,
|
|
},
|
|
next_actions: [
|
|
{ tool: "rcart.online", action: "list-for-sale", label: "Sell in community shop", endpoint: "/api/catalog/ingest", method: "POST" },
|
|
{ tool: "rswag.online", action: "edit-design", label: "Edit design", endpoint: "/editor", method: "GET" },
|
|
{ tool: "rfiles.online", action: "archive", label: "Save to files", endpoint: "/api/v1/files/import", method: "POST" },
|
|
],
|
|
};
|
|
|
|
await writeFile(join(artifactDir, "artifact.json"), JSON.stringify(artifact, null, 2));
|
|
|
|
return c.json(artifact, 201);
|
|
} catch (error) {
|
|
console.error("Artifact generation error:", error);
|
|
return c.json({ error: error instanceof Error ? error.message : "Artifact generation failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── GET /api/artifact/:id/file — Download render target ──
|
|
routes.get("/api/artifact/:id/file", async (c) => {
|
|
const id = c.req.param("id");
|
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
return c.json({ error: "Invalid artifact ID" }, 400);
|
|
}
|
|
|
|
const artifactDir = join(ARTIFACTS_DIR, id);
|
|
try { await stat(artifactDir); } catch { return c.json({ error: "Artifact not found" }, 404); }
|
|
|
|
const files = await readdir(artifactDir);
|
|
const printFile = files.find((f) => f.includes("print-ready") && (f.endsWith(".png") || f.endsWith(".tiff")));
|
|
if (!printFile) return c.json({ error: "Print file not found" }, 404);
|
|
|
|
const buffer = await readFile(join(artifactDir, printFile));
|
|
const contentType = printFile.endsWith(".tiff") ? "image/tiff" : "image/png";
|
|
|
|
return new Response(new Uint8Array(buffer), {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": contentType,
|
|
"Content-Disposition": `inline; filename="${printFile}"`,
|
|
"Content-Length": String(buffer.length),
|
|
},
|
|
});
|
|
});
|
|
|
|
// ── GET /api/artifact/:id — Get artifact metadata ──
|
|
routes.get("/api/artifact/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
return c.json({ error: "Invalid artifact ID" }, 400);
|
|
}
|
|
|
|
const artifactPath = join(ARTIFACTS_DIR, id, "artifact.json");
|
|
try {
|
|
const data = await readFile(artifactPath, "utf-8");
|
|
return c.json(JSON.parse(data));
|
|
} catch {
|
|
return c.json({ error: "Artifact not found" }, 404);
|
|
}
|
|
});
|
|
|
|
// ── Design storage (filesystem-based, per-slug) ──
|
|
|
|
const DESIGNS_DIR = process.env.SWAG_DESIGNS_DIR || join(ARTIFACTS_DIR, "designs");
|
|
|
|
interface DesignMeta {
|
|
slug: string;
|
|
name: string;
|
|
description: string;
|
|
tags: string[];
|
|
category: string;
|
|
author: string;
|
|
created: string;
|
|
source: "ai-generated" | "upload" | "artifact";
|
|
status: "draft" | "active" | "paused" | "removed";
|
|
imageFile: string;
|
|
products: { type: string; provider: string; sku: string; variants: string[]; retailPrice: number }[];
|
|
}
|
|
|
|
/** In-memory design index (loaded from filesystem) */
|
|
let designIndex: Map<string, DesignMeta> = new Map();
|
|
let designIndexLoaded = false;
|
|
|
|
async function loadDesignIndex() {
|
|
designIndex.clear();
|
|
try {
|
|
await stat(DESIGNS_DIR);
|
|
} catch {
|
|
await mkdir(DESIGNS_DIR, { recursive: true });
|
|
designIndexLoaded = true;
|
|
return;
|
|
}
|
|
|
|
const categories = await readdir(DESIGNS_DIR);
|
|
for (const cat of categories) {
|
|
const catDir = join(DESIGNS_DIR, cat);
|
|
try {
|
|
const catStat = await stat(catDir);
|
|
if (!catStat.isDirectory()) continue;
|
|
} catch { continue; }
|
|
|
|
const slugs = await readdir(catDir);
|
|
for (const slug of slugs) {
|
|
const metaPath = join(catDir, slug, "meta.json");
|
|
try {
|
|
const raw = await readFile(metaPath, "utf-8");
|
|
const meta: DesignMeta = JSON.parse(raw);
|
|
designIndex.set(slug, meta);
|
|
} catch { /* skip invalid designs */ }
|
|
}
|
|
}
|
|
designIndexLoaded = true;
|
|
}
|
|
|
|
async function ensureDesignIndex() {
|
|
if (!designIndexLoaded) await loadDesignIndex();
|
|
}
|
|
|
|
async function saveDesignMeta(meta: DesignMeta) {
|
|
const dir = join(DESIGNS_DIR, meta.category, meta.slug);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
|
|
designIndex.set(meta.slug, meta);
|
|
}
|
|
|
|
function getDesignImagePath(slug: string): string | null {
|
|
const meta = designIndex.get(slug);
|
|
if (!meta) return null;
|
|
return join(DESIGNS_DIR, meta.category, meta.slug, meta.imageFile);
|
|
}
|
|
|
|
function slugify(text: string): string {
|
|
return text.toLowerCase().trim()
|
|
.replace(/[^\w\s-]/g, "")
|
|
.replace(/[\s_-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
// ── POST /api/design/generate — AI-powered design generation ──
|
|
routes.post("/api/design/generate", async (c) => {
|
|
try {
|
|
const body = await c.req.json();
|
|
const { concept, name, tags = [], product_type = "sticker" } = body;
|
|
|
|
if (!concept || !name) {
|
|
return c.json({ error: "concept and name are required" }, 400);
|
|
}
|
|
|
|
const geminiKey = process.env.GEMINI_API_KEY;
|
|
if (!geminiKey) {
|
|
return c.json({ error: "AI generation not configured. Set GEMINI_API_KEY." }, 503);
|
|
}
|
|
|
|
let slug = slugify(name);
|
|
if (!slug) slug = `design-${randomUUID().slice(0, 8)}`;
|
|
|
|
await ensureDesignIndex();
|
|
if (designIndex.has(slug)) {
|
|
return c.json({ error: `Design '${slug}' already exists` }, 409);
|
|
}
|
|
|
|
// Build AI prompt
|
|
const stylePrompt = `A striking sticker design for "${name}".
|
|
${concept}
|
|
The design should have a clean, modern spatial-web aesthetic with interconnected
|
|
nodes, network patterns, and a collaborative/commons feel.
|
|
Colors: vibrant cyan, warm orange accents on dark background.
|
|
High contrast, suitable for vinyl sticker printing.
|
|
Square format, clean edges for die-cut sticker.`;
|
|
|
|
// Call Gemini API for image generation
|
|
const resp = await fetch(
|
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${geminiKey}`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
contents: [{ parts: [{ text: stylePrompt }] }],
|
|
generationConfig: { responseModalities: ["image", "text"] },
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!resp.ok) {
|
|
const errText = await resp.text();
|
|
return c.json({ error: `AI generation failed (${resp.status}): ${errText.slice(0, 300)}` }, 502);
|
|
}
|
|
|
|
const result = await resp.json();
|
|
let imageData: string | null = null;
|
|
|
|
for (const candidate of result.candidates || []) {
|
|
for (const part of candidate.content?.parts || []) {
|
|
if (part.inlineData?.data) {
|
|
imageData = part.inlineData.data;
|
|
break;
|
|
}
|
|
}
|
|
if (imageData) break;
|
|
}
|
|
|
|
if (!imageData) {
|
|
return c.json({ error: "AI did not return an image" }, 502);
|
|
}
|
|
|
|
// Save design
|
|
const imageBuffer = Buffer.from(imageData, "base64");
|
|
const category = product_type === "sticker" ? "stickers" : product_type === "shirt" || product_type === "tee" ? "apparel" : "prints";
|
|
|
|
const meta: DesignMeta = {
|
|
slug,
|
|
name,
|
|
description: concept,
|
|
tags: tags.length ? tags : ["rspace", "ai-generated"],
|
|
category,
|
|
author: "ai-generated",
|
|
created: new Date().toISOString().split("T")[0],
|
|
source: "ai-generated",
|
|
status: "draft",
|
|
imageFile: `${slug}.png`,
|
|
products: getPodProducts(product_type).map(p => ({
|
|
type: p.type, provider: p.provider, sku: p.sku,
|
|
variants: p.variants, retailPrice: p.retailPrice,
|
|
})),
|
|
};
|
|
|
|
await saveDesignMeta(meta);
|
|
const imgDir = join(DESIGNS_DIR, category, slug);
|
|
await writeFile(join(imgDir, `${slug}.png`), imageBuffer);
|
|
|
|
return c.json({
|
|
slug,
|
|
name,
|
|
image_url: `/api/designs/${slug}/image`,
|
|
status: "draft",
|
|
}, 201);
|
|
} catch (error) {
|
|
console.error("[rSwag] Design generation error:", error);
|
|
return c.json({ error: error instanceof Error ? error.message : "Generation failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── POST /api/design/upload — User artwork upload ──
|
|
routes.post("/api/design/upload", async (c) => {
|
|
try {
|
|
const formData = await c.req.formData();
|
|
const imageFile = formData.get("image") as File | null;
|
|
const name = (formData.get("name") as string) || "Untitled Upload";
|
|
const description = (formData.get("description") as string) || "";
|
|
const tagsRaw = formData.get("tags") as string | null;
|
|
const tags = tagsRaw ? tagsRaw.split(",").map(t => t.trim()) : [];
|
|
const productType = (formData.get("product_type") as string) || "sticker";
|
|
|
|
if (!imageFile) return c.json({ error: "image file is required" }, 400);
|
|
|
|
// Validate format
|
|
const validTypes = ["image/png", "image/jpeg", "image/webp"];
|
|
if (!validTypes.includes(imageFile.type)) {
|
|
return c.json({ error: `Invalid format. Accepted: PNG, JPEG, WebP` }, 400);
|
|
}
|
|
|
|
// Validate size (max 10MB)
|
|
if (imageFile.size > 10 * 1024 * 1024) {
|
|
return c.json({ error: "Image too large (max 10MB)" }, 400);
|
|
}
|
|
|
|
const inputBuffer = Buffer.from(await imageFile.arrayBuffer());
|
|
|
|
// Validate dimensions (min 500x500)
|
|
const metadata = await sharp(inputBuffer).metadata();
|
|
if (!metadata.width || !metadata.height || metadata.width < 500 || metadata.height < 500) {
|
|
return c.json({ error: "Image too small (minimum 500x500 pixels)" }, 400);
|
|
}
|
|
|
|
let slug = slugify(name);
|
|
if (!slug) slug = `upload-${randomUUID().slice(0, 8)}`;
|
|
|
|
await ensureDesignIndex();
|
|
// Deduplicate slug
|
|
let finalSlug = slug;
|
|
let counter = 1;
|
|
while (designIndex.has(finalSlug)) {
|
|
finalSlug = `${slug}-${counter++}`;
|
|
}
|
|
slug = finalSlug;
|
|
|
|
const category = productType === "sticker" ? "stickers" : productType === "tee" || productType === "shirt" ? "apparel" : "prints";
|
|
|
|
// Process to standard 1024x1024 PNG
|
|
const processed = await sharp(inputBuffer)
|
|
.resize(1024, 1024, { fit: "contain", background: { r: 255, g: 255, b: 255, alpha: 0 } })
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const meta: DesignMeta = {
|
|
slug,
|
|
name,
|
|
description,
|
|
tags,
|
|
category,
|
|
author: "upload",
|
|
created: new Date().toISOString().split("T")[0],
|
|
source: "upload",
|
|
status: "draft",
|
|
imageFile: `${slug}.png`,
|
|
products: getPodProducts(productType).map(p => ({
|
|
type: p.type, provider: p.provider, sku: p.sku,
|
|
variants: p.variants, retailPrice: p.retailPrice,
|
|
})),
|
|
};
|
|
|
|
await saveDesignMeta(meta);
|
|
const imgDir = join(DESIGNS_DIR, category, slug);
|
|
await writeFile(join(imgDir, `${slug}.png`), processed);
|
|
|
|
return c.json({
|
|
slug,
|
|
name,
|
|
image_url: `/api/designs/${slug}/image`,
|
|
status: "draft",
|
|
}, 201);
|
|
} catch (error) {
|
|
console.error("[rSwag] Upload error:", error);
|
|
return c.json({ error: error instanceof Error ? error.message : "Upload failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── POST /api/design/:slug/activate — Publish draft design ──
|
|
routes.post("/api/design/:slug/activate", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
await ensureDesignIndex();
|
|
const meta = designIndex.get(slug);
|
|
if (!meta) return c.json({ error: "Design not found" }, 404);
|
|
|
|
meta.status = "active";
|
|
await saveDesignMeta(meta);
|
|
return c.json({ status: "activated", slug });
|
|
});
|
|
|
|
// ── DELETE /api/design/:slug — Delete draft design ──
|
|
routes.delete("/api/design/:slug", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
await ensureDesignIndex();
|
|
const meta = designIndex.get(slug);
|
|
if (!meta) return c.json({ error: "Design not found" }, 404);
|
|
|
|
if (meta.status === "active") {
|
|
return c.json({ error: "Cannot delete active designs. Set to draft first." }, 400);
|
|
}
|
|
|
|
const designDir = join(DESIGNS_DIR, meta.category, slug);
|
|
try { await rm(designDir, { recursive: true }); } catch { /* ok */ }
|
|
designIndex.delete(slug);
|
|
|
|
return c.json({ status: "deleted", slug });
|
|
});
|
|
|
|
// ── GET /api/designs — List designs with filtering ──
|
|
routes.get("/api/designs", async (c) => {
|
|
await ensureDesignIndex();
|
|
const statusFilter = c.req.query("status");
|
|
const categoryFilter = c.req.query("category");
|
|
const search = c.req.query("q")?.toLowerCase();
|
|
|
|
let designs = Array.from(designIndex.values());
|
|
|
|
if (statusFilter) designs = designs.filter(d => d.status === statusFilter);
|
|
if (categoryFilter) designs = designs.filter(d => d.category === categoryFilter);
|
|
if (search) {
|
|
designs = designs.filter(d =>
|
|
d.name.toLowerCase().includes(search) ||
|
|
d.description.toLowerCase().includes(search) ||
|
|
d.tags.some(t => t.toLowerCase().includes(search)),
|
|
);
|
|
}
|
|
|
|
return c.json({
|
|
designs: designs.map(d => ({
|
|
slug: d.slug,
|
|
name: d.name,
|
|
description: d.description,
|
|
category: d.category,
|
|
tags: d.tags,
|
|
status: d.status,
|
|
source: d.source,
|
|
image_url: `/api/designs/${d.slug}/image`,
|
|
products: d.products,
|
|
created: d.created,
|
|
})),
|
|
});
|
|
});
|
|
|
|
// ── GET /api/designs/:slug — Single design metadata ──
|
|
routes.get("/api/designs/:slug", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
await ensureDesignIndex();
|
|
const meta = designIndex.get(slug);
|
|
if (!meta) return c.json({ error: "Design not found" }, 404);
|
|
|
|
return c.json({
|
|
slug: meta.slug,
|
|
name: meta.name,
|
|
description: meta.description,
|
|
category: meta.category,
|
|
tags: meta.tags,
|
|
status: meta.status,
|
|
source: meta.source,
|
|
image_url: `/api/designs/${slug}/image`,
|
|
products: meta.products,
|
|
created: meta.created,
|
|
});
|
|
});
|
|
|
|
// ── GET /api/designs/:slug/image — Serve design PNG ──
|
|
routes.get("/api/designs/:slug/image", async (c) => {
|
|
await ensureDesignIndex();
|
|
const imagePath = getDesignImagePath(c.req.param("slug"));
|
|
if (!imagePath) return c.json({ error: "Design not found" }, 404);
|
|
|
|
try {
|
|
const buffer = await readFile(imagePath);
|
|
return new Response(new Uint8Array(buffer), {
|
|
headers: {
|
|
"Content-Type": "image/png",
|
|
"Cache-Control": "public, max-age=86400",
|
|
"Content-Length": String(buffer.length),
|
|
},
|
|
});
|
|
} catch {
|
|
return c.json({ error: "Image file not found" }, 404);
|
|
}
|
|
});
|
|
|
|
// ── GET /api/designs/:slug/mockup — Photorealistic mockup ──
|
|
routes.get("/api/designs/:slug/mockup", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
const type = c.req.query("type") || "shirt";
|
|
const fresh = c.req.query("fresh") === "true";
|
|
|
|
await ensureDesignIndex();
|
|
const imagePath = getDesignImagePath(slug);
|
|
if (!imagePath) return c.json({ error: "Design not found" }, 404);
|
|
|
|
try {
|
|
const designBuffer = await readFile(imagePath);
|
|
const meta = designIndex.get(slug);
|
|
|
|
// Find Printful SKU if available
|
|
const printfulProduct = meta?.products?.find(p => p.provider === "printful");
|
|
const printfulSku = printfulProduct ? parseInt(printfulProduct.sku, 10) : undefined;
|
|
|
|
// Build public URL for Printful API (needs accessible URL)
|
|
const proto = c.req.header("x-forwarded-proto") || "https";
|
|
const host = c.req.header("host") || "rswag.online";
|
|
const designImageUrl = printfulSku ? `${proto}://${host}/api/designs/${slug}/image` : undefined;
|
|
|
|
const mockup = await generateMockup(designBuffer, type, {
|
|
printfulSku,
|
|
designImageUrl,
|
|
fresh,
|
|
});
|
|
|
|
return new Response(new Uint8Array(mockup), {
|
|
headers: {
|
|
"Content-Type": "image/png",
|
|
"Cache-Control": "public, max-age=86400",
|
|
"Content-Length": String(mockup.length),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("[rSwag] Mockup error:", error);
|
|
return c.json({ error: "Mockup generation failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── GET /api/designs/:slug/dither — Dithered PNG ──
|
|
routes.get("/api/designs/:slug/dither", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
const algorithm = (c.req.query("algorithm") || "floyd-steinberg") as DitherAlgorithm;
|
|
const numColors = parseInt(c.req.query("num_colors") || "8", 10);
|
|
const customColors = c.req.query("colors")?.split(",").filter(Boolean);
|
|
const threshold = parseInt(c.req.query("threshold") || "64", 10);
|
|
const order = parseInt(c.req.query("order") || "3", 10);
|
|
const format = c.req.query("format") || "image";
|
|
|
|
if (!ALGORITHMS.includes(algorithm)) {
|
|
return c.json({ error: `Unknown algorithm. Available: ${ALGORITHMS.join(", ")}` }, 400);
|
|
}
|
|
|
|
await ensureDesignIndex();
|
|
const imagePath = getDesignImagePath(slug);
|
|
if (!imagePath) return c.json({ error: "Design not found" }, 404);
|
|
|
|
try {
|
|
const imageBuffer = await readFile(imagePath);
|
|
const result = await ditherDesign(imageBuffer, slug, {
|
|
algorithm,
|
|
numColors: Math.min(Math.max(numColors, 2), 32),
|
|
customColors,
|
|
threshold,
|
|
order,
|
|
});
|
|
|
|
if (format === "json") {
|
|
return c.json({
|
|
slug,
|
|
algorithm: result.algorithm,
|
|
palette_mode: result.paletteMode,
|
|
num_colors: result.numColors,
|
|
colors_used: result.colorsUsed,
|
|
cached: result.cached,
|
|
image_url: `/api/designs/${slug}/dither?algorithm=${algorithm}&num_colors=${numColors}`,
|
|
});
|
|
}
|
|
|
|
return new Response(new Uint8Array(result.buffer), {
|
|
headers: {
|
|
"Content-Type": "image/png",
|
|
"Content-Disposition": `inline; filename="${slug}-${algorithm}.png"`,
|
|
"X-Colors-Used": result.colorsUsed.join(","),
|
|
"X-Algorithm": result.algorithm,
|
|
"X-Cached": String(result.cached),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("[rSwag] Dither error:", error);
|
|
return c.json({ error: "Dithering failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── POST /api/designs/:slug/screen-print — Color separation PNGs ──
|
|
routes.post("/api/designs/:slug/screen-print", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
|
|
await ensureDesignIndex();
|
|
const imagePath = getDesignImagePath(slug);
|
|
if (!imagePath) return c.json({ error: "Design not found" }, 404);
|
|
|
|
try {
|
|
const body = await c.req.json();
|
|
const numColors = Math.min(Math.max(body.num_colors || 4, 2), 16);
|
|
const algorithm = (body.algorithm || "floyd-steinberg") as DitherAlgorithm;
|
|
const spotColors = body.spot_colors as string[] | undefined;
|
|
|
|
const imageBuffer = await readFile(imagePath);
|
|
const result = await generateColorSeparations(
|
|
imageBuffer, slug, numColors, algorithm, spotColors,
|
|
);
|
|
|
|
const separationUrls: Record<string, string> = {};
|
|
for (const color of result.colors) {
|
|
separationUrls[color] = `/api/designs/${slug}/screen-print/${color}`;
|
|
}
|
|
|
|
// Cache separations for the GET endpoint
|
|
screenPrintCache.set(slug, result);
|
|
|
|
return c.json({
|
|
slug,
|
|
num_colors: result.colors.length,
|
|
algorithm,
|
|
colors: result.colors,
|
|
composite_url: `/api/designs/${slug}/screen-print/composite`,
|
|
separation_urls: separationUrls,
|
|
});
|
|
} catch (error) {
|
|
console.error("[rSwag] Screen-print error:", error);
|
|
return c.json({ error: "Color separation failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// Screen-print separation cache (slug → result)
|
|
const screenPrintCache = new Map<string, { composite: Buffer; separations: Map<string, Buffer>; colors: string[] }>();
|
|
|
|
// ── GET /api/designs/:slug/screen-print/:channel — Serve separation channel ──
|
|
routes.get("/api/designs/:slug/screen-print/:channel", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
const channel = c.req.param("channel");
|
|
|
|
const cached = screenPrintCache.get(slug);
|
|
if (!cached) return c.json({ error: "Run POST screen-print first" }, 404);
|
|
|
|
let buffer: Buffer;
|
|
if (channel === "composite") {
|
|
buffer = cached.composite;
|
|
} else {
|
|
const sep = cached.separations.get(channel.toUpperCase());
|
|
if (!sep) return c.json({ error: `Channel ${channel} not found` }, 404);
|
|
buffer = sep;
|
|
}
|
|
|
|
return new Response(new Uint8Array(buffer), {
|
|
headers: {
|
|
"Content-Type": "image/png",
|
|
"Content-Disposition": `inline; filename="${slug}-${channel}.png"`,
|
|
},
|
|
});
|
|
});
|
|
|
|
// ── GET /api/storefront — Storefront product listing ──
|
|
routes.get("/api/storefront", async (c) => {
|
|
await ensureDesignIndex();
|
|
|
|
const products: StorefrontProduct[] = [];
|
|
|
|
for (const meta of designIndex.values()) {
|
|
if (meta.status !== "active") continue;
|
|
|
|
for (const prod of meta.products) {
|
|
products.push({
|
|
slug: `${meta.slug}-${prod.type}`,
|
|
designSlug: meta.slug,
|
|
name: `${meta.name} ${prod.type}`,
|
|
description: meta.description,
|
|
category: meta.category,
|
|
productType: prod.type,
|
|
imageUrl: `/api/designs/${meta.slug}/image`,
|
|
basePrice: prod.retailPrice,
|
|
variants: prod.variants,
|
|
provider: prod.provider,
|
|
sku: prod.sku,
|
|
isActive: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return c.json({ products });
|
|
});
|
|
|
|
// ── GET /api/storefront/:slug — Single storefront product ──
|
|
routes.get("/api/storefront/:slug", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
await ensureDesignIndex();
|
|
|
|
// Parse "designSlug-productType" format
|
|
for (const meta of designIndex.values()) {
|
|
for (const prod of meta.products) {
|
|
const prodSlug = `${meta.slug}-${prod.type}`;
|
|
if (prodSlug === slug) {
|
|
return c.json({
|
|
slug: prodSlug,
|
|
designSlug: meta.slug,
|
|
name: `${meta.name} ${prod.type}`,
|
|
description: meta.description,
|
|
category: meta.category,
|
|
productType: prod.type,
|
|
imageUrl: `/api/designs/${meta.slug}/image`,
|
|
mockupUrl: `/api/designs/${meta.slug}/mockup?type=${prod.type}`,
|
|
basePrice: prod.retailPrice,
|
|
variants: prod.variants,
|
|
provider: prod.provider,
|
|
sku: prod.sku,
|
|
isActive: meta.status === "active",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.json({ error: "Product not found" }, 404);
|
|
});
|
|
|
|
// ── POST /api/fulfill/submit — Submit rCart order to POD provider ──
|
|
routes.post("/api/fulfill/submit", async (c) => {
|
|
try {
|
|
const body = await c.req.json() as FulfillmentRequest;
|
|
if (!body.orderId || !body.items?.length || !body.recipient) {
|
|
return c.json({ error: "orderId, items, and recipient are required" }, 400);
|
|
}
|
|
|
|
const results = await submitFulfillment(body);
|
|
return c.json({ results }, 201);
|
|
} catch (error) {
|
|
console.error("[rSwag] Fulfillment error:", error);
|
|
return c.json({ error: error instanceof Error ? error.message : "Fulfillment failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── POST /api/webhooks/printful — Printful shipment webhook ──
|
|
routes.post("/api/webhooks/printful", async (c) => {
|
|
try {
|
|
const body = await c.req.json();
|
|
const event = parsePrintfulWebhook(body);
|
|
if (!event) return c.json({ error: "Invalid webhook" }, 400);
|
|
|
|
console.log(`[rSwag] Printful webhook: ${event.type} for order ${event.orderId}`);
|
|
|
|
// TODO: push tracking update to rCart order doc
|
|
return c.json({ received: true, type: event.type });
|
|
} catch {
|
|
return c.json({ error: "Webhook processing failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── POST /api/webhooks/prodigi — Prodigi shipment webhook ──
|
|
routes.post("/api/webhooks/prodigi", async (c) => {
|
|
try {
|
|
const body = await c.req.json();
|
|
const event = parseProdigiWebhook(body);
|
|
if (!event) return c.json({ error: "Invalid webhook" }, 400);
|
|
|
|
console.log(`[rSwag] Prodigi webhook: ${event.type} for order ${event.orderId}`);
|
|
|
|
// TODO: push tracking update to rCart order doc
|
|
return c.json({ received: true, type: event.type });
|
|
} catch {
|
|
return c.json({ error: "Webhook processing failed" }, 500);
|
|
}
|
|
});
|
|
|
|
// ── GET /api/orders/:id/tracking — Tracking info lookup ──
|
|
routes.get("/api/orders/:id/tracking", async (c) => {
|
|
const orderId = c.req.param("id");
|
|
const fulfillments = getFulfillmentStatus(orderId);
|
|
if (!fulfillments) return c.json({ error: "Order not found" }, 404);
|
|
|
|
const tracking = [];
|
|
for (const f of fulfillments) {
|
|
const info = await getTrackingInfo(f.provider, f.providerOrderId);
|
|
tracking.push({
|
|
provider: f.provider,
|
|
providerOrderId: f.providerOrderId,
|
|
status: f.status,
|
|
tracking: info,
|
|
});
|
|
}
|
|
|
|
return c.json({ orderId, fulfillments: tracking });
|
|
});
|
|
|
|
// ── GET /api/dither/algorithms — List available dithering algorithms ──
|
|
routes.get("/api/dither/algorithms", (c) => {
|
|
return c.json({ algorithms: ALGORITHMS });
|
|
});
|
|
|
|
// ── POST /api/admin/designs/sync — Reload designs from storage ──
|
|
routes.post("/api/admin/designs/sync", async (c) => {
|
|
designIndexLoaded = false;
|
|
await loadDesignIndex();
|
|
return c.json({ synced: true, count: designIndex.size });
|
|
});
|
|
|
|
// ── PUT /api/admin/products/:slug/override — Price/visibility override ──
|
|
routes.put("/api/admin/products/:slug/override", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
await ensureDesignIndex();
|
|
const meta = designIndex.get(slug);
|
|
if (!meta) return c.json({ error: "Design not found" }, 404);
|
|
|
|
const body = await c.req.json();
|
|
if (body.status) meta.status = body.status;
|
|
if (body.products) meta.products = body.products;
|
|
await saveDesignMeta(meta);
|
|
|
|
return c.json({ updated: true, slug });
|
|
});
|
|
|
|
// ── GET /api/admin/analytics/summary — Sales metrics ──
|
|
routes.get("/api/admin/analytics/summary", async (c) => {
|
|
await ensureDesignIndex();
|
|
|
|
const totalDesigns = designIndex.size;
|
|
const activeDesigns = Array.from(designIndex.values()).filter(d => d.status === "active").length;
|
|
const draftDesigns = Array.from(designIndex.values()).filter(d => d.status === "draft").length;
|
|
|
|
// Count artifacts
|
|
let totalArtifacts = 0;
|
|
try {
|
|
const dirs = await readdir(ARTIFACTS_DIR);
|
|
totalArtifacts = dirs.filter(d => d.match(/^[0-9a-f-]{36}$/)).length;
|
|
} catch { /* ok */ }
|
|
|
|
return c.json({
|
|
totalDesigns,
|
|
activeDesigns,
|
|
draftDesigns,
|
|
totalArtifacts,
|
|
designsByCategory: Array.from(designIndex.values()).reduce((acc, d) => {
|
|
acc[d.category] = (acc[d.category] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>),
|
|
designsBySource: Array.from(designIndex.values()).reduce((acc, d) => {
|
|
acc[d.source] = (acc[d.source] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>),
|
|
});
|
|
});
|
|
|
|
// ── Page route: swag designer ──
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = c.get("effectiveSpace") || space;
|
|
return c.html(renderShell({
|
|
title: `Swag Designer | rSpace`,
|
|
moduleId: "rswag",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-swag-designer space="${space}"></folk-swag-designer>`,
|
|
scripts: `<script type="module" src="/modules/rswag/folk-swag-designer.js?v=3"></script>
|
|
<script type="module" src="/modules/rswag/folk-revenue-sankey.js?v=1"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rswag/swag.css">`,
|
|
}));
|
|
});
|
|
|
|
export const swagModule: RSpaceModule = {
|
|
id: "rswag",
|
|
name: "rSwag",
|
|
icon: "🎨",
|
|
description: "Design print-ready swag: stickers, posters, tees",
|
|
scoping: { defaultScope: 'global', userConfigurable: true },
|
|
routes,
|
|
landingPage: renderLanding,
|
|
standaloneDomain: "rswag.online",
|
|
feeds: [
|
|
{
|
|
id: "designs",
|
|
name: "Designs",
|
|
kind: "resource",
|
|
description: "Print-ready design artifacts for stickers, posters, and tees",
|
|
filterable: true,
|
|
},
|
|
{
|
|
id: "merch-orders",
|
|
name: "Merch Orders",
|
|
kind: "economic",
|
|
description: "Merchandise order events and fulfillment tracking",
|
|
},
|
|
],
|
|
acceptsFeeds: ["economic", "resource"],
|
|
outputPaths: [
|
|
{ path: "merchandise", name: "Merchandise", icon: "👕", description: "Print-ready swag designs" },
|
|
],
|
|
};
|