462 lines
34 KiB
JavaScript
462 lines
34 KiB
JavaScript
'use strict';
|
|
/* 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, 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);
|
|
saved.hidden = false;
|
|
clearTimeout(savedTimer);
|
|
savedTimer = setTimeout(() => { saved.hidden = true; }, 1500);
|
|
}
|
|
const check = (key, text, hint) => h('label', { class: 'check' },
|
|
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }),
|
|
h('span', {}, text, hint ? h('div', { class: 'muted small' }, hint) : null));
|
|
|
|
const startMode = h('select', { onchange: e => save({ startMode: e.target.value }) },
|
|
h('option', { value: 'last' }, 'The mode I used last'),
|
|
h('option', { value: 'flow' }, 'Always Flow (pages below each other)'),
|
|
h('option', { value: 'page' }, 'Always Page (one page at a time)'),
|
|
h('option', { value: 'book' }, 'Always Book (two pages side by side)'));
|
|
startMode.value = S.startMode;
|
|
const tone = h('select', { onchange: e => save({ pageTone: e.target.value }) },
|
|
h('option', { value: 'original' }, 'Original'),
|
|
h('option', { value: 'inverted' }, 'Inverted'),
|
|
h('option', { value: 'sepia' }, 'Sepia'),
|
|
h('option', { value: 'dim' }, 'Dim (70% brightness)'),
|
|
h('option', { value: 'extra-dim' }, 'Extra dim (45% brightness)'));
|
|
tone.value = S.pageTone;
|
|
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)));
|
|
|
|
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('label', { class: 'field' }, h('span', {}, 'Page tone'), tone,
|
|
h('div', { class: 'muted small' }, 'The reader button cycles through these choices. Only page artwork changes; the site theme and controls stay as they are.')),
|
|
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' });
|
|
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,
|
|
h('div', { class: 'muted small' }, 'The server POSTs a short text message to this URL, e.g. an ntfy.sh or Gotify topic.')),
|
|
h('div', { class: 'row' }, h('button', { class: 'btn', onclick: async () => {
|
|
try { await save({ webhook: hook.value }); hookMsg.textContent = ''; } catch (e) { hookMsg.textContent = e.message; hookMsg.className = 'small error'; }
|
|
} }, 'Save webhook'), hookMsg)));
|
|
|
|
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' });
|
|
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)));
|
|
|
|
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);
|
|
|
|
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' });
|
|
section(id).append(card);
|
|
const draw = data => {
|
|
const s = data.scan;
|
|
const scanText = s.running ? 'Scanning…' : s.error ? 'Last scan failed: ' + s.error
|
|
: s.at ? `${s.mangas} manga, ${s.chapters} chapters, ${s.pages} pages · scanned ${ago(s.at)} in ${s.ms} ms` : 'Not scanned yet';
|
|
const rescan = full => async e => {
|
|
e.target.disabled = true; e.target.textContent = 'Scanning…';
|
|
try { await api('api/admin/rescan', { full }); } finally { load(); }
|
|
};
|
|
const name = h('input', { placeholder: 'user name', autocomplete: 'off' });
|
|
const pw = h('input', { type: 'password', placeholder: 'password', autocomplete: 'new-password' });
|
|
const isAdmin = h('input', { type: 'checkbox' });
|
|
const msg = h('span', { class: 'small' });
|
|
card.textContent = '';
|
|
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),
|
|
u.name === me.name ? h('span', { class: 'muted small' }, 'you') : h('button', { class: 'btn small', onclick: async () => {
|
|
if (!confirm(`Delete ${u.name} and their reading progress?`)) return;
|
|
await api('api/admin/user/delete', { name: u.name }); load();
|
|
} }, 'Delete'))))),
|
|
h('div', { class: 'field' }, h('span', {}, 'Add a user or reset a password'),
|
|
h('div', { class: 'row' }, name, pw, h('label', { class: 'row' }, isAdmin, 'admin'),
|
|
h('button', { class: 'btn', onclick: async () => {
|
|
try { await api('api/admin/user', { name: name.value, password: pw.value, admin: isAdmin.checked }); load(); }
|
|
catch (e) { msg.textContent = e.message; msg.className = 'small error'; }
|
|
} }, 'Save user'), msg)));
|
|
};
|
|
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();
|
|
})();
|