From 7e07170304d312755809e91f3082f4c114e36ee5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 5 Apr 2026 16:03:58 -0400 Subject: [PATCH] fix: ranking drag-drop, spider slider drag, video gen timeout & progress - Ranking: replace broken :hover drop-target with getBoundingClientRect hit testing - Spider: add #isSliding guard to prevent slider destruction during drag - Video gen: bump timeout to 10min, show real fal.ai queue position/status - Fix NotificationCategory type to include 'payment' in db.ts Co-Authored-By: Claude Opus 4.6 --- lib/folk-choice-rank.ts | 14 ++++++++------ lib/folk-choice-spider.ts | 7 +++++-- lib/folk-video-gen.ts | 32 ++++++++++++++++++++++---------- server/index.ts | 11 ++++++++--- src/encryptid/db.ts | 4 ++-- 5 files changed, 45 insertions(+), 23 deletions(-) diff --git a/lib/folk-choice-rank.ts b/lib/folk-choice-rank.ts index 3c31dcc..354921c 100644 --- a/lib/folk-choice-rank.ts +++ b/lib/folk-choice-rank.ts @@ -839,13 +839,15 @@ export class FolkChoiceRank extends FolkShape { const onMove = (me: PointerEvent) => { me.stopPropagation(); - const target = this.#rankPanel!.querySelector( - `.rank-item:not(.dragging):hover` - ) as HTMLElement; - // Clear previous drag-over states this.#rankPanel!.querySelectorAll(".drag-over").forEach((d) => d.classList.remove("drag-over")); - - if (target) target.classList.add("drag-over"); + const items = this.#rankPanel!.querySelectorAll(".rank-item:not(.dragging)"); + for (const item of items) { + const rect = (item as HTMLElement).getBoundingClientRect(); + if (me.clientY >= rect.top && me.clientY <= rect.bottom) { + item.classList.add("drag-over"); + break; + } + } }; const onUp = (ue: PointerEvent) => { diff --git a/lib/folk-choice-spider.ts b/lib/folk-choice-spider.ts index 94ae599..08353cb 100644 --- a/lib/folk-choice-spider.ts +++ b/lib/folk-choice-spider.ts @@ -525,6 +525,7 @@ export class FolkChoiceSpider extends FolkShape { #selectedOptionId = ""; #drawerOpen = false; #settingsOpen = false; + #isSliding = false; // DOM refs #wrapperEl: HTMLElement | null = null; @@ -748,7 +749,7 @@ export class FolkChoiceSpider extends FolkShape { #render() { this.#renderOptionTabs(); this.#renderChart(); - this.#renderSliders(); + if (!this.#isSliding) this.#renderSliders(); this.#renderLegend(); this.#renderSummary(); if (this.#drawerOpen) this.#renderDrawer(); @@ -889,7 +890,9 @@ export class FolkChoiceSpider extends FolkShape { this.#slidersEl.querySelectorAll(".slider-input").forEach((slider) => { const input = slider as HTMLInputElement; input.addEventListener("click", (e) => e.stopPropagation()); - input.addEventListener("pointerdown", (e) => e.stopPropagation()); + input.addEventListener("pointerdown", (e) => { e.stopPropagation(); this.#isSliding = true; }); + input.addEventListener("pointerup", () => { this.#isSliding = false; }); + input.addEventListener("lostpointercapture", () => { this.#isSliding = false; }); input.addEventListener("input", (e) => { e.stopPropagation(); const critId = input.dataset.crit!; diff --git a/lib/folk-video-gen.ts b/lib/folk-video-gen.ts index 8a62021..1166600 100644 --- a/lib/folk-video-gen.ts +++ b/lib/folk-video-gen.ts @@ -459,7 +459,7 @@ export class FolkVideoGen extends FolkShape { this.#error = null; this.#progress = 0; if (this.#generateBtn) this.#generateBtn.disabled = true; - this.#renderLoading(); + this.#renderLoading("Submitting..."); try { const endpoint = this.#mode === "i2v" ? "/api/video-gen/i2v" : "/api/video-gen/t2v"; @@ -481,23 +481,35 @@ export class FolkVideoGen extends FolkShape { const submitData = await submitRes.json(); - // Poll for job completion (up to 5 minutes) + // Poll for job completion (up to 10 minutes) const jobId = submitData.job_id; if (!jobId) throw new Error("No job ID returned"); - const deadline = Date.now() + 300_000; - let elapsed = 0; + const deadline = Date.now() + 600_000; + let statusMsg = "Submitting to queue..."; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 3000)); - elapsed += 3; - this.#progress = Math.min(90, (elapsed / 120) * 100); - this.#renderLoading(); const pollRes = await fetch(`/api/video-gen/${jobId}`); if (!pollRes.ok) continue; const pollData = await pollRes.json(); + // Update progress from real fal.ai status + const falStatus = pollData.fal_status; + const queuePos = pollData.queue_position; + if (falStatus === "IN_QUEUE") { + this.#progress = 10; + statusMsg = queuePos != null ? `In queue (position ${queuePos})...` : "Waiting in queue..."; + } else if (falStatus === "IN_PROGRESS") { + this.#progress = Math.min(85, this.#progress < 40 ? 40 : this.#progress + 2); + statusMsg = "Generating video..."; + } else if (!falStatus && pollData.status === "processing") { + this.#progress = Math.min(20, this.#progress + 2); + statusMsg = "Starting generation..."; + } + this.#renderLoading(statusMsg); + if (pollData.status === "complete") { const video: GeneratedVideo = { id: crypto.randomUUID(), @@ -530,16 +542,16 @@ export class FolkVideoGen extends FolkShape { } } - #renderLoading() { + #renderLoading(message = "Generating video...") { if (!this.#videoArea) return; this.#videoArea.innerHTML = `
- Generating video... + ${this.#escapeHtml(message)}
- This may take 30-60 seconds + This may take a few minutes
`; } diff --git a/server/index.ts b/server/index.ts index 5c9d7fa..efb63c6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1060,6 +1060,8 @@ interface VideoGenJob { error?: string; createdAt: number; completedAt?: number; + queuePosition?: number; + falStatus?: string; } const videoGenJobs = new Map(); @@ -1103,8 +1105,8 @@ async function processVideoGenJob(job: VideoGenJob) { const { request_id } = await submitRes.json() as { request_id: string }; - // Poll for completion (up to 5 min) - const deadline = Date.now() + 300_000; + // Poll for completion (up to 10 min) + const deadline = Date.now() + 600_000; let responseUrl = ""; let completed = false; @@ -1115,8 +1117,10 @@ async function processVideoGenJob(job: VideoGenJob) { { headers: falHeaders }, ); if (!statusRes.ok) continue; - const statusData = await statusRes.json() as { status: string; response_url?: string }; + const statusData = await statusRes.json() as { status: string; response_url?: string; queue_position?: number }; console.log(`[video-gen] Poll ${job.id}: status=${statusData.status}`); + job.falStatus = statusData.status; + job.queuePosition = statusData.queue_position; if (statusData.response_url) responseUrl = statusData.response_url; if (statusData.status === "COMPLETED") { completed = true; break; } if (statusData.status === "FAILED") { @@ -1667,6 +1671,7 @@ app.get("/api/video-gen/:jobId", async (c) => { const response: Record = { job_id: job.id, status: job.status, created_at: job.createdAt, + fal_status: job.falStatus, queue_position: job.queuePosition, }; if (job.status === "complete") { response.url = job.resultUrl; diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 0fe8aa4..e28d0c7 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -1143,7 +1143,7 @@ export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise< export interface StoredNotification { id: string; userDid: string; - category: 'space' | 'module' | 'system' | 'social'; + category: 'space' | 'module' | 'system' | 'social' | 'payment'; eventType: string; title: string; body: string | null; @@ -1196,7 +1196,7 @@ async function rowToNotification(row: any): Promise { export async function createNotification(notif: { id: string; userDid: string; - category: 'space' | 'module' | 'system' | 'social'; + category: 'space' | 'module' | 'system' | 'social' | 'payment'; eventType: string; title: string; body?: string;