/**
* 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 } from "node:fs/promises";
import { join } from "node:path";
import { getProduct, PRODUCTS } from "./products";
import { processImage } from "./process-image";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
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,
}));
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") || "swag.mycofi.earth";
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: "swag.mycofi.earth",
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: "swag.mycofi.earth", 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);
}
});
// ── Page route: swag designer ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Swag Designer | rSpace`,
moduleId: "swag",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "light",
styles: ``,
body: ``,
scripts: ``,
}));
});
export const swagModule: RSpaceModule = {
id: "swag",
name: "Swag",
icon: "\u{1F3A8}",
description: "Design print-ready swag: stickers, posters, tees",
routes,
standaloneDomain: "swag.mycofi.earth",
};