Files
ytplayer/frontend/flag-ui.js

121 lines
6.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
})();