Consolidate MangaCircus app, scraper, and Compose baseline

This commit is contained in:
Maashkinoozhe committed 2026-10-07 23:28:20 +02:00
1 parent cfb2d79103
commit eef4ffa07c
103 files changed
+9316 -172

No files matched your search

+360 -13
View File
@@ -1,13 +1,19 @@
'use strict';
/* Settings: reading preferences, notifications, password and (for admins) library + users. */
/* Settings workspace: User/Admin sections, with local scraper tabs. */
(async function () {
let me;
try { me = await api('api/me'); } catch (e) { return showError(e); }
topNav(me);
const S = me.settings, main = $('main');
main.textContent = '';
const S = me.settings, shell = settingsShell(me);
const sections = new Map(), controllers = new Map();
const section = id => {
if (!sections.has(id)) sections.set(id, h('div', { 'data-settings-section': id }));
return sections.get(id);
};
let route = settingsRoute(location.hash, me.admin);
const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
shell.titleRow.append(saved);
let savedTimer = 0;
async function save(patch) {
Object.assign(S, (await api('api/settings', patch)).settings);
@@ -28,22 +34,59 @@
const LABEL = { flow: 'Flow', page: 'Page', book: 'Book', strip: 'Webtoon flow' };
const zooms = () => Object.keys(LABEL).map(k => `${LABEL[k]} ${Math.round((S.zoom[k] || 1) * 100)}%`).join(' · ');
const zoomText = h('span', { class: 'muted' }, zooms());
const readingPassword = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' });
const readingDataMessage = h('span', { class: 'small' });
const readingData = h('details', { class: 'reading-data' },
h('summary', {}, 'Manage my reading-time data'),
h('p', { class: 'muted small' }, 'Reading Story is private to your account. Turning the clock off stops collection but keeps existing history.'),
h('div', { class: 'row' },
h('button', { class: 'btn small', onclick: async () => {
try {
const data = await api('api/reading/export');
const blob = new Blob([JSON.stringify(data, null, 2) + '\n'], { type: 'application/json' });
const link = h('a', { href: URL.createObjectURL(blob), download: `mangacircus-reading-${me.name}.json` });
document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(link.href), 0);
readingDataMessage.textContent = 'Export created'; readingDataMessage.className = 'saved';
} catch (error) { readingDataMessage.textContent = error.message; readingDataMessage.className = 'small error'; }
} }, 'Export my summary')),
h('div', { class: 'reading-danger' },
h('b', {}, 'Delete all reading-time history'),
h('p', { class: 'muted small' }, 'Reading progress and accounts are not affected. This cannot be undone.'),
h('div', { class: 'row' }, readingPassword,
h('button', { class: 'btn small danger', onclick: async () => {
if (!readingPassword.value || !confirm('Permanently delete all of your reading-time history?')) return;
try {
await api('api/reading/delete', { all: true, password: readingPassword.value });
readingPassword.value = '';
readingDataMessage.textContent = 'Reading-time history deleted'; readingDataMessage.className = 'saved';
} catch (error) { readingDataMessage.textContent = error.message; readingDataMessage.className = 'small error'; }
} }, 'Delete history'), readingDataMessage)));
main.append(h('section', { class: 'card' },
h('h2', {}, 'Reading ', saved),
section('user/reading').append(h('section', { class: 'card' },
h('h2', {}, 'Reader preferences'),
h('label', { class: 'field' }, h('span', {}, 'Open chapters in'), startMode,
h('div', { class: 'muted small' }, `Last used: ${LABEL[S.lastMode]}. Only clicking a mode in the reader changes it.`)),
check('adapt', 'Use the fitting mode when the content is not made for my choice',
'Example: webtoons are one long strip, so they always open in Flow. This never changes the mode you picked.'),
check('skipExtras', 'Skip extra pages and extra chapters', 'Pages or chapters marked as extra (omake, notes, specials) are left out while reading.'),
check('skipCovers', 'Skip chapter cover pages'),
check('flowPageGap', 'Show a gap between pages in Flow mode',
'Turn this off for continuous webtoon artwork or text that crosses page edges.'),
h('div', { class: 'field' }, h('span', {}, 'Page zoom'), zoomText, ' ',
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
section('user/reading-time').append(h('section', { class: 'card' },
h('h2', {}, 'Clock & Reading Story'),
check('readingClock', 'Track and show the Active Reading Clock',
'Counts a visit after you view 60% of its pages, with no scrolling-speed limit. Pauses in the background or after 30 seconds without reader activity. Disabled by default.'),
check('readingStory', 'Show Your Reading Story',
'Shows your private chapter times and personal summaries on manga and library pages. Hiding it does not delete existing history.'),
readingData));
const hook = h('input', { type: 'url', placeholder: 'https://ntfy.sh/my-manga-topic', value: S.webhook || '', style: 'width:100%' });
const hookMsg = h('div', { class: 'small' });
main.append(h('section', { class: 'card' },
section('user/notifications').append(h('section', { class: 'card' },
h('h2', {}, 'Notifications'),
h('p', { class: 'muted' }, 'Press "Notify me" on a manga page. New chapters then show up under the bell at the top.'),
h('label', { class: 'field' }, h('span', {}, 'Also send them to a webhook (optional)'), hook,
@@ -55,18 +98,290 @@
const oldPw = h('input', { type: 'password', autocomplete: 'current-password', placeholder: 'Current password' });
const newPw = h('input', { type: 'password', autocomplete: 'new-password', placeholder: 'New password' });
const pwMsg = h('span', { class: 'small' });
main.append(h('section', { class: 'card' },
section('user/account').append(h('section', { class: 'card' },
h('h2', {}, 'Account · ' + me.name),
h('div', { class: 'row' }, oldPw, newPw, h('button', { class: 'btn', onclick: async () => {
try { await api('api/password', { old: oldPw.value, new: newPw.value }); pwMsg.textContent = 'Password changed'; pwMsg.className = 'saved'; oldPw.value = newPw.value = ''; }
catch (e) { pwMsg.textContent = e.message; pwMsg.className = 'small error'; }
} }, 'Change password'), pwMsg)));
if (me.admin) adminCard();
function scraperCard() {
const card = h('section', { class: 'card scraper-card' });
section('admin/scraper').append(card);
let timer = 0, active = false, lastState = null, dirty = false, sourceFilter = '', onlyEnabled = false;
const disclosureOpen = { add: false, completed: false, history: false };
const commandError = h('p', { class: 'error small', role: 'alert', hidden: true });
card.addEventListener('input', () => { dirty = true; });
// A poll can resolve between the summary click and the browser's deferred
// details toggle event. Record the intended state synchronously, rather
// than sampling the old DOM immediately before replacing it.
card.addEventListener('click', event => {
const summary = event.target.closest('summary');
const details = summary?.parentElement;
const key = details?.dataset.scraperDisclosure;
if (key && Object.hasOwn(disclosureOpen, key)) disclosureOpen[key] = !details.open;
});
card.addEventListener('toggle', event => {
const details = event.target;
const key = details.dataset?.scraperDisclosure;
if (details.isConnected && key && Object.hasOwn(disclosureOpen, key)) disclosureOpen[key] = details.open;
}, true);
async function adminCard() {
const command = async payload => {
try {
await api('api/admin/scraper', payload);
dirty = false; commandError.hidden = true;
await load(true);
} catch (e) { commandError.textContent = e.message; commandError.hidden = false; }
};
const statusText = entry => {
const job = entry.job;
const bits = [entry.current.title];
if (job?.current?.book?.title) bits.push(job.current.book.title);
if (job?.current?.number) bits.push(job.current.label || 'chapter ' + job.current.number);
if (job?.current?.expectedPages) bits.push(`page ${job.current.downloadedPages || 0}/${job.current.expectedPages}`);
return bits.join(' · ');
};
const draw = state => {
lastState = state;
card.textContent = '';
const tabs = h('nav', { class: 'settings-tabs', 'aria-label': 'Scraper sections' },
[['activity', 'Activity'], ['queue', 'Queue'], ['updates', 'Updates'], ['sources', 'Sources']].map(([id, label]) =>
h('a', { class: 'btn', href: '#admin/scraper/' + id, 'aria-current': route.tab === id ? 'page' : null }, label)));
card.append(tabs, commandError);
if (!state.available) {
card.append(h('h2', {}, 'Scraper'),
h('p', { class: 'error' }, state.error || 'Scraper service unavailable'),
h('p', { class: 'muted small' }, 'Check the managed services and their logs. Do not start a second scraper against the same library.'),
h('a', { class: 'btn small', href: 'health.html' }, 'Service health & incidents'));
return;
}
const activeEntries = state.active?.length ? state.active : state.current ? [{ source: state.current.source, current: state.current, job: state.job }] : [];
const url = h('input', { type: 'url', placeholder: 'WeebCentral or MangaDex title URL', class: 'grow' });
const title = h('input', { placeholder: 'Title override (optional)' });
const scope = h('select', {},
h('option', { value: 'missing' }, 'All missing chapters'),
h('option', { value: 'latest' }, 'Newest chapter only'),
h('option', { value: 'last' }, 'Last N chapters'),
h('option', { value: 'first' }, 'First N chapters'),
h('option', { value: 'chapter' }, 'One chapter'),
h('option', { value: 'after' }, 'After chapter'),
h('option', { value: 'volume' }, 'One volume'));
const scopeValue = h('input', { type: 'number', min: '0', step: '0.1', placeholder: 'N / chapter / volume', disabled: true });
scope.onchange = () => { scopeValue.disabled = ['missing', 'latest'].includes(scope.value); scopeValue.placeholder = ['last', 'first'].includes(scope.value) ? 'Count' : scope.value === 'volume' ? 'Volume' : 'Chapter'; };
const addSource = h('input', { type: 'checkbox' });
const libraryTarget = h('select', {}, h('option', { value: '' }, 'Auto-detect library manga'),
...(state.library || []).map(manga => h('option', { value: manga.id }, manga.title)));
const addMessage = h('span', { class: 'small' });
const rate = state.settings || {};
const sourceRateEditors = (state.adapters || []).map(adapter => {
const sourceRate = { ...rate, ...((rate.sourceRates || {})[adapter.id] || {}) };
const min = h('input', { type: 'number', min: '.5', step: '.5', value: (sourceRate.minMs ?? 1000) / 1000, title: 'Minimum seconds between image and API requests' });
const max = h('input', { type: 'number', min: '.5', step: '.5', value: (sourceRate.maxMs ?? 4000) / 1000, title: 'Maximum seconds between image and API requests' });
const chapterMin = h('input', { type: 'number', min: '.5', step: '1', value: (sourceRate.chapterMinMs ?? 5000) / 1000, title: 'Minimum seconds between chapters' });
const chapterMax = h('input', { type: 'number', min: '.5', step: '1', value: (sourceRate.chapterMaxMs ?? 15000) / 1000, title: 'Maximum seconds between chapters' });
return h('div', { class: 'source-rate' },
h('b', {}, adapter.label),
h('div', { class: 'row rate-row' },
h('label', {}, h('small', {}, 'Requests min (s)'), min), h('label', {}, h('small', {}, 'Requests max (s)'), max),
h('label', {}, h('small', {}, 'Chapters min (s)'), chapterMin), h('label', {}, h('small', {}, 'Chapters max (s)'), chapterMax),
h('button', { class: 'btn', onclick: () => command({ command: 'settings', settings: { sourceRates: { [adapter.id]: {
minMs: +min.value * 1000, maxMs: +max.value * 1000,
chapterMinMs: +chapterMin.value * 1000, chapterMaxMs: +chapterMax.value * 1000,
} } } }) }, `Save ${adapter.label}`)));
});
const autoEnabled = h('input', { type: 'checkbox', checked: rate.autoUpdateEnabled !== false });
const autoTime = h('input', { type: 'time', value: rate.autoUpdateTime || '03:00' });
const visible = items => items.filter(item => !sourceFilter || item.source === sourceFilter);
const pending = visible(state.queue.filter(item => ['queued', 'retry_wait'].includes(item.status)));
const attention = visible(state.queue.filter(item => ['ready', 'error', 'quarantined', 'unavailable', 'skipped'].includes(item.status)));
const completed = visible(state.queue.filter(item => item.status === 'complete' && item.kind === 'download'));
const when = value => value && !Number.isNaN(Date.parse(value)) ? new Date(value).toLocaleString() : 'Never';
const chips = h('div', { class: 'row scraper-filters', role: 'group', 'aria-label': 'Filter by source' },
[{ id: '', label: 'All sources' }, ...(state.adapters || []).map(adapter => ({ id: adapter.id, label: adapter.label }))].map(option =>
h('button', { class: 'btn small', type: 'button', 'aria-pressed': sourceFilter === option.id ? 'true' : 'false',
onclick: () => { sourceFilter = option.id; draw(lastState); } }, option.label)));
card.append(chips);
const move = async (item, delta) => {
const position = pending.findIndex(entry => entry.id === item.id), other = pending[position + delta];
if (!other) return;
const ids = state.queue.map(entry => entry.id), a = ids.indexOf(item.id), b = ids.indexOf(other.id);
[ids[a], ids[b]] = [ids[b], ids[a]];
await command({ command: 'reorder', ids });
};
const jobRow = (item, movable = false) => {
const running = activeEntries.some(entry => entry.current.id === item.id);
const availability = item.availability;
const outcome = item.status === 'complete'
? `${item.publishedCount ?? 0} chapter${item.publishedCount === 1 ? '' : 's'} published · finished ${when(item.completed)}`
: item.status === 'ready' ? 'Awaiting edition confirmation' : item.status.replaceAll('_', ' ');
return h('div', { class: 'scraper-item' + (running ? ' active' : ''), 'data-source': item.source },
h('div', { class: 'grow' }, h('b', {}, item.title),
h('div', { class: 'muted small' }, `${item.source} · ${item.kind === 'discovery' ? 'Checking for updates' : item.scopeLabel || 'all missing chapters'} · ${outcome}`
+ (item.existingPolicy === 'add-source' ? ' · alternate source' : '')),
item.error ? h('div', { class: 'small error' }, item.error) : null,
item.sourceTitle ? h('div', { class: 'small' }, `Source edition: ${item.sourceTitle}`) : null,
availability ? h('div', { class: 'muted small' }, `${availability.selectedChapters} hosted selections${availability.specialReleases ? ` · ${availability.specialReleases} unnumbered releases` : ''}${availability.expectedLastChapter ? ` · metadata last chapter: ${availability.expectedLastChapter}` : ''} · ${availability.coverage === 'partial' ? 'Partial source coverage' : 'Whole-series completeness not verified'}`) : null),
movable ? h('button', { class: 'btn small', disabled: running || pending[0]?.id === item.id, title: 'Move up', onclick: () => move(item, -1) }, '↑') : null,
movable ? h('button', { class: 'btn small', disabled: running || pending.at(-1)?.id === item.id, title: 'Move down', onclick: () => move(item, 1) }, '↓') : null,
item.status === 'error' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'retry', id: item.id }) }, 'Retry') : null,
['unavailable', 'quarantined', 'complete', 'ready'].includes(item.status) ? h('button', { class: 'btn small', onclick: () => command({ command: 'control', action: 'inspect', id: item.id }) }, 'Inspect again') : null,
item.status === 'ready' ? h('button', { class: 'btn small primary', onclick: () => command({ command: 'control', action: 'confirm', id: item.id }) }, 'Confirm download') : null,
h('button', { class: 'btn small', disabled: running, onclick: async () => {
if (confirm(`Remove ${item.title} from the queue? Its library chapters will stay.`)) await command({ command: 'remove', id: item.id });
} }, 'Remove'));
};
const activeDownloads = h('div', { class: 'scraper-queue' });
const activeVisible = activeEntries.filter(entry => !sourceFilter || entry.source === sourceFilter || entry.current?.source === sourceFilter);
if (!activeVisible.length) activeDownloads.append(h('p', { class: 'muted small' }, state.paused ? 'Queue paused' : 'No active download on this source'));
activeVisible.forEach(entry => {
const job = entry.job, percent = job?.percent || 0;
const problem = job?.lastError || job?.lastProblem;
activeDownloads.append(h('div', { class: 'scraper-item active' }, h('div', { class: 'grow' },
h('b', {}, entry.source || entry.current.source),
h('div', {}, statusText(entry)),
job?.total ? h('div', { class: 'scraper-progress', title: `${job.completed}/${job.total}` }, h('i', { style: `width:${percent}%` })) : null,
job?.total ? h('div', { class: 'muted small' }, `${job.completed}/${job.total} chapters · ${percent}%`) : null,
problem ? h('div', { class: problem.level === 'warning' ? 'small' : 'small error' }, problem.message || String(problem)) : null)));
});
const activityPanel = h('div', { 'data-scraper-tab': 'activity', hidden: route.tab !== 'activity' },
h('h2', {}, 'Activity ', h('span', { class: 'tag' }, state.status)),
h('div', { class: 'scraper-overview' },
h('span', {}, `${activeVisible.length} active`), h('span', {}, `${pending.length} pending`),
h('span', {}, `${attention.length} need attention`)),
h('div', { class: 'field' }, h('span', {}, 'Downloading now'), activeDownloads,
h('div', { class: 'row', style: 'margin-top:8px' }, state.paused
? h('button', { class: 'btn primary', onclick: () => command({ command: 'control', action: 'resume' }) }, 'Resume queue')
: h('button', { class: 'btn', onclick: () => command({ command: 'control', action: 'pause' }) }, 'Pause queue'))),
h('div', { class: 'field' }, h('span', {}, `Needs attention · ${attention.length}`),
attention.length ? h('div', { class: 'scraper-queue' }, attention.map(item => jobRow(item)))
: h('p', { class: 'muted small' }, 'Nothing needs action on this source.'),
h('a', { class: 'btn small', href: 'health.html' }, 'Service health & incidents')),
h('div', { class: 'field' }, h('span', {}, 'Up next'), pending.filter(item => item.status !== 'ready').length
? h('div', { class: 'scraper-queue' }, pending.filter(item => item.status !== 'ready').slice(0, 5).map(item => jobRow(item)))
: h('p', { class: 'muted small' }, 'No downloads waiting.')));
const addPanel = h('details', { class: 'scraper-add', 'data-scraper-disclosure': 'add', open: disclosureOpen.add }, h('summary', {}, 'Add manga to queue'),
h('div', { class: 'field' },
h('div', { class: 'row' }, url, title),
h('div', { class: 'row', style: 'margin-top:8px' }, scope, scopeValue, libraryTarget,
h('label', { class: 'row small' }, addSource, 'Add as another source version'),
h('button', { class: 'btn primary', onclick: async () => {
try {
const selectedScope = { mode: scope.value };
if (['last', 'first'].includes(scope.value)) selectedScope.count = +scopeValue.value;
if (['chapter', 'after'].includes(scope.value)) selectedScope.chapter = scopeValue.value;
if (scope.value === 'volume') selectedScope.volume = scopeValue.value;
await command({
command: 'enqueue', url: url.value, title: title.value, scope: selectedScope,
existingPolicy: addSource.checked ? 'add-source' : 'skip-any',
libraryMangaId: libraryTarget.value || undefined,
inspect: true,
});
}
catch (e) { addMessage.textContent = e.message; addMessage.className = 'small error'; }
} }, 'Inspect & queue'), addMessage),
h('div', { class: 'muted small' }, 'Inspection uses the normal source lane and request delays. Confirm the source edition before images are downloaded.'),
h('div', { class: 'muted small' }, 'Existing chapters are never overwritten. Enable alternate versions only when you want the same chapter from this additional host.')));
const queuePanel = h('div', { 'data-scraper-tab': 'queue', hidden: route.tab !== 'queue' },
h('h2', {}, 'Queue'), addPanel,
h('div', { class: 'field' }, h('span', {}, `Pending · ${pending.length}`),
pending.length ? h('div', { class: 'scraper-queue' }, pending.map(item => jobRow(item, true)))
: h('p', { class: 'muted small' }, 'No pending downloads on this source.')),
h('details', { class: 'scraper-completed', 'data-scraper-disclosure': 'completed', open: disclosureOpen.completed }, h('summary', {}, `Completed downloads · ${completed.length}`),
h('p', { class: 'muted small' }, 'Clearing removes finished queue rows only. Library chapters and activity records stay.'),
h('button', { class: 'btn small', disabled: !completed.length, onclick: async () => {
if (confirm(`Clear ${completed.length} completed download job${completed.length === 1 ? '' : 's'}${sourceFilter ? ` from ${sourceFilter}` : ''} from the queue? Library chapters will stay.`)) {
await command({ command: 'clear-completed', source: sourceFilter });
}
} }, `Clear completed downloads (${completed.length})`),
h('div', { class: 'scraper-queue' }, completed.slice().reverse().map(item => jobRow(item)))));
const checkFor = (manga, source) => (state.updateChecks || []).find(item => item.mangaId === manga.id && item.source === source);
const updateRows = (state.library || []).filter(manga => !onlyEnabled || manga.autoUpdate)
.filter(manga => !sourceFilter || manga.sources?.some(source => source.adapter === sourceFilter))
.sort((a, b) => a.title.localeCompare(b.title));
const updatesPanel = h('div', { 'data-scraper-tab': 'updates', hidden: route.tab !== 'updates' },
h('h2', {}, 'Updates'),
h('div', { class: 'field' },
state.libraryStatus?.error ? h('div', { class: 'small error' }, `Library index refresh failed: ${state.libraryStatus.error}`) : null,
state.libraryStatus?.refreshing ? h('div', { class: 'muted small', role: 'status' },
state.libraryStatus.ready ? 'Refreshing library index; showing the previous snapshot.' : 'Loading library index… Queue and rate controls are ready.') : null,
h('div', { class: 'row' },
h('label', { class: 'row' }, autoEnabled, 'Nightly discovery'),
h('label', {}, h('small', {}, 'Local time'), autoTime),
h('button', { class: 'btn', onclick: () => command({ command: 'settings', settings: {
autoUpdateEnabled: autoEnabled.checked, autoUpdateTime: autoTime.value,
} }) }, 'Save schedule'),
h('button', { class: 'btn', onclick: () => command({ command: 'discover' }) }, 'Check now')),
h('div', { class: 'muted small' }, state.nextDiscoveryAt ? `Next check: ${new Date(state.nextDiscoveryAt).toLocaleString()}` : 'Nightly discovery is disabled.'),
h('label', { class: 'row small' }, h('input', { type: 'checkbox', checked: onlyEnabled,
onchange: e => { onlyEnabled = e.target.checked; draw(lastState); } }), 'Only auto-updated manga'),
h('div', { class: 'scraper-update-list' }, updateRows.map(manga => {
const checks = (manga.sources || []).filter(source => source.adapter && (!sourceFilter || source.adapter === sourceFilter))
.map(source => checkFor(manga, source.adapter)).filter(Boolean);
const newest = key => checks.map(check => check[key]).filter(Boolean).sort().at(-1);
const found = newest('lastNewContentAt'), downloaded = newest('lastDownloadedAt');
return h('div', { class: 'scraper-update-row' },
h('label', { class: 'row' }, h('input', { type: 'checkbox', checked: !!manga.autoUpdate,
onchange: e => command({ command: 'auto-update', id: manga.id, enabled: e.target.checked }) }), h('b', {}, manga.title)),
h('span', { class: 'muted small' }, `${manga.status} · ${manga.chapters} chapters`),
h('span', {}, `Last checked: ${when(newest('lastCheckedAt'))}`),
h('span', {}, `Last downloaded: ${when(downloaded)}`),
found && (!downloaded || found > downloaded) ? h('span', { class: 'small saved' }, `New content found ${when(found)}; download pending`) : null);
})),
!updateRows.length ? h('p', { class: 'muted small' }, 'No manga match this filter.') : null,
h('div', { class: 'muted small' }, 'Ongoing manga default to enabled. Completed manga default to disabled. A successful check updates “Last checked”; a published chapter updates “Last downloaded”.')),
h('details', { class: 'scraper-history', 'data-scraper-disclosure': 'history', open: disclosureOpen.history }, h('summary', {}, 'Recent check activity'),
h('div', { class: 'scraper-queue' }, (state.history || []).filter(item => item.status === 'checked' && (!sourceFilter || item.source === sourceFilter)).slice(0, 20).map(item =>
h('div', { class: 'scraper-item small' }, `${item.title} · ${item.source} · checked ${when(item.time)} · no new chapters`)))));
const sourcesPanel = h('div', { 'data-scraper-tab': 'sources', hidden: route.tab !== 'sources' },
h('h2', {}, 'Sources'),
(state.adapters || []).filter(adapter => !sourceFilter || adapter.id === sourceFilter).map(adapter => {
const running = activeEntries.find(entry => entry.source === adapter.id);
const waiting = state.queue.filter(item => item.source === adapter.id && ['queued', 'retry_wait', 'ready'].includes(item.status)).length;
const problems = state.queue.filter(item => item.source === adapter.id && ['error', 'quarantined', 'unavailable'].includes(item.status)).length;
return h('section', { class: 'scraper-source-card' }, h('h3', {}, adapter.label),
h('p', { class: 'muted small' }, `${running ? 'Active' : state.sourcePaused?.[adapter.id] ? 'Paused by source protection' : 'Idle'} · ${waiting} waiting · ${problems} need attention`),
sourceRateEditors.find(editor => editor.querySelector('b')?.textContent === adapter.label));
}), h('p', { class: 'muted small' }, 'Each source runs sequentially with its own limits. New limits apply when that source starts or resumes a manga.'));
card.append(activityPanel, queuePanel, updatesPanel, sourcesPanel);
};
async function load(force = false) {
if (!active) return;
try {
const state = await api('api/admin/scraper');
lastState = state;
const focused = card.contains(document.activeElement) && /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement.tagName);
// Replacing a live <details> during polling is disruptive on mobile:
// the browser can deliver its toggle after the replacement. Leave the
// disclosure's DOM alone until the user closes it or takes an action.
const readingDisclosure = [...card.querySelectorAll('details[data-scraper-disclosure]')]
.some(details => details.open);
if (force || (!focused && !dirty && !readingDisclosure)) draw(state);
}
catch (error) { draw({ available: false, error: error.message }); }
clearTimeout(timer);
if (active) timer = setTimeout(load, document.hidden ? 15000 : 3000);
}
return {
activate() { active = true; if (lastState && !dirty) draw(lastState); load(); },
deactivate() { active = false; clearTimeout(timer); },
tab() {
card.querySelectorAll('[data-scraper-tab]').forEach(panel => { panel.hidden = panel.getAttribute('data-scraper-tab') !== route.tab; });
card.querySelectorAll('.settings-tabs a').forEach(link => {
if (link.getAttribute('href') === '#admin/scraper/' + route.tab) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
});
},
};
}
function adminCard(id) {
const card = h('section', { class: 'card' });
main.append(card);
section(id).append(card);
const draw = data => {
const s = data.scan;
const scanText = s.running ? 'Scanning…' : s.error ? 'Last scan failed: ' + s.error
@@ -80,11 +395,14 @@
const isAdmin = h('input', { type: 'checkbox' });
const msg = h('span', { class: 'small' });
card.textContent = '';
card.append(h('h2', {}, 'Admin'),
if (id === 'admin/library') card.append(h('h2', {}, 'Library maintenance'),
h('div', { class: 'field' }, h('span', {}, 'Library'), h('div', { class: 'muted small' }, s.library), h('div', {}, scanText),
h('div', { class: 'row', style: 'margin-top:8px' },
h('button', { class: 'btn', onclick: rescan(false) }, 'Rescan'),
h('button', { class: 'btn', onclick: rescan(true), title: 'Also re-read all image sizes' }, 'Full rescan'))),
h('p', { class: 'muted small' }, 'Deployment, mounts and service startup are managed by Docker Compose. Library scans do not change download settings.'),
h('a', { class: 'btn', href: 'health.html' }, 'Service health & incidents'));
if (id === 'admin/users') card.append(h('h2', {}, 'User administration'),
h('div', { class: 'field' }, h('span', {}, 'Users'),
h('div', { class: 'users' }, data.users.map(u => h('div', {},
h('span', {}, u.name, u.admin ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'admin') : null),
@@ -99,7 +417,36 @@
catch (e) { msg.textContent = e.message; msg.className = 'small error'; }
} }, 'Save user'), msg)));
};
async function load() { draw(await api('api/admin')); }
load();
async function load() {
try { draw(await api('api/admin')); }
catch (e) { card.textContent = ''; card.append(h('p', { class: 'error', role: 'alert' }, e.message), h('button', { class: 'btn', onclick: load }, 'Try again')); }
}
return { activate: load, deactivate() {} };
}
function navigate(focus = false) {
const next = settingsRoute(location.hash, me.admin);
const entry = SETTINGS_SECTIONS.find(item => item.id === next.id);
if (entry.href) { location.href = entry.href; return; }
if (route.id !== next.id) controllers.get(route.id)?.deactivate();
const changed = route.id !== next.id;
route = next;
shell.select(route.id, focus);
if (!controllers.has(route.id) && route.id.startsWith('admin/')) {
controllers.set(route.id, route.id === 'admin/scraper' ? scraperCard() : adminCard(route.id));
}
shell.content.replaceChildren(section(route.id));
const controller = controllers.get(route.id);
if (changed || !controller?.started) {
if (controller) { controller.started = true; controller.activate(); }
} else controller?.tab?.();
// Normalize unknown/unauthorized routes without creating an extra history entry.
if (location.hash !== '#' + route.id && !location.hash.startsWith('#' + route.id + '/')) {
history.replaceState(null, '', '#' + route.id);
}
}
window.addEventListener('hashchange', () => navigate(true));
window.addEventListener('pagehide', () => controllers.get(route.id)?.deactivate());
window.addEventListener('pageshow', e => { if (e.persisted) controllers.get(route.id)?.activate(); });
navigate();
})();