import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; const styles = css` :host { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); min-width: 420px; min-height: 520px; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: linear-gradient(135deg, #f59e0b, #ec4899); color: white; border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions { display: flex; gap: 4px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .content { display: flex; flex-direction: column; height: calc(100% - 36px); overflow-y: auto; } .section { padding: 12px; border-bottom: 1px solid #e2e8f0; } .section-label { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } /* Brand references */ .upload-zone { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 16px; text-align: center; cursor: pointer; font-size: 12px; color: #94a3b8; transition: border-color 0.2s; } .upload-zone:hover, .upload-zone.drag-over { border-color: #f59e0b; color: #f59e0b; } .thumb-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .thumb { position: relative; width: 60px; height: 60px; border-radius: 6px; overflow: hidden; } .thumb img { width: 100%; height: 100%; object-fit: cover; } .thumb .remove-btn { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; background: rgba(0,0,0,0.6); color: white; border: none; border-radius: 50%; font-size: 10px; line-height: 16px; text-align: center; cursor: pointer; padding: 0; } .analyze-btn, .generate-btn { padding: 8px 16px; background: linear-gradient(135deg, #f59e0b, #ec4899); color: white; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; } .analyze-btn:hover, .generate-btn:hover { opacity: 0.9; } .analyze-btn:disabled, .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; } /* Style analysis display */ .style-display { margin-top: 8px; padding: 10px; background: #fefce8; border-radius: 6px; font-size: 12px; } .palette { display: flex; gap: 4px; margin: 6px 0; } .swatch { width: 24px; height: 24px; border-radius: 4px; border: 1px solid #e2e8f0; } .keywords { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; } .keyword-tag { padding: 2px 8px; background: #fef3c7; border-radius: 12px; font-size: 11px; color: #92400e; } .brand-prefix { width: 100%; padding: 6px 8px; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 11px; resize: vertical; font-family: inherit; min-height: 40px; } /* Source + Controls */ .source-preview { margin-top: 8px; } .source-preview img { max-width: 100%; max-height: 150px; border-radius: 6px; } .prompt-input { width: 100%; padding: 10px 12px; border: 2px solid #e2e8f0; border-radius: 8px; font-size: 13px; resize: none; outline: none; font-family: inherit; margin-top: 8px; } .prompt-input:focus { border-color: #ec4899; } .controls { display: flex; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap; } .provider-select { padding: 6px 10px; border: 2px solid #e2e8f0; border-radius: 6px; font-size: 12px; background: white; cursor: pointer; } .strength-group { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #64748b; } .strength-slider { width: 80px; } .brand-toggle { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #64748b; } /* Results */ .image-area { flex: 1; padding: 12px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; } .placeholder { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; text-align: center; gap: 8px; padding: 24px; } .placeholder-icon { font-size: 48px; opacity: 0.5; } .image-item { position: relative; } .generated-image { width: 100%; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .image-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #64748b; margin-top: 4px; padding: 4px 8px; background: #f1f5f9; border-radius: 4px; } .provider-badge { padding: 1px 6px; border-radius: 8px; font-size: 10px; font-weight: 600; color: white; } .provider-badge.fal { background: #8b5cf6; } .provider-badge.gemini { background: #0ea5e9; } .loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 24px; gap: 12px; } .spinner { width: 32px; height: 32px; border: 3px solid #e2e8f0; border-top-color: #ec4899; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .error { color: #ef4444; padding: 12px; background: #fef2f2; border-radius: 6px; font-size: 13px; } `; interface StyleAnalysis { style_description: string; color_palette: string[]; style_keywords: string[]; brand_prompt_prefix: string; } interface StudioResult { id: string; prompt: string; url: string; provider: string; timestamp: string; } declare global { interface HTMLElementTagNameMap { "folk-image-studio": FolkImageStudio; } } export class FolkImageStudio extends FolkShape { static override tagName = "folk-image-studio"; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules) .map((r) => r.cssText) .join("\n"); const childRules = Array.from(styles.cssRules) .map((r) => r.cssText) .join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #brandReferences: string[] = []; // server paths or data URLs #styleAnalysis: StyleAnalysis | null = null; #sourceImage: string | null = null; #results: StudioResult[] = []; #provider: string = "fal"; #strength = 0.85; #applyBrandStyle = true; #isAnalyzing = false; #isGenerating = false; #error: string | null = null; // DOM refs #wrapper: HTMLElement | null = null; #refUploadInput: HTMLInputElement | null = null; #sourceUploadInput: HTMLInputElement | null = null; #promptInput: HTMLTextAreaElement | null = null; #brandPrefixInput: HTMLTextAreaElement | null = null; override createRenderRoot() { const root = super.createRenderRoot(); this.#wrapper = document.createElement("div"); this.#wrapper.innerHTML = this.#renderAll(); const slot = root.querySelector("slot"); const containerDiv = slot?.parentElement as HTMLElement; if (containerDiv) containerDiv.replaceWith(this.#wrapper); this.#bindEvents(); return root; } #renderAll(): string { return html`
🖌️ Image Studio
Click or drag images to add brand references
${this.#renderThumbs()} ${this.#brandReferences.length >= 1 ? `
` : ""} ${this.#renderStyleDisplay()}
${this.#sourceImage ? `
` : "Click or drag a source image"}
${this.#strength}
${this.#error ? `
${this.#escapeHtml(this.#error)}
` : ""} ${this.#isGenerating ? '
Generating image...
' : ""} ${this.#results.length > 0 ? this.#renderImageList() : (!this.#isGenerating ? '
🖌️Upload a source image and generate
' : "")}
`; } #renderThumbs(): string { if (!this.#brandReferences.length) return ""; return `
${this.#brandReferences .map( (ref, i) => `
` ) .join("")}
`; } #renderStyleDisplay(): string { if (!this.#styleAnalysis) return ""; const s = this.#styleAnalysis; return `
Style Analysis
${this.#escapeHtml(s.style_description)}
${s.color_palette.map((c) => `
`).join("")}
${s.style_keywords.map((k) => `${this.#escapeHtml(k)}`).join("")}
Brand Prompt Prefix
`; } #renderImageList(): string { return this.#results .map( (r) => `
${this.#escapeHtml(r.provider)} ${this.#escapeHtml(r.prompt)}
` ) .join(""); } #rerender() { if (!this.#wrapper) return; this.#wrapper.innerHTML = this.#renderAll(); this.#bindEvents(); } #bindEvents() { if (!this.#wrapper) return; const w = this.#wrapper; this.#refUploadInput = w.querySelector(".ref-upload-input"); this.#sourceUploadInput = w.querySelector(".source-upload-input"); this.#promptInput = w.querySelector(".prompt-input"); this.#brandPrefixInput = w.querySelector(".brand-prefix"); // Close w.querySelector(".close-btn")?.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Ref upload zone const refZone = w.querySelector(".ref-upload-zone") as HTMLElement | null; refZone?.addEventListener("click", (e) => { e.stopPropagation(); this.#refUploadInput?.click(); }); refZone?.addEventListener("dragover", (e) => { e.preventDefault(); refZone.classList.add("drag-over"); }); refZone?.addEventListener("dragleave", () => refZone.classList.remove("drag-over")); refZone?.addEventListener("drop", (e) => { e.preventDefault(); refZone.classList.remove("drag-over"); if (e.dataTransfer?.files) this.#handleRefFiles(e.dataTransfer.files); }); this.#refUploadInput?.addEventListener("change", () => { if (this.#refUploadInput?.files) this.#handleRefFiles(this.#refUploadInput.files); }); // Source upload zone const srcZone = w.querySelector(".source-upload-zone") as HTMLElement | null; srcZone?.addEventListener("click", (e) => { e.stopPropagation(); this.#sourceUploadInput?.click(); }); srcZone?.addEventListener("dragover", (e) => { e.preventDefault(); srcZone.classList.add("drag-over"); }); srcZone?.addEventListener("dragleave", () => srcZone.classList.remove("drag-over")); srcZone?.addEventListener("drop", (e) => { e.preventDefault(); srcZone.classList.remove("drag-over"); if (e.dataTransfer?.files?.[0]) this.#handleSourceFile(e.dataTransfer.files[0]); }); this.#sourceUploadInput?.addEventListener("change", () => { if (this.#sourceUploadInput?.files?.[0]) this.#handleSourceFile(this.#sourceUploadInput.files[0]); }); // Remove ref buttons w.querySelectorAll(".remove-btn[data-ref-index]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const idx = parseInt((btn as HTMLElement).dataset.refIndex || "0", 10); this.#brandReferences.splice(idx, 1); this.#rerender(); }); }); // Analyze button w.querySelector(".analyze-btn")?.addEventListener("click", (e) => { e.stopPropagation(); this.#analyzeStyle(); }); // Provider select const provSelect = w.querySelector(".provider-select") as HTMLSelectElement | null; provSelect?.addEventListener("change", () => { this.#provider = provSelect.value; this.#rerender(); }); // Strength slider const slider = w.querySelector(".strength-slider") as HTMLInputElement | null; slider?.addEventListener("input", () => { this.#strength = parseFloat(slider.value); const valEl = w.querySelector(".strength-val"); if (valEl) valEl.textContent = String(this.#strength); }); // Brand toggle const brandCheck = w.querySelector(".brand-check") as HTMLInputElement | null; brandCheck?.addEventListener("change", () => { this.#applyBrandStyle = brandCheck.checked; }); // Brand prefix textarea this.#brandPrefixInput?.addEventListener("input", () => { if (this.#styleAnalysis && this.#brandPrefixInput) { this.#styleAnalysis.brand_prompt_prefix = this.#brandPrefixInput.value; } }); // Generate button w.querySelector(".generate-btn")?.addEventListener("click", (e) => { e.stopPropagation(); this.#generate(); }); // Prevent drag on interactive elements this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); this.#brandPrefixInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); slider?.addEventListener("pointerdown", (e) => e.stopPropagation()); provSelect?.addEventListener("pointerdown", (e) => e.stopPropagation()); brandCheck?.addEventListener("pointerdown", (e) => e.stopPropagation()); } #fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); } async #handleRefFiles(files: FileList) { for (const file of Array.from(files)) { if (!file.type.startsWith("image/")) continue; try { // Upload to server for persistence const dataUrl = await this.#fileToDataUrl(file); const res = await fetch("/api/image-upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: dataUrl }), }); if (res.ok) { const { url } = await res.json(); this.#brandReferences.push(url); } else { this.#brandReferences.push(dataUrl); } } catch { const dataUrl = await this.#fileToDataUrl(file); this.#brandReferences.push(dataUrl); } } this.#rerender(); } async #handleSourceFile(file: File) { if (!file.type.startsWith("image/")) return; try { const dataUrl = await this.#fileToDataUrl(file); const res = await fetch("/api/image-upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: dataUrl }), }); if (res.ok) { const { url } = await res.json(); this.#sourceImage = url; } else { this.#sourceImage = dataUrl; } } catch { this.#sourceImage = await this.#fileToDataUrl(file); } this.#rerender(); } async #analyzeStyle() { if (this.#isAnalyzing || !this.#brandReferences.length) return; this.#isAnalyzing = true; this.#error = null; this.#rerender(); try { const res = await fetch("/api/image-gen/analyze-style", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ images: this.#brandReferences }), }); if (!res.ok) throw new Error("Style analysis failed"); this.#styleAnalysis = await res.json(); } catch (e: any) { this.#error = e.message || "Analysis failed"; } finally { this.#isAnalyzing = false; this.#rerender(); } } async #generate() { const prompt = this.#promptInput?.value.trim(); if (!prompt || !this.#sourceImage || this.#isGenerating) return; this.#isGenerating = true; this.#error = null; this.#rerender(); try { let fullPrompt = prompt; if (this.#applyBrandStyle && this.#styleAnalysis?.brand_prompt_prefix) { fullPrompt = this.#styleAnalysis.brand_prompt_prefix + " " + prompt; } const isGemini = this.#provider !== "fal"; const body: any = { prompt: fullPrompt, source_image: this.#sourceImage, provider: isGemini ? "gemini" : "fal", strength: this.#strength, }; if (isGemini && this.#provider !== "gemini") { body.model = this.#provider; // "gemini-pro" or "gemini-flash-2" } if (this.#brandReferences.length) { body.reference_images = this.#brandReferences; } const res = await fetch("/api/image-gen/img2img", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error("Generation failed"); const data = await res.json(); const url = data.url || data.image_url; if (!url) throw new Error("No image returned"); this.#results.unshift({ id: crypto.randomUUID(), prompt, url, provider: this.#provider, timestamp: new Date().toISOString(), }); if (this.#promptInput) this.#promptInput.value = ""; } catch (e: any) { this.#error = e.message || "Generation failed"; } finally { this.#isGenerating = false; this.#rerender(); } } #escapeHtml(text: string): string { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } override toJSON() { return { ...super.toJSON(), type: "folk-image-studio", provider: this.#provider, strength: this.#strength, styleAnalysis: this.#styleAnalysis, brandReferences: this.#brandReferences, results: this.#results, }; } }