121 lines
6.1 KiB
JavaScript
121 lines
6.1 KiB
JavaScript
/* Report-a-wrong-lyric-line: API client + bottom sheet. No dependencies.
|
||
* window.FlagUI.open({videoId, text, existing, profile}) -> Promise<result|null>
|
||
* 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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg>';
|
||
|
||
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 = `
|
||
<div class="fs-sheet" role="dialog" aria-modal="true" aria-label="Report a wrong line">
|
||
<div class="fs-grab"></div>
|
||
<div class="fs-title">
|
||
<span class="fs-badge">${ICON}</span>
|
||
<div><h2>${mine ? 'Your report' : 'Report this line'}</h2>
|
||
<small>Helps whoever maintains the lyrics find mistakes</small></div>
|
||
<button class="fs-x" type="button" aria-label="Close">✕</button>
|
||
</div>
|
||
<blockquote class="fs-quote">${esc(text)}</blockquote>
|
||
${others ? `<div class="fs-notice info">${others} other ${others === 1 ? 'person has' : 'people have'} reported this line too.</div>` : ''}
|
||
<div class="fs-label">What’s wrong? <em>optional</em></div>
|
||
<div class="fs-chips">${REASONS.map(([k, l]) => `<button type="button" class="fs-chip" data-r="${k}" aria-pressed="${reason === k}">${l}</button>`).join('')}</div>
|
||
<div class="fs-label">Describe it <em>optional</em></div>
|
||
<textarea class="fs-note" maxlength="${NOTE_MAX}" placeholder="e.g. it should say “sound”, not “sownd”"></textarea>
|
||
<div class="fs-count"><span class="fs-n">0</span>/${NOTE_MAX}</div>
|
||
<div class="fs-err fs-notice err" hidden></div>
|
||
<div class="fs-actions ${mine ? 'three' : ''}">
|
||
<button type="button" class="fs-btn fs-cancel">Cancel</button>
|
||
${mine ? '<button type="button" class="fs-btn danger fs-withdraw">Withdraw</button>' : ''}
|
||
<button type="button" class="fs-btn primary fs-send">${mine ? 'Update report' : 'Send report'}</button>
|
||
</div>
|
||
</div>`;
|
||
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 };
|
||
})();
|