Spaces:
Sleeping
Sleeping
| ; | |
| const AXES = ["art_style", "color", "art_medium", "lighting"]; | |
| const TOKEN_STORAGE_KEY = "aamcq_token"; | |
| const EMAIL_STORAGE_KEY = "aamcq_email"; | |
| const THEME_STORAGE_KEY = "aamcq_theme"; | |
| const PASSWORD_SESSION_KEY = "aamcq_access_password"; | |
| const FIRST_SESSION_CAP = 20; | |
| const EXTRA_ROUND_CAP = 10; | |
| function setTheme(theme) { | |
| document.documentElement.setAttribute("data-theme", theme); | |
| localStorage.setItem(THEME_STORAGE_KEY, theme); | |
| const btn = document.getElementById("theme-toggle"); | |
| if (btn) btn.textContent = theme === "dark" ? "☾" : "☀"; | |
| } | |
| function initThemeToggle() { | |
| const current = localStorage.getItem(THEME_STORAGE_KEY) || "light"; | |
| setTheme(current); | |
| const btn = document.getElementById("theme-toggle"); | |
| btn.addEventListener("click", () => { | |
| const now = document.documentElement.getAttribute("data-theme") === "dark" | |
| ? "light" | |
| : "dark"; | |
| setTheme(now); | |
| }); | |
| } | |
| class HttpError extends Error { | |
| constructor(status, body) { | |
| super(`${status}: ${body}`); | |
| this.status = status; | |
| this.body = body; | |
| } | |
| } | |
| async function fetchJSON(path, init) { | |
| const resp = await fetch(path, init); | |
| if (!resp.ok) { | |
| const body = await resp.text(); | |
| throw new HttpError(resp.status, body); | |
| } | |
| return resp.json(); | |
| } | |
| async function attemptRegister(params, password) { | |
| const qs = new URLSearchParams(params); | |
| if (password) qs.set("password", password); | |
| return fetch(`/api/register?${qs.toString()}`, { method: "POST" }); | |
| } | |
| async function registerWithParams(params) { | |
| // Try with cached password (could be empty). | |
| let password = sessionStorage.getItem(PASSWORD_SESSION_KEY) || ""; | |
| let resp = await attemptRegister(params, password); | |
| while (resp.status === 403) { | |
| sessionStorage.removeItem(PASSWORD_SESSION_KEY); | |
| const entered = window.prompt( | |
| password | |
| ? "Wrong access password. Try again:" | |
| : "Enter the access password to start labeling:" | |
| ); | |
| if (entered == null) throw new Error("Access password required."); | |
| password = entered; | |
| resp = await attemptRegister(params, password); | |
| } | |
| if (resp.status === 429) { | |
| throw new HttpError(429, await resp.text()); | |
| } | |
| if (!resp.ok) { | |
| const body = await resp.text(); | |
| throw new HttpError(resp.status, body); | |
| } | |
| if (password) sessionStorage.setItem(PASSWORD_SESSION_KEY, password); | |
| const data = await resp.json(); | |
| localStorage.setItem(TOKEN_STORAGE_KEY, data.token); | |
| return data; | |
| } | |
| async function ensureToken() { | |
| const urlToken = new URL(window.location.href).searchParams.get("token"); | |
| if (urlToken) { | |
| localStorage.setItem(TOKEN_STORAGE_KEY, urlToken); | |
| return urlToken; | |
| } | |
| const stored = localStorage.getItem(TOKEN_STORAGE_KEY); | |
| if (stored) return stored; | |
| const { token } = await registerWithParams({ cap: String(FIRST_SESSION_CAP) }); | |
| return token; | |
| } | |
| function renderProfileCard(idx, profile) { | |
| const ul = document.createElement("ul"); | |
| ul.className = "profile"; | |
| for (const axis of AXES) { | |
| const li = document.createElement("li"); | |
| const key = document.createElement("span"); | |
| key.className = "axis"; | |
| key.textContent = axis.replace("_", " ") + ": "; | |
| const val = document.createElement("span"); | |
| val.className = "value"; | |
| val.textContent = profile[axis] ?? "?"; | |
| li.appendChild(key); | |
| li.appendChild(val); | |
| ul.appendChild(li); | |
| } | |
| const wrapper = document.createElement("label"); | |
| wrapper.className = "option"; | |
| const input = document.createElement("input"); | |
| input.type = "radio"; | |
| input.name = "choice"; | |
| input.value = String(idx); | |
| wrapper.appendChild(input); | |
| const badge = document.createElement("div"); | |
| badge.className = "badge"; | |
| badge.textContent = String.fromCharCode(65 + idx); | |
| wrapper.appendChild(badge); | |
| wrapper.appendChild(ul); | |
| return wrapper; | |
| } | |
| let currentItem = null; | |
| let shownAt = 0; | |
| async function loadNext(token) { | |
| const data = await fetchJSON(`/api/task?token=${encodeURIComponent(token)}`); | |
| const card = document.getElementById("card"); | |
| const submit = document.getElementById("submit"); | |
| const err = document.getElementById("error"); | |
| err.textContent = ""; | |
| if (data.done) { | |
| await renderDonePage(token, data); | |
| submit.style.display = "none"; | |
| updateProgress(data.labeled, data.cap); | |
| return; | |
| } | |
| currentItem = data; | |
| shownAt = performance.now(); | |
| document.getElementById("stimulus").src = data.image_url; | |
| document.getElementById("base-prompt").textContent = | |
| data.payload.base_prompt ? `"${data.payload.base_prompt}"` : ""; | |
| const form = document.getElementById("options"); | |
| form.innerHTML = ""; | |
| const options = data.payload.options || []; | |
| options.forEach((opt, i) => form.appendChild(renderProfileCard(i, opt))); | |
| submit.style.display = ""; | |
| submit.disabled = true; | |
| form.querySelectorAll("input[type=radio]").forEach((el) => { | |
| el.addEventListener("change", () => { submit.disabled = false; }); | |
| }); | |
| updateProgress(data.labeled, data.cap); | |
| } | |
| async function renderDonePage(token, taskData) { | |
| const card = document.getElementById("card"); | |
| const labeled = taskData.labeled ?? 0; | |
| if (taskData.reason !== "cap_reached") { | |
| // Pool is drained entirely. Thank and stop. | |
| card.innerHTML = `<p class='done'>All items are fully labeled (you contributed ${labeled}). Thank you!</p>`; | |
| appendAnnouncement(card); | |
| return; | |
| } | |
| // cap_reached: fetch detailed session status to decide UI state | |
| let status; | |
| try { | |
| status = await fetchJSON(`/api/session_status?token=${encodeURIComponent(token)}`); | |
| } catch (e) { | |
| card.innerHTML = `<p class='done'>Session complete (${labeled} labeled). Couldn't load status: ${e.message}</p>`; | |
| return; | |
| } | |
| card.innerHTML = ""; | |
| const msg = document.createElement("p"); | |
| msg.className = "done"; | |
| card.appendChild(msg); | |
| if (!status.acc_pass) { | |
| // Fail state | |
| msg.innerHTML = | |
| `<strong>Low agreement rate detected.</strong><br>` + | |
| `Your ${labeled}-item session doesn't meet the quality threshold ` + | |
| `and can't be credited. Please try again more carefully.`; | |
| msg.classList.add("fail"); | |
| const btn = document.createElement("button"); | |
| btn.id = "retry-session"; | |
| if (status.round_number === 1) { | |
| btn.textContent = "Try again"; | |
| btn.addEventListener("click", () => { | |
| localStorage.removeItem(TOKEN_STORAGE_KEY); | |
| localStorage.removeItem(EMAIL_STORAGE_KEY); | |
| location.reload(); | |
| }); | |
| } else { | |
| btn.textContent = `Redo round ${status.round_number}`; | |
| btn.addEventListener("click", async () => { | |
| localStorage.removeItem(TOKEN_STORAGE_KEY); | |
| const email = status.email || localStorage.getItem(EMAIL_STORAGE_KEY); | |
| if (!email) { location.reload(); return; } | |
| try { | |
| await registerWithParams({ | |
| cap: String(EXTRA_ROUND_CAP), | |
| email, | |
| round: String(status.round_number), | |
| }); | |
| location.reload(); | |
| } catch (e) { | |
| document.getElementById("error").textContent = `Retry failed: ${e.message}`; | |
| } | |
| }); | |
| } | |
| card.appendChild(btn); | |
| appendAnnouncement(card); | |
| return; | |
| } | |
| // Pass state | |
| if (status.round_number === 1 && !status.email) { | |
| msg.innerHTML = | |
| `<strong>Great job!</strong> Your session passed the quality check. ` + | |
| `Submit your email to enter the lottery.`; | |
| const form = document.createElement("form"); | |
| form.id = "email-form"; | |
| form.innerHTML = | |
| `<input type="email" id="email-input" placeholder="your@email" required autocomplete="email" />` + | |
| `<button type="submit">Submit email</button>`; | |
| card.appendChild(form); | |
| form.addEventListener("submit", async (ev) => { | |
| ev.preventDefault(); | |
| const email = document.getElementById("email-input").value.trim(); | |
| if (!email) return; | |
| try { | |
| await fetchJSON("/api/submit_email", { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ token, email }), | |
| }); | |
| localStorage.setItem(EMAIL_STORAGE_KEY, email); | |
| await renderDonePage(token, taskData); | |
| } catch (e) { | |
| document.getElementById("error").textContent = `Submit failed: ${e.message}`; | |
| } | |
| }); | |
| appendAnnouncement(card); | |
| return; | |
| } | |
| // Already on email chain: thank + offer "Label more" if not maxed | |
| const entries = status.labels_in_lottery ?? 0; | |
| msg.innerHTML = | |
| `<strong>Thanks, ${status.email}!</strong> You're entered in the lottery ` + | |
| `with <strong>${entries}</strong> labels counted so far.`; | |
| if (status.can_extend) { | |
| const hint = document.createElement("p"); | |
| hint.className = "muted-info"; | |
| hint.textContent = | |
| `Want better odds? Label ${EXTRA_ROUND_CAP} more to add to your entry count.`; | |
| card.appendChild(hint); | |
| const btn = document.createElement("button"); | |
| btn.id = "extend-session"; | |
| btn.textContent = `Label ${EXTRA_ROUND_CAP} more`; | |
| btn.addEventListener("click", async () => { | |
| try { | |
| await registerWithParams({ | |
| cap: String(EXTRA_ROUND_CAP), | |
| email: status.email, | |
| round: String(status.round_number + 1), | |
| }); | |
| location.reload(); | |
| } catch (e) { | |
| document.getElementById("error").textContent = `Couldn't start round: ${e.message}`; | |
| } | |
| }); | |
| card.appendChild(btn); | |
| } else { | |
| const hint = document.createElement("p"); | |
| hint.className = "muted-info"; | |
| hint.textContent = "Thanks for participating!"; | |
| card.appendChild(hint); | |
| } | |
| appendAnnouncement(card); | |
| } | |
| function appendAnnouncement(card) { | |
| const div = document.createElement("div"); | |
| div.className = "announcement"; | |
| div.innerHTML = | |
| `Winning probability is proportional to labels that pass the quality check. ` + | |
| `Prize pool: <strong>10 × $20 Amazon gift cards</strong>, drawn after ` + | |
| `we reach 2,000 labels total.`; | |
| card.appendChild(div); | |
| } | |
| function updateProgress(labeled, cap) { | |
| const el = document.getElementById("progress"); | |
| if (cap != null) { | |
| el.textContent = `${labeled ?? 0} / ${cap} done`; | |
| } else { | |
| el.textContent = `${labeled ?? 0} labeled`; | |
| } | |
| } | |
| async function submitLabel(token) { | |
| const err = document.getElementById("error"); | |
| err.textContent = ""; | |
| const chosen = document.querySelector("input[name=choice]:checked"); | |
| if (!chosen || !currentItem) return; | |
| const elapsed = (performance.now() - shownAt) / 1000; | |
| await fetchJSON("/api/label", { | |
| method: "POST", | |
| headers: { "content-type": "application/json" }, | |
| body: JSON.stringify({ | |
| token, | |
| item_id: currentItem.item_id, | |
| chosen_index: Number(chosen.value), | |
| seconds: elapsed, | |
| confidence: null, | |
| }), | |
| }); | |
| await loadNext(token); | |
| } | |
| async function recoverFromInvalidToken() { | |
| // Server doesn't know this token. Wipe client state and start fresh. | |
| localStorage.removeItem(TOKEN_STORAGE_KEY); | |
| localStorage.removeItem(EMAIL_STORAGE_KEY); | |
| const { token } = await registerWithParams({ cap: String(FIRST_SESSION_CAP) }); | |
| return token; | |
| } | |
| async function main() { | |
| initThemeToggle(); | |
| let token; | |
| try { | |
| token = await ensureToken(); | |
| } catch (e) { | |
| document.getElementById("error").textContent = e.message; | |
| return; | |
| } | |
| document.getElementById("submit").addEventListener("click", async () => { | |
| try { | |
| await submitLabel(token); | |
| } catch (e) { | |
| if (e instanceof HttpError && e.status === 401) { | |
| token = await recoverFromInvalidToken(); | |
| await loadNext(token); | |
| } else { | |
| document.getElementById("error").textContent = `Submit failed: ${e.message}`; | |
| } | |
| } | |
| }); | |
| try { | |
| await loadNext(token); | |
| } catch (e) { | |
| if (e instanceof HttpError && e.status === 401) { | |
| try { | |
| token = await recoverFromInvalidToken(); | |
| await loadNext(token); | |
| } catch (e2) { | |
| document.getElementById("error").textContent = `Load failed: ${e2.message}`; | |
| } | |
| } else { | |
| document.getElementById("error").textContent = `Load failed: ${e.message}`; | |
| } | |
| } | |
| } | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", main); | |
| } else { | |
| main(); | |
| } | |