Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-02-27 13:58:03 -08:00
commit be995b33ff
19 changed files with 2815 additions and 679 deletions

444
lib/folk-blender.ts Normal file
View File

@ -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`
<div class="header">
<span class="header-title">
<span>\u{1F9CA}</span>
<span>3D Blender</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">\u00D7</button>
</div>
</div>
<div class="content">
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Describe the 3D scene to generate...\ne.g. 'A low-poly fox sitting on a tree stump'" rows="3"></textarea>
<div class="controls">
<button class="generate-btn">\u{1F9CA} Generate 3D</button>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="preview">\u{1F5BC} Render</button>
<button class="tab" data-tab="code">\u{1F4DC} Script</button>
</div>
<div class="preview-area">
<div class="render-preview">
<div class="placeholder">
<span class="placeholder-icon">\u{1F9CA}</span>
<span>Describe a 3D scene and click Generate</span>
</div>
</div>
<div class="code-area" style="display:none">
<pre>// Blender Python script will appear here</pre>
</div>
</div>
<div class="download-row" style="display:none">
<a class="download-btn blend-dl" href="#" download>\u{2B07} .blend</a>
</div>
</div>
`;
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 = '<div class="loading"><div class="spinner"></div><span>Generating 3D scene...</span></div>';
}
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 = `<div class="error">${this.#escapeHtml(this.#error)}</div>`;
}
} finally {
this.#isLoading = false;
if (this.#generateBtn) this.#generateBtn.disabled = false;
}
}
#renderResult() {
if (this.#previewArea) {
if (this.#renderUrl) {
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#renderUrl)}" alt="3D Render" />`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">\u2705</span><span>Script generated (see Script tab)</span></div>';
}
}
if (this.#codeArea && this.#script) {
this.#codeArea.innerHTML = `<pre>${this.#escapeHtml(this.#script)}</pre>`;
}
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,
};
}
}

456
lib/folk-drawfast.ts Normal file
View File

@ -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`
<div class="header">
<span class="header-title">
<span>\u270F\uFE0F</span>
<span>Drawfast</span>
</span>
<div class="header-actions">
<button class="export-png-btn" title="Export PNG">\u{1F4BE}</button>
<button class="close-btn" title="Close">\u00D7</button>
</div>
</div>
<div class="content">
<div class="toolbar-row">
<button class="tool-btn active" data-tool="pen" title="Pen">\u270F\uFE0F</button>
<button class="tool-btn" data-tool="eraser" title="Eraser">\u{1F9F9}</button>
<span style="width:1px;height:20px;background:#e2e8f0;margin:0 2px"></span>
${COLORS.map((c) => `<span class="color-swatch${c === "#0f172a" ? " active" : ""}" data-color="${c}" style="background:${c}"></span>`).join("")}
<span style="width:1px;height:20px;background:#e2e8f0;margin:0 2px"></span>
<input type="range" class="size-slider" min="1" max="24" value="4" />
<span class="size-label">4</span>
<span style="flex:1"></span>
<button class="tool-btn" data-tool="clear" title="Clear all">\u{1F5D1}</button>
</div>
<div class="canvas-area">
<canvas></canvas>
</div>
</div>
`;
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,
})),
};
}
}

378
lib/folk-freecad.ts Normal file
View File

@ -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`
<div class="header">
<span class="header-title">
<span>\u{1F4D0}</span>
<span>FreeCAD</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">\u00D7</button>
</div>
</div>
<div class="content">
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Describe the parametric part to generate...\ne.g. 'Enclosure box 80x50x30mm with 3mm walls, ventilation slots, and M3 screw bosses'" rows="3"></textarea>
<div class="controls">
<button class="generate-btn">\u{1F4D0} Generate CAD</button>
</div>
</div>
<div class="preview-area">
<div class="placeholder">
<span class="placeholder-icon">\u{1F4D0}</span>
<span>Describe a part and click Generate</span>
</div>
</div>
<div class="export-row" style="display:none">
<a class="export-btn primary step-dl" href="#" download>\u{2B07} STEP</a>
<a class="export-btn stl-dl" href="#" download>\u{2B07} STL</a>
</div>
</div>
`;
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 = '<div class="loading"><div class="spinner"></div><span>Generating CAD model...</span></div>';
}
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 = `<div class="error">${this.#escapeHtml(this.#error)}</div>`;
}
} finally {
this.#isLoading = false;
if (this.#generateBtn) this.#generateBtn.disabled = false;
}
}
#renderResult() {
if (this.#previewArea) {
if (this.#previewUrl) {
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#previewUrl)}" alt="CAD Preview" />`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">\u2705</span><span>Model generated! Download files below.</span></div>';
}
}
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,
};
}
}

