feat: creative tools suite — 7 tools in unified canvas toolbar
- Delete rProviders module (unused) - Add hidden flag to module system, hide rSplat from app switcher - Add fal.ai API proxies: image-gen (Flux Pro), video-gen t2v (WAN 2.1), i2v (Kling) - New canvas shapes: folk-splat (3D viewer), folk-blender (3D gen), folk-drawfast (freehand drawing), folk-freecad (parametric CAD), folk-kicad (PCB design) - Restructure canvas toolbar: new "Creative" group with all 7 tools, reduced "Media" group - Add blender-gen, kicad, freecad REST-to-MCP bridge endpoints - Fix standalone domain navigation to rspace.online landing pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
25643060e0
commit
3b6ea5afcd
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/* Providers module theme */
|
||||
body[data-theme="light"] main {
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
286
server/index.ts
286
server/index.ts
|
|
@ -425,6 +425,271 @@ app.get("/api/modules", (c) => {
|
|||
return c.json({ modules: getModuleInfoList() });
|
||||
});
|
||||
|
||||
// ── Creative tools API endpoints ──
|
||||
|
||||
const FAL_KEY = process.env.FAL_KEY || "";
|
||||
|
||||
// Image generation via fal.ai Flux Pro
|
||||
app.post("/api/image-gen", async (c) => {
|
||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||||
|
||||
const { prompt, style } = await c.req.json();
|
||||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||||
|
||||
const stylePrompts: Record<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);
|
||||
|
|
@ -646,6 +911,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);
|
||||
|
|
@ -655,7 +926,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 = "";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue