worldguide-comparison / ratings.js
ankanmbz's picture
Publish WorldGuide video comparison website
05be1fa verified
Raw History Blame Contribute Delete
8.88 kB
"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 };
})();