/* Report-a-wrong-lyric-line: API client + bottom sheet. No dependencies. * window.FlagUI.open({videoId, text, existing, profile}) -> Promise * result = {action:'saved'|'withdrawn', id?, reason, note} * Descriptions are optional; a bare report is valid. */ (function () { 'use strict'; const REASONS = [ ['words', 'Wrong words'], ['timing', 'Wrong timing'], ['typo', 'Typo'], ['other', 'Other'], ]; const NOTE_MAX = 500; const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const base = (id) => `/api/notes/${encodeURIComponent(id)}/flags`; const jpost = (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); async function list(videoId, profile) { try { const r = await fetch(base(videoId), { headers: profile ? { 'X-Profile': profile } : {} }); if (!r.ok) return []; const j = await r.json(); return Array.isArray(j.flags) ? j.flags : []; } catch { return []; } } async function submit(videoId, profile, text, reason, note) { const r = await jpost(base(videoId), { profile, text, reason, note }); const j = await r.json().catch(() => ({})); return { status: r.status, ...j }; } async function withdraw(videoId, profile, id) { const r = await jpost(`${base(videoId)}/${id}/withdraw`, { profile }); return r.ok; } const ICON = ''; function open({ videoId, text, existing, profile }) { return new Promise((resolve) => { const mine = existing && existing.mine ? existing : null; const others = existing ? Math.max(0, (existing.count || 0) - (mine ? 1 : 0)) : 0; let reason = mine ? mine.reason || '' : ''; const back = document.createElement('div'); back.className = 'fs-backdrop'; back.innerHTML = ` `; const $ = (s) => back.querySelector(s); const ta = $('.fs-note'); ta.value = mine ? mine.note || '' : ''; const count = () => { $('.fs-n').textContent = ta.value.length; }; count(); ta.addEventListener('input', count); const prevFocus = document.activeElement; let done = false; const close = (val) => { if (done) return; done = true; document.removeEventListener('keydown', onKey, true); back.classList.add('closing'); setTimeout(() => { back.remove(); if (prevFocus && prevFocus.focus) try { prevFocus.focus(); } catch {} }, 190); resolve(val); }; const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(null); } }; document.addEventListener('keydown', onKey, true); back.addEventListener('click', (e) => { if (e.target === back) close(null); }); $('.fs-x').onclick = $('.fs-cancel').onclick = () => close(null); back.querySelectorAll('.fs-chip').forEach((b) => b.addEventListener('click', () => { reason = reason === b.dataset.r ? '' : b.dataset.r; // tap again to clear back.querySelectorAll('.fs-chip').forEach((c) => c.setAttribute('aria-pressed', String(c.dataset.r === reason))); })); const fail = (msg) => { const e = $('.fs-err'); e.textContent = msg; e.hidden = false; }; const busy = (on) => back.querySelectorAll('.fs-btn').forEach((b) => { b.disabled = on; }); $('.fs-send').onclick = async () => { busy(true); try { const r = await submit(videoId, profile, text, reason, ta.value.trim()); if (r.ok) return close({ action: 'saved', id: r.id, reason, note: ta.value.trim() }); busy(false); fail(r.stale ? 'This line was just changed — it may already be fixed.' : r.status === 401 ? 'Link an online profile to report lines.' : r.status === 429 ? 'Slow down a little — too many reports.' : (r.error || 'Could not send the report.')); } catch { busy(false); fail('You appear to be offline.'); } }; const w = $('.fs-withdraw'); if (w) w.onclick = async () => { busy(true); try { if (await withdraw(videoId, profile, mine.id)) return close({ action: 'withdrawn' }); } catch {} busy(false); fail('Could not withdraw — try again.'); }; document.body.appendChild(back); setTimeout(() => ta.blur(), 0); $('.fs-send').focus(); }); } window.FlagUI = { open, list, submit, withdraw, REASONS }; })();