rspace-online/modules/swag/components/folk-swag-designer.ts

198 lines
8.3 KiB
TypeScript

/**
* <folk-swag-designer> — upload artwork → generate print-ready files.
* Product selector (sticker, poster, tee), image upload with preview,
* generate button, artifact result display with download link.
*/
class FolkSwagDesigner extends HTMLElement {
private shadow: ShadowRoot;
private selectedProduct = "sticker";
private imageFile: File | null = null;
private imagePreview = "";
private title = "";
private generating = false;
private artifact: any = null;
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/swag` : "/demo/swag";
}
private async generate() {
if (!this.imageFile || this.generating) return;
this.generating = true;
this.error = "";
this.artifact = null;
this.render();
try {
const formData = new FormData();
formData.append("image", this.imageFile);
formData.append("product", this.selectedProduct);
formData.append("title", this.title || "Untitled Design");
const res = await fetch(`${this.getApiBase()}/api/artifact`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `Failed: ${res.status}`);
}
this.artifact = await res.json();
} catch (e) {
this.error = e instanceof Error ? e.message : "Generation failed";
} finally {
this.generating = false;
this.render();
}
}
private render() {
const products = [
{ id: "sticker", name: "Sticker Sheet", icon: "\u{1F4CB}", desc: "A4 vinyl stickers" },
{ id: "poster", name: "Poster (A3)", icon: "\u{1F5BC}", desc: "A3 art print" },
{ id: "tee", name: "T-Shirt", icon: "\u{1F455}", desc: "12x16\" DTG print" },
];
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; max-width: 900px; margin: 0 auto; }
h2 { color: #f1f5f9; margin: 0 0 1.5rem; font-size: 1.5rem; }
.products { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1.5rem; }
.product { padding: 1rem; border-radius: 12px; border: 2px solid #334155; background: #1e293b; cursor: pointer; text-align: center; transition: all 0.15s; }
.product:hover { border-color: #475569; }
.product.active { border-color: #6366f1; background: rgba(99,102,241,0.1); }
.product-icon { font-size: 2rem; margin-bottom: 0.375rem; }
.product-name { color: #f1f5f9; font-weight: 600; font-size: 0.875rem; }
.product-desc { color: #64748b; font-size: 0.75rem; margin-top: 0.25rem; }
.upload-area { border: 2px dashed #334155; border-radius: 12px; padding: 2rem; text-align: center; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.15s; background: #1e293b; }
.upload-area:hover { border-color: #6366f1; }
.upload-area.has-image { border-style: solid; border-color: #475569; }
.upload-label { color: #94a3b8; font-size: 0.875rem; }
.preview-img { max-width: 200px; max-height: 200px; border-radius: 8px; }
.title-input { width: 100%; padding: 0.625rem 0.75rem; border: 1px solid #334155; border-radius: 8px; background: #1e293b; color: #f1f5f9; font-size: 0.875rem; margin-bottom: 1rem; box-sizing: border-box; }
.title-input:focus { outline: none; border-color: #6366f1; }
.generate-btn { width: 100%; padding: 0.75rem; border: none; border-radius: 8px; background: #4f46e5; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; margin-bottom: 1rem; }
.generate-btn:hover { background: #4338ca; }
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.error { background: rgba(239,68,68,0.1); border: 1px solid #ef4444; border-radius: 8px; padding: 0.75rem; color: #fca5a5; font-size: 0.875rem; margin-bottom: 1rem; }
.result { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.result-title { color: #f1f5f9; font-weight: 600; margin: 0 0 0.5rem; }
.result-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 1rem; }
.result-actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.result-btn { padding: 0.5rem 1rem; border-radius: 8px; font-size: 0.875rem; text-decoration: none; font-weight: 500; cursor: pointer; border: none; }
.result-btn-primary { background: #4f46e5; color: #fff; }
.result-btn-primary:hover { background: #4338ca; }
.result-btn-secondary { background: #334155; color: #f1f5f9; }
.result-btn-secondary:hover { background: #475569; }
.json-toggle { color: #818cf8; cursor: pointer; font-size: 0.75rem; margin-top: 0.75rem; display: inline-block; }
.json-pre { background: #0f172a; border-radius: 8px; padding: 0.75rem; overflow-x: auto; font-size: 0.6875rem; color: #94a3b8; margin-top: 0.5rem; max-height: 300px; display: none; }
.json-pre.visible { display: block; }
input[type="file"] { display: none; }
</style>
<h2>\u{1F3A8} Swag Designer</h2>
<div class="products">
${products.map((p) => `
<div class="product ${this.selectedProduct === p.id ? 'active' : ''}" data-product="${p.id}">
<div class="product-icon">${p.icon}</div>
<div class="product-name">${p.name}</div>
<div class="product-desc">${p.desc}</div>
</div>
`).join("")}
</div>
<div class="upload-area ${this.imagePreview ? 'has-image' : ''}">
${this.imagePreview
? `<img class="preview-img" src="${this.imagePreview}" alt="Preview">`
: `<div class="upload-label">\u{1F4C1} Click or drag to upload artwork (PNG, JPG, SVG)</div>`}
<input type="file" accept="image/*">
</div>
<input class="title-input" type="text" placeholder="Design title" value="${this.esc(this.title)}">
<button class="generate-btn" ${!this.imageFile || this.generating ? 'disabled' : ''}>
${this.generating ? '\u23F3 Generating...' : '\u{1F680} Generate Print-Ready Files'}
</button>
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.artifact ? `
<div class="result">
<h3 class="result-title">\u2705 ${this.esc(this.artifact.payload?.title || "Artifact")}</h3>
<div class="result-meta">
${this.esc(this.artifact.spec?.product_type || "")} \u2022
${this.artifact.spec?.dimensions?.width_mm}x${this.artifact.spec?.dimensions?.height_mm}mm \u2022
${this.artifact.spec?.dpi}dpi
</div>
<div class="result-actions">
${Object.entries(this.artifact.render_targets || {}).map(([key, target]: [string, any]) => `
<a class="result-btn result-btn-primary" href="${target.url}" target="_blank">\u{2B07} Download ${target.format.toUpperCase()}</a>
`).join("")}
<button class="result-btn result-btn-secondary" data-action="copy-json">\u{1F4CB} Copy Artifact JSON</button>
</div>
<span class="json-toggle">Show artifact envelope \u25BC</span>
<pre class="json-pre">${this.esc(JSON.stringify(this.artifact, null, 2))}</pre>
</div>` : ""}
`;
// Event listeners
this.shadow.querySelectorAll(".product").forEach((el) => {
el.addEventListener("click", () => {
this.selectedProduct = (el as HTMLElement).dataset.product || "sticker";
this.render();
});
});
const uploadArea = this.shadow.querySelector(".upload-area");
const fileInput = this.shadow.querySelector('input[type="file"]') as HTMLInputElement;
uploadArea?.addEventListener("click", () => fileInput?.click());
fileInput?.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) {
this.imageFile = file;
this.imagePreview = URL.createObjectURL(file);
this.render();
}
});
this.shadow.querySelector(".title-input")?.addEventListener("input", (e) => {
this.title = (e.target as HTMLInputElement).value;
});
this.shadow.querySelector(".generate-btn")?.addEventListener("click", () => this.generate());
this.shadow.querySelector(".json-toggle")?.addEventListener("click", () => {
const pre = this.shadow.querySelector(".json-pre");
pre?.classList.toggle("visible");
});
this.shadow.querySelector('[data-action="copy-json"]')?.addEventListener("click", () => {
navigator.clipboard.writeText(JSON.stringify(this.artifact, null, 2));
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-swag-designer", FolkSwagDesigner);