A done chapter after the head now counts as left again when it was finished more than rereadDays before the head (default 7) or before a run of more than rereadChapters chapters read in order up to the head (default 5). Both are user settings under Library page; 0 turns a rule off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
612 lines
47 KiB
JavaScript
612 lines
47 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;
|
|
let refreshSessions = () => {};
|
|
async function save(patch) {
|
|
Object.assign(S, (await api('api/settings', patch)).settings);
|
|
saved.hidden = false;
|
|
clearTimeout(savedTimer);
|
|
savedTimer = setTimeout(() => { saved.hidden = true; }, 1500);
|
|
refreshSessions();
|
|
}
|
|
|
|
// Which login session holds which reader settings (reader-sessions.js on the server).
|
|
function readerSessionsCard() {
|
|
const TONE = { original: 'Original', inverted: 'Inverted', sepia: 'Sepia', dim: 'Dim', 'extra-dim': 'Extra dim' };
|
|
const START = { last: 'last mode', flow: 'always Flow', page: 'always Page', book: 'always Book' };
|
|
const summary = r => [`${LABEL[r.lastMode] || r.lastMode} (opens: ${START[r.startMode] || r.startMode})`, `tone ${TONE[r.pageTone] || r.pageTone}`,
|
|
'zoom ' + (Object.keys(r.zoom || {}).length ? Object.entries(r.zoom).map(([k, z]) => `${LABEL[k] || k} ${Math.round(z * 100)}%`).join(', ') : 'fit'),
|
|
r.skipExtras ? 'skips extras' : null, r.skipCovers ? 'skips covers' : null, r.flowPageGap === false ? 'no Flow gap' : null].filter(Boolean).join(' · ');
|
|
const state = h('div', {}, h('p', { class: 'muted' }, 'Loading sessions…'));
|
|
const message = h('span', { class: 'small' });
|
|
async function act(id, action, current) {
|
|
try {
|
|
draw(await api('api/reader-sessions', { id, action }));
|
|
// This page's controls show this session's settings; reload them when they changed.
|
|
if (current && action === 'follow') location.reload();
|
|
} catch (error) { message.textContent = error.message; message.className = 'small error'; }
|
|
}
|
|
function draw(o) {
|
|
const current = o.sessions.find(s => s.current), source = o.sessions.find(s => s.id === o.latestFrom);
|
|
const latestText = !o.latestFrom ? 'one shared set (changed while per-session settings were off, or never changed)'
|
|
: source ? (source.current ? 'this session' : `${source.device}, active ${ago(source.seen)}`) : 'a session that has since signed out';
|
|
const intent = !o.enabled ? 'Off: every session uses the same reader settings, and a change applies everywhere.'
|
|
: current?.locked ? 'This session has its own reader settings. Changes here stay here and become the starting point for new sessions.'
|
|
: 'This session follows the latest reader settings. Your first change here gives it its own copy, which stays put when other sessions change theirs.';
|
|
state.replaceChildren(
|
|
h('p', {}, h('b', {}, 'Now: '), intent),
|
|
o.enabled ? h('p', { class: 'muted small' }, 'New sessions start from the latest settings: ', h('b', {}, summary(o.latest)), ` — from ${latestText}.`)
|
|
: h('p', { class: 'muted small' }, 'Shared settings: ', h('b', {}, summary(o.latest))),
|
|
h('ul', { class: 'reader-sessions' }, o.sessions.map(s => h('li', {},
|
|
h('div', {}, h('b', {}, s.device), s.current ? ' (this session)' : '', ' · ',
|
|
h('span', { class: 'muted small' }, `signed in ${new Date(s.created).toLocaleDateString()}, active ${ago(s.seen)}`)),
|
|
h('div', { class: 'small' }, !o.enabled ? 'Uses the shared settings' + (s.locked ? ' (its own copy is kept for when this is on again)' : '')
|
|
: s.locked ? 'Own settings: ' + summary(s.reader) : 'Follows the latest settings'),
|
|
o.enabled ? h('div', { class: 'row' },
|
|
s.locked ? h('button', { class: 'btn small', onclick: () => act(s.id, 'follow', s.current) }, 'Follow latest again') : null,
|
|
s.locked && s.id !== o.latestFrom ? h('button', { class: 'btn small', onclick: () => act(s.id, 'latest', s.current) }, 'Use for new sessions') : null) : null))),
|
|
message);
|
|
}
|
|
refreshSessions = () => api('api/reader-sessions').then(draw, error => { message.textContent = error.message; message.className = 'small error'; });
|
|
refreshSessions();
|
|
return h('section', { class: 'card' },
|
|
h('h2', {}, 'Reader settings per session'),
|
|
// The controls above switch between this session's set and the shared one, so reload them.
|
|
h('label', { class: 'check' },
|
|
h('input', { type: 'checkbox', checked: !!S.readerPerSession, onchange: async e => { await save({ readerPerSession: e.target.checked }); location.reload(); } }),
|
|
h('span', {}, 'Keep reader settings per session', h('div', { class: 'muted small' }, 'Each sign-in (device or browser) keeps its own reader mode, zoom, tone and the other reader preferences above once you change one there. A new sign-in copies the settings of the session you used last. Turn this off for one shared set.'))),
|
|
state);
|
|
}
|
|
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 number = (key, text, max) => h('label', { class: 'field' }, h('span', {}, text),
|
|
h('input', { type: 'number', min: 0, max, step: 1, value: S[key], style: 'width:6em',
|
|
onchange: e => { const n = Math.round(+e.target.value); if (n >= 0 && n <= max) save({ [key]: n }); else e.target.value = S[key]; } }));
|
|
|
|
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(readerSessionsCard());
|
|
|
|
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 that still have chapters left after the last one you read. It hides itself while you search, filter by status, or sort by "Recently read".'),
|
|
h('p', { class: 'muted small' }, 'Rereading: later chapters you finished in an earlier pass count as left again when either rule applies. 0 turns a rule off.'),
|
|
number('rereadDays', 'Finished more than this many days before my last read', 3650),
|
|
number('rereadChapters', 'Or I have since read more than this many chapters in a row', 1000)));
|
|
|
|
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();
|
|
})();
|