CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 18s
CI and images / frontend-build (push) Successful in 26s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m24s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 6m13s
CI and images / build-web (push) Successful in 1m38s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 2s
On a Discord channel page the Add panel lists who posted in the newest 200 messages, the creator the server is named for (or owned by) ticked. On a channel FabledCurator already follows, the chip opens the same list with Save and Open artist. The list is kept as user ids, so a rename never quietly stops it matching; the name each was picked under rides beside it for display, and the Subscriptions dialog shows it. - DiscordClient.recent_posters + rank_posters (owner / name-matches-server; bots never suggested; it suggests, the operator ticks). - GET/POST /api/extension/discord/posters; quick-add takes discord_authors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
546 lines
22 KiB
JavaScript
546 lines
22 KiB
JavaScript
(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') {
|
||
// A followed Discord channel opens its poster list (#4488); the artist
|
||
// page is one click further, in that panel.
|
||
if (probe.platform === 'discord' && probe.discord?.channel_id) {
|
||
if (document.getElementById('fc-add-panel')) closePanel();
|
||
else openPostersPanel(btn, probe);
|
||
return;
|
||
}
|
||
await openArtist(btn, probe.artist?.slug);
|
||
return;
|
||
}
|
||
// Every add goes through the panel, so the operator can match the page to
|
||
// an artist FabledCurator already has (the same creator is often spelled
|
||
// differently per platform) instead of minting a duplicate.
|
||
if (document.getElementById('fc-add-panel')) {
|
||
closePanel();
|
||
return;
|
||
}
|
||
await openAddPanel(btn, probe);
|
||
}
|
||
|
||
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';
|
||
const renamed = r.renamed_from ? ` — renamed from “${r.renamed_from}”` : '';
|
||
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})${renamed}`, '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;
|
||
}
|
||
}
|
||
|
||
// ---- Add panel ----
|
||
// Who: the suggested artist, one found by search, or a new one by name —
|
||
// on every platform. Where (Discord only): this channel or the whole
|
||
// server. On Patreon, joining an artist known by another name offers the
|
||
// Patreon name, which the operator treats as canon. 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-add-panel')?.remove();
|
||
}
|
||
|
||
// ---- Poster picker (#4488) ----
|
||
// Who posted in this channel lately, read by FabledCurator with its Discord
|
||
// token, the creator the server is named for (or owned by) ticked. The
|
||
// source keeps their ids: a username can change on a whim, an id can't, so
|
||
// the list never quietly stops matching. Names are other people's text —
|
||
// createElement only.
|
||
|
||
function posterSection() {
|
||
const list = el('div', { class: 'fc-panel__posters' }, [
|
||
el('div', { class: 'fc-panel__hint', text: 'Reading who posts here…' }),
|
||
]);
|
||
const node = el('div', {}, [
|
||
el('div', { class: 'fc-panel__label', text: 'Only posts by' }),
|
||
list,
|
||
]);
|
||
const state = { data: null, picked: new Set() };
|
||
|
||
function render() {
|
||
const posters = withSelected(state.data);
|
||
if (!posters.length) {
|
||
list.replaceChildren(el('div', { class: 'fc-panel__hint', text: 'No one has posted here lately.' }));
|
||
return;
|
||
}
|
||
const rows = posters.map((p) => {
|
||
const id = String(p.id);
|
||
const box = el('input', { type: 'checkbox', checked: state.picked.has(id) });
|
||
box.addEventListener('change', () => {
|
||
if (box.checked) state.picked.add(id);
|
||
else state.picked.delete(id);
|
||
hint.textContent = pickHint();
|
||
});
|
||
const bits = [`${p.images} image${p.images === 1 ? '' : 's'}`, `${p.messages} message${p.messages === 1 ? '' : 's'}`];
|
||
if (p.reasons?.length) bits.unshift(p.reasons.join(', '));
|
||
return el('label', { class: 'fc-panel__radio fc-panel__poster' }, [
|
||
box,
|
||
el('span', { text: posterLabel(p) }),
|
||
el('span', { class: 'fc-panel__poster-meta', text: bits.join(' · ') }),
|
||
]);
|
||
});
|
||
const hint = el('div', { class: 'fc-panel__hint', text: pickHint() });
|
||
list.replaceChildren(...rows, hint);
|
||
}
|
||
|
||
function pickHint() {
|
||
return state.picked.size
|
||
? 'Only the ticked people’s posts are taken.'
|
||
: 'Nothing ticked: everyone’s posts are taken.';
|
||
}
|
||
|
||
async function load() {
|
||
let r;
|
||
try {
|
||
r = await browser.runtime.sendMessage({ type: 'DISCORD_POSTERS', url: window.location.href });
|
||
} catch (e) {
|
||
r = { error: e.message };
|
||
}
|
||
if (r?.error) {
|
||
list.replaceChildren(el('div', { class: 'fc-panel__hint', text: `Couldn’t list posters — ${r.error}` }));
|
||
return;
|
||
}
|
||
state.data = r;
|
||
state.picked = initialPosterPick(r);
|
||
render();
|
||
}
|
||
|
||
// null until the list has loaded: an add made before then leaves the
|
||
// source's list as it is instead of clearing it.
|
||
const request = () => (state.data ? postersRequest(state.data.posters, state.picked) : null);
|
||
return { node, load, request };
|
||
}
|
||
|
||
function openPostersPanel(btn, probe) {
|
||
closePanel();
|
||
const d = probe.discord || {};
|
||
const posters = posterSection();
|
||
const saveBtn = el('button', { class: 'fc-panel__btn fc-panel__btn--primary', text: 'Save' });
|
||
const artistBtn = el('button', { class: 'fc-panel__btn', text: `Open ${probe.artist?.name || 'artist'}` });
|
||
const cancelBtn = el('button', { class: 'fc-panel__btn', text: 'Close' });
|
||
const panel = el('div', { id: 'fc-add-panel', class: 'fc-panel' }, [
|
||
el('div', { class: 'fc-panel__title', text: 'Discord source' }),
|
||
el('div', { class: 'fc-panel__sub', text: d.channel_id ? channelLabel(d) : serverLabel(d) }),
|
||
posters.node,
|
||
el('div', { class: 'fc-panel__actions' }, [artistBtn, cancelBtn, saveBtn]),
|
||
]);
|
||
panel.addEventListener('keydown', (e) => {
|
||
e.stopPropagation();
|
||
if (e.key === 'Escape') closePanel();
|
||
});
|
||
cancelBtn.addEventListener('click', closePanel);
|
||
artistBtn.addEventListener('click', () => { closePanel(); openArtist(btn, probe.artist?.slug); });
|
||
saveBtn.addEventListener('click', async () => {
|
||
const authors = posters.request();
|
||
if (!authors) return;
|
||
saveBtn.disabled = true;
|
||
try {
|
||
const r = await browser.runtime.sendMessage({
|
||
type: 'SET_DISCORD_POSTERS', url: window.location.href, authors,
|
||
});
|
||
if (r?.error) {
|
||
showToast(`Error: ${r.error}`, 'error');
|
||
return;
|
||
}
|
||
showToast(authors.length ? `Only posts by ${authors.map((a) => a.name || a.id).join(', ')}` : 'Taking everyone’s posts', 'success');
|
||
closePanel();
|
||
} catch (e) {
|
||
showToast(`Error: ${e.message}`, 'error');
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
}
|
||
});
|
||
document.body.appendChild(panel);
|
||
posters.load();
|
||
}
|
||
|
||
async function openAddPanel(btn, probe) {
|
||
closePanel();
|
||
const platformName = PLATFORMS[probe.platform]?.name || probe.platform;
|
||
// Discord's probe already carries its names. Patreon/SubscribeStar read the
|
||
// creator's display name only now, when the panel needs it — a request to
|
||
// the platform the chip's own probe deliberately doesn't make.
|
||
if (probe.platform !== 'discord') {
|
||
const original = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = `Reading the ${platformName} name…`;
|
||
try {
|
||
const named = await browser.runtime.sendMessage({
|
||
type: 'PROBE_SOURCE', url: window.location.href, names: true,
|
||
});
|
||
if (named && !named.error) probe = named;
|
||
} catch { /* fall back to the chip's probe: the URL handle */ }
|
||
btn.disabled = false;
|
||
btn.textContent = original;
|
||
if (probe.state === 'source_match') {
|
||
renderButton(probe);
|
||
return;
|
||
}
|
||
}
|
||
const d = probe.discord || {};
|
||
const discord = probe.platform === 'discord';
|
||
const choice = panelDefaults(probe, window.location.href);
|
||
// #4488: on a channel page, pick whose posts the new source takes.
|
||
const posters = discord && d.channel_id ? posterSection() : null;
|
||
|
||
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' });
|
||
|
||
// Patreon is canon: joining an artist known by another name takes the
|
||
// Patreon name unless this is unticked. Shown only when it would rename.
|
||
const renameBox = el('input', { type: 'checkbox', checked: choice.adoptPlatformName });
|
||
const renameText = el('span');
|
||
const renameRow = el('label', { class: 'fc-panel__radio fc-panel__rename' }, [renameBox, renameText]);
|
||
renameBox.addEventListener('change', () => { choice.adoptPlatformName = renameBox.checked; refresh(); });
|
||
|
||
const sub = discord
|
||
? serverLabel(d)
|
||
: [probe.display_name, probe.slug].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(' · ');
|
||
const panel = el('div', { id: 'fc-add-panel', class: 'fc-panel' }, [
|
||
el('div', { class: 'fc-panel__title', text: `Add ${platformName} source` }),
|
||
el('div', { class: 'fc-panel__sub', text: sub }),
|
||
...(discord ? [
|
||
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]),
|
||
renameRow,
|
||
...(posters ? [posters.node] : []),
|
||
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 = addRequest(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 if (req.artistName) hint.textContent = `Creates a new artist “${req.artistName}”.`;
|
||
else hint.textContent = `Creates a new artist, named from the ${platformName} page.`;
|
||
hint.classList.toggle('fc-panel__hint--match', !!req && req.artistId != null);
|
||
const offer = renameOffer(choice);
|
||
renameRow.hidden = !offer;
|
||
if (offer) renameText.textContent = `Rename “${offer.from}” to the Patreon name “${offer.to}”`;
|
||
}
|
||
|
||
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 = addRequest(choice);
|
||
if (!req) return;
|
||
const authors = posters?.request();
|
||
if (authors) req.discordAuthors = authors;
|
||
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();
|
||
posters?.load();
|
||
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);
|
||
}
|
||
})();
|