(function () { if (window.__fc_addsource_injected) return; window.__fc_addsource_injected = true; // Cached probe result for the current URL so click-handlers know which // action to dispatch without round-tripping again. let currentProbe = null; // Bumped on every evaluate(): a probe that answers after the operator has // navigated on is for a page they've left, and must not repaint the chip. let generation = 0; let lastUrl = window.location.href; evaluate(); // SPA navigation. Patreon, SubscribeStar and above all Discord change // channel/page without a reload. Patching history.pushState from here never // worked: a content script runs in an isolated world, so the page's own // pushState is not the function we'd replace. Polling the URL is the one // signal that sees every navigation, and costs a string compare. window.addEventListener('popstate', () => onUrlMaybeChanged()); setInterval(onUrlMaybeChanged, 500); function onUrlMaybeChanged() { if (window.location.href === lastUrl) return; lastUrl = window.location.href; closePanel(); evaluate(); } async function evaluate() { const mine = ++generation; const url = window.location.href; const platform = getPlatformFromUrl(url); const onArtist = platform && isArtistPage(url, platform); if (!onArtist) { removeButton(); currentProbe = null; return; } // Ask the backend what state the URL is in BEFORE drawing the button, so // the chip renders the right state on first paint instead of flashing the // generic "Add" copy and updating afterwards. let probe; try { probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url }); } catch (e) { probe = { error: e?.message || 'probe failed' }; } if (mine !== generation) return; currentProbe = probe; if (probe?.state === 'unknown_platform') { removeButton(); return; } renderButton(probe); } function removeButton() { document.getElementById('fc-add-source-btn')?.remove(); closePanel(); } function renderButton(probe) { let btn = document.getElementById('fc-add-source-btn'); if (!btn) { btn = document.createElement('button'); btn.id = 'fc-add-source-btn'; btn.addEventListener('click', onClick); document.body.appendChild(btn); } // Reset state classes so re-renders (SPA navigation) don't stack. btn.className = 'fc-add-source-btn'; btn.classList.add(`fc-add-source-btn--${chipState(probe)}`); btn.textContent = chipLabel(probe, PLATFORMS[probe?.platform]?.name || probe?.platform || ''); btn.disabled = false; } async function onClick() { const btn = document.getElementById('fc-add-source-btn'); if (!btn) return; const probe = currentProbe; if (probe?.state === 'source_match') { await openArtist(btn, probe.artist?.slug); return; } // A Discord URL names a channel, not a creator — ask which artist. if (probe?.platform === 'discord') { if (document.getElementById('fc-discord-panel')) closePanel(); else openDiscordPanel(probe); return; } await add(btn, { url: window.location.href }); } async function openArtist(btn, slug) { btn.disabled = true; const original = btn.textContent; btn.textContent = 'Opening…'; try { const r = await browser.runtime.sendMessage({ type: 'OPEN_ARTIST_PAGE', slug }); if (r?.error) showToast(`Error: ${r.error}`, 'error'); } catch (e) { showToast(`Error: ${e.message}`, 'error'); } finally { btn.disabled = false; btn.textContent = original; } } // One add, shared by the one-click chip and the Discord panel. Resolves // true on success. async function add(btn, request) { btn.disabled = true; const original = btn.textContent; btn.textContent = 'Adding…'; try { const r = await browser.runtime.sendMessage({ type: 'ADD_AS_SOURCE', ...request }); if (r?.error) { showToast(`Error: ${r.error}`, 'error'); return false; } const verb = r.created_source ? 'Added to' : 'Already a source for'; showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success'); // Re-probe so the chip flips green without waiting for a navigation. evaluate(); return true; } catch (e) { showToast(`Error: ${e.message}`, 'error'); return false; } finally { btn.disabled = false; btn.textContent = original; } } // ---- Discord Add panel ---- // Where: this channel or the whole server. Who: the suggested artist, one // found by search, or a new one by name. Built with createElement only — // server, channel and artist names are other people's text. function el(tag, props = {}, children = []) { const node = document.createElement(tag); const { class: className, text, ...rest } = props; if (className) node.className = className; if (text != null) node.textContent = text; Object.assign(node, rest); for (const c of children) node.appendChild(c); return node; } function closePanel() { document.getElementById('fc-discord-panel')?.remove(); } function openDiscordPanel(probe) { closePanel(); const d = probe.discord || {}; const choice = discordPanelDefaults(probe); const scopeRow = (value, label, disabled) => { const input = el('input', { type: 'radio', name: 'fc-discord-scope', value, checked: choice.scope === value, disabled, }); input.addEventListener('change', () => { choice.scope = value; refresh(); }); return el('label', { class: 'fc-panel__radio' }, [input, el('span', { text: label })]); }; // The artist field is an autocomplete over FC's artists: it searches as // soon as the panel opens (with the prefilled name) and on every keystroke, // lists the matches under the field, fills in the rest of the top match as // you type (Tab or Enter accepts it, typing on replaces it), and picks an // artist whose name IS the text, spacing and case aside, without being // asked. ↑/↓ walk the list; the last row creates a new artist instead. const nameInput = el('input', { type: 'text', class: 'fc-panel__input', value: choice.artistName, placeholder: 'Search artists or type a new name', autocomplete: 'off', spellcheck: false, role: 'combobox', }); nameInput.setAttribute('aria-autocomplete', 'both'); nameInput.setAttribute('aria-expanded', 'false'); const results = el('div', { class: 'fc-panel__results', role: 'listbox' }); const hint = el('div', { class: 'fc-panel__hint' }); const addBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Add' }); const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Cancel' }); const panel = el('div', { id: 'fc-discord-panel', class: 'fc-panel' }, [ el('div', { class: 'fc-panel__title', text: 'Add Discord source' }), el('div', { class: 'fc-panel__sub', text: serverLabel(d) }), el('div', { class: 'fc-panel__label', text: 'Follow' }), scopeRow('channel', d.channel_id ? channelLabel(d) : 'this channel', !d.channel_id), scopeRow('server', `Every channel in ${serverLabel(d)}`, false), el('div', { class: 'fc-panel__label', text: 'Artist' }), el('div', { class: 'fc-panel__combo' }, [nameInput, results]), hint, el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]), ]); // Search state: the rows on screen, which one ↑/↓ has highlighted (-1 = // none), and whether the list is open. let rows = []; let active = -1; let listOpen = false; function refresh() { const req = discordAddRequest(choice); addBtn.disabled = !req; if (!req) hint.textContent = 'Pick an artist or type a name.'; else if (req.artistId != null) hint.textContent = `✓ Connects to ${choice.artist.name}, already in FabledCurator.`; else hint.textContent = `Creates a new artist “${req.artistName}”.`; hint.classList.toggle('fc-panel__hint--match', !!req && req.artistId != null); } function pick(artist) { choice.artist = artist ? { id: artist.id, name: artist.name } : null; if (artist) { choice.artistName = artist.name; nameInput.value = artist.name; } closeList(); refresh(); } function closeList() { listOpen = false; active = -1; results.replaceChildren(); nameInput.setAttribute('aria-expanded', 'false'); } function renderList() { const typed = nameInput.value.trim(); const exact = exactArtistMatch(typed, rows); const items = rows.map((a, i) => { const row = el('button', { type: 'button', class: 'fc-panel__result', role: 'option' }, [ el('span', { text: a.name }), ]); if (choice.artist && choice.artist.id === a.id) { row.appendChild(el('span', { class: 'fc-panel__result-tag', text: 'selected' })); } row.classList.toggle('fc-panel__result--active', i === active); // mousedown, not click: it fires before the input's blur closes the list. row.addEventListener('mousedown', (e) => { e.preventDefault(); pick(a); }); return row; }); if (typed && !exact) { const i = rows.length; const create = el('button', { type: 'button', class: 'fc-panel__result fc-panel__result--new', role: 'option', text: `+ New artist “${typed}”` }); create.classList.toggle('fc-panel__result--active', i === active); create.addEventListener('mousedown', (e) => { e.preventDefault(); pick(null); choice.artistName = typed; refresh(); }); items.push(create); } if (typed && !rows.length) { items.unshift(el('div', { class: 'fc-panel__empty', text: 'No FabledCurator artist matches.' })); } results.replaceChildren(...items); listOpen = items.length > 0; nameInput.setAttribute('aria-expanded', String(listOpen)); results.querySelector('.fc-panel__result--active')?.scrollIntoView({ block: 'nearest' }); } let debounce = null; let searchSeq = 0; // `autofill` is false for deletions: filling the name back in as you // backspace would make it impossible to delete. function search(autofill) { clearTimeout(debounce); const q = nameInput.value.trim(); if (!q) { rows = []; closeList(); return; } debounce = setTimeout(async () => { const mine = ++searchSeq; let r; try { r = await browser.runtime.sendMessage({ type: 'SEARCH_ARTISTS', q }); } catch { return; } // Stale: a later keystroke has its own search coming. if (mine !== searchSeq || r?.error || nameInput.value.trim() !== q) return; rows = r.artists || []; active = -1; const exact = exactArtistMatch(q, rows); if (exact && !choice.artist) { choice.artist = { id: exact.id, name: exact.name }; } else if (autofill && document.activeElement === nameInput) { const hit = inlineCompletion(nameInput.value, rows); const caret = nameInput.value.length; if (hit && nameInput.selectionStart === caret) { nameInput.value = nameInput.value + hit.name.slice(caret); nameInput.setSelectionRange(caret, hit.name.length); choice.artist = { id: hit.id, name: hit.name }; choice.artistName = hit.name; } } renderList(); refresh(); }, 150); } nameInput.addEventListener('input', (e) => { choice.artistName = nameInput.value; // Any edit un-picks: the artist is whatever the field now says. choice.artist = null; refresh(); search(!String(e.inputType || '').startsWith('delete')); }); nameInput.addEventListener('focus', () => { if (rows.length) renderList(); }); nameInput.addEventListener('blur', () => closeList()); // Keep Discord's global shortcuts from eating keystrokes meant for us. panel.addEventListener('keydown', (e) => { e.stopPropagation(); const count = results.querySelectorAll('.fc-panel__result').length; if (e.target === nameInput && listOpen && count) { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); active = e.key === 'ArrowDown' ? (active + 1) % count : (active <= 0 ? count - 1 : active - 1); renderList(); return; } if ((e.key === 'Enter' || e.key === 'Tab') && active >= 0) { e.preventDefault(); results.querySelectorAll('.fc-panel__result')[active] .dispatchEvent(new MouseEvent('mousedown', { cancelable: true })); return; } if ((e.key === 'Tab' || e.key === 'Enter') && choice.artist && nameInput.selectionEnd > nameInput.selectionStart) { // Accept the inline autofill — Enter too, but only to accept: the // add itself takes a second Enter, once the hint names the artist. e.preventDefault(); pick(choice.artist); return; } } if (e.key === 'Escape') { if (listOpen) closeList(); else closePanel(); return; } if (e.key === 'Enter' && e.target === nameInput && !addBtn.disabled) addBtn.click(); }); cancelBtn.addEventListener('click', closePanel); addBtn.addEventListener('click', async () => { const req = discordAddRequest(choice); if (!req) return; const btn = document.getElementById('fc-add-source-btn'); addBtn.disabled = true; const ok = await add(btn || addBtn, req); if (ok) closePanel(); else refresh(); }); document.body.appendChild(panel); refresh(); nameInput.focus(); nameInput.select(); // Search what the field opens with — the server's name, usually — so an // artist it already matches is picked before the operator types anything. if (!choice.artist && nameInput.value.trim()) search(false); } function showToast(text, kind) { const t = document.createElement('div'); t.className = `fc-toast fc-toast--${kind}`; t.textContent = text; document.body.appendChild(t); setTimeout(() => t.remove(), 4000); } })();