/** * Rating Editor Component - Interactive star rating */ import { updateAssetRating } from "../api/client.js"; import { ASSET_RATING_CHANGED_EVENT } from "../app/events.js"; import { comfyToast } from "../app/toast.js"; import { t } from "../app/i18n.js"; import { safeDispatchCustomEvent } from "../utils/events.js"; /** * Create interactive star rating editor * @param {Object} asset - Asset object with id and current rating * @param {Function} onUpdate - Callback when rating changes * @returns {HTMLElement} */ export function createRatingEditor(asset: Record, onUpdate?: (...args: any[]) => void): HTMLElement { const container = document.createElement("div"); container.className = "mjr-rating-editor"; let currentRating = Math.max(0, Math.min(5, Number(asset.rating) || 0)); let hoveredStar = 0; let savedRating = currentRating; let desiredRating = currentRating; let saving = false; let saveAC: any = null; try { container.setAttribute("role", "slider"); container.setAttribute("aria-label", "Rating"); container.setAttribute("aria-valuemin", "0"); container.setAttribute("aria-valuemax", "5"); container.setAttribute("aria-valuenow", String(currentRating)); container.setAttribute("tabindex", "0"); } catch (e: any) { console.debug?.(e); } const setDisabled = (disabled: any) => { try { for (const btn of container.querySelectorAll?.("button.mjr-rating-star") || []) { try { (btn as HTMLButtonElement).disabled = Boolean(disabled); } catch (e: any) { console.debug?.(e); } } } catch (e: any) { console.debug?.(e); } }; const retryDelay = (attemptIndex: any) => Math.min(100 * 2 ** Math.max(0, attemptIndex - 1), 2000); const wait = (ms: any) => new Promise((resolve: any) => setTimeout(resolve, ms)); const flushSaves = async () => { if (saving) return; saving = true; setDisabled(true); const flushStartTime = Date.now(); try { let attempts = 0; while (attempts < 10) { // Timeout guard: bail after 30 seconds to prevent infinite loops. if (Date.now() - flushStartTime > 30_000) { asset.rating = savedRating; currentRating = savedRating; desiredRating = savedRating; updateStarColors(container, savedRating); comfyToast(t("toast.ratingUpdateFailed"), "error"); break; } if (attempts > 0) { await wait(retryDelay(attempts)); } attempts += 1; // Snapshot desiredRating at iteration start to avoid reading a live-mutated value. const toSave = Math.max(0, Math.min(5, Number(desiredRating) || 0)); const ac = typeof AbortController !== "undefined" ? new AbortController() : null; saveAC = ac; let result: any = null; try { result = await updateAssetRating( asset, toSave, ac ? { signal: ac.signal } : {}, ); } catch (err: any) { console.error("Failed to update rating:", err); } if (!result?.ok) { if (result?.code === "ABORTED" && desiredRating !== toSave) { continue; } asset.rating = savedRating; currentRating = savedRating; desiredRating = savedRating; updateStarColors(container, savedRating); comfyToast(result?.error || t("toast.ratingUpdateFailed"), "error"); break; } try { const newId = result?.data?.asset_id ?? null; if (asset.id == null && newId != null) asset.id = newId; } catch (e: any) { console.debug?.(e); } savedRating = toSave; currentRating = toSave; asset.rating = toSave; try { onUpdate?.(toSave); } catch (e: any) { console.debug?.(e); } safeDispatchCustomEvent( ASSET_RATING_CHANGED_EVENT, { assetId: String(asset.id), rating: toSave }, { warnPrefix: "[RatingEditor]" }, ); if (desiredRating === toSave) { comfyToast(t("toast.ratingSetN", { n: toSave }), "success", 1000); break; } } if (attempts >= 10) { asset.rating = savedRating; currentRating = savedRating; desiredRating = savedRating; updateStarColors(container, savedRating); comfyToast(t("toast.ratingUpdateFailed"), "error"); } } catch (e: any) { console.debug?.(e); } try { saveAC = null; } catch (e: any) { console.debug?.(e); } saving = false; setDisabled(false); }; // Create 5 stars for (let i = 1; i <= 5; i++) { const star = document.createElement("button"); star.type = "button"; star.className = "mjr-rating-star"; star.textContent = "*"; star.dataset.rating = String(i); try { star.setAttribute("aria-label", `Set rating ${i}`); } catch (e: any) { console.debug?.(e); } star.style.color = i <= currentRating ? "#FFD45A" : "#555"; // Hover effects (passive: pure visual update, no preventDefault). star.addEventListener( "mouseenter", () => { hoveredStar = i; updateStarColors(container, hoveredStar); }, { passive: true }, ); star.addEventListener( "mouseleave", () => { hoveredStar = 0; updateStarColors(container, currentRating); }, { passive: true }, ); // Click to set rating (coalesced + abortable) star.addEventListener("click", (e: any) => { e.stopPropagation(); e.preventDefault(); desiredRating = i; // Optimistic UI update. asset.rating = i; currentRating = i; updateStarColors(container, i); // Cancel in-flight request (best-effort) and save latest. try { saveAC?.abort?.(); } catch (e: any) { console.debug?.(e); } flushSaves(); }); container.appendChild(star); } // Keyboard navigation for accessibility container.addEventListener("keydown", (e: any) => { if (saving) return; let newRating; if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); newRating = Math.min(5, currentRating + 1); } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); newRating = Math.max(0, currentRating - 1); } else if (e.key === "Home") { e.preventDefault(); newRating = 0; } else if (e.key === "End") { e.preventDefault(); newRating = 5; } else if (e.key >= "0" && e.key <= "5") { e.preventDefault(); newRating = parseInt(e.key, 10); } else { return; } if (newRating !== currentRating) { desiredRating = newRating; asset.rating = newRating; currentRating = newRating; updateStarColors(container, newRating); try { container.setAttribute("aria-valuenow", String(newRating)); } catch (e: any) { console.debug?.(e); } try { saveAC?.abort?.(); } catch (e: any) { console.debug?.(e); } flushSaves(); } }); // Allow external updates (e.g. hotkeys) to refresh the editor UI while keeping behavior consistent. try { (container as any)._mjrSetRating = (rating: any) => { const next = Math.max(0, Math.min(5, Number(rating) || 0)); asset.rating = next; currentRating = next; updateStarColors(container, next); try { container.setAttribute("aria-valuenow", String(next)); } catch (e: any) { console.debug?.(e); } }; } catch (e: any) { console.debug?.(e); } return container; } /** * Update star colors based on rating */ function updateStarColors(container: any, rating: any) { const stars = container.querySelectorAll(".mjr-rating-star"); stars.forEach((star: any, index: any) => { const starValue = index + 1; star.style.color = starValue <= rating ? "#FFD45A" : "#555"; star.style.transform = starValue <= rating ? "scale(1.1)" : "scale(1)"; try { star.setAttribute("aria-pressed", starValue <= rating ? "true" : "false"); } catch (e: any) { console.debug?.(e); } }); }