251 lines
9.1 KiB
TypeScript
251 lines
9.1 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 } 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: `<link rel="stylesheet" href="/modules/swag/swag.css">`,
|
|
body: `<folk-swag-designer></folk-swag-designer>`,
|
|
scripts: `<script type="module" src="/modules/swag/folk-swag-designer.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const swagModule: RSpaceModule = {
|
|
id: "swag",
|
|
name: "Swag",
|
|
icon: "\u{1F3A8}",
|
|
description: "Design print-ready swag: stickers, posters, tees",
|
|
routes,
|
|
standaloneDomain: "swag.mycofi.earth",
|
|
};
|