(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); let searchSeq = 0; 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 })]); }; const nameInput = el('input', { type: 'text', class: 'fc-panel__input', value: choice.artistName, placeholder: 'Artist name — search or type a new one', autocomplete: 'off', spellcheck: false, }); const results = el('div', { class: 'fc-panel__results' }); 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' }), nameInput, results, hint, el('div', { class: 'fc-panel__actions' }, [cancelBtn, addBtn]), ]); 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} in FabledCurator.`; else hint.textContent = `Adds to “${req.artistName}” — created if FabledCurator has no artist by that name.`; } function showResults(rows) { results.replaceChildren(...rows.map((a) => { const row = el('button', { class: 'fc-panel__result', text: a.name }); row.addEventListener('click', () => { choice.artist = { id: a.id, name: a.name }; choice.artistName = a.name; nameInput.value = a.name; results.replaceChildren(); refresh(); }); return row; })); } let debounce = null; nameInput.addEventListener('input', () => { choice.artistName = nameInput.value; refresh(); clearTimeout(debounce); const q = nameInput.value.trim(); if (!q) { results.replaceChildren(); return; } debounce = setTimeout(async () => { const mine = ++searchSeq; let r; try { r = await browser.runtime.sendMessage({ type: 'SEARCH_ARTISTS', q }); } catch { return; } if (mine !== searchSeq || r?.error) return; showResults(r.artists || []); }, 200); }); // Keep Discord's global shortcuts from eating keystrokes meant for us. panel.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Escape') closePanel(); 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(); } 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); } })();