"use strict"; window.WorldGuideRatings = (() => { const STORAGE_KEY = "worldguide-anonymous-voter"; const LOCAL_SCORES_KEY = "worldguide-local-ratings"; const descriptions = ["Poor", "Fair", "Good", "Very good", "Excellent"]; const records = new Map(); const loads = new Map(); const versions = new Map(); const listeners = new Map(); let apiBase, voterId, mode, storageAvailable = true; const configured = fetch("./ratings-config.json") .then(response => { if (!response.ok) throw new Error("No ratings configuration"); return response.json(); }) .then(config => { if (config.mode === "local") { mode = "local"; document.getElementById("ratings-notice").textContent = "Scores are saved on this device only. Shared score collection is not connected yet."; return true; } if (config.mode === "supabase") { window.WorldGuideSupabaseRatings.connect(config); mode = "supabase"; document.getElementById("ratings-notice").textContent = "Your scores are saved anonymously to the shared ratings database. Select 1 (Poor) to 5 (Excellent)."; return true; } const url = new URL(config.apiBase); if (url.protocol !== "https:" || !url.hostname.endsWith(".hf.space")) throw new Error("Invalid ratings service"); apiBase = url.origin; mode = "remote"; document.getElementById("ratings-notice").textContent = "Rate overall performance from 1 (Poor) to 5 (Excellent). Scores are saved anonymously."; return true; }).catch(() => false); try { voterId = localStorage.getItem(STORAGE_KEY); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(voterId || "")) { voterId = crypto.randomUUID(); localStorage.setItem(STORAGE_KEY, voterId); } } catch { voterId = crypto.randomUUID(); storageAvailable = false; } const keyFor = (sample, model) => `${sample}:${model}`; function emit(key) { for (const entry of listeners.get(key) || []) { if (entry.node.isConnected) entry.update(); } } async function request(path, data) { if (!(await configured)) throw new Error("Rating service is not connected yet."); let response; try { response = await fetch(apiBase + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ voter_id: voterId, ...data }), signal: AbortSignal.timeout(45000), }); } catch { throw new Error("Couldn’t reach the rating service. Please retry."); } if (!response.ok) { if (response.status === 429) throw new Error("Please wait a minute, then try again."); throw new Error("Rating service is unavailable. Please retry shortly."); } return response.json(); } function loadSample(sample) { if (loads.has(sample)) return loads.get(sample); const startingVersions = new Map(versions); const pending = configured.then(connected => { if (!connected) throw new Error("Ratings not configured"); if (mode === "local") { let scores = {}; try { scores = JSON.parse(localStorage.getItem(LOCAL_SCORES_KEY) || "{}"); } catch { /* Keep this visit's scores. */ } const ratings = {}; for (const [key, value] of Object.entries(scores || {})) { if (key.startsWith(sample + ":")) ratings[key.slice(sample.length + 1)] = value.score; } return { ratings }; } if (mode === "supabase") return window.WorldGuideSupabaseRatings.read(sample); return request("/api/ratings/mine", { sample_id: sample }); }).then(data => { for (const [model, score] of Object.entries(data.ratings || {})) { if (!Number.isInteger(score) || score < 1 || score > 5) continue; const key = keyFor(sample, model); if ((versions.get(key) || 0) === (startingVersions.get(key) || 0)) { records.set(key, { score, state: mode === "local" ? "local" : "saved" }); emit(key); } } }).catch(() => { // Loading a previous rating must not invent a saved score or block a new vote. loads.delete(sample); }); loads.set(sample, pending); return pending; } async function save(sample, model, score) { const key = keyFor(sample, model); if (records.get(key)?.state === "saving") return; const previous = records.get(key)?.score; versions.set(key, (versions.get(key) || 0) + 1); records.set(key, { score: previous, pending: score, state: "saving" }); emit(key); try { if (!(await configured)) throw new Error("Rating service is not connected yet."); if (mode === "local") { let remembered = false; try { const stored = JSON.parse(localStorage.getItem(LOCAL_SCORES_KEY) || "{}"); stored[key] = { score, updatedAt: new Date().toISOString() }; localStorage.setItem(LOCAL_SCORES_KEY, JSON.stringify(stored)); remembered = true; } catch { storageAvailable = false; } records.set(key, { score, state: remembered ? "local" : "session" }); emit(key); return; } const result = mode === "supabase" ? await window.WorldGuideSupabaseRatings.save(sample, model, score) : await request("/api/ratings", { sample_id: sample, model_id: model, score }); if (result.saved !== true || result.score !== score) throw new Error("Score was not confirmed. Please retry."); records.set(key, { score, state: "saved" }); } catch (error) { records.set(key, { score: previous, pending: score, state: "error", message: error.message }); } emit(key); } function createPanel({ sampleId, modelId, label }) { const key = keyFor(sampleId, modelId); const panel = document.createElement("div"); panel.className = "rating-panel"; const header = document.createElement("div"); header.className = "rating-header"; const heading = document.createElement("span"); heading.className = "rating-label"; heading.textContent = "Rate this video"; const scale = document.createElement("span"); scale.className = "rating-scale"; scale.textContent = "1 Poor · 5 Excellent"; header.append(heading, scale); const group = document.createElement("div"); group.className = "rating-options"; group.setAttribute("role", "group"); group.setAttribute("aria-label", `Rate ${label} from 1 to 5`); const buttons = []; for (let score = 1; score <= 5; score++) { const button = document.createElement("button"); button.type = "button"; button.className = "rating-button"; button.textContent = String(score); button.setAttribute("aria-label", `${score} — ${descriptions[score - 1]}`); button.setAttribute("aria-pressed", "false"); button.title = descriptions[score - 1]; button.addEventListener("click", () => save(sampleId, modelId, score)); buttons.push(button); group.append(button); } const status = document.createElement("div"); status.className = "rating-status"; status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); const message = document.createElement("span"); const retry = document.createElement("button"); retry.className = "text-button rating-retry"; retry.type = "button"; retry.textContent = "Retry"; retry.hidden = true; retry.addEventListener("click", () => save(sampleId, modelId, records.get(key).pending)); status.append(message, retry); panel.append(header, group, status); function update() { const record = records.get(key); const saving = record?.state === "saving"; buttons.forEach((button, index) => { button.disabled = saving; button.setAttribute("aria-pressed", String(record?.score === index + 1)); button.classList.toggle("pending", saving && record.pending === index + 1); }); panel.classList.toggle("rating-failed", record?.state === "error"); retry.hidden = record?.state !== "error"; message.textContent = saving ? "Saving your score…" : record?.state === "error" ? record.message : record?.state === "local" ? `Saved on this device · ${record.score}/5` : record?.state === "session" ? `For this visit only · ${record.score}/5` : record?.score ? `Saved · Your score: ${record.score}/5` : "Select a score. You can change it later."; if (!storageAvailable && record?.state === "saved") message.textContent += " Remembered for this visit only."; } const entries = (listeners.get(key) || []).filter(entry => entry.node.isConnected); entries.push({ node: panel, update }); listeners.set(key, entries); update(); loadSample(sampleId); return panel; } return { createPanel }; })();