Files
MangaCircus/public/settings.js
MaashkinoozheandClaude Opus 5.5 74842c59e0 Moments: per-user saved pages and regions with one category (BL-0055)
Reader: bookmark button and M save the page (Flow: page at the reading
line, Book: the spread) with an optional dragged region, exactly one
category (Twist, Hot, Awesome, Cute, Funny, Feels) and a note. I now
toggles the bars; ? lists all keys. Keys dispatch through public/keys.js,
which also feeds the new Settings -> Keyboard shortcuts section.

Manga page: "Your moments" strip of crops (story or recent order,
category filters), viewer with step, full page, edit, delete and
"Open in reader" (read.html ...&p=). Moments point at pages by file
name and show "page moved" when the file is gone. Private per user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:30:24 +01:00

554 lines
42 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 readDate = h('select', { onchange: e => save({ readDate: e.target.value }) },
h('option', { value: 'hour' }, 'Date and hour (e.g. 23 Oct, 21h)'),
h('option', { value: 'date' }, 'Date only'),
h('option', { value: 'off' }, 'Do not show'));
readDate.value = S.readDate;
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').append(h('section', { class: 'card' },
h('h2', {}, 'Library page'),
check('continueRow', 'Show "Continue where you left off" at the top',
'One row with the five manga you read most recently and have not finished. It hides itself while you search, filter by status, or sort by "Recently read".')));
section('user/reading').append(h('section', { class: 'card' },
h('h2', {}, 'Chapter list dates'),
h('label', { class: 'field' }, h('span', {}, 'When I read a chapter'), readDate,
h('div', { class: 'muted small' }, 'Shown next to ✓ in the chapter list: when you first finished the chapter, in your local time. Marking it unread clears it.')),
check('releaseDate', 'Show when each chapter was released',
'Best known date first: the official release, else the upload at the source (≈), else when it first reached Manga Circus (◎), else when it was added here (↓). Hover a date for all known dates.')));
const readingHoursBody = h('div', {}, h('p', { class: 'muted' }, 'Loading server reading hours…'));
section('user/reading-hours').append(h('section', { class: 'card' },
h('h2', {}, 'Server reading hours'), readingHoursBody,
h('p', { class: 'muted small' }, 'These hours are set in the server deployment and cannot be changed here. Overnight windows continue into the next day.')));
api('api/reading-schedule').then(schedule => {
readingHoursBody.textContent = '';
if (!schedule.enabled) {
readingHoursBody.append(h('p', {}, 'Reading is available at any time. No schedule is configured.'));
return;
}
readingHoursBody.append(h('p', { class: 'muted' }, `Server timezone: ${schedule.timezone}`),
h('div', { class: 'reading-hours-list' }, READING_DAYS.map((day, index) =>
h('div', { class: 'reading-hours-day' }, h('strong', {}, day),
h('span', {}, readingDayText(schedule, index))))));
}).catch(error => { readingHoursBody.replaceChildren(h('p', { class: 'error', role: 'alert' }, `Could not load reading hours: ${error.message}`)); });
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/shortcuts').append(h('section', { class: 'card' }, keyTable()));
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)) {
const intended = !details.open;
disclosureOpen[key] = intended;
// A filter click can redraw the panel while this summary click is
// bubbling. Apply the click to the replacement node as well; the
// browser's native toggle will only affect the detached original.
if (!details.isConnected) {
const replacement = card.querySelector(`[data-scraper-disclosure="${key}"]`);
if (replacement) replacement.open = intended;
}
}
});
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 usable = (state.adapters || []).filter(adapter => state.settings?.sourcesEnabled?.[adapter.id] !== false).map(adapter => adapter.label);
const url = h('input', { type: 'url', placeholder: `${usable.join(', ') || 'Source'} 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' });
// Sources with a switch (sourcesEnabled) start switched off on new installations.
const switchable = Object.hasOwn(rate.sourcesEnabled || {}, adapter.id);
const enabled = switchable ? h('input', { type: 'checkbox', checked: rate.sourcesEnabled[adapter.id] === true,
onchange: e => command({ command: 'settings', settings: { sourcesEnabled: { [adapter.id]: e.target.checked } } }) }) : null;
return h('div', { class: 'source-rate' },
h('b', {}, adapter.label),
switchable ? h('label', { class: 'check' }, enabled, h('span', {}, `Use ${adapter.label}`,
h('div', { class: 'muted small' }, adapter.id === 'mangadex'
? 'Official API; titles removed at a publisher\'s request are not offered.'
: 'Unofficial site that mostly hosts unlicensed copies. Switch it on only for what you may copy. While it is off, its queued jobs wait and update checks skip it.'))) : null,
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 + (state.settings?.sourcesEnabled?.[adapter.id] === false ? ' (off)' : '') }))].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 expected = availability?.expectedLastChapter;
const sparse = /^\d+$/.test(String(expected || '')) && Number(availability?.numberedChapters) < Number(expected);
const partialCoverage = availability?.coverage === 'partial' || sparse;
const sourceIsPaused = !!state.sourcePaused?.[item.source];
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 === 'queued' && sourceIsPaused ? 'Confirmed · waiting for source access review'
: item.status.replaceAll('_', ' ');
return h('div', { class: 'scraper-item' + (running ? ' active' : ''), 'data-source': item.source },
h('div', { class: 'grow' }, h('b', {}, item.title),
item.status === 'complete' || availability ? h('div', { class: 'coverage-indicators' },
item.status === 'complete' ? h('span', { class: 'coverage-badge coverage-complete' }, 'Hosted selection complete') : null,
availability ? h('span', { class: 'coverage-badge ' + (partialCoverage ? 'coverage-partial' : 'coverage-unknown') },
partialCoverage ? 'Series coverage partial' : 'Series coverage unverified') : null,
item.status === 'complete' && !availability ? h('span', { class: 'coverage-badge coverage-unknown' }, 'Series coverage unverified') : null) : null,
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,
item.status === 'ready' && sourceIsPaused ? h('div', { class: 'small error' },
'This source is paused after an access response. Confirming saves your choice, but downloading will not start until access is reviewed and the source is resumed.') : null,
availability ? h('div', { class: 'muted small' }, `${availability.selectedChapters} hosted selections${availability.specialReleases ? ` · ${availability.specialReleases} unnumbered releases` : ''}${availability.expectedLastChapter ? ` · metadata last chapter: ${availability.expectedLastChapter}` : ''}`) : 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', disabled: sourceIsPaused,
title: sourceIsPaused ? 'Source access must be reviewed before another inspection' : '',
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'))),
Object.entries(state.sourcePaused || {}).filter(([, paused]) => paused).map(([source]) =>
h('p', { class: 'small error', role: 'status' }, `${source} is paused after a source-access problem. Confirmed jobs wait; confirmation does not resume this source.`)),
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.'),
h('h2', {}, 'Available-source log'),
h('p', { class: 'muted small' }, 'A queued URL is only a lead. Catalog found, adapter compatibility and download rights are separate facts. This log does not approve or start a download.'),
h('div', { class: 'scraper-queue' }, (state.sourceLedger || []).filter(entry => !sourceFilter || entry.source === sourceFilter).map(entry =>
h('div', { class: 'scraper-item' }, h('div', { class: 'grow' },
h('b', {}, entry.title || entry.sourceTitle || entry.seriesId || 'Unknown title'),
h('div', { class: 'muted small' }, `${entry.source} · catalog ${entry.catalog} · adapter ${entry.adapter} · rights ${entry.rights}`),
h('div', { class: 'muted small' }, `${entry.hostedCount == null ? 'Chapter count unknown' : `${entry.hostedCount} selected chapters`} · series coverage ${entry.coverage || 'unknown'} · checked ${when(entry.lastObservedAt)}`),
entry.sourceTitle && entry.sourceTitle !== entry.title ? h('div', { class: 'small' }, `Source edition: ${entry.sourceTitle}`) : null,
entry.reason ? h('div', { class: 'small' }, `Availability: ${entry.reason}`) : null,
entry.lastFailure ? h('div', { class: 'small error' }, `${entry.lastFailure.kind}: ${entry.lastFailure.message}`) : null),
entry.url && /^https?:\/\//.test(entry.url) ? h('a', { class: 'btn small', href: entry.url, target: '_blank', rel: 'noopener noreferrer' }, 'Source ↗') : null))),
!(state.sourceLedger || []).some(entry => !sourceFilter || entry.source === sourceFilter)
? h('p', { class: 'muted small' }, 'No source observations yet for this filter.') : null);
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('span', { class: 'row' },
h('button', { class: 'btn small', onclick: async () => {
if (!confirm(u.admin ? `Remove admin rights from ${u.name}?` : `Make ${u.name} an admin? Admins can manage users, the library and the scraper.`)) return;
try { await api('api/admin/user/role', { name: u.name, admin: !u.admin }); load(); }
catch (e) { msg.textContent = e.message; msg.className = 'small error'; }
} }, u.admin ? 'Remove admin' : 'Make admin'),
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();
})();