/** * 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", };