feat(rsplat): add % progress bar for 3D generation, fix auth token lookup

Replace indeterminate sliding animation with a realistic percentage fill
bar using logarithmic curve (asymptotes at 95%, based on ~60s typical
Trellis 2 timing). Jumps to 100% on completion.

Fix "sign in to save" showing for authenticated users by checking both
localStorage and cookie for auth token, and improving the 401 message
to "Session expired" when a token exists locally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 13:02:38 -07:00
parent 95db01b451
commit cf93b33c8b
2 changed files with 40 additions and 19 deletions

View File

@ -114,7 +114,7 @@ export class FolkSplatViewer extends HTMLElement {
} }
private async loadMyHistory() { private async loadMyHistory() {
const token = localStorage.getItem("encryptid_token"); const token = this.getAuthToken();
if (!token || this._spaceSlug === "demo") return; if (!token || this._spaceSlug === "demo") return;
try { try {
@ -355,7 +355,7 @@ export class FolkSplatViewer extends HTMLElement {
formData.append("tags", tagsInput.value.trim()); formData.append("tags", tagsInput.value.trim());
try { try {
const token = localStorage.getItem("encryptid_token") || ""; const token = this.getAuthToken();
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats`, { const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats`, {
method: "POST", method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
@ -496,6 +496,17 @@ export class FolkSplatViewer extends HTMLElement {
{ t: 75, msg: "Almost there..." }, { t: 75, msg: "Almost there..." },
]; ];
// Realistic progress curve — typical Trellis 2 takes 45-75s
const EXPECTED_SECONDS = 60;
const progressBar = progress.querySelector(".splat-generate__progress-bar") as HTMLElement;
const estimatePercent = (elapsed: number): number => {
if (elapsed <= 0) return 0;
// Logarithmic curve: fast start, slows toward 95% asymptote
const ratio = elapsed / EXPECTED_SECONDS;
return Math.min(95, 100 * (1 - Math.exp(-2.5 * ratio)));
};
const onVisChange = () => { const onVisChange = () => {
if (document.hidden) { if (document.hidden) {
hiddenAt = Date.now(); hiddenAt = Date.now();
@ -510,10 +521,12 @@ export class FolkSplatViewer extends HTMLElement {
if (document.hidden) return; // Don't update while hidden if (document.hidden) return; // Don't update while hidden
const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000); const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
const phase = [...phases].reverse().find(p => elapsed >= p.t); const phase = [...phases].reverse().find(p => elapsed >= p.t);
const pct = Math.round(estimatePercent(elapsed));
if (progressBar) progressBar.style.setProperty("--splat-progress", `${pct}%`);
if (progressText && phase) { if (progressText && phase) {
progressText.textContent = `${phase.msg} (${elapsed}s)`; progressText.textContent = `${phase.msg} ${pct}% (${elapsed}s)`;
} }
}, 1000); }, 500);
try { try {
const imageUrl = await this.stageImage(selectedFile!); const imageUrl = await this.stageImage(selectedFile!);
@ -552,6 +565,10 @@ export class FolkSplatViewer extends HTMLElement {
} }
const data = await res.json() as { url: string; format: string }; const data = await res.json() as { url: string; format: string };
// Jump to 100% before hiding
if (progressBar) progressBar.style.setProperty("--splat-progress", "100%");
if (progressText) progressText.textContent = "Complete!";
await new Promise(r => setTimeout(r, 400));
progress.style.display = "none"; progress.style.display = "none";
const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000); const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
status.textContent = `Generated in ${elapsed}s`; status.textContent = `Generated in ${elapsed}s`;
@ -588,7 +605,7 @@ export class FolkSplatViewer extends HTMLElement {
// ── Auto-save after generation ── // ── Auto-save after generation ──
private async autoSave() { private async autoSave() {
const token = localStorage.getItem("encryptid_token"); const token = this.getAuthToken();
if (!token || !this._generatedUrl || this._spaceSlug === "demo") return; if (!token || !this._generatedUrl || this._spaceSlug === "demo") return;
try { try {
@ -739,6 +756,12 @@ export class FolkSplatViewer extends HTMLElement {
} }
} }
private getAuthToken(): string {
return localStorage.getItem("encryptid_token")
|| document.cookie.match(/encryptid_token=([^;]+)/)?.[1]
|| "";
}
private async saveToGallery() { private async saveToGallery() {
const saveBtn = this.querySelector("#splat-save-btn") as HTMLButtonElement; const saveBtn = this.querySelector("#splat-save-btn") as HTMLButtonElement;
if (!saveBtn || !this._generatedUrl) return; if (!saveBtn || !this._generatedUrl) return;
@ -747,7 +770,7 @@ export class FolkSplatViewer extends HTMLElement {
saveBtn.textContent = "Saving..."; saveBtn.textContent = "Saving...";
try { try {
const token = localStorage.getItem("encryptid_token") || ""; const token = this.getAuthToken();
if (!token) { if (!token) {
saveBtn.textContent = "Sign in to save"; saveBtn.textContent = "Sign in to save";
saveBtn.disabled = false; saveBtn.disabled = false;
@ -768,7 +791,7 @@ export class FolkSplatViewer extends HTMLElement {
}); });
if (res.status === 401) { if (res.status === 401) {
saveBtn.textContent = "Sign in to save"; saveBtn.textContent = "Session expired — sign in again";
saveBtn.disabled = false; saveBtn.disabled = false;
return; return;
} }

View File

@ -340,8 +340,9 @@
} }
.splat-generate__progress-bar { .splat-generate__progress-bar {
height: 4px; --splat-progress: 0%;
border-radius: 2px; height: 6px;
border-radius: 3px;
background: var(--splat-border); background: var(--splat-border);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@ -350,16 +351,13 @@
.splat-generate__progress-bar::after { .splat-generate__progress-bar::after {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; top: 0;
background: var(--splat-accent); left: 0;
width: 40%; bottom: 0;
border-radius: 2px; width: var(--splat-progress);
animation: splat-progress-slide 1.2s ease-in-out infinite; background: linear-gradient(90deg, var(--splat-accent), #a78bfa);
} border-radius: 3px;
transition: width 0.5s ease-out;
@keyframes splat-progress-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
} }
.splat-generate__progress-text { .splat-generate__progress-text {