rspace-online/modules/splat/components/folk-splat-viewer.ts

320 lines
11 KiB
TypeScript

/**
* <folk-splat-viewer> — Gaussian splat gallery + 3D viewer web component.
*
* Gallery mode: card grid of splats with upload form.
* Viewer mode: full-viewport Three.js + GaussianSplats3D renderer.
*
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
*/
interface SplatItem {
id: string;
slug: string;
title: string;
description?: string;
file_format: string;
file_size_bytes: number;
view_count: number;
contributor_name?: string;
created_at: string;
}
export class FolkSplatViewer extends HTMLElement {
private _mode: "gallery" | "viewer" = "gallery";
private _splats: SplatItem[] = [];
private _spaceSlug = "demo";
private _splatUrl = "";
private _splatTitle = "";
private _splatDesc = "";
private _viewer: any = null;
static get observedAttributes() {
return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"];
}
set splats(val: SplatItem[]) {
this._splats = val;
if (this._mode === "gallery") this.renderGallery();
}
set spaceSlug(val: string) {
this._spaceSlug = val;
}
connectedCallback() {
this._mode = (this.getAttribute("mode") as "gallery" | "viewer") || "gallery";
this._splatUrl = this.getAttribute("splat-url") || "";
this._splatTitle = this.getAttribute("splat-title") || "";
this._splatDesc = this.getAttribute("splat-desc") || "";
this._spaceSlug = this.getAttribute("space-slug") || "demo";
if (this._mode === "viewer") {
this.renderViewer();
} else {
this.renderGallery();
}
}
disconnectedCallback() {
if (this._viewer) {
try { this._viewer.dispose(); } catch {}
this._viewer = null;
}
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === "mode") this._mode = val as "gallery" | "viewer";
if (name === "splat-url") this._splatUrl = val;
if (name === "splat-title") this._splatTitle = val;
if (name === "splat-desc") this._splatDesc = val;
if (name === "space-slug") this._spaceSlug = val;
}
// ── Gallery ──
private renderGallery() {
const cards = this._splats.map((s) => `
<a class="splat-card" href="/${this._spaceSlug}/splat/view/${s.slug}">
<div class="splat-card__preview">
<span>🔮</span>
</div>
<div class="splat-card__body">
<div class="splat-card__title">${esc(s.title)}</div>
<div class="splat-card__meta">
<span class="splat-badge splat-badge--${s.file_format}">${s.file_format}</span>
<span>${formatSize(s.file_size_bytes)}</span>
<span>${s.view_count} views</span>
</div>
</div>
</a>
`).join("");
const empty = this._splats.length === 0 ? `
<div class="splat-empty">
<div class="splat-empty__icon">🔮</div>
<h3>No splats yet</h3>
<p>Upload a .ply, .splat, or .spz file to get started</p>
</div>
` : "";
this.innerHTML = `
<div class="splat-gallery">
<h1>rSplat</h1>
<p class="splat-gallery__subtitle">3D Gaussian Splat Gallery</p>
${empty}
<div class="splat-grid">${cards}</div>
<div class="splat-upload" id="splat-drop">
<div class="splat-upload__icon">📤</div>
<p class="splat-upload__text">
Drag &amp; drop a <strong>.ply</strong>, <strong>.splat</strong>, or <strong>.spz</strong> file here
<br>or <strong id="splat-browse">browse</strong> to upload
</p>
<input type="file" id="splat-file" accept=".ply,.splat,.spz" hidden>
<div class="splat-upload__form" id="splat-form">
<input type="text" id="splat-title-input" placeholder="Title (required)" required>
<textarea id="splat-desc-input" placeholder="Description (optional)" rows="2"></textarea>
<input type="text" id="splat-tags-input" placeholder="Tags (comma-separated)">
<button class="splat-upload__btn" id="splat-submit" disabled>Upload Splat</button>
<div class="splat-upload__status" id="splat-status"></div>
</div>
</div>
</div>
`;
this.setupUploadHandlers();
}
private setupUploadHandlers() {
const drop = this.querySelector("#splat-drop") as HTMLElement;
const fileInput = this.querySelector("#splat-file") as HTMLInputElement;
const browse = this.querySelector("#splat-browse") as HTMLElement;
const form = this.querySelector("#splat-form") as HTMLElement;
const titleInput = this.querySelector("#splat-title-input") as HTMLInputElement;
const descInput = this.querySelector("#splat-desc-input") as HTMLTextAreaElement;
const tagsInput = this.querySelector("#splat-tags-input") as HTMLInputElement;
const submitBtn = this.querySelector("#splat-submit") as HTMLButtonElement;
const status = this.querySelector("#splat-status") as HTMLElement;
if (!drop || !fileInput) return;
let selectedFile: File | null = null;
browse?.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
if (fileInput.files?.[0]) {
selectedFile = fileInput.files[0];
form.classList.add("active");
// Auto-populate title from filename
const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, "");
titleInput.value = name.replace(/[-_]/g, " ");
titleInput.dispatchEvent(new Event("input"));
}
});
drop.addEventListener("dragover", (e) => {
e.preventDefault();
drop.classList.add("splat-upload--dragover");
});
drop.addEventListener("dragleave", () => {
drop.classList.remove("splat-upload--dragover");
});
drop.addEventListener("drop", (e) => {
e.preventDefault();
drop.classList.remove("splat-upload--dragover");
const file = e.dataTransfer?.files[0];
if (file && /\.(ply|splat|spz)$/i.test(file.name)) {
selectedFile = file;
form.classList.add("active");
const name = file.name.replace(/\.(ply|splat|spz)$/i, "");
titleInput.value = name.replace(/[-_]/g, " ");
titleInput.dispatchEvent(new Event("input"));
}
});
titleInput?.addEventListener("input", () => {
submitBtn.disabled = !titleInput.value.trim() || !selectedFile;
});
submitBtn?.addEventListener("click", async () => {
if (!selectedFile || !titleInput.value.trim()) return;
submitBtn.disabled = true;
status.textContent = "Uploading...";
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("title", titleInput.value.trim());
formData.append("description", descInput.value.trim());
formData.append("tags", tagsInput.value.trim());
try {
const token = localStorage.getItem("encryptid_token") || "";
const res = await fetch(`/${this._spaceSlug}/splat/api/splats`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (res.status === 402) {
status.textContent = "Payment required for upload (x402)";
submitBtn.disabled = false;
return;
}
if (res.status === 401) {
status.textContent = "Sign in with rStack Identity to upload";
submitBtn.disabled = false;
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Upload failed" }));
status.textContent = (err as any).error || "Upload failed";
submitBtn.disabled = false;
return;
}
const splat = await res.json() as SplatItem;
status.textContent = "Uploaded!";
// Navigate to viewer
setTimeout(() => {
window.location.href = `/${this._spaceSlug}/splat/view/${splat.slug}`;
}, 500);
} catch (e) {
status.textContent = "Network error";
submitBtn.disabled = false;
}
});
}
// ── Viewer ──
private renderViewer() {
this.innerHTML = `
<div class="splat-viewer">
<div class="splat-loading" id="splat-loading">
<div class="splat-loading__spinner"></div>
<div class="splat-loading__text">Loading splat...</div>
</div>
<div class="splat-viewer__controls">
<a class="splat-viewer__back" href="/${this._spaceSlug}/splat">← Gallery</a>
</div>
${this._splatTitle ? `
<div class="splat-viewer__info">
<p class="splat-viewer__title">${esc(this._splatTitle)}</p>
${this._splatDesc ? `<p class="splat-viewer__desc">${esc(this._splatDesc)}</p>` : ""}
</div>
` : ""}
<div id="splat-container" class="splat-viewer__canvas"></div>
</div>
`;
this.initThreeViewer();
}
private async initThreeViewer() {
const container = this.querySelector("#splat-container") as HTMLElement;
const loading = this.querySelector("#splat-loading") as HTMLElement;
if (!container || !this._splatUrl) return;
try {
// Dynamic import from CDN (via importmap)
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
const viewer = new GaussianSplats3D.Viewer({
cameraUp: [0, 1, 0],
initialCameraPosition: [5, 3, 5],
initialCameraLookAt: [0, 0, 0],
rootElement: container,
sharedMemoryForWorkers: false,
});
this._viewer = viewer;
viewer.addSplatScene(this._splatUrl, {
showLoadingUI: false,
progressiveLoad: true,
})
.then(() => {
viewer.start();
if (loading) loading.classList.add("hidden");
})
.catch((e: Error) => {
console.error("[rSplat] Scene load error:", e);
if (loading) {
const text = loading.querySelector(".splat-loading__text");
if (text) text.textContent = `Error: ${e.message}`;
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
if (spinner) spinner.style.display = "none";
}
});
} catch (e) {
console.error("[rSplat] Viewer init error:", e);
if (loading) {
const text = loading.querySelector(".splat-loading__text");
if (text) text.textContent = `Error loading viewer: ${(e as Error).message}`;
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
if (spinner) spinner.style.display = "none";
}
}
}
}
// ── Helpers ──
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
customElements.define("folk-splat-viewer", FolkSplatViewer);