From 3b6ea5afcdb5bbe63f5e25c40d92c0eaab5246d2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 27 Feb 2026 13:57:50 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20creative=20tools=20suite=20=E2=80=94=20?= =?UTF-8?q?7=20tools=20in=20unified=20canvas=20toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete rProviders module (unused) - Add hidden flag to module system, hide rSplat from app switcher - Add fal.ai API proxies: image-gen (Flux Pro), video-gen t2v (WAN 2.1), i2v (Kling) - New canvas shapes: folk-splat (3D viewer), folk-blender (3D gen), folk-drawfast (freehand drawing), folk-freecad (parametric CAD), folk-kicad (PCB design) - Restructure canvas toolbar: new "Creative" group with all 7 tools, reduced "Media" group - Add blender-gen, kicad, freecad REST-to-MCP bridge endpoints - Fix standalone domain navigation to rspace.online landing pages Co-Authored-By: Claude Opus 4.6 --- lib/folk-blender.ts | 444 ++++++++++++++++ lib/folk-drawfast.ts | 456 ++++++++++++++++ lib/folk-freecad.ts | 378 +++++++++++++ lib/folk-kicad.ts | 496 ++++++++++++++++++ lib/folk-splat.ts | 438 ++++++++++++++++ lib/index.ts | 7 + .../components/folk-provider-directory.ts | 182 ------- modules/providers/components/providers.css | 6 - modules/providers/db/schema.sql | 70 --- modules/providers/mod.ts | 370 ------------- modules/splat/mod.ts | 1 + server/index.ts | 286 +++++++++- server/shell.ts | 2 +- shared/components/rstack-app-switcher.ts | 6 +- shared/module.ts | 23 +- shared/url-helpers.ts | 18 +- vite.config.ts | 27 - website/canvas.html | 64 ++- 18 files changed, 2598 insertions(+), 676 deletions(-) create mode 100644 lib/folk-blender.ts create mode 100644 lib/folk-drawfast.ts create mode 100644 lib/folk-freecad.ts create mode 100644 lib/folk-kicad.ts create mode 100644 lib/folk-splat.ts delete mode 100644 modules/providers/components/folk-provider-directory.ts delete mode 100644 modules/providers/components/providers.css delete mode 100644 modules/providers/db/schema.sql delete mode 100644 modules/providers/mod.ts diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts new file mode 100644 index 0000000..7b8c0df --- /dev/null +++ b/lib/folk-blender.ts @@ -0,0 +1,444 @@ +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: 380px; + min-height: 480px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: linear-gradient(135deg, #ea580c, #f59e0b); + 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: hidden; + } + + .prompt-area { + padding: 12px; + border-bottom: 1px solid #e2e8f0; + } + + .prompt-input { + width: 100%; + padding: 10px 12px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + resize: none; + outline: none; + font-family: inherit; + } + + .prompt-input:focus { + border-color: #ea580c; + } + + .controls { + display: flex; + gap: 8px; + margin-top: 8px; + } + + .generate-btn { + flex: 1; + padding: 8px 16px; + background: linear-gradient(135deg, #ea580c, #f59e0b); + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + } + + .generate-btn:hover { + opacity: 0.9; + } + + .generate-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .tabs { + display: flex; + border-bottom: 1px solid #e2e8f0; + } + + .tab { + flex: 1; + padding: 8px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: #64748b; + border: none; + background: none; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + } + + .tab:hover { + color: #ea580c; + } + + .tab.active { + color: #ea580c; + border-bottom-color: #ea580c; + } + + .preview-area { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .render-preview { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + overflow: hidden; + } + + .render-preview img { + max-width: 100%; + max-height: 100%; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .code-area { + flex: 1; + overflow: auto; + padding: 12px; + } + + .code-area pre { + margin: 0; + padding: 12px; + background: #1e293b; + color: #e2e8f0; + border-radius: 6px; + font-size: 11px; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + } + + .download-row { + display: flex; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .download-btn { + padding: 6px 12px; + background: #334155; + color: white; + border: none; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + text-decoration: none; + } + + .download-btn:hover { + background: #475569; + } + + .placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #94a3b8; + text-align: center; + gap: 8px; + } + + .placeholder-icon { + font-size: 48px; + opacity: 0.5; + } + + .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: #ea580c; + 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; + margin: 12px; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-blender": FolkBlender; + } +} + +export class FolkBlender extends FolkShape { + static override tagName = "folk-blender"; + + 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; + } + + #isLoading = false; + #error: string | null = null; + #renderUrl: string | null = null; + #script: string | null = null; + #blendUrl: string | null = null; + #activeTab: "preview" | "code" = "preview"; + #promptInput: HTMLTextAreaElement | null = null; + #generateBtn: HTMLButtonElement | null = null; + #previewArea: HTMLElement | null = null; + #codeArea: HTMLElement | null = null; + #downloadRow: HTMLElement | null = null; + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \u{1F9CA} + 3D Blender + +
+ +
+
+
+
+ +
+ +
+
+
+ + +
+
+
+
+ \u{1F9CA} + Describe a 3D scene and click Generate +
+
+ +
+ +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#promptInput = wrapper.querySelector(".prompt-input"); + this.#generateBtn = wrapper.querySelector(".generate-btn"); + this.#previewArea = wrapper.querySelector(".render-preview"); + this.#codeArea = wrapper.querySelector(".code-area"); + this.#downloadRow = wrapper.querySelector(".download-row"); + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + + // Tab switching + wrapper.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", (e) => { + e.stopPropagation(); + const tabName = (tab as HTMLElement).dataset.tab as "preview" | "code"; + this.#activeTab = tabName; + wrapper.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); + if (this.#previewArea) this.#previewArea.style.display = tabName === "preview" ? "flex" : "none"; + if (this.#codeArea) this.#codeArea.style.display = tabName === "code" ? "block" : "none"; + }); + }); + + this.#generateBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#generate(); + }); + + this.#promptInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.#generate(); + } + }); + this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + return root; + } + + async #generate() { + const prompt = this.#promptInput?.value.trim(); + if (!prompt || this.#isLoading) return; + + this.#isLoading = true; + this.#error = null; + if (this.#generateBtn) this.#generateBtn.disabled = true; + + if (this.#previewArea) { + this.#previewArea.innerHTML = '
Generating 3D scene...
'; + } + + try { + const res = await fetch("/api/blender-gen", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || "Generation failed"); + } + + const data = await res.json(); + this.#renderUrl = data.render_url || null; + this.#script = data.script || null; + this.#blendUrl = data.blend_url || null; + + this.#renderResult(); + } catch (err) { + this.#error = err instanceof Error ? err.message : "Generation failed"; + if (this.#previewArea) { + this.#previewArea.innerHTML = `
${this.#escapeHtml(this.#error)}
`; + } + } finally { + this.#isLoading = false; + if (this.#generateBtn) this.#generateBtn.disabled = false; + } + } + + #renderResult() { + if (this.#previewArea) { + if (this.#renderUrl) { + this.#previewArea.innerHTML = `3D Render`; + } else { + this.#previewArea.innerHTML = '
\u2705Script generated (see Script tab)
'; + } + } + + if (this.#codeArea && this.#script) { + this.#codeArea.innerHTML = `
${this.#escapeHtml(this.#script)}
`; + } + + if (this.#downloadRow && this.#blendUrl) { + this.#downloadRow.style.display = "flex"; + const blendLink = this.#downloadRow.querySelector(".blend-dl") as HTMLAnchorElement; + if (blendLink) blendLink.href = this.#blendUrl; + } + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-blender", + renderUrl: this.#renderUrl, + script: this.#script, + blendUrl: this.#blendUrl, + }; + } +} diff --git a/lib/folk-drawfast.ts b/lib/folk-drawfast.ts new file mode 100644 index 0000000..f621992 --- /dev/null +++ b/lib/folk-drawfast.ts @@ -0,0 +1,456 @@ +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: 360px; + min-height: 420px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: linear-gradient(135deg, #f97316, #eab308); + 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: hidden; + } + + .toolbar-row { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid #e2e8f0; + flex-wrap: wrap; + } + + .tool-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #e2e8f0; + border-radius: 6px; + background: white; + cursor: pointer; + font-size: 14px; + transition: all 0.15s; + } + + .tool-btn:hover { + border-color: #f97316; + } + + .tool-btn.active { + border-color: #f97316; + background: #fff7ed; + } + + .color-swatch { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid #e2e8f0; + cursor: pointer; + transition: transform 0.1s; + } + + .color-swatch:hover { + transform: scale(1.2); + } + + .color-swatch.active { + border-color: #0f172a; + transform: scale(1.15); + } + + .size-slider { + width: 80px; + accent-color: #f97316; + } + + .size-label { + font-size: 11px; + color: #64748b; + min-width: 20px; + } + + .canvas-area { + flex: 1; + position: relative; + cursor: crosshair; + overflow: hidden; + } + + .canvas-area canvas { + width: 100%; + height: 100%; + display: block; + } + + .export-btn { + padding: 6px 12px; + background: #f97316; + color: white; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + } + + .export-btn:hover { + opacity: 0.9; + } +`; + +const COLORS = ["#0f172a", "#ef4444", "#f97316", "#eab308", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#ffffff"]; + +interface Stroke { + points: { x: number; y: number; pressure: number }[]; + color: string; + size: number; + tool: string; +} + +declare global { + interface HTMLElementTagNameMap { + "folk-drawfast": FolkDrawfast; + } +} + +export class FolkDrawfast extends FolkShape { + static override tagName = "folk-drawfast"; + + 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; + } + + #strokes: Stroke[] = []; + #currentStroke: Stroke | null = null; + #canvas: HTMLCanvasElement | null = null; + #ctx: CanvasRenderingContext2D | null = null; + #color = "#0f172a"; + #brushSize = 4; + #tool = "pen"; // pen | eraser + #isDrawing = false; + + get strokes() { + return this.#strokes; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \u270F\uFE0F + Drawfast + +
+ + +
+
+
+
+ + + + ${COLORS.map((c) => ``).join("")} + + + 4 + + +
+
+ +
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#canvas = wrapper.querySelector(".canvas-area canvas") as HTMLCanvasElement; + this.#ctx = this.#canvas.getContext("2d"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const exportBtn = wrapper.querySelector(".export-png-btn") as HTMLButtonElement; + const sizeSlider = wrapper.querySelector(".size-slider") as HTMLInputElement; + const sizeLabel = wrapper.querySelector(".size-label") as HTMLElement; + const canvasArea = wrapper.querySelector(".canvas-area") as HTMLElement; + + // Tool buttons + wrapper.querySelectorAll(".tool-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const tool = (btn as HTMLElement).dataset.tool; + if (tool === "clear") { + this.#strokes = []; + this.#redraw(); + return; + } + this.#tool = tool || "pen"; + wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active")); + if (tool !== "clear") btn.classList.add("active"); + }); + }); + + // Color swatches + wrapper.querySelectorAll(".color-swatch").forEach((swatch) => { + swatch.addEventListener("click", (e) => { + e.stopPropagation(); + this.#color = (swatch as HTMLElement).dataset.color || "#0f172a"; + wrapper.querySelectorAll(".color-swatch").forEach((s) => s.classList.remove("active")); + swatch.classList.add("active"); + // Switch to pen when picking a color + this.#tool = "pen"; + wrapper.querySelectorAll(".tool-btn").forEach((b) => b.classList.remove("active")); + wrapper.querySelector('[data-tool="pen"]')?.classList.add("active"); + }); + }); + + // Size slider + sizeSlider.addEventListener("input", (e) => { + e.stopPropagation(); + this.#brushSize = parseInt(sizeSlider.value); + sizeLabel.textContent = sizeSlider.value; + }); + sizeSlider.addEventListener("pointerdown", (e) => e.stopPropagation()); + + // Drawing events + this.#canvas.addEventListener("pointerdown", (e) => { + e.stopPropagation(); + e.preventDefault(); + this.#isDrawing = true; + this.#canvas!.setPointerCapture(e.pointerId); + const pos = this.#getCanvasPos(e); + this.#currentStroke = { + points: [{ ...pos, pressure: e.pressure || 0.5 }], + color: this.#tool === "eraser" ? "#ffffff" : this.#color, + size: this.#tool === "eraser" ? this.#brushSize * 3 : this.#brushSize, + tool: this.#tool, + }; + }); + + this.#canvas.addEventListener("pointermove", (e) => { + if (!this.#isDrawing || !this.#currentStroke) return; + e.stopPropagation(); + const pos = this.#getCanvasPos(e); + this.#currentStroke.points.push({ ...pos, pressure: e.pressure || 0.5 }); + this.#drawStroke(this.#currentStroke); + }); + + const endDraw = (e: PointerEvent) => { + if (!this.#isDrawing) return; + this.#isDrawing = false; + if (this.#currentStroke && this.#currentStroke.points.length > 0) { + this.#strokes.push(this.#currentStroke); + this.dispatchEvent(new CustomEvent("stroke-complete", { + detail: { stroke: this.#currentStroke }, + })); + } + this.#currentStroke = null; + }; + + this.#canvas.addEventListener("pointerup", endDraw); + this.#canvas.addEventListener("pointerleave", endDraw); + + // Export + exportBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.#exportPNG(); + }); + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Size canvas on next frame + requestAnimationFrame(() => { + this.#resizeCanvas(canvasArea); + this.#redraw(); + }); + + // Watch for resize + const ro = new ResizeObserver(() => { + this.#resizeCanvas(canvasArea); + this.#redraw(); + }); + ro.observe(canvasArea); + + return root; + } + + #resizeCanvas(container: HTMLElement) { + if (!this.#canvas) return; + const rect = container.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + this.#canvas.width = rect.width * devicePixelRatio; + this.#canvas.height = rect.height * devicePixelRatio; + this.#canvas.style.width = rect.width + "px"; + this.#canvas.style.height = rect.height + "px"; + if (this.#ctx) { + this.#ctx.scale(devicePixelRatio, devicePixelRatio); + } + } + } + + #getCanvasPos(e: PointerEvent): { x: number; y: number } { + const rect = this.#canvas!.getBoundingClientRect(); + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }; + } + + #drawStroke(stroke: Stroke) { + if (!this.#ctx || stroke.points.length < 2) return; + const ctx = this.#ctx; + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = stroke.color; + + // Draw last segment for live drawing + const len = stroke.points.length; + const p0 = stroke.points[len - 2]; + const p1 = stroke.points[len - 1]; + const pressure = (p0.pressure + p1.pressure) / 2; + + ctx.lineWidth = stroke.size * (0.5 + pressure); + if (stroke.tool === "eraser") { + ctx.globalCompositeOperation = "destination-out"; + ctx.lineWidth = stroke.size * 3; + } else { + ctx.globalCompositeOperation = "source-over"; + } + + ctx.beginPath(); + ctx.moveTo(p0.x, p0.y); + ctx.lineTo(p1.x, p1.y); + ctx.stroke(); + + ctx.globalCompositeOperation = "source-over"; + } + + #redraw() { + if (!this.#ctx || !this.#canvas) return; + const ctx = this.#ctx; + const w = this.#canvas.width / devicePixelRatio; + const h = this.#canvas.height / devicePixelRatio; + + ctx.clearRect(0, 0, w, h); + + // Fill white background + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, w, h); + + for (const stroke of this.#strokes) { + if (stroke.points.length < 2) continue; + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.strokeStyle = stroke.color; + + if (stroke.tool === "eraser") { + ctx.globalCompositeOperation = "destination-out"; + } else { + ctx.globalCompositeOperation = "source-over"; + } + + for (let i = 1; i < stroke.points.length; i++) { + const p0 = stroke.points[i - 1]; + const p1 = stroke.points[i]; + const pressure = (p0.pressure + p1.pressure) / 2; + ctx.lineWidth = stroke.size * (0.5 + pressure); + + ctx.beginPath(); + ctx.moveTo(p0.x, p0.y); + ctx.lineTo(p1.x, p1.y); + ctx.stroke(); + } + } + + ctx.globalCompositeOperation = "source-over"; + } + + #exportPNG() { + if (!this.#canvas) return; + const dataUrl = this.#canvas.toDataURL("image/png"); + const link = document.createElement("a"); + link.download = `drawfast-${Date.now()}.png`; + link.href = dataUrl; + link.click(); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-drawfast", + strokes: this.#strokes.map((s) => ({ + points: s.points, + color: s.color, + size: s.size, + tool: s.tool, + })), + }; + } +} diff --git a/lib/folk-freecad.ts b/lib/folk-freecad.ts new file mode 100644 index 0000000..491947b --- /dev/null +++ b/lib/folk-freecad.ts @@ -0,0 +1,378 @@ +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: 360px; + min-height: 440px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: linear-gradient(135deg, #0891b2, #06b6d4); + 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: hidden; + } + + .prompt-area { + padding: 12px; + border-bottom: 1px solid #e2e8f0; + } + + .prompt-input { + width: 100%; + padding: 10px 12px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + resize: none; + outline: none; + font-family: inherit; + } + + .prompt-input:focus { + border-color: #0891b2; + } + + .controls { + display: flex; + gap: 8px; + margin-top: 8px; + } + + .generate-btn { + flex: 1; + padding: 8px 16px; + background: linear-gradient(135deg, #0891b2, #06b6d4); + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + } + + .generate-btn:hover { + opacity: 0.9; + } + + .generate-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .preview-area { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + overflow: hidden; + } + + .preview-area img { + max-width: 100%; + max-height: 100%; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .export-row { + display: flex; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .export-btn { + padding: 6px 12px; + background: #334155; + color: white; + border: none; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + text-decoration: none; + } + + .export-btn:hover { + background: #475569; + } + + .export-btn.primary { + background: #0891b2; + } + + .export-btn.primary:hover { + background: #0e7490; + } + + .placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #94a3b8; + text-align: center; + gap: 8px; + } + + .placeholder-icon { + font-size: 48px; + opacity: 0.5; + } + + .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: #0891b2; + 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; + margin: 12px; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-freecad": FolkFreeCAD; + } +} + +export class FolkFreeCAD extends FolkShape { + static override tagName = "folk-freecad"; + + 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; + } + + #isLoading = false; + #error: string | null = null; + #previewUrl: string | null = null; + #stepUrl: string | null = null; + #stlUrl: string | null = null; + #promptInput: HTMLTextAreaElement | null = null; + #generateBtn: HTMLButtonElement | null = null; + #previewArea: HTMLElement | null = null; + #exportRow: HTMLElement | null = null; + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \u{1F4D0} + FreeCAD + +
+ +
+
+
+
+ +
+ +
+
+
+
+ \u{1F4D0} + Describe a part and click Generate +
+
+ +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#promptInput = wrapper.querySelector(".prompt-input"); + this.#generateBtn = wrapper.querySelector(".generate-btn"); + this.#previewArea = wrapper.querySelector(".preview-area"); + this.#exportRow = wrapper.querySelector(".export-row"); + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + + this.#generateBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#generate(); + }); + + this.#promptInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.#generate(); + } + }); + this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + return root; + } + + async #generate() { + const prompt = this.#promptInput?.value.trim(); + if (!prompt || this.#isLoading) return; + + this.#isLoading = true; + this.#error = null; + if (this.#generateBtn) this.#generateBtn.disabled = true; + + if (this.#previewArea) { + this.#previewArea.innerHTML = '
Generating CAD model...
'; + } + + try { + const res = await fetch("/api/freecad/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || "Generation failed"); + } + + const data = await res.json(); + this.#previewUrl = data.preview_url || null; + this.#stepUrl = data.step_url || null; + this.#stlUrl = data.stl_url || null; + + this.#renderResult(); + } catch (err) { + this.#error = err instanceof Error ? err.message : "Generation failed"; + if (this.#previewArea) { + this.#previewArea.innerHTML = `
${this.#escapeHtml(this.#error)}
`; + } + } finally { + this.#isLoading = false; + if (this.#generateBtn) this.#generateBtn.disabled = false; + } + } + + #renderResult() { + if (this.#previewArea) { + if (this.#previewUrl) { + this.#previewArea.innerHTML = `CAD Preview`; + } else { + this.#previewArea.innerHTML = '
\u2705Model generated! Download files below.
'; + } + } + + if (this.#exportRow && (this.#stepUrl || this.#stlUrl)) { + this.#exportRow.style.display = "flex"; + const stepLink = this.#exportRow.querySelector(".step-dl") as HTMLAnchorElement; + const stlLink = this.#exportRow.querySelector(".stl-dl") as HTMLAnchorElement; + if (stepLink && this.#stepUrl) { + stepLink.href = this.#stepUrl; + stepLink.style.display = ""; + } + if (stlLink && this.#stlUrl) { + stlLink.href = this.#stlUrl; + stlLink.style.display = ""; + } + } + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-freecad", + previewUrl: this.#previewUrl, + stepUrl: this.#stepUrl, + stlUrl: this.#stlUrl, + }; + } +} diff --git a/lib/folk-kicad.ts b/lib/folk-kicad.ts new file mode 100644 index 0000000..74ed220 --- /dev/null +++ b/lib/folk-kicad.ts @@ -0,0 +1,496 @@ +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: 380px; + min-height: 460px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: linear-gradient(135deg, #059669, #34d399); + 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: hidden; + } + + .prompt-area { + padding: 12px; + border-bottom: 1px solid #e2e8f0; + } + + .prompt-input { + width: 100%; + padding: 10px 12px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + resize: none; + outline: none; + font-family: inherit; + } + + .prompt-input:focus { + border-color: #059669; + } + + .component-input { + width: 100%; + padding: 8px 10px; + border: 2px solid #e2e8f0; + border-radius: 6px; + font-size: 12px; + outline: none; + margin-top: 8px; + font-family: inherit; + } + + .component-input:focus { + border-color: #059669; + } + + .controls { + display: flex; + gap: 8px; + margin-top: 8px; + } + + .generate-btn { + flex: 1; + padding: 8px 16px; + background: linear-gradient(135deg, #059669, #34d399); + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + } + + .generate-btn:hover { + opacity: 0.9; + } + + .generate-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .tabs { + display: flex; + border-bottom: 1px solid #e2e8f0; + } + + .tab { + flex: 1; + padding: 8px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: #64748b; + border: none; + background: none; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + } + + .tab:hover { + color: #059669; + } + + .tab.active { + color: #059669; + border-bottom-color: #059669; + } + + .preview-area { + flex: 1; + overflow: auto; + padding: 12px; + } + + .preview-area img { + max-width: 100%; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .drc-results { + font-size: 12px; + color: #334155; + } + + .drc-results .pass { + color: #059669; + font-weight: 600; + } + + .drc-results .fail { + color: #ef4444; + font-weight: 600; + } + + .export-row { + display: flex; + gap: 8px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + flex-wrap: wrap; + } + + .export-btn { + padding: 6px 12px; + background: #334155; + color: white; + border: none; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + text-decoration: none; + } + + .export-btn:hover { + background: #475569; + } + + .export-btn.primary { + background: #059669; + } + + .export-btn.primary:hover { + background: #047857; + } + + .placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #94a3b8; + text-align: center; + gap: 8px; + } + + .placeholder-icon { + font-size: 48px; + opacity: 0.5; + } + + .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: #059669; + 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; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-kicad": FolkKiCAD; + } +} + +export class FolkKiCAD extends FolkShape { + static override tagName = "folk-kicad"; + + 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; + } + + #isLoading = false; + #error: string | null = null; + #schematicSvg: string | null = null; + #boardSvg: string | null = null; + #gerberUrl: string | null = null; + #bomUrl: string | null = null; + #pdfUrl: string | null = null; + #drcResults: any = null; + #activeTab: "schematic" | "board" | "drc" = "schematic"; + #promptInput: HTMLTextAreaElement | null = null; + #componentInput: HTMLInputElement | null = null; + #generateBtn: HTMLButtonElement | null = null; + #previewArea: HTMLElement | null = null; + #exportRow: HTMLElement | null = null; + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \u{1F50C} + KiCAD PCB + +
+ +
+
+
+
+ + +
+ +
+
+
+ + + +
+
+
+ \u{1F50C} + Describe a PCB design and click Generate +
+
+ +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#promptInput = wrapper.querySelector(".prompt-input"); + this.#componentInput = wrapper.querySelector(".component-input"); + this.#generateBtn = wrapper.querySelector(".generate-btn"); + this.#previewArea = wrapper.querySelector(".preview-area"); + this.#exportRow = wrapper.querySelector(".export-row"); + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + + // Tab switching + wrapper.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", (e) => { + e.stopPropagation(); + const tabName = (tab as HTMLElement).dataset.tab as "schematic" | "board" | "drc"; + this.#activeTab = tabName; + wrapper.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); + this.#renderPreview(); + }); + }); + + this.#generateBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#generate(); + }); + + this.#promptInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.#generate(); + } + }); + this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#componentInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + return root; + } + + async #generate() { + const prompt = this.#promptInput?.value.trim(); + if (!prompt || this.#isLoading) return; + + const components = this.#componentInput?.value + .split(",") + .map((c) => c.trim()) + .filter(Boolean) || []; + + this.#isLoading = true; + this.#error = null; + if (this.#generateBtn) this.#generateBtn.disabled = true; + + if (this.#previewArea) { + this.#previewArea.innerHTML = '
Generating PCB design...
'; + } + + try { + // Step 1: Create project + const createRes = await fetch("/api/kicad/create_project", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, components }), + }); + + if (!createRes.ok) { + const err = await createRes.json().catch(() => ({ error: createRes.statusText })); + throw new Error(err.error || "PCB generation failed"); + } + + const data = await createRes.json(); + this.#schematicSvg = data.schematic_svg || null; + this.#boardSvg = data.board_svg || null; + this.#gerberUrl = data.gerber_url || null; + this.#bomUrl = data.bom_url || null; + this.#pdfUrl = data.pdf_url || null; + this.#drcResults = data.drc || null; + + this.#renderPreview(); + this.#showExports(); + } catch (err) { + this.#error = err instanceof Error ? err.message : "Generation failed"; + if (this.#previewArea) { + this.#previewArea.innerHTML = `
${this.#escapeHtml(this.#error)}
`; + } + } finally { + this.#isLoading = false; + if (this.#generateBtn) this.#generateBtn.disabled = false; + } + } + + #renderPreview() { + if (!this.#previewArea) return; + + switch (this.#activeTab) { + case "schematic": + if (this.#schematicSvg) { + this.#previewArea.innerHTML = `Schematic`; + } else { + this.#previewArea.innerHTML = '
\u{1F4CB}Schematic will appear here
'; + } + break; + case "board": + if (this.#boardSvg) { + this.#previewArea.innerHTML = `Board Layout`; + } else { + this.#previewArea.innerHTML = '
\u{1F4DF}Board layout will appear here
'; + } + break; + case "drc": + if (this.#drcResults) { + const violations = this.#drcResults.violations || []; + const passed = violations.length === 0; + this.#previewArea.innerHTML = ` +
+

${passed + ? '\u2705 DRC Passed' + : `\u274C ${violations.length} Violation(s)` + }

+ ${violations.map((v: any) => `

\u2022 ${this.#escapeHtml(v.message || v)}

`).join("")} +
+ `; + } else { + this.#previewArea.innerHTML = '
\u2705DRC results will appear here
'; + } + break; + } + } + + #showExports() { + if (this.#exportRow && (this.#gerberUrl || this.#bomUrl || this.#pdfUrl)) { + this.#exportRow.style.display = "flex"; + const gerberLink = this.#exportRow.querySelector(".gerber-dl") as HTMLAnchorElement; + const bomLink = this.#exportRow.querySelector(".bom-dl") as HTMLAnchorElement; + const pdfLink = this.#exportRow.querySelector(".pdf-dl") as HTMLAnchorElement; + if (gerberLink) gerberLink.href = this.#gerberUrl || "#"; + if (bomLink) bomLink.href = this.#bomUrl || "#"; + if (pdfLink) pdfLink.href = this.#pdfUrl || "#"; + } + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-kicad", + schematicSvg: this.#schematicSvg, + boardSvg: this.#boardSvg, + gerberUrl: this.#gerberUrl, + bomUrl: this.#bomUrl, + pdfUrl: this.#pdfUrl, + }; + } +} diff --git a/lib/folk-splat.ts b/lib/folk-splat.ts new file mode 100644 index 0000000..57dc9a6 --- /dev/null +++ b/lib/folk-splat.ts @@ -0,0 +1,438 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: #0f172a; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + min-width: 360px; + min-height: 400px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: linear-gradient(135deg, #7c3aed, #2563eb); + 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: hidden; + } + + .controls { + padding: 10px 12px; + border-bottom: 1px solid #1e293b; + display: flex; + gap: 8px; + align-items: center; + } + + .splat-url-input { + flex: 1; + padding: 8px 10px; + border: 2px solid #334155; + border-radius: 6px; + background: #1e293b; + color: #e2e8f0; + font-size: 12px; + outline: none; + } + + .splat-url-input:focus { + border-color: #7c3aed; + } + + .load-btn { + padding: 8px 14px; + background: linear-gradient(135deg, #7c3aed, #2563eb); + color: white; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + } + + .load-btn:hover { + opacity: 0.9; + } + + .viewer-area { + flex: 1; + position: relative; + background: #0a0a0a; + overflow: hidden; + } + + .viewer-area canvas { + width: 100%; + height: 100%; + display: block; + } + + .placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #64748b; + text-align: center; + gap: 8px; + } + + .placeholder-icon { + font-size: 48px; + opacity: 0.5; + } + + .gallery-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + padding: 10px; + overflow-y: auto; + max-height: 200px; + } + + .gallery-item { + padding: 8px; + background: #1e293b; + border-radius: 6px; + cursor: pointer; + color: #cbd5e1; + font-size: 11px; + text-align: center; + border: 2px solid transparent; + transition: border-color 0.2s; + } + + .gallery-item:hover { + border-color: #7c3aed; + } + + .gallery-item .title { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .gallery-item .meta { + font-size: 10px; + color: #64748b; + margin-top: 2px; + } + + .loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + gap: 12px; + color: #94a3b8; + } + + .spinner { + width: 32px; + height: 32px; + border: 3px solid #334155; + border-top-color: #7c3aed; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .error { + color: #ef4444; + padding: 12px; + background: #1c1017; + border-radius: 6px; + font-size: 13px; + margin: 10px; + } + + .upload-btn { + padding: 8px 14px; + background: #334155; + color: #e2e8f0; + border: none; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + } + + .upload-btn:hover { + background: #475569; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-splat": FolkSplat; + } +} + +export class FolkSplat extends FolkShape { + static override tagName = "folk-splat"; + + 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; + } + + #splatUrl = ""; + #isLoading = false; + #error: string | null = null; + #viewer: any = null; + #viewerCanvas: HTMLCanvasElement | null = null; + #gallerySplats: any[] = []; + #urlInput: HTMLInputElement | null = null; + + get splatUrl() { + return this.#splatUrl; + } + set splatUrl(v: string) { + this.#splatUrl = v; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \u{1F52E} + 3D Splat + +
+ + +
+
+
+
+ + +
+ +
+
+ \u{1F52E} + Enter a splat URL or browse the gallery +
+
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#urlInput = wrapper.querySelector(".splat-url-input"); + const loadBtn = wrapper.querySelector(".load-btn") as HTMLButtonElement; + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const galleryBtn = wrapper.querySelector(".gallery-btn") as HTMLButtonElement; + const galleryList = wrapper.querySelector(".gallery-list") as HTMLElement; + const viewerArea = wrapper.querySelector(".viewer-area") as HTMLElement; + + loadBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const url = this.#urlInput?.value.trim(); + if (url) this.#loadSplat(url, viewerArea); + }); + + this.#urlInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + const url = this.#urlInput?.value.trim(); + if (url) this.#loadSplat(url, viewerArea); + } + }); + this.#urlInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + galleryBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const isVisible = galleryList.style.display !== "none"; + galleryList.style.display = isVisible ? "none" : "grid"; + if (!isVisible && this.#gallerySplats.length === 0) { + this.#loadGallery(galleryList, viewerArea); + } + }); + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // If splatUrl was set before render + if (this.#splatUrl) { + this.#loadSplat(this.#splatUrl, viewerArea); + } + + return root; + } + + async #loadGallery(container: HTMLElement, viewerArea: HTMLElement) { + container.innerHTML = '
Loading gallery...
'; + try { + const spaceSlug = this.#getSpaceSlug(); + const res = await fetch(`/${spaceSlug}/rsplat/api/splats?limit=20`); + if (!res.ok) throw new Error("Failed to load splats"); + const data = await res.json(); + this.#gallerySplats = data.splats || []; + + if (this.#gallerySplats.length === 0) { + container.innerHTML = '
No splats in this space yet
'; + return; + } + + container.innerHTML = this.#gallerySplats.map((s) => ` + + `).join(""); + + container.querySelectorAll(".gallery-item").forEach((item) => { + item.addEventListener("click", (e) => { + e.stopPropagation(); + const slug = (item as HTMLElement).dataset.slug; + const splat = this.#gallerySplats.find((s) => s.slug === slug); + if (splat) { + const spaceSlug = this.#getSpaceSlug(); + const url = `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.file_format}`; + if (this.#urlInput) this.#urlInput.value = url; + container.style.display = "none"; + this.#loadSplat(url, viewerArea); + } + }); + }); + } catch (err) { + container.innerHTML = `
Failed to load gallery
`; + } + } + + async #loadSplat(url: string, viewerArea: HTMLElement) { + this.#splatUrl = url; + this.#isLoading = true; + this.#error = null; + + viewerArea.innerHTML = '
Loading 3D splat...
'; + + try { + // Use Three.js + GaussianSplats3D via CDN importmap (not bundled) + const threeId = "three"; + const gs3dId = "@mkkellogg/gaussian-splats-3d"; + const THREE = await (Function("id", "return import(id)")(threeId) as Promise); + const GS3D = await (Function("id", "return import(id)")(gs3dId) as Promise); + + viewerArea.innerHTML = ""; + + const canvas = document.createElement("canvas"); + canvas.style.width = "100%"; + canvas.style.height = "100%"; + viewerArea.appendChild(canvas); + + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + renderer.setSize(viewerArea.clientWidth, viewerArea.clientHeight); + + const viewer = new GS3D.Viewer({ + renderer, + cameraUp: [0, -1, 0], + initialCameraPosition: [0, 0, 5], + initialCameraLookAt: [0, 0, 0], + }); + + await viewer.addSplatScene(url); + this.#viewer = viewer; + this.#viewerCanvas = canvas; + + // Simple animation loop + const animate = () => { + if (!this.#viewer) return; + (viewer as any).update(); + (viewer as any).render(); + requestAnimationFrame(animate); + }; + animate(); + } catch (err) { + // Fallback: show as iframe pointing to splat viewer page + console.warn("[folk-splat] Three.js not available, using iframe fallback"); + const spaceSlug = this.#getSpaceSlug(); + const slug = url.split("/").filter(Boolean).pop()?.split(".")[0] || ""; + viewerArea.innerHTML = ``; + } finally { + this.#isLoading = false; + } + } + + #getSpaceSlug(): string { + const pathParts = window.location.pathname.split("/").filter(Boolean); + return pathParts[0] || "demo"; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-splat", + splatUrl: this.#splatUrl, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 9519134..ac2d22e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -39,6 +39,13 @@ export * from "./folk-video-gen"; export * from "./folk-prompt"; export * from "./folk-transcription"; +// Creative Tools Shapes +export * from "./folk-splat"; +export * from "./folk-blender"; +export * from "./folk-drawfast"; +export * from "./folk-freecad"; +export * from "./folk-kicad"; + // Advanced Shapes export * from "./folk-video-chat"; export * from "./folk-obs-note"; diff --git a/modules/providers/components/folk-provider-directory.ts b/modules/providers/components/folk-provider-directory.ts deleted file mode 100644 index bd44eb6..0000000 --- a/modules/providers/components/folk-provider-directory.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * — browseable provider directory. - * Shows a grid of provider cards with search, capability filter, and proximity sorting. - */ - -class FolkProviderDirectory extends HTMLElement { - private shadow: ShadowRoot; - private providers: any[] = []; - private capabilities: string[] = []; - private selectedCap = ""; - private searchQuery = ""; - private userLat: number | null = null; - private userLng: number | null = null; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: "open" }); - } - - connectedCallback() { - this.render(); - this.loadProviders(); - } - - private getApiBase(): string { - const path = window.location.pathname; - const parts = path.split("/").filter(Boolean); - return parts.length >= 2 ? `/${parts[0]}/providers` : "/demo/providers"; - } - - private async loadProviders() { - try { - const params = new URLSearchParams(); - if (this.selectedCap) params.set("capability", this.selectedCap); - if (this.userLat && this.userLng) { - params.set("lat", String(this.userLat)); - params.set("lng", String(this.userLng)); - } - params.set("limit", "100"); - - const res = await fetch(`${this.getApiBase()}/api/providers?${params}`); - const data = await res.json(); - this.providers = data.providers || []; - - // Collect unique capabilities - const capSet = new Set(); - for (const p of this.providers) { - for (const cap of (p.capabilities || [])) capSet.add(cap); - } - this.capabilities = Array.from(capSet).sort(); - - this.render(); - } catch (e) { - console.error("Failed to load providers:", e); - } - } - - private render() { - const filtered = this.providers.filter((p) => { - if (this.searchQuery) { - const q = this.searchQuery.toLowerCase(); - const name = (p.name || "").toLowerCase(); - const city = (p.location?.city || "").toLowerCase(); - const country = (p.location?.country || "").toLowerCase(); - if (!name.includes(q) && !city.includes(q) && !country.includes(q)) return false; - } - return true; - }); - - this.shadow.innerHTML = ` - - -
- Provider Directory - - -
- - ${this.capabilities.length > 0 ? ` -
- All - ${this.capabilities.map((cap) => ` - ${cap} - `).join("")} -
` : ""} - - ${filtered.length === 0 ? `
No providers found
` : ` -
- ${filtered.map((p) => ` -
-
-
-

${this.esc(p.name)}

-
${this.esc(p.location?.city || "")}${p.location?.region ? `, ${this.esc(p.location.region)}` : ""} ${this.esc(p.location?.country || "")}
-
-
- \u2713 Active - ${p.distance_km !== undefined ? `${p.distance_km} km` : ""} -
-
-
- ${(p.capabilities || []).map((cap: string) => `${this.esc(cap)}`).join("")} -
- ${p.turnaround?.standard_days ? `
\u23F1 ${p.turnaround.standard_days} days standard${p.turnaround.rush_days ? ` / ${p.turnaround.rush_days} days rush (+${p.turnaround.rush_surcharge_pct || 0}%)` : ""}
` : ""} - -
- `).join("")} -
`} - `; - - // Event listeners - this.shadow.querySelector(".search")?.addEventListener("input", (e) => { - this.searchQuery = (e.target as HTMLInputElement).value; - this.render(); - }); - - this.shadow.querySelector(".locate-btn")?.addEventListener("click", () => { - if (this.userLat) { - this.userLat = null; - this.userLng = null; - this.loadProviders(); - } else { - navigator.geolocation?.getCurrentPosition( - (pos) => { - this.userLat = pos.coords.latitude; - this.userLng = pos.coords.longitude; - this.loadProviders(); - }, - () => { console.warn("Geolocation denied"); } - ); - } - }); - - this.shadow.querySelectorAll(".cap[data-cap]").forEach((el) => { - el.addEventListener("click", () => { - this.selectedCap = (el as HTMLElement).dataset.cap || ""; - this.loadProviders(); - }); - }); - } - - private esc(s: string): string { - const d = document.createElement("div"); - d.textContent = s; - return d.innerHTML; - } -} - -customElements.define("folk-provider-directory", FolkProviderDirectory); diff --git a/modules/providers/components/providers.css b/modules/providers/components/providers.css deleted file mode 100644 index 49892d2..0000000 --- a/modules/providers/components/providers.css +++ /dev/null @@ -1,6 +0,0 @@ -/* Providers module theme */ -body[data-theme="light"] main { - background: #0f172a; - min-height: calc(100vh - 56px); - padding: 0; -} diff --git a/modules/providers/db/schema.sql b/modules/providers/db/schema.sql deleted file mode 100644 index 48c805f..0000000 --- a/modules/providers/db/schema.sql +++ /dev/null @@ -1,70 +0,0 @@ --- Provider Registry schema (inside rSpace shared DB, schema: providers) --- Uses earth_distance extension for proximity queries (lighter than PostGIS) - -CREATE EXTENSION IF NOT EXISTS "cube"; -CREATE EXTENSION IF NOT EXISTS "earthdistance"; - -CREATE TABLE IF NOT EXISTS providers.providers ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name VARCHAR(200) NOT NULL, - description TEXT, - - -- Location - lat DOUBLE PRECISION NOT NULL, - lng DOUBLE PRECISION NOT NULL, - address TEXT, - city VARCHAR(100), - region VARCHAR(100), - country CHAR(2), - service_radius_km DOUBLE PRECISION DEFAULT 0, - offers_shipping BOOLEAN DEFAULT FALSE, - - -- Capabilities & substrates (arrays for containment queries) - capabilities TEXT[] NOT NULL DEFAULT '{}', - substrates TEXT[] NOT NULL DEFAULT '{}', - - -- Turnaround - standard_days INTEGER, - rush_days INTEGER, - rush_surcharge_pct DOUBLE PRECISION DEFAULT 0, - - -- Pricing (JSONB -- keyed by capability) - pricing JSONB DEFAULT '{}', - - -- Community membership - communities TEXT[] NOT NULL DEFAULT '{}', - - -- Contact - contact_email VARCHAR(255), - contact_phone VARCHAR(50), - contact_website VARCHAR(500), - - -- Payment - wallet VARCHAR(255), - - -- Reputation - jobs_completed INTEGER DEFAULT 0, - avg_rating DOUBLE PRECISION, - member_since TIMESTAMPTZ DEFAULT NOW(), - - -- Status - active BOOLEAN DEFAULT TRUE, - - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_providers_location - ON providers.providers USING gist (ll_to_earth(lat, lng)); - -CREATE INDEX IF NOT EXISTS idx_providers_capabilities - ON providers.providers USING gin (capabilities); - -CREATE INDEX IF NOT EXISTS idx_providers_substrates - ON providers.providers USING gin (substrates); - -CREATE INDEX IF NOT EXISTS idx_providers_communities - ON providers.providers USING gin (communities); - -CREATE INDEX IF NOT EXISTS idx_providers_active - ON providers.providers (active) WHERE active = TRUE; diff --git a/modules/providers/mod.ts b/modules/providers/mod.ts deleted file mode 100644 index 70694bd..0000000 --- a/modules/providers/mod.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Providers module — local provider directory. - * - * Ported from /opt/apps/provider-registry/ (Express + pg → Hono + postgres.js). - * Uses earthdistance extension for proximity queries. - */ - -import { Hono } from "hono"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { sql } from "../../shared/db/pool"; -import { renderShell } from "../../server/shell"; -import { getModuleInfoList } from "../../shared/module"; -import type { RSpaceModule } from "../../shared/module"; -import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; - -const routes = new Hono(); - -// ── DB initialization ── -const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); - -async function initDB() { - try { - await sql.unsafe(SCHEMA_SQL); - console.log("[Providers] DB schema initialized"); - } catch (e) { - console.error("[Providers] DB init error:", e); - } -} - -initDB(); - -// ── Seed data (if empty) ── -async function seedIfEmpty() { - const count = await sql.unsafe("SELECT count(*) FROM providers.providers"); - if (parseInt(count[0].count) > 0) return; - - const providers = [ - { name: "Radiant Hall Press", lat: 40.4732, lng: -79.9535, city: "Pittsburgh", region: "PA", country: "US", caps: ["risograph","saddle-stitch","perfect-bind","laser-print","fold"], subs: ["paper-80gsm","paper-100gsm","paper-100gsm-recycled","paper-160gsm-cover"], radius: 25, shipping: true, days: 3, rush: 1, rushPct: 50, email: "hello@radianthallpress.com", website: "https://radianthallpress.com", community: "pittsburgh.mycofi.earth" }, - { name: "Tiny Splendor", lat: 37.7799, lng: -122.2822, city: "Oakland", region: "CA", country: "US", caps: ["risograph","saddle-stitch","fold"], subs: ["paper-80gsm","paper-100gsm-recycled"], radius: 30, shipping: true, days: 5, rush: 2, rushPct: 40, email: "print@tinysplendor.com", website: "https://tinysplendor.com", community: "oakland.mycofi.earth" }, - { name: "People's Print Shop", lat: 40.7282, lng: -73.7949, city: "New York", region: "NY", country: "US", caps: ["risograph","screen-print","saddle-stitch"], subs: ["paper-80gsm","paper-100gsm","fabric-cotton"], radius: 15, shipping: true, days: 4, rush: 2, rushPct: 50, email: "hello@peoplesprintshop.com", website: "https://peoplesprintshop.com", community: "nyc.mycofi.earth" }, - { name: "Colour Code Press", lat: 51.5402, lng: -0.1449, city: "London", region: "England", country: "GB", caps: ["risograph","perfect-bind","fold","laser-print"], subs: ["paper-80gsm","paper-100gsm","paper-160gsm-cover"], radius: 20, shipping: true, days: 5, rush: 2, rushPct: 50, email: "info@colourcodepress.com", website: "https://colourcodepress.com", community: "london.mycofi.earth" }, - { name: "Druckwerkstatt Berlin", lat: 52.5200, lng: 13.4050, city: "Berlin", region: "Berlin", country: "DE", caps: ["risograph","screen-print","saddle-stitch","fold"], subs: ["paper-80gsm","paper-100gsm-recycled","paper-160gsm-cover"], radius: 20, shipping: true, days: 4, rush: 1, rushPct: 60, email: "hallo@druckwerkstatt.de", website: "https://druckwerkstatt.de", community: "berlin.mycofi.earth" }, - { name: "Kinko Printing Collective", lat: 35.6762, lng: 139.6503, city: "Tokyo", region: "Tokyo", country: "JP", caps: ["risograph","saddle-stitch","fold","perfect-bind"], subs: ["paper-80gsm","paper-100gsm","washi-paper"], radius: 30, shipping: true, days: 5, rush: 2, rushPct: 50, email: "info@kinkoprint.jp", website: "https://kinkoprint.jp", community: "tokyo.mycofi.earth" }, - ]; - - for (const p of providers) { - await sql.unsafe( - `INSERT INTO providers.providers ( - name, lat, lng, city, region, country, - capabilities, substrates, service_radius_km, offers_shipping, - standard_days, rush_days, rush_surcharge_pct, - contact_email, contact_website, communities - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, - [p.name, p.lat, p.lng, p.city, p.region, p.country, - p.caps, p.subs, p.radius, p.shipping, - p.days, p.rush, p.rushPct, p.email, p.website, [p.community]] - ); - } - console.log("[Providers] Seeded 6 providers"); -} - -initDB().then(seedIfEmpty).catch(() => {}); - -// ── Transform DB row → API response ── -function toProviderResponse(row: Record) { - return { - id: row.id, - name: row.name, - description: row.description, - location: { - lat: row.lat, - lng: row.lng, - address: row.address, - city: row.city, - region: row.region, - country: row.country, - service_radius_km: row.service_radius_km, - offers_shipping: row.offers_shipping, - }, - capabilities: row.capabilities, - substrates: row.substrates, - turnaround: { - standard_days: row.standard_days, - rush_days: row.rush_days, - rush_surcharge_pct: row.rush_surcharge_pct, - }, - pricing: row.pricing, - communities: row.communities, - contact: { - email: row.contact_email, - phone: row.contact_phone, - website: row.contact_website, - }, - wallet: row.wallet, - reputation: { - jobs_completed: row.jobs_completed, - avg_rating: row.avg_rating, - member_since: row.member_since, - }, - active: row.active, - ...(row.distance_km !== undefined && { distance_km: parseFloat(row.distance_km as string) }), - }; -} - -// ── GET /api/providers — List/search providers ── -routes.get("/api/providers", async (c) => { - const { capability, substrate, community, lat, lng, radius_km, active, limit = "50", offset = "0" } = c.req.query(); - - const conditions: string[] = []; - const params: any[] = []; - let paramIdx = 1; - - if (active !== "false") { - conditions.push("active = TRUE"); - } - - if (capability) { - const caps = capability.split(","); - conditions.push(`capabilities @> $${paramIdx}`); - params.push(caps); - paramIdx++; - } - - if (substrate) { - const subs = substrate.split(","); - conditions.push(`substrates && $${paramIdx}`); - params.push(subs); - paramIdx++; - } - - if (community) { - conditions.push(`$${paramIdx} = ANY(communities)`); - params.push(community); - paramIdx++; - } - - let distanceSelect = ""; - let orderBy = "ORDER BY name"; - if (lat && lng) { - const latNum = parseFloat(lat); - const lngNum = parseFloat(lng); - distanceSelect = `, round((earth_distance(ll_to_earth(lat, lng), ll_to_earth($${paramIdx}, $${paramIdx + 1})) / 1000)::numeric, 1) as distance_km`; - params.push(latNum, lngNum); - - if (radius_km) { - conditions.push(`earth_distance(ll_to_earth(lat, lng), ll_to_earth($${paramIdx}, $${paramIdx + 1})) <= $${paramIdx + 2} * 1000`); - params.push(parseFloat(radius_km)); - paramIdx += 3; - } else { - paramIdx += 2; - } - orderBy = "ORDER BY distance_km ASC"; - } - - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - const limitNum = Math.min(parseInt(limit) || 50, 100); - const offsetNum = parseInt(offset) || 0; - - const [result, countResult] = await Promise.all([ - sql.unsafe(`SELECT *${distanceSelect} FROM providers.providers ${where} ${orderBy} LIMIT ${limitNum} OFFSET ${offsetNum}`, params), - sql.unsafe(`SELECT count(*) FROM providers.providers ${where}`, params), - ]); - - return c.json({ - providers: result.map(toProviderResponse), - total: parseInt(countResult[0].count as string), - limit: limitNum, - offset: offsetNum, - }); -}); - -// ── GET /api/providers/match — Match providers for an artifact ── -routes.get("/api/providers/match", async (c) => { - const { capabilities, substrates, lat, lng, community } = c.req.query(); - - if (!capabilities || !lat || !lng) { - return c.json({ error: "Required query params: capabilities (comma-separated), lat, lng" }, 400); - } - - const caps = capabilities.split(","); - const latNum = parseFloat(lat); - const lngNum = parseFloat(lng); - - const conditions = ["active = TRUE", "capabilities @> $1"]; - const params: any[] = [caps, latNum, lngNum]; - let paramIdx = 4; - - if (substrates) { - const subs = substrates.split(","); - conditions.push(`substrates && $${paramIdx}`); - params.push(subs); - paramIdx++; - } - - if (community) { - conditions.push(`$${paramIdx} = ANY(communities)`); - params.push(community); - paramIdx++; - } - - const result = await sql.unsafe( - `SELECT *, - round((earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) / 1000)::numeric, 1) as distance_km - FROM providers.providers - WHERE ${conditions.join(" AND ")} - AND (service_radius_km = 0 OR offers_shipping = TRUE - OR earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) <= service_radius_km * 1000) - ORDER BY earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) ASC - LIMIT 20`, - params - ); - - return c.json({ - matches: result.map(toProviderResponse), - query: { capabilities: caps, location: { lat: latNum, lng: lngNum } }, - }); -}); - -// ── GET /api/providers/:id — Single provider ── -routes.get("/api/providers/:id", async (c) => { - const id = c.req.param("id"); - const result = await sql.unsafe("SELECT * FROM providers.providers WHERE id = $1", [id]); - if (result.length === 0) return c.json({ error: "Provider not found" }, 404); - return c.json(toProviderResponse(result[0])); -}); - -// ── POST /api/providers — Register a new provider ── -routes.post("/api/providers", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const body = await c.req.json(); - const { name, description, location, capabilities, substrates, turnaround, pricing, communities, contact, wallet } = body; - - if (!name || !location?.lat || !location?.lng || !capabilities?.length) { - return c.json({ error: "Required: name, location.lat, location.lng, capabilities (non-empty array)" }, 400); - } - - const result = await sql.unsafe( - `INSERT INTO providers.providers ( - name, description, lat, lng, address, city, region, country, - service_radius_km, offers_shipping, capabilities, substrates, - standard_days, rush_days, rush_surcharge_pct, pricing, communities, - contact_email, contact_phone, contact_website, wallet - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) - RETURNING *`, - [ - name, description || null, - location.lat, location.lng, location.address || null, - location.city || null, location.region || null, location.country || null, - location.service_radius_km || 0, location.offers_shipping || false, - capabilities, substrates || [], - turnaround?.standard_days || null, turnaround?.rush_days || null, turnaround?.rush_surcharge_pct || 0, - JSON.stringify(pricing || {}), communities || [], - contact?.email || null, contact?.phone || null, contact?.website || null, - wallet || null, - ] - ); - - return c.json(toProviderResponse(result[0]), 201); -}); - -// ── PUT /api/providers/:id — Update provider ── -routes.put("/api/providers/:id", async (c) => { - const token = extractToken(c.req.raw.headers); - if (!token) return c.json({ error: "Authentication required" }, 401); - try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } - - const id = c.req.param("id"); - const existing = await sql.unsafe("SELECT id FROM providers.providers WHERE id = $1", [id]); - if (existing.length === 0) return c.json({ error: "Provider not found" }, 404); - - const body = await c.req.json(); - const fields: string[] = []; - const params: any[] = []; - let paramIdx = 1; - - const settable = ["name", "description", "capabilities", "substrates", "communities", "wallet", "active"]; - for (const key of settable) { - if (body[key] !== undefined) { - fields.push(`${key} = $${paramIdx}`); - params.push(body[key]); - paramIdx++; - } - } - - if (body.location) { - for (const [key, col] of Object.entries({ lat: "lat", lng: "lng", address: "address", city: "city", region: "region", country: "country", service_radius_km: "service_radius_km", offers_shipping: "offers_shipping" })) { - if (body.location[key] !== undefined) { - fields.push(`${col} = $${paramIdx}`); - params.push(body.location[key]); - paramIdx++; - } - } - } - - if (body.turnaround) { - for (const [key, col] of Object.entries({ standard_days: "standard_days", rush_days: "rush_days", rush_surcharge_pct: "rush_surcharge_pct" })) { - if (body.turnaround[key] !== undefined) { - fields.push(`${col} = $${paramIdx}`); - params.push(body.turnaround[key]); - paramIdx++; - } - } - } - - if (body.pricing !== undefined) { - fields.push(`pricing = $${paramIdx}`); - params.push(JSON.stringify(body.pricing)); - paramIdx++; - } - - if (body.contact) { - for (const [key, col] of Object.entries({ email: "contact_email", phone: "contact_phone", website: "contact_website" })) { - if (body.contact[key] !== undefined) { - fields.push(`${col} = $${paramIdx}`); - params.push(body.contact[key]); - paramIdx++; - } - } - } - - if (fields.length === 0) return c.json({ error: "No fields to update" }, 400); - - fields.push("updated_at = NOW()"); - params.push(id); - - const result = await sql.unsafe( - `UPDATE providers.providers SET ${fields.join(", ")} WHERE id = $${paramIdx} RETURNING *`, - params - ); - - return c.json(toProviderResponse(result[0])); -}); - -// ── DELETE /api/providers/:id — Deactivate provider ── -routes.delete("/api/providers/:id", async (c) => { - const result = await sql.unsafe( - "UPDATE providers.providers SET active = FALSE, updated_at = NOW() WHERE id = $1 RETURNING *", - [c.req.param("id")] - ); - if (result.length === 0) return c.json({ error: "Provider not found" }, 404); - return c.json({ message: "Provider deactivated", provider: toProviderResponse(result[0]) }); -}); - -// ── Page route: browse providers ── -routes.get("/", (c) => { - const space = c.req.param("space") || "demo"; - return c.html(renderShell({ - title: `Providers | rSpace`, - moduleId: "rproviders", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - })); -}); - -export const providersModule: RSpaceModule = { - id: "rproviders", - name: "rProviders", - icon: "\u{1F3ED}", - description: "Local provider directory for cosmolocal production", - routes, - standaloneDomain: "providers.mycofi.earth", -}; diff --git a/modules/splat/mod.ts b/modules/splat/mod.ts index 343ba2f..a4a934c 100644 --- a/modules/splat/mod.ts +++ b/modules/splat/mod.ts @@ -540,6 +540,7 @@ export const splatModule: RSpaceModule = { description: "3D Gaussian splat viewer", routes, standaloneDomain: "rsplat.online", + hidden: true, async onSpaceCreate(_spaceSlug: string) { // Splats are scoped by space_slug column. No per-space setup needed. diff --git a/server/index.ts b/server/index.ts index bef3e66..b8dff5b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -425,6 +425,271 @@ app.get("/api/modules", (c) => { return c.json({ modules: getModuleInfoList() }); }); +// ── Creative tools API endpoints ── + +const FAL_KEY = process.env.FAL_KEY || ""; + +// Image generation via fal.ai Flux Pro +app.post("/api/image-gen", async (c) => { + if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + + const { prompt, style } = await c.req.json(); + if (!prompt) return c.json({ error: "prompt required" }, 400); + + const stylePrompts: Record = { + illustration: "digital illustration style, ", + photorealistic: "photorealistic, high detail, ", + painting: "oil painting style, artistic, ", + sketch: "pencil sketch style, hand-drawn, ", + "punk-zine": "punk zine aesthetic, cut-and-paste collage, bold contrast, ", + }; + const styledPrompt = (stylePrompts[style] || "") + prompt; + + const res = await fetch("https://queue.fal.run/fal-ai/flux-pro/v1.1", { + method: "POST", + headers: { + Authorization: `Key ${FAL_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: styledPrompt, + image_size: "landscape_4_3", + num_images: 1, + safety_tolerance: "2", + }), + }); + + if (!res.ok) { + const err = await res.text(); + console.error("[image-gen] fal.ai error:", err); + return c.json({ error: "Image generation failed" }, 502); + } + + const data = await res.json(); + const imageUrl = data.images?.[0]?.url || data.output?.url; + if (!imageUrl) return c.json({ error: "No image returned" }, 502); + + return c.json({ url: imageUrl, image_url: imageUrl }); +}); + +// Text-to-video via fal.ai WAN 2.1 +app.post("/api/video-gen/t2v", async (c) => { + if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + + const { prompt, duration } = await c.req.json(); + if (!prompt) return c.json({ error: "prompt required" }, 400); + + const res = await fetch("https://queue.fal.run/fal-ai/wan/v2.1", { + method: "POST", + headers: { + Authorization: `Key ${FAL_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt, + num_frames: duration === "5s" ? 81 : 49, + resolution: "480p", + }), + }); + + if (!res.ok) { + const err = await res.text(); + console.error("[video-gen/t2v] fal.ai error:", err); + return c.json({ error: "Video generation failed" }, 502); + } + + const data = await res.json(); + const videoUrl = data.video?.url || data.output?.url; + if (!videoUrl) return c.json({ error: "No video returned" }, 502); + + return c.json({ url: videoUrl, video_url: videoUrl }); +}); + +// Image-to-video via fal.ai Kling +app.post("/api/video-gen/i2v", async (c) => { + if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + + const { image, prompt, duration } = await c.req.json(); + if (!image) return c.json({ error: "image required" }, 400); + + const res = await fetch("https://queue.fal.run/fal-ai/kling-video/v1/standard/image-to-video", { + method: "POST", + headers: { + Authorization: `Key ${FAL_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + image_url: image, + prompt: prompt || "", + duration: duration === "5s" ? "5" : "5", + }), + }); + + if (!res.ok) { + const err = await res.text(); + console.error("[video-gen/i2v] fal.ai error:", err); + return c.json({ error: "Video generation failed" }, 502); + } + + const data = await res.json(); + const videoUrl = data.video?.url || data.output?.url; + if (!videoUrl) return c.json({ error: "No video returned" }, 502); + + return c.json({ url: videoUrl, video_url: videoUrl }); +}); + +// Blender 3D generation via LLM + RunPod +const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || ""; + +app.post("/api/blender-gen", async (c) => { + if (!RUNPOD_API_KEY) return c.json({ error: "RUNPOD_API_KEY not configured" }, 503); + + const { prompt } = await c.req.json(); + if (!prompt) return c.json({ error: "prompt required" }, 400); + + // Step 1: Generate Blender Python script via LLM + const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; + let script = ""; + + try { + const llmRes = await fetch(`${OLLAMA_URL}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: process.env.OLLAMA_MODEL || "llama3.1", + prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`, + stream: false, + }), + }); + + if (llmRes.ok) { + const llmData = await llmRes.json(); + script = llmData.response || ""; + // Extract code block if wrapped in markdown + const codeMatch = script.match(/```python\n([\s\S]*?)```/); + if (codeMatch) script = codeMatch[1]; + } + } catch (e) { + console.error("[blender-gen] LLM error:", e); + } + + if (!script) { + return c.json({ error: "Failed to generate Blender script" }, 502); + } + + // Step 2: Execute on RunPod (headless Blender) + try { + const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", { + method: "POST", + headers: { + Authorization: `Bearer ${RUNPOD_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + input: { + script, + render: true, + }, + }), + }); + + if (!runpodRes.ok) { + // Return just the script if RunPod fails + return c.json({ script, error_detail: "RunPod execution failed" }); + } + + const runpodData = await runpodRes.json(); + return c.json({ + render_url: runpodData.output?.render_url || null, + script, + blend_url: runpodData.output?.blend_url || null, + }); + } catch (e) { + // Return the script even if RunPod is unavailable + return c.json({ script, error_detail: "RunPod unavailable" }); + } +}); + +// KiCAD PCB design — REST-to-MCP bridge +const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://localhost:3001"; + +app.post("/api/kicad/:action", async (c) => { + const action = c.req.param("action"); + const body = await c.req.json(); + + const validActions = [ + "create_project", "add_schematic_component", "add_schematic_connection", + "export_svg", "run_drc", "export_gerber", "export_bom", "export_pdf", + "search_symbols", "search_footprints", "place_component", + ]; + + if (!validActions.includes(action)) { + return c.json({ error: `Unknown action: ${action}` }, 400); + } + + try { + const mcpRes = await fetch(`${KICAD_MCP_URL}/call-tool`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: action, + arguments: body, + }), + }); + + if (!mcpRes.ok) { + const err = await mcpRes.text(); + console.error(`[kicad/${action}] MCP error:`, err); + return c.json({ error: `KiCAD action failed: ${action}` }, 502); + } + + const data = await mcpRes.json(); + return c.json(data); + } catch (e) { + console.error(`[kicad/${action}] Connection error:`, e); + return c.json({ error: "KiCAD MCP server not available" }, 503); + } +}); + +// FreeCAD parametric CAD — REST-to-MCP bridge +const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://localhost:3002"; + +app.post("/api/freecad/:action", async (c) => { + const action = c.req.param("action"); + const body = await c.req.json(); + + const validActions = [ + "generate", "export_step", "export_stl", "update_parameters", + ]; + + if (!validActions.includes(action)) { + return c.json({ error: `Unknown action: ${action}` }, 400); + } + + try { + const mcpRes = await fetch(`${FREECAD_MCP_URL}/call-tool`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: action, + arguments: body, + }), + }); + + if (!mcpRes.ok) { + const err = await mcpRes.text(); + console.error(`[freecad/${action}] MCP error:`, err); + return c.json({ error: `FreeCAD action failed: ${action}` }, 502); + } + + const data = await mcpRes.json(); + return c.json(data); + } catch (e) { + console.error(`[freecad/${action}] Connection error:`, e); + return c.json({ error: "FreeCAD MCP server not available" }, 503); + } +}); + // ── Auto-provision personal space ── app.post("/api/spaces/auto-provision", async (c) => { const token = extractToken(c.req.raw.headers); @@ -646,6 +911,12 @@ const server = Bun.serve({ // ── Standalone domain → internal rewrite to module routes ── const standaloneModuleId = domainToModule.get(hostClean); if (standaloneModuleId) { + // Self-fetch detection: landing proxy uses this User-Agent; + // return 404 to break circular fetch so the generic fallback is used + if (req.headers.get("user-agent") === "rSpace-Proxy/1.0") { + return new Response("Not found", { status: 404 }); + } + // Static assets pass through if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) { const assetPath = url.pathname.slice(1); @@ -655,7 +926,20 @@ const server = Bun.serve({ } } - // Rewrite path internally: / → /demo/{moduleId} + // Root path → serve landing page (not the module app) + if (url.pathname === "/") { + const allModules = getAllModules(); + const mod = allModules.find((m) => m.id === standaloneModuleId); + if (mod) { + const html = renderModuleLanding({ + module: mod, + modules: getModuleInfoList(), + }); + return new Response(html, { headers: { "Content-Type": "text/html" } }); + } + } + + // Sub-paths: rewrite internally → /{space}/{moduleId}/... const pathParts = url.pathname.split("/").filter(Boolean); let space = "demo"; let suffix = ""; diff --git a/server/shell.ts b/server/shell.ts index 50f2cee..42b0ddd 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -45,7 +45,7 @@ export function renderShell(opts: ShellOptions): string { } = opts; const moduleListJSON = JSON.stringify(modules); - const shellDemoUrl = `https://rspace.online/${escapeAttr(moduleId)}`; + const shellDemoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`; return ` diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 7248d9b..2d82152 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -101,7 +101,7 @@ const CATEGORY_ORDER = [ "Identity & Infrastructure", ]; -import { rspaceNavUrl, getCurrentSpace } from "../url-helpers"; +import { rspaceNavUrl, getCurrentSpace, isStandaloneDomain } from "../url-helpers"; export class RStackAppSwitcher extends HTMLElement { #shadow: ShadowRoot; @@ -187,11 +187,11 @@ export class RStackAppSwitcher extends HTMLElement { : `${m.icon}`; const space = this.#getSpaceSlug(); - // On demo (bare domain or demo subdomain): link to landing pages + // On demo (bare domain, demo subdomain, or standalone r*.online): link to landing pages const host = window.location.host.split(":")[0]; const onRspace = host.includes("rspace.online"); const href = - onRspace && space === "demo" + (onRspace && space === "demo") || isStandaloneDomain() ? `${window.location.protocol}//rspace.online/${m.id}` : rspaceNavUrl(space, m.id); diff --git a/shared/module.ts b/shared/module.ts index 92df586..079afd7 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -50,6 +50,8 @@ export interface RSpaceModule { onSpaceCreate?: (spaceSlug: string) => Promise; /** Called when a space is deleted (e.g. to clean up module-specific data) */ onSpaceDelete?: (spaceSlug: string) => Promise; + /** If true, module is hidden from app switcher (still has routes) */ + hidden?: boolean; } /** Registry of all loaded modules */ @@ -76,16 +78,19 @@ export interface ModuleInfo { standaloneDomain?: string; feeds?: FeedDefinition[]; acceptsFeeds?: FlowKind[]; + hidden?: boolean; } export function getModuleInfoList(): ModuleInfo[] { - return getAllModules().map((m) => ({ - id: m.id, - name: m.name, - icon: m.icon, - description: m.description, - ...(m.standaloneDomain ? { standaloneDomain: m.standaloneDomain } : {}), - ...(m.feeds ? { feeds: m.feeds } : {}), - ...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}), - })); + return getAllModules() + .filter((m) => !m.hidden) + .map((m) => ({ + id: m.id, + name: m.name, + icon: m.icon, + description: m.description, + ...(m.standaloneDomain ? { standaloneDomain: m.standaloneDomain } : {}), + ...(m.feeds ? { feeds: m.feeds } : {}), + ...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}), + })); } diff --git a/shared/url-helpers.ts b/shared/url-helpers.ts index fa0eb1f..8247b9b 100644 --- a/shared/url-helpers.ts +++ b/shared/url-helpers.ts @@ -24,13 +24,19 @@ export function isBareDomain(): boolean { return host === "rspace.online" || host === "www.rspace.online"; } +/** Detect if the current page is on a standalone r*.online domain (e.g. rvote.online) */ +export function isStandaloneDomain(): boolean { + const host = window.location.host.split(":")[0]; + return /^r[a-z]+\.online$/.test(host) && host !== "rspace.online"; +} + /** Get the current space from subdomain or path */ export function getCurrentSpace(): string { if (isSubdomain()) { return window.location.host.split(":")[0].split(".")[0]; } - // Bare domain: space is implicit (demo by default, until auto-provision) - if (isBareDomain()) { + // Bare domain or standalone domain: space is implicit (demo) + if (isBareDomain() || isStandaloneDomain()) { return "demo"; } // Path-based (localhost): /{space}/{moduleId} @@ -68,6 +74,14 @@ export function rspaceNavUrl(space: string, moduleId: string): string { hostParts.slice(-2).join(".") === "rspace.online" && !RESERVED_SUBDOMAINS.includes(hostParts[0]); + // Standalone r*.online domains → redirect to rspace.online for navigation + if (isStandaloneDomain()) { + if (space === "demo") { + return `${window.location.protocol}//demo.rspace.online/${moduleId}`; + } + return `${window.location.protocol}//${space}.rspace.online/${moduleId}`; + } + if (onSubdomain) { // Same space → just change the path if (hostParts[0] === space) { diff --git a/vite.config.ts b/vite.config.ts index 8017154..8ce5dc4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -160,33 +160,6 @@ export default defineConfig({ resolve(__dirname, "dist/modules/cart/cart.css"), ); - // Build providers module component - await build({ - configFile: false, - root: resolve(__dirname, "modules/providers/components"), - build: { - emptyOutDir: false, - outDir: resolve(__dirname, "dist/modules/providers"), - lib: { - entry: resolve(__dirname, "modules/providers/components/folk-provider-directory.ts"), - formats: ["es"], - fileName: () => "folk-provider-directory.js", - }, - rollupOptions: { - output: { - entryFileNames: "folk-provider-directory.js", - }, - }, - }, - }); - - // Copy providers CSS - mkdirSync(resolve(__dirname, "dist/modules/providers"), { recursive: true }); - copyFileSync( - resolve(__dirname, "modules/providers/components/providers.css"), - resolve(__dirname, "dist/modules/providers/providers.css"), - ); - // Build swag module component await build({ configFile: false, diff --git a/website/canvas.html b/website/canvas.html index d1ab1d9..4a80a00 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -403,6 +403,11 @@ folk-choice-rank, folk-choice-spider, folk-social-post, + folk-splat, + folk-blender, + folk-drawfast, + folk-freecad, + folk-kicad, folk-rapp { position: absolute; } @@ -414,7 +419,8 @@ folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, - folk-social-post, folk-rapp) { + folk-social-post, folk-splat, folk-blender, folk-drawfast, + folk-freecad, folk-kicad, folk-rapp) { cursor: crosshair; } @@ -425,7 +431,8 @@ folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, - folk-social-post, folk-rapp):hover { + folk-social-post, folk-splat, folk-blender, folk-drawfast, + folk-freecad, folk-kicad, folk-rapp):hover { outline: 2px dashed #3b82f6; outline-offset: 4px; } @@ -608,10 +615,21 @@
- +
+ + + + + +
+
+ +
+ +
@@ -676,7 +694,6 @@ -
@@ -740,6 +757,11 @@ FolkChoiceRank, FolkChoiceSpider, FolkSocialPost, + FolkSplat, + FolkBlender, + FolkDrawfast, + FolkFreeCAD, + FolkKiCAD, FolkCanvas, FolkRApp, FolkFeed, @@ -853,6 +875,11 @@ FolkChoiceRank.define(); FolkChoiceSpider.define(); FolkSocialPost.define(); + FolkSplat.define(); + FolkBlender.define(); + FolkDrawfast.define(); + FolkFreeCAD.define(); + FolkKiCAD.define(); FolkCanvas.define(); FolkRApp.define(); FolkFeed.define(); @@ -908,6 +935,8 @@ "folk-token-mint", "folk-token-ledger", "folk-choice-vote", "folk-choice-rank", "folk-choice-spider", "folk-social-post", + "folk-splat", "folk-blender", "folk-drawfast", + "folk-freecad", "folk-kicad", "folk-rapp", "folk-feed" ].join(", "); @@ -1301,6 +1330,22 @@ if (data.hashtags) shape.hashtags = data.hashtags; if (data.stepNumber) shape.stepNumber = data.stepNumber; break; + case "folk-splat": + shape = document.createElement("folk-splat"); + if (data.splatUrl) shape.splatUrl = data.splatUrl; + break; + case "folk-blender": + shape = document.createElement("folk-blender"); + break; + case "folk-drawfast": + shape = document.createElement("folk-drawfast"); + break; + case "folk-freecad": + shape = document.createElement("folk-freecad"); + break; + case "folk-kicad": + shape = document.createElement("folk-kicad"); + break; case "folk-canvas": shape = document.createElement("folk-canvas"); shape.parentSlug = communitySlug; // pass parent context for nest-from @@ -1394,6 +1439,11 @@ "folk-choice-rank": { width: 380, height: 480 }, "folk-choice-spider": { width: 440, height: 540 }, "folk-social-post": { width: 300, height: 380 }, + "folk-splat": { width: 480, height: 420 }, + "folk-blender": { width: 420, height: 520 }, + "folk-drawfast": { width: 500, height: 480 }, + "folk-freecad": { width: 400, height: 480 }, + "folk-kicad": { width: 420, height: 500 }, "folk-canvas": { width: 600, height: 400 }, "folk-rapp": { width: 500, height: 400 }, "folk-feed": { width: 280, height: 360 }, @@ -1549,6 +1599,11 @@ document.getElementById("new-video-chat").addEventListener("click", () => newShape("folk-video-chat")); document.getElementById("new-obs-note").addEventListener("click", () => newShape("folk-obs-note")); document.getElementById("new-workflow").addEventListener("click", () => newShape("folk-workflow-block")); + document.getElementById("new-splat").addEventListener("click", () => newShape("folk-splat")); + document.getElementById("new-blender").addEventListener("click", () => newShape("folk-blender")); + document.getElementById("new-drawfast").addEventListener("click", () => newShape("folk-drawfast")); + document.getElementById("new-freecad").addEventListener("click", () => newShape("folk-freecad")); + document.getElementById("new-kicad").addEventListener("click", () => newShape("folk-kicad")); document.getElementById("new-google-item").addEventListener("click", () => { newShape("folk-google-item", { service: "drive", title: "New Google Item" }); }); @@ -1664,7 +1719,6 @@ { btnId: "embed-cart", moduleId: "rcart" }, { btnId: "embed-data", moduleId: "rdata" }, { btnId: "embed-network", moduleId: "rnetwork" }, - { btnId: "embed-splat", moduleId: "rsplat" }, { btnId: "embed-swag", moduleId: "rswag" }, ];