496
lib/folk-kicad.ts Normal file
View File

@ -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`
<div class="header">
<span class="header-title">
<span>\u{1F50C}</span>
<span>KiCAD PCB</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">\u00D7</button>
</div>
</div>
<div class="content">
<div class="prompt-area">
<textarea class="prompt-input" placeholder="Describe the PCB design...\ne.g. 'ESP32-based sensor board with BME280, OLED display, and USB-C'" rows="2"></textarea>
<input class="component-input" type="text" placeholder="Components (comma-separated): ESP32-WROOM, BME280, SSD1306..." />
<div class="controls">
<button class="generate-btn">\u{1F50C} Generate PCB</button>
</div>
</div>
<div class="tabs">
<button class="tab active" data-tab="schematic">\u{1F4CB} Schematic</button>
<button class="tab" data-tab="board">\u{1F4DF} Board</button>
<button class="tab" data-tab="drc">\u2705 DRC</button>
</div>
<div class="preview-area">
<div class="placeholder">
<span class="placeholder-icon">\u{1F50C}</span>
<span>Describe a PCB design and click Generate</span>
</div>
</div>
<div class="export-row" style="display:none">
<a class="export-btn primary gerber-dl" href="#" download>\u{2B07} Gerber</a>
<a class="export-btn bom-dl" href="#" download>\u{1F4CB} BOM</a>
<a class="export-btn pdf-dl" href="#" download>\u{1F4C4} PDF</a>
</div>
</div>
`;
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 = '<div class="loading"><div class="spinner"></div><span>Generating PCB design...</span></div>';
}
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 = `<div class="error">${this.#escapeHtml(this.#error)}</div>`;
}
} 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 = `<img src="${this.#escapeHtml(this.#schematicSvg)}" alt="Schematic" />`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">\u{1F4CB}</span><span>Schematic will appear here</span></div>';
}
break;
case "board":
if (this.#boardSvg) {
this.#previewArea.innerHTML = `<img src="${this.#escapeHtml(this.#boardSvg)}" alt="Board Layout" />`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">\u{1F4DF}</span><span>Board layout will appear here</span></div>';
}
break;
case "drc":
if (this.#drcResults) {
const violations = this.#drcResults.violations || [];
const passed = violations.length === 0;
this.#previewArea.innerHTML = `
<div class="drc-results" style="padding:12px">
<h4 style="margin:0 0 8px">${passed
? '<span class="pass">\u2705 DRC Passed</span>'
: `<span class="fail">\u274C ${violations.length} Violation(s)</span>`
}</h4>
${violations.map((v: any) => `<p style="margin:4px 0;font-size:12px">\u2022 ${this.#escapeHtml(v.message || v)}</p>`).join("")}
</div>
`;
} else {
this.#previewArea.innerHTML = '<div class="placeholder"><span class="placeholder-icon">\u2705</span><span>DRC results will appear here</span></div>';
}
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,
};
}
}

438
lib/folk-splat.ts Normal file
View File

@ -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`
<div class="header">
<span class="header-title">
<span>\u{1F52E}</span>
<span>3D Splat</span>
</span>
<div class="header-actions">
<button class="gallery-btn" title="Browse gallery">\u{1F4C2}</button>
<button class="close-btn" title="Close">\u00D7</button>
</div>
</div>
<div class="content">
<div class="controls">
<input class="splat-url-input" type="text" placeholder="Paste .splat URL or pick from gallery..." />
<button class="load-btn">Load</button>
</div>
<div class="gallery-list" style="display:none"></div>
<div class="viewer-area">
<div class="placeholder">
<span class="placeholder-icon">\u{1F52E}</span>
<span>Enter a splat URL or browse the gallery</span>
</div>
</div>
</div>
`;
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 = '<div class="loading"><div class="spinner"></div><span>Loading gallery...</span></div>';
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 = '<div class="placeholder" style="padding:16px"><span>No splats in this space yet</span></div>';
return;
}
container.innerHTML = this.#gallerySplats.map((s) => `
<div class="gallery-item" data-slug="${s.slug}">
<div class="title">${this.#escapeHtml(s.title)}</div>
<div class="meta">${s.file_format} \u2022 ${(s.file_size_bytes / 1024 / 1024).toFixed(1)}MB</div>
</div>
`).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 = `<div class="error">Failed to load gallery</div>`;
}
}
async #loadSplat(url: string, viewerArea: HTMLElement) {
this.#splatUrl = url;
this.#isLoading = true;
this.#error = null;
viewerArea.innerHTML = '<div class="loading"><div class="spinner"></div><span>Loading 3D splat...</span></div>';
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<any>);
const GS3D = await (Function("id", "return import(id)")(gs3dId) as Promise<any>);
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 = `<iframe src="/${spaceSlug}/rsplat/view/${slug}" style="width:100%;height:100%;border:none;border-radius:0 0 8px 8px"></iframe>`;
} 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,
};
}
}

View File

@ -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";

View File

@ -1,182 +0,0 @@
/**
* <folk-provider-directory> 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<string>();
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 = `
<style>
:host { display: block; padding: 1.5rem; }
.rapp-nav { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-bottom: 1rem; min-height: 36px; }
.rapp-nav__title { font-size: 15px; font-weight: 600; color: #e2e8f0; flex: 1; }
.search { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #f1f5f9; font-size: 0.875rem; width: 240px; }
.search:focus { outline: none; border-color: #6366f1; }
.locate-btn { padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.75rem; }
.locate-btn:hover { background: #334155; color: #f1f5f9; }
.locate-btn.active { border-color: #6366f1; color: #a5b4fc; }
.caps { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 1.5rem; }
.cap { padding: 0.25rem 0.625rem; border-radius: 999px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; font-size: 0.75rem; cursor: pointer; }
.cap:hover { border-color: #6366f1; color: #c7d2fe; }
.cap.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.card:hover { border-color: #475569; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; }
.card-name { font-size: 1.125rem; font-weight: 600; color: #f1f5f9; margin: 0; }
.card-location { font-size: 0.8125rem; color: #94a3b8; margin-top: 0.25rem; }
.badge { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; }
.badge-active { background: rgba(34,197,94,0.15); color: #4ade80; }
.badge-distance { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.caps-list { display: flex; flex-wrap: wrap; gap: 0.25rem; margin: 0.75rem 0; }
.cap-tag { padding: 0.125rem 0.5rem; border-radius: 4px; background: rgba(99,102,241,0.1); color: #818cf8; font-size: 0.6875rem; }
.card-footer { display: flex; gap: 1rem; font-size: 0.75rem; color: #64748b; border-top: 1px solid #334155; padding-top: 0.75rem; margin-top: 0.75rem; }
.card-footer a { color: #818cf8; text-decoration: none; }
.card-footer a:hover { text-decoration: underline; }
.turnaround { font-size: 0.75rem; color: #94a3b8; }
.empty { text-align: center; padding: 3rem; color: #64748b; }
</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Provider Directory</span>
<input class="search" type="text" placeholder="Search providers..." value="${this.searchQuery}">
<button class="locate-btn ${this.userLat ? 'active' : ''}">\u{1F4CD} ${this.userLat ? 'Nearby' : 'Use location'}</button>
</div>
${this.capabilities.length > 0 ? `
<div class="caps">
<span class="cap ${!this.selectedCap ? 'active' : ''}" data-cap="">All</span>
${this.capabilities.map((cap) => `
<span class="cap ${this.selectedCap === cap ? 'active' : ''}" data-cap="${cap}">${cap}</span>
`).join("")}
</div>` : ""}
${filtered.length === 0 ? `<div class="empty">No providers found</div>` : `
<div class="grid">
${filtered.map((p) => `
<div class="card">
<div class="card-header">
<div>
<h3 class="card-name">${this.esc(p.name)}</h3>
<div class="card-location">${this.esc(p.location?.city || "")}${p.location?.region ? `, ${this.esc(p.location.region)}` : ""} ${this.esc(p.location?.country || "")}</div>
</div>
<div style="display:flex;gap:0.375rem;flex-direction:column;align-items:flex-end">
<span class="badge badge-active">\u2713 Active</span>
${p.distance_km !== undefined ? `<span class="badge badge-distance">${p.distance_km} km</span>` : ""}
</div>
</div>
<div class="caps-list">
${(p.capabilities || []).map((cap: string) => `<span class="cap-tag">${this.esc(cap)}</span>`).join("")}
</div>
${p.turnaround?.standard_days ? `<div class="turnaround">\u23F1 ${p.turnaround.standard_days} days standard${p.turnaround.rush_days ? ` / ${p.turnaround.rush_days} days rush (+${p.turnaround.rush_surcharge_pct || 0}%)` : ""}</div>` : ""}
<div class="card-footer">
${p.contact?.email ? `<a href="mailto:${this.esc(p.contact.email)}">\u2709 Email</a>` : ""}
${p.contact?.website ? `<a href="${this.esc(p.contact.website)}" target="_blank">\u{1F310} Website</a>` : ""}
${p.location?.offers_shipping ? `<span>\u{1F4E6} Ships</span>` : ""}
</div>
</div>
`).join("")}
</div>`}
`;
// 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);

View File

@ -1,6 +0,0 @@
/* Providers module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -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;

View File

@ -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<string, unknown>) {
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: `<folk-provider-directory space="${space}"></folk-provider-directory>`,
scripts: `<script type="module" src="/modules/providers/folk-provider-directory.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/providers/providers.css">`,
}));
});
export const providersModule: RSpaceModule = {
id: "rproviders",
name: "rProviders",
icon: "\u{1F3ED}",
description: "Local provider directory for cosmolocal production",
routes,
standaloneDomain: "providers.mycofi.earth",
};

View File

@ -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.

View File

@ -65,6 +65,7 @@ import { photosModule } from "../modules/photos/mod";
import { rsocialsModule } from "../modules/rsocials/mod";
import { spaces } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { fetchLandingPage } from "./landing-proxy";
import { syncServer } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";
@ -426,6 +427,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<string, string> = {
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);
@ -647,6 +913,12 @@ const server = Bun.serve<WSData>({
// ── 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);
@ -656,7 +928,20 @@ const server = Bun.serve<WSData>({
}
}
// 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 = "";
@ -790,7 +1075,14 @@ const server = Bun.serve<WSData>({
const mod = allModules.find((m) => m.id === firstSegment);
if (mod) {
if (pathSegments.length === 1) {
// Exact module path → show landing page
// Try proxying the rich standalone landing page
const proxyHtml = await fetchLandingPage(mod, getModuleInfoList());
if (proxyHtml) {
return new Response(proxyHtml, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
// Fallback to generic landing page
const html = renderModuleLanding({
module: mod,
modules: getModuleInfoList(),

206
server/landing-proxy.ts Normal file
View File

@ -0,0 +1,206 @@
/**
* Landing page proxy.
*
* Fetches pre-rendered HTML from standalone r*.online domains, transforms it
* with Bun's HTMLRewriter (rewrite asset URLs, strip scripts, inject rSpace
* shell header), caches the result, and serves it on rspace.online/{moduleId}.
*
* Falls back to the generic landing page if the fetch fails.
*/
import type { ModuleInfo } from "../shared/module";
import { escapeHtml, escapeAttr } from "./shell";
// ── Cache ──
interface CacheEntry {
html: string;
fetchedAt: number;
}
const cache = new Map<string, CacheEntry>();
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
// ── Ecosystem link rewriting ──
/** Map standalone domains → rspace.online paths for footer links */
function buildEcosystemMap(modules: ModuleInfo[]): Map<string, string> {
const map = new Map<string, string>();
for (const m of modules) {
if (m.standaloneDomain) {
map.set(`https://${m.standaloneDomain}`, `https://rspace.online/${m.id}`);
// Also catch without trailing slash
map.set(`https://www.${m.standaloneDomain}`, `https://rspace.online/${m.id}`);
}
}
return map;
}
// ── Shell header HTML ──
function renderShellHeader(moduleId: string, modules: ModuleInfo[]): string {
const moduleListJSON = JSON.stringify(modules);
const demoUrl = `https://demo.rspace.online/${escapeAttr(moduleId)}`;
return `<header class="rstack-header" data-theme="dark">
<div class="rstack-header__left">
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
</div>
<div class="rstack-header__center"></div>
<div class="rstack-header__right">
<a class="rstack-header__demo-btn" href="${demoUrl}">Try Demo</a>
<rstack-identity></rstack-identity>
</div>
</header>
<script type="module">
import '/shell.js';
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
// Logged-in users: redirect CTA to personal space
try {
var raw = localStorage.getItem('encryptid_session');
if (raw) {
var session = JSON.parse(raw);
if (session?.claims?.username) {
var username = session.claims.username.toLowerCase();
var btn = document.querySelector('.rstack-header__demo-btn');
if (btn) {
btn.textContent = 'Go to My Space';
btn.href = 'https://' + username + '.rspace.online/${escapeAttr(moduleId)}';
}
}
}
} catch(e) {}
</script>`;
}
// ── Main export ──
export async function fetchLandingPage(
mod: { id: string; standaloneDomain?: string },
modules: ModuleInfo[],
): Promise<string | null> {
const domain = mod.standaloneDomain;
if (!domain) return null;
const now = Date.now();
const cached = cache.get(domain);
// Return fresh cache
if (cached && now - cached.fetchedAt < CACHE_TTL) {
return cached.html;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`https://${domain}`, {
headers: { "User-Agent": "rSpace-Proxy/1.0" },
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
// Return stale cache if available
if (cached) return cached.html;
return null;
}
const rawHtml = await res.text();
// Transform with HTMLRewriter
const html = await transformWithRewriter(rawHtml, domain, mod.id, modules);
cache.set(domain, { html, fetchedAt: now });
return html;
} catch (e) {
// Network error / timeout — return stale cache if available
if (cached) return cached.html;
console.error(`[LandingProxy] Failed to fetch ${domain}:`, (e as Error).message);
return null;
}
}
/** Async HTMLRewriter transform (Bun's HTMLRewriter works on Response objects) */
async function transformWithRewriter(
rawHtml: string,
domain: string,
moduleId: string,
modules: ModuleInfo[],
): Promise<string> {
const ecosystemMap = buildEcosystemMap(modules);
const origin = `https://${domain}`;
let headerInjected = false;
const rewriter = new HTMLRewriter();
// 1. Rewrite _next asset URLs to absolute (CSS, fonts, images)
rewriter.on('link[href^="/_next/"]', {
element(el) {
const href = el.getAttribute("href");
if (href) el.setAttribute("href", `${origin}${href}`);
},
});
rewriter.on('img[src^="/_next/"]', {
element(el) {
const src = el.getAttribute("src");
if (src) el.setAttribute("src", `${origin}${src}`);
},
});
// 2. Strip all <script> tags
rewriter.on("script", {
element(el) {
el.remove();
},
});
// 3. Replace first <nav> with rSpace shell header
rewriter.on("nav", {
element(el) {
if (!headerInjected) {
headerInjected = true;
el.replace(renderShellHeader(moduleId, modules), { html: true });
}
},
});
// 4. Inject shell.css + analytics into <head>
rewriter.on("head", {
element(el) {
el.append(
`<link rel="stylesheet" href="/shell.css">
<script defer src="https://rdata.online/collect.js" data-website-id="6ee7917b-0ed7-44cb-a4c8-91037638526b"></script>`,
{ html: true },
);
},
});
// 5. If no <nav> was found, prepend shell header to <body>
rewriter.on("body", {
element(el) {
if (!headerInjected) {
headerInjected = true;
el.prepend(renderShellHeader(moduleId, modules), { html: true });
}
},
});
// 6. Rewrite ecosystem links (standalone domain URLs → rspace.online paths)
rewriter.on("a[href]", {
element(el) {
const href = el.getAttribute("href");
if (href) {
for (const [standaloneUrl, rspaceUrl] of ecosystemMap) {
if (href === standaloneUrl || href === standaloneUrl + "/") {
el.setAttribute("href", rspaceUrl);
break;
}
}
}
},
});
const transformed = rewriter.transform(new Response(rawHtml));
return transformed.text();
}

View File

@ -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 `<!DOCTYPE html>
<html lang="en">
@ -526,10 +526,10 @@ body {
@media (max-width: 600px) { .ml-name { font-size: 2rem; } .ml-icon { font-size: 3rem; } }
`;
function escapeHtml(s: string): string {
export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escapeAttr(s: string): string {
export function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

View File

@ -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 {
: `<span class="item-icon">${m.icon}</span>`;
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);

View File

@ -50,6 +50,8 @@ export interface RSpaceModule {
onSpaceCreate?: (spaceSlug: string) => Promise<void>;
/** Called when a space is deleted (e.g. to clean up module-specific data) */
onSpaceDelete?: (spaceSlug: string) => Promise<void>;
/** 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 } : {}),
}));
}

View File

@ -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) {

View File

@ -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,

View File

@ -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 @@
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">🎨 Media</button>
<button class="toolbar-group-toggle">🔮 Creative</button>
<div class="toolbar-dropdown">
<button id="new-image-gen" title="New AI Image">🎨 AI Image</button>
<button id="new-video-gen" title="New AI Video">🎬 AI Video</button>
<button id="new-splat" title="New 3D Splat">🔮 3D Splat</button>
<button id="new-blender" title="New 3D Blender">🧊 3D Blender</button>
<button id="new-drawfast" title="New Drawing">✏️ Drawfast</button>
<button id="new-freecad" title="New FreeCAD">📐 FreeCAD</button>
<button id="new-kicad" title="New KiCAD PCB">🔌 KiCAD PCB</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">🎨 Media</button>
<div class="toolbar-dropdown">
<button id="new-transcription" title="New Transcription">🎤 Transcribe</button>
<button id="new-video-chat" title="New Video Call">📹 Video Call</button>
<button id="new-piano" title="New Piano">🎹 Piano</button>
@ -676,7 +694,6 @@
<button id="embed-cart" title="Embed rCart">🛒 rCart</button>
<button id="embed-data" title="Embed rData">📊 rData</button>
<button id="embed-network" title="Embed rNetwork">🌍 rNetwork</button>
<button id="embed-splat" title="Embed rSplat">🔮 rSplat</button>
<button id="embed-swag" title="Embed rSwag">🎨 rSwag</button>
</div>
</div>
@ -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" },
];