Adds QoL and experience-enhancing features to kenmei.co. Includes mobile and desktop support.
// ==UserScript==
// @name Kenmei+
// @description Adds QoL and experience-enhancing features to kenmei.co. Includes mobile and desktop support.
// @author Totem
// @match *://*.kenmei.co/*
// @run-at document-end
// @version 2.2.1
// @grant none
// @license Creative Commons Attribution 4.0 International Public License; http://creativecommons.org/licenses/by/4.0/
// @namespace https://greasyfork.org/users/1078112
// @icon https://www.kenmei.co/favicon-196.png
// ==/UserScript==
/*
* ┌─────────────────────────────────────────────────────────┐
* │ Kenmei+ v2.2.1 │
* │ https://greasyfork.org/scripts/466167 │
* └─────────────────────────────────────────────────────────┘
*
* FEATURES
*
* Dark-Mode Toggle
* Toggle for the site's dark mode — unlocked for everyone.
*
* Open Updated (batched)
* Opens the next unread chapter for every updated series
* in batches (default 10), with a confirm prompt between
* batches so 60 tabs don't appear at once.
*
* Set All Latest (API)
* Sets every updated series' last-read chapter to the
* latest chapter via PUT /api/v2/manga_entries/:id —
* no more DOM scraping or mobile/desktop selector dance.
*
* Unread Badge
* Live count of unread series in the toolbar. Tooltip
* shows last-refresh time.
*
* Auto-Refresh
* Periodically re-checks for new chapters in the
* background. Interval configurable in settings.
*
* Desktop Notifications
* Browser notification when auto-refresh detects a new
* chapter drop. Seen-set persists across reloads so you
* don't get duplicate notifications.
*
* Series Filter
* Exclude specific series from "Open Updated" so only
* the ones you care about get opened. Cached so reopening
* settings doesn't refetch.
*
* Covers Mode (experimental, opt-in)
* Replaces the site's gated list/covers toggle with a
* cover grid rendered from the API. Must be enabled in
* Settings → Experimental before use. Disabled by default.
*
* Keyboard Shortcuts
* Configurable bindings for all major actions plus arrow
* keys for page navigation.
*
* Settings Panel
* Cog icon next to your avatar. All toggles, the series
* filter, refresh interval, batch size, and shortcut
* rebinder live here. Settings persist via localStorage.
*
* ─────────────────────────────────────────────────────────
* CHANGELOG
* v2.2.1
* • Improve upon current 'auto-set latest' implementation.
*
* v2.2.0
* • New setting: auto-set latest after open (disabled by default)
* • Fixed batch-modal disappearing after the first batch
*
* v2.1.6
* • Added app icon
*
* v2.1.5
* • Toasts for Open Updated and Set All Latest. Set All
* Latest also auto-clicks the site Refresh button.
* • Arrow-key page nav now matches the anchor-based pager.
* • List view hidden off-screen (kept mounted) so cover
* Edit / Delete / Share / Report relay reliably and the
* cover checkbox mirrors the row's selection state.
* • Cover body no longer navigates — only the title does.
*
* v2.1.4
* • Covers Mode is now an opt-in experimental feature
* (disabled by default). Enable it under
* Settings → Experimental before use. When disabled,
* clicking the site's list/covers toggle passes through
* to Kenmei's own handler as normal. The coversMode
* active-state is reset whenever the override is turned
* off so stale grid state cannot linger.
*
* v2.1.3
* • Cover-card buttons that depended on Vue's edit/share/
* delete/report flows are now wired by clicking the
* corresponding button in the list row — Vue's
* @click handlers run regardless of CSS visibility, so
* the real modals open. Edit, ellipsis menu, decrement,
* check-check now match list-mode behaviour.
* • Mutation-aware refresh: any non-GET to
* /api/v2/manga_entries (add, edit, delete, set-latest)
* triggers a re-fetch of the current page so the covers
* grid reflects new state without a manual reload.
* • Dark mode re-applies on every SPA route change so it
* can't be wiped by Vue rehydration on later screens.
* • Covers mode persistence already works (localStorage),
* and re-enters automatically on dashboard re-init.
*
* v2.1.2
* • Cover cards rebuilt to mirror Kenmei's premium DOM
* 1:1.
* • Increment / set-to-latest stepper buttons wired to the
* PUT /api/v2/manga_entries endpoint. Decrement, edit,
* and ellipsis are stubbed (visual-only) until the
* relevant endpoints are captured.
*
* v2.1.1
* • Covers mode now hijacks the site's existing list/covers
* toggle instead of adding a separate toolbar button —
* click goes through capture-phase, premium prompt is
* suppressed, the toggle's two icons reflect the active
* state. Grid uses Kenmei's native card markup.
*
* v2.1.0
* Reliability:
* • Set-All-Latest now uses the real PUT endpoint
* instead of DOM clicks. Mobile/desktop selectors
* are gone.
* • Notification seen-set persists in localStorage so
* a reload between drops does not eat the alert.
*
* UX:
* • Badge shows just the number, with tooltip for the
* "X unread • Last checked …" detail.
* • Open Updated runs in batches (default 15) with a
* confirm prompt between each batch.
* • Settings panel caches its series list (no refetch
* on every open) with a manual refresh button.
* • Auto-refresh interval is editable from the panel.
*
* New:
* • Experimental Covers Mode
* • Keyboard shortcuts
*
* v2.0.0 — Full rewrite (zero deps, SPA-aware, API-powered).
* v1.x — Legacy (jQuery + js-cookie). See git history.
*
* ─────────────────────────────────────────────────────────
*/
(function () {
'use strict';
/* ================================================================
* CONSTANTS & ICONS
* ================================================================ */
const STORAGE_KEY = 'kenmei-plus-v2';
const SEEN_KEY = 'kenmei-plus-v2-seen';
const PREFIX = 'kp';
const MIN_REFRESH_S = 5;
const API_BASE = 'https://api.kenmei.co';
const COVERS_PER_PAGE = 30;
const BTN_PRIMARY = 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-white transition-colors focus-visible_outline-none focus-visible_ring-2 focus-visible_ring-blue-500 focus-visible_ring-offset-2 disabled_pointer-events-none disabled_opacity-50 dark_ring-offset-0 dark_focus-visible_ring-moon-yellow-400 bg-blue-600 text-white hover_bg-blue-600/90 dark_bg-blue-600/30 dark_text-blue-300 dark_hover_bg-blue-600/40 py-2 h-9 px-2 lg_px-3 shadow rounded-md';
const BTN_OUTLINE = 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-white transition-colors focus-visible_outline-none focus-visible_ring-2 focus-visible_ring-blue-500 focus-visible_ring-offset-2 disabled_pointer-events-none disabled_opacity-50 dark_ring-offset-0 dark_focus-visible_ring-moon-yellow-400 border border-gray-300 bg-white hover_bg-gray-100 hover_text-gray-900 dark_border-gray-800 dark_bg-gray-700 dark_hover_bg-gray-600 dark_hover_text-gray-300 dark_text-white py-2 h-9 px-2 lg_px-3 shadow rounded-md';
const ICON = {
openUpdated: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="-ml-1 mr-2 h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>',
setLatest: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="-ml-1 mr-2 h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
cog: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-6 w-6"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>',
refresh: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-3.5 w-3.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>',
};
/* ================================================================
* SETTINGS
* ================================================================ */
const DEFAULT_SHORTCUTS = Object.freeze({
openUpdated: 'o',
setLatest: 'l',
toggleSettings: ',',
toggleCovers: 'c',
refresh: 'r',
toggleDark: 'd',
prevPage: 'ArrowLeft',
nextPage: 'ArrowRight',
});
const DEFAULTS = Object.freeze({
darkMode: true,
autoRefresh: true,
autoRefreshInterval: 15, // seconds
notifications: false,
excludedSeries: [],
batchSize: 15, // tabs per Open Updated batch
autoSetLatestAfterOpen: false, // auto-set to latest read after Open Updated
// ── Experimental ──────────────────────────────────────────────
// coversOverride gates the entire covers-mode feature. When false
// the site's own list/covers toggle is left untouched so Kenmei's
// premium prompt runs as normal. coversMode tracks active state
// and is only honoured when coversOverride is also true.
coversOverride: false,
coversMode: false,
shortcuts: { ...DEFAULT_SHORTCUTS },
});
function loadSettings () {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
// Merge shortcuts so newly added defaults don't disappear for upgraders.
const shortcuts = { ...DEFAULT_SHORTCUTS, ...(raw.shortcuts || {}) };
return { ...DEFAULTS, ...raw, shortcuts };
} catch { return { ...DEFAULTS, shortcuts: { ...DEFAULT_SHORTCUTS } }; }
}
function saveSettings (s) { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); }
let settings = loadSettings();
/* ================================================================
* DARK MODE
* ================================================================ */
let darkEnforcer = null;
function applyDarkMode (isDark) {
document.documentElement.classList.toggle('dark', isDark);
settings.darkMode = isDark;
saveSettings(settings);
updateCogColor();
enforceDarkMode(isDark);
}
function enforceDarkMode (isDark) {
if (darkEnforcer) { darkEnforcer.disconnect(); darkEnforcer = null; }
const html = document.documentElement;
darkEnforcer = new MutationObserver(() => {
if (html.classList.contains('dark') !== isDark) {
html.classList.toggle('dark', isDark);
updateCogColor();
}
});
darkEnforcer.observe(html, { attributes: true, attributeFilter: ['class'] });
setTimeout(() => { if (darkEnforcer) { darkEnforcer.disconnect(); darkEnforcer = null; } }, 5000);
}
/* ================================================================
* AUTH TOKEN INTERCEPTION
* ================================================================ */
let authToken = null;
let authExpired = false; // set true on 401, cleared when fresh token arrives
const tokenListeners = [];
function onTokenRestored (cb) { tokenListeners.push(cb); }
function setAuthToken (t) {
authToken = t;
if (authExpired) {
authExpired = false;
hideAuthToast();
tokenListeners.forEach(cb => { try { cb(); } catch (e) { /* swallow */ } });
}
}
const origXHROpen = XMLHttpRequest.prototype.open;
const origXHRSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._kpUrl = url;
return origXHROpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
if (name.toLowerCase() === 'authorization' && typeof value === 'string'
&& value.startsWith('Bearer ') && this._kpUrl?.includes('kenmei.co')) {
setAuthToken(value.slice(7));
}
return origXHRSetHeader.call(this, name, value);
};
const origFetch = window.fetch;
window.fetch = function (input, init) {
try {
let auth = null;
let url = null;
if (input instanceof Request) {
auth = input.headers.get('Authorization');
url = input.url;
} else if (typeof input === 'string') {
url = input;
} else if (input && typeof input.url === 'string') {
url = input.url;
}
if (!auth && init?.headers) {
const h = init.headers;
if (h instanceof Headers) auth = h.get('Authorization');
else if (Array.isArray(h)) auth = (h.find(([k]) => k.toLowerCase() === 'authorization') || [])[1];
else if (typeof h === 'object') auth = h.Authorization || h.authorization;
}
if (typeof auth === 'string' && auth.startsWith('Bearer ')
&& typeof url === 'string' && url.includes('kenmei.co')) {
setAuthToken(auth.slice(7));
}
// Watch /api/v2/manga_entries responses so covers mode auto-syncs
// with the page the site itself just loaded.
if (typeof url === 'string' && url.includes('api.kenmei.co/api/v2/manga_entries')) {
const isList = url.includes('/manga_entries?');
const method = (init?.method || (input instanceof Request ? input.method : 'GET') || 'GET').toUpperCase();
const promise = origFetch.call(this, input, init);
return promise.then((res) => {
try {
if (isList && method === 'GET') {
res.clone().json().then(onEntriesPageResponse).catch(() => {});
} else if (method !== 'GET' && res.ok) {
// POST / PUT / PATCH / DELETE on a manga_entry — refresh covers grid.
scheduleCoversRefresh();
}
} catch (_) { /* swallow */ }
return res;
});
}
} catch (e) { /* ignore */ }
return origFetch.call(this, input, init);
};
/* ================================================================
* API HELPERS
* ================================================================ */
async function apiFetch (path, opts = {}) {
if (!authToken) throw new Error('No auth token captured yet.');
const res = await fetch(`${API_BASE}${path}`, {
method: opts.method || 'GET',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${authToken}`,
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
...(opts.headers || {}),
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) {
authToken = null;
authExpired = true;
showAuthToast();
throw new Error('API 401: token expired — refresh the page');
}
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
if (res.status === 204) return null;
const ct = res.headers.get('Content-Type') || '';
return ct.includes('application/json') ? res.json() : res.text();
}
async function fetchEntriesPage (page, status = 1) {
return apiFetch(`/api/v2/manga_entries?page=${page}&status=${status}&search_term=&sort%5BUnread%5D=desc`);
}
async function fetchAllEntries (status = 1) {
const all = [];
let page = 1, pages = 1;
do {
const data = await fetchEntriesPage(page, status);
all.push(...data.entries);
pages = data.pagy.pages;
page++;
} while (page <= pages);
return all;
}
// For the series filter — pull every status (1..6).
async function fetchEntriesAllStatuses () {
const out = [];
for (let status = 1; status <= 6; status++) {
try {
const all = await fetchAllEntries(status);
out.push(...all);
} catch (e) { /* status may be empty or restricted; skip */ }
}
return out;
}
async function fetchUnreadEntries () {
const all = await fetchAllEntries(1);
return all.filter(e => e.attributes.unread && e.chapters.next);
}
async function setEntryToLatest (entryId, latestChapterId) {
return apiFetch(`/api/v2/manga_entries/${entryId}`, {
method: 'PUT',
body: { manga_entry: { manga_source_chapter_id: latestChapterId } },
});
}
/* ================================================================
* DOM HELPERS
* ================================================================ */
function h (tag, attrs = {}, children = []) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (v == null) continue;
if (k === 'className') el.className = v;
else if (k === 'textContent') el.textContent = v;
else if (k === 'innerHTML') el.innerHTML = v;
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
else if (k.startsWith('on') && typeof v === 'function')
el.addEventListener(k.slice(2).toLowerCase(), v);
else el.setAttribute(k, v);
}
for (const c of children) {
if (c == null || c === false) continue;
if (typeof c === 'string') el.append(c);
else el.appendChild(c);
}
return el;
}
const qs = (sel, root = document) => root.querySelector(sel);
const qsa = (sel, root = document) => [...root.querySelectorAll(sel)];
function waitFor (selector, timeout = 10000) {
return new Promise((resolve, reject) => {
const found = qs(selector);
if (found) return resolve(found);
const obs = new MutationObserver(() => {
const el = qs(selector);
if (el) { obs.disconnect(); clearTimeout(timer); resolve(el); }
});
obs.observe(document.body, { childList: true, subtree: true });
const timer = timeout > 0
? setTimeout(() => { obs.disconnect(); reject(new Error(`waitFor timed out`)); }, timeout)
: null;
});
}
function isTypingTarget (el) {
if (!el) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (el.isContentEditable) return true;
return false;
}
function relativeTime (ts) {
if (!ts) return 'never';
const s = Math.round((Date.now() - ts) / 1000);
if (s < 5) return 'just now';
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
/* ================================================================
* SPA NAVIGATION DETECTION
* ================================================================ */
function setupNavDetection (callback) {
let lastPath = location.pathname;
const check = () => {
if (location.pathname !== lastPath) {
const prev = lastPath;
lastPath = location.pathname;
callback(lastPath, prev);
}
};
const wrap = (fn) => function (...args) { fn.apply(this, args); requestAnimationFrame(check); };
history.pushState = wrap(history.pushState);
history.replaceState = wrap(history.replaceState);
window.addEventListener('popstate', () => requestAnimationFrame(check));
setInterval(check, 500);
callback(lastPath, null);
}
/* ================================================================
* MODAL SYSTEM
* ================================================================ */
let modalOverlay = null;
let modalPanel = null;
let modalHideTimer = null;
function ensureModal () {
if (modalOverlay) return;
modalOverlay = h('div', { id: `${PREFIX}-modal-overlay` });
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) hideModal();
});
document.body.appendChild(modalOverlay);
}
function showModal (content) {
if (modalHideTimer) { clearTimeout(modalHideTimer); modalHideTimer = null; }
ensureModal();
modalPanel = h('div', { className: `${PREFIX}-modal-panel` });
modalPanel.appendChild(content);
modalOverlay.replaceChildren(modalPanel);
modalOverlay.classList.remove(`${PREFIX}-modal-out`);
modalOverlay.classList.add(`${PREFIX}-modal-visible`);
requestAnimationFrame(() => modalOverlay.classList.add(`${PREFIX}-modal-in`));
}
function hideModal () {
if (!modalOverlay) return;
modalOverlay.classList.remove(`${PREFIX}-modal-in`);
modalOverlay.classList.add(`${PREFIX}-modal-out`);
modalHideTimer = setTimeout(() => {
modalOverlay.classList.remove(`${PREFIX}-modal-visible`, `${PREFIX}-modal-out`);
modalOverlay.replaceChildren();
modalPanel = null;
modalHideTimer = null;
}, 200);
}
function isModalOpen () {
return !!(modalOverlay && modalOverlay.classList.contains(`${PREFIX}-modal-visible`));
}
function buildAlertModal (message, isDark, opts = {}) {
const buttons = opts.buttons || [{ label: 'OK', primary: true, onClick: hideModal }];
return h('div', { style: {
borderRadius: '12px', padding: '28px', textAlign: 'center', minWidth: '280px', maxWidth: '680px',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)', fontFamily: 'system-ui, sans-serif',
background: isDark ? '#1f2937' : '#fff', color: isDark ? '#d1d5db' : '#111827',
}}, [
opts.title && h('h4', { textContent: opts.title, style: { margin: '0 0 12px', fontSize: '15px', fontWeight: '700' } }),
h('p', { textContent: message, style: { margin: '0 0 18px', fontSize: '14px', lineHeight: '1.4' } }),
h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'center' } },
buttons.map(b => h('button', {
textContent: b.label,
className: b.primary ? BTN_PRIMARY : BTN_OUTLINE,
style: { minWidth: '100px', justifyContent: 'center' },
onClick: () => { hideModal(); b.onClick && b.onClick(); },
})),
),
]);
}
/* ================================================================
* TOASTS
*
* Generic toast system. Stack of small pills bottom-right.
* Auth-expired uses the same system but with `sticky:true` so it
* doesn't auto-dismiss until the token comes back.
* ================================================================ */
let toastStack = null;
let authToastEl = null;
function ensureToastStack () {
if (toastStack && document.body.contains(toastStack)) return toastStack;
toastStack = h('div', { id: `${PREFIX}-toast-stack` });
document.body.appendChild(toastStack);
return toastStack;
}
function showToast (message, opts = {}) {
const type = opts.type || 'info'; // info | success | error
const duration = opts.duration ?? 3500;
const sticky = !!opts.sticky;
const stack = ensureToastStack();
const el = h('div', {
className: `${PREFIX}-toast ${PREFIX}-toast-${type}`,
textContent: message,
});
stack.appendChild(el);
requestAnimationFrame(() => el.classList.add(`${PREFIX}-toast-in`));
if (!sticky) {
setTimeout(() => {
el.classList.remove(`${PREFIX}-toast-in`);
setTimeout(() => el.remove(), 250);
}, duration);
}
return el;
}
function showAuthToast () {
if (authToastEl && document.body.contains(authToastEl)) return;
authToastEl = showToast('Kenmei+ session expired — refresh the page to resume', {
type: 'error', sticky: true,
});
}
function hideAuthToast () {
if (!authToastEl) return;
authToastEl.classList.remove(`${PREFIX}-toast-in`);
setTimeout(() => { authToastEl?.remove(); authToastEl = null; }, 250);
}
/* ---- Site Refresh-button helper ----
The dashboard has a native Refresh button (the round-arrow icon
followed by the literal text "Refresh"). Clicking it triggers the
site's own data refetch, which our /api/v2/manga_entries
interceptor catches and feeds into both the list view and our
covers grid. */
function findRefreshButton () {
return [...qsa('button')].find(b => b.textContent.trim() === 'Refresh') || null;
}
function clickSiteRefresh () {
const btn = findRefreshButton();
if (btn) { btn.click(); return true; }
return false;
}
/* ================================================================
* DESKTOP NOTIFICATIONS (persisted seen-set)
* ================================================================ */
function loadSeenUnread () {
try { return new Set(JSON.parse(localStorage.getItem(SEEN_KEY)) || []); }
catch { return new Set(); }
}
function saveSeenUnread (set) {
localStorage.setItem(SEEN_KEY, JSON.stringify([...set]));
}
let previousUnreadIds = loadSeenUnread();
// First-load grace: if the seen-set is empty (fresh install / cleared),
// skip notifications on the very first refresh and just seed.
let notifInitialized = previousUnreadIds.size > 0;
async function requestNotifPermission () {
if (!('Notification' in window)) return false;
if (Notification.permission === 'granted') return true;
if (Notification.permission === 'denied') return false;
const result = await Notification.requestPermission();
return result === 'granted';
}
function fireNotification (title, chapterInfo) {
if (Notification.permission !== 'granted') return;
try {
new Notification('Kenmei+ — New Chapter', {
body: `${title}\n${chapterInfo}`,
icon: 'https://www.kenmei.co/assets/default_small-DEwRdcqo.jpeg',
tag: `kp-${title}`,
});
} catch (e) { console.warn('[Kenmei+] Notification error:', e); }
}
function checkForNewChapters (unreadEntries) {
if (!settings.notifications || !notifInitialized) {
previousUnreadIds = new Set(unreadEntries.map(e => e.id));
saveSeenUnread(previousUnreadIds);
notifInitialized = true;
return;
}
for (const entry of unreadEntries) {
if (!previousUnreadIds.has(entry.id)) {
const ch = entry.chapters.next;
const chLabel = ch ? `Ch. ${ch.chapter}` : 'New chapter';
fireNotification(entry.attributes.title, chLabel);
}
}
previousUnreadIds = new Set(unreadEntries.map(e => e.id));
saveSeenUnread(previousUnreadIds);
}
/* ================================================================
* ENTRY CACHE (used by settings filter + covers mode)
* ================================================================ */
let entriesCache = null; // all-statuses entries
let entriesCachedAt = 0;
async function getEntriesCached (force = false) {
if (!force && entriesCache && Date.now() - entriesCachedAt < 60_000) return entriesCache;
entriesCache = await fetchEntriesAllStatuses();
entriesCachedAt = Date.now();
return entriesCache;
}
/* ================================================================
* SETTINGS PANEL
* ================================================================ */
function buildSettingsPanel () {
const isDark = document.documentElement.classList.contains('dark');
const bg = isDark ? '#1f2937' : '#ffffff';
const fg = isDark ? '#e5e7eb' : '#111827';
const muted = isDark ? '#9ca3af' : '#6b7280';
const border = isDark ? '#374151' : '#e5e7eb';
const inputBg = isDark ? '#111827' : '#f9fafb';
const panel = h('div', { style: {
borderRadius: '12px', padding: '24px', width: '680px',
maxWidth: '92vw', maxHeight: '85vh', overflowY: 'auto',
boxShadow: '0 24px 64px rgba(0,0,0,0.35)',
fontFamily: 'system-ui, -apple-system, sans-serif',
background: bg, color: fg,
}});
panel.appendChild(h('div', { style: {
display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px',
}}, [
h('h3', { textContent: 'Kenmei+ Settings', style: { margin: '0', fontSize: '16px', fontWeight: '700' } }),
h('button', {
innerHTML: '×', onClick: hideModal,
style: { background: 'none', border: 'none', fontSize: '22px', cursor: 'pointer', color: muted, lineHeight: '1' },
}),
]));
// ── Appearance
panel.appendChild(sectionLabel('APPEARANCE', muted));
panel.appendChild(buildToggleRow('Dark Mode', settings.darkMode, (val) => {
applyDarkMode(val);
reopenSettings();
}, isDark));
panel.appendChild(divider(border));
// ── Notifications
panel.appendChild(sectionLabel('NOTIFICATIONS', muted));
panel.appendChild(buildToggleRow('Chapter drop alerts', settings.notifications, async (val) => {
if (val) {
const granted = await requestNotifPermission();
if (!granted) {
settings.notifications = false;
saveSettings(settings);
reopenSettings();
return;
}
}
settings.notifications = val;
saveSettings(settings);
}, isDark));
panel.appendChild(h('p', {
textContent: 'Fires when auto-refresh detects a new unread chapter.',
style: { margin: '6px 0 0', fontSize: '11px', color: muted },
}));
panel.appendChild(divider(border));
// ── Auto-refresh
panel.appendChild(sectionLabel('AUTO-REFRESH', muted));
panel.appendChild(buildToggleRow('Periodically re-count unread', settings.autoRefresh, (val) => {
settings.autoRefresh = val;
saveSettings(settings);
val ? startAutoRefresh() : stopAutoRefresh();
}, isDark));
panel.appendChild(buildNumberRow(
'Interval (seconds)', settings.autoRefreshInterval, MIN_REFRESH_S, 3600,
(val) => { settings.autoRefreshInterval = val; saveSettings(settings); if (settings.autoRefresh) startAutoRefresh(); },
isDark, inputBg, border, fg,
));
panel.appendChild(divider(border));
// ── Open Updated
panel.appendChild(sectionLabel('OPEN UPDATED', muted));
panel.appendChild(buildNumberRow(
'Tabs per batch', settings.batchSize, 1, 100,
(val) => { settings.batchSize = val; saveSettings(settings); },
isDark, inputBg, border, fg,
));
panel.appendChild(buildToggleRow('Auto-set latest after Open', settings.autoSetLatestAfterOpen, (val) => {
settings.autoSetLatestAfterOpen = val;
saveSettings(settings);
}, isDark));
panel.appendChild(h('p', {
textContent: 'After a chapter is opened via Open Updated, automatically mark it as read.',
style: { margin: '6px 0 0', fontSize: '11px', color: muted },
}));
panel.appendChild(divider(border));
// ── Series filter
const filterHeader = h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' } }, [
h('p', { textContent: 'OPEN UPDATED FILTER', style: { margin: 0, fontSize: '11px', fontWeight: '700', color: muted, letterSpacing: '0.08em' } }),
h('button', {
innerHTML: ICON.refresh, title: 'Refresh series list',
style: { background: 'none', border: 'none', cursor: 'pointer', color: muted, padding: '2px' },
onClick: () => loadSeriesFilter(seriesList, isDark, fg, muted, border, true),
}),
]);
panel.appendChild(filterHeader);
panel.appendChild(h('p', {
textContent: 'Unchecked series will be skipped by "Open Updated".',
style: { margin: '0 0 10px', fontSize: '11px', color: muted },
}));
const seriesList = h('div', {
id: `${PREFIX}-series-filter`,
style: {
maxHeight: '200px', overflowY: 'auto', borderRadius: '8px',
border: `1px solid ${border}`, background: inputBg, padding: '4px 0',
},
});
seriesList.appendChild(h('p', {
textContent: 'Loading series...',
style: { textAlign: 'center', padding: '12px', fontSize: '12px', color: muted },
}));
panel.appendChild(seriesList);
loadSeriesFilter(seriesList, isDark, fg, muted, border, false);
panel.appendChild(divider(border));
// ── Experimental features
panel.appendChild(buildExperimentalSection(isDark, fg, muted, border, inputBg));
panel.appendChild(divider(border));
// ── Keyboard shortcuts
panel.appendChild(sectionLabel('KEYBOARD SHORTCUTS', muted));
panel.appendChild(buildShortcutsTable(isDark, fg, muted, border, inputBg));
panel.appendChild(divider(border));
// ── API status
const tokenStatus = authExpired ? '✗ Expired — refresh page' : (authToken ? '✓' : '✗ Waiting for token...');
const tokenColor = authToken && !authExpired ? '#22c55e' : '#ef4444';
panel.appendChild(h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } }, [
h('span', { textContent: 'API Auth', style: { fontSize: '13px', color: fg } }),
h('span', { textContent: tokenStatus, style: { fontSize: '12px', color: tokenColor, fontWeight: '600' } }),
]));
panel.appendChild(h('p', {
textContent: 'Kenmei+ v2.2.1',
style: { margin: '16px 0 0', fontSize: '11px', color: muted, textAlign: 'center' },
}));
return panel;
}
/* ── Experimental section ──────────────────────────────────── */
function buildExperimentalSection (isDark, fg, muted, border, inputBg) {
const wrap = h('div');
// Header row: label + "EXPERIMENTAL" badge
const headerRow = h('div', { style: {
display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px',
}});
headerRow.appendChild(h('p', {
textContent: 'EXPERIMENTAL',
style: { margin: 0, fontSize: '11px', fontWeight: '700', color: muted, letterSpacing: '0.08em' },
}));
headerRow.appendChild(h('span', {
textContent: 'may break with site updates',
style: {
fontSize: '10px', fontWeight: '600', padding: '2px 6px',
borderRadius: '4px', letterSpacing: '0.04em',
background: isDark ? 'rgba(234,179,8,0.15)' : 'rgba(234,179,8,0.12)',
color: isDark ? '#fde047' : '#a16207',
border: `1px solid ${isDark ? 'rgba(234,179,8,0.3)' : 'rgba(234,179,8,0.4)'}`,
},
}));
wrap.appendChild(headerRow);
// Covers override toggle
wrap.appendChild(buildToggleRow('Covers Mode override', settings.coversOverride, (val) => {
settings.coversOverride = val;
if (!val) {
// Turning the feature off: exit any active covers view and reset state.
settings.coversMode = false;
exitCoversMode();
const toggle = findCoversToggle();
if (toggle) syncCoversToggleVisual(toggle);
}
saveSettings(settings);
// Re-bind (or unbind) the toggle interception.
bindCoversToggle();
if (val) window.location.reload();
}, isDark));
wrap.appendChild(h('p', {
textContent: 'Intercepts the list/covers toggle and renders a cover grid from the API, bypassing the Kenmei premium gate. When disabled, the toggle behaves normally.',
style: { margin: '8px 0 0', fontSize: '11px', color: muted, lineHeight: '1.5' },
}));
// Warning callout
wrap.appendChild(h('div', {
style: {
marginTop: '10px', padding: '8px 10px', borderRadius: '6px',
fontSize: '11px', lineHeight: '1.5',
background: isDark ? 'rgba(234,179,8,0.08)' : 'rgba(234,179,8,0.07)',
border: `1px solid ${isDark ? 'rgba(234,179,8,0.2)' : 'rgba(234,179,8,0.3)'}`,
color: isDark ? '#fde047' : '#92400e',
},
textContent: '⚠ This feature relies on internal CSS class names that Kenmei may change at any time. Cards may display incorrectly after a site update. Many features do not work or are broken.',
}));
return wrap;
}
function reopenSettings () {
hideModal();
setTimeout(() => showModal(buildSettingsPanel()), 250);
}
function sectionLabel (text, color) {
return h('p', {
textContent: text,
style: { margin: '0 0 10px', fontSize: '11px', fontWeight: '700', color, letterSpacing: '0.08em' },
});
}
function divider (borderColor) {
return h('hr', { style: { border: 'none', borderTop: `1px solid ${borderColor}`, margin: '16px 0' } });
}
function buildToggleRow (label, isOn, onChange, isDark) {
const row = h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } });
row.appendChild(h('span', { textContent: label, style: { fontSize: '13px' } }));
const toggle = h('button', { style: {
width: '44px', height: '24px', borderRadius: '12px', border: 'none',
cursor: 'pointer', position: 'relative', transition: 'background 0.2s', flexShrink: '0',
background: isOn ? '#3b82f6' : (isDark ? '#4b5563' : '#d1d5db'),
}});
const knob = h('span', { style: {
display: 'block', width: '20px', height: '20px', borderRadius: '50%',
background: '#fff', position: 'absolute', top: '2px', left: '2px',
transition: 'transform 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
transform: isOn ? 'translateX(20px)' : 'translateX(0)',
}});
toggle.appendChild(knob);
toggle.addEventListener('click', () => {
isOn = !isOn;
toggle.style.background = isOn ? '#3b82f6' : (isDark ? '#4b5563' : '#d1d5db');
knob.style.transform = isOn ? 'translateX(20px)' : 'translateX(0)';
onChange(isOn);
});
row.appendChild(toggle);
return row;
}
function buildNumberRow (label, value, min, max, onChange, isDark, inputBg, border, fg) {
const row = h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '8px' } });
row.appendChild(h('span', { textContent: label, style: { fontSize: '13px' } }));
const input = h('input', {
type: 'textField', min: String(min), max: String(max),
value: String(value),
style: {
width: '80px', padding: '4px 8px', borderRadius: '6px',
border: `1px solid ${border}`, background: inputBg, color: fg,
fontSize: '13px', textAlign: 'right',
},
});
input.addEventListener('change', () => {
const v = Math.max(min, Math.min(max, parseInt(input.value, 10) || min));
input.value = String(v);
onChange(v);
});
row.appendChild(input);
return row;
}
/* ── Series Filter List (cached) ─────────────────────────────── */
async function loadSeriesFilter (container, isDark, fg, muted, border, force = false) {
if (!authToken) {
container.innerHTML = `<p style="text-align:center;padding:12px;font-size:12px;color:${muted}">Need auth token — refresh page.</p>`;
return;
}
container.replaceChildren(h('p', {
textContent: 'Loading series...',
style: { textAlign: 'center', padding: '12px', fontSize: '12px', color: muted },
}));
try {
const entries = await getEntriesCached(force);
container.replaceChildren();
entries.sort((a, b) => a.attributes.title.localeCompare(b.attributes.title));
for (const entry of entries) {
const excluded = settings.excludedSeries.includes(entry.id);
const row = h('label', { style: {
display: 'flex', alignItems: 'center', gap: '8px',
padding: '6px 10px', cursor: 'pointer', fontSize: '12px',
color: excluded ? muted : fg,
transition: 'background 0.1s',
}});
row.addEventListener('mouseenter', () => row.style.background = isDark ? '#1f2937' : '#f3f4f6');
row.addEventListener('mouseleave', () => row.style.background = 'transparent');
const checkbox = h('input', { type: 'checkbox' });
checkbox.checked = !excluded;
checkbox.style.cssText = 'accent-color: #3b82f6; cursor: pointer; flex-shrink: 0;';
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
settings.excludedSeries = settings.excludedSeries.filter(id => id !== entry.id);
} else if (!settings.excludedSeries.includes(entry.id)) {
settings.excludedSeries.push(entry.id);
}
saveSettings(settings);
row.style.color = checkbox.checked ? fg : muted;
});
const title = h('span', { textContent: entry.attributes.title, style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } });
if (entry.attributes.unread) {
const dot = h('span', { style: {
width: '6px', height: '6px', borderRadius: '50%', flexShrink: '0',
background: '#3b82f6', marginLeft: 'auto',
}});
row.append(checkbox, title, dot);
} else {
row.append(checkbox, title);
}
container.appendChild(row);
}
} catch (err) {
container.innerHTML = `<p style="text-align:center;padding:12px;font-size:12px;color:#ef4444">Error: ${err.message}</p>`;
}
}
/* ── Shortcut bindings UI ────────────────────────────────────── */
const SHORTCUT_LABELS = {
openUpdated: 'Open Updated',
setLatest: 'Set All Latest',
toggleSettings: 'Toggle Settings',
toggleCovers: 'Toggle Covers Mode',
refresh: 'Refresh Badge',
toggleDark: 'Toggle Dark Mode',
prevPage: 'Previous Page',
nextPage: 'Next Page',
};
function prettyKey (k) {
if (!k) return '—';
return k
.replace('Arrow', '')
.replace(/^./, c => c.toUpperCase());
}
function buildShortcutsTable (isDark, fg, muted, border, inputBg) {
const table = h('div', { style: {
borderRadius: '8px', border: `1px solid ${border}`, background: inputBg, overflow: 'hidden',
}});
Object.keys(SHORTCUT_LABELS).forEach((action, i) => {
const row = h('div', { style: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', fontSize: '13px',
borderTop: i === 0 ? 'none' : `1px solid ${border}`,
}}, [
h('span', { textContent: SHORTCUT_LABELS[action], style: { color: fg } }),
]);
const btn = h('button', {
textContent: prettyKey(settings.shortcuts[action]),
style: {
padding: '3px 10px', minWidth: '54px', fontSize: '12px', fontFamily: 'monospace',
borderRadius: '5px', border: `1px solid ${border}`,
background: isDark ? '#0b1220' : '#fff', color: fg, cursor: 'pointer',
},
});
let listening = false;
const onKey = (ev) => {
if (!listening) return;
ev.preventDefault();
ev.stopPropagation();
if (ev.key === 'Escape') {
listening = false;
btn.textContent = prettyKey(settings.shortcuts[action]);
btn.style.background = isDark ? '#0b1220' : '#fff';
document.removeEventListener('keydown', onKey, true);
return;
}
// Accept printable single-char keys + arrows
const key = ev.key;
if (key.length === 1 || key.startsWith('Arrow')) {
settings.shortcuts[action] = key;
saveSettings(settings);
listening = false;
btn.textContent = prettyKey(key);
btn.style.background = isDark ? '#0b1220' : '#fff';
document.removeEventListener('keydown', onKey, true);
}
};
btn.addEventListener('click', () => {
if (listening) return;
listening = true;
btn.textContent = 'press key…';
btn.style.background = '#3b82f6';
document.addEventListener('keydown', onKey, true);
});
row.appendChild(btn);
table.appendChild(row);
});
return table;
}
/* ================================================================
* DASHBOARD – STATE
* ================================================================ */
let dashActive = false;
let autoTimer = null;
let unreadBadge = null;
let cogBtn = null;
let injectedEls = [];
let lastRefreshAt = 0;
/* ================================================================
* DASHBOARD – SETUP / TEARDOWN
* ================================================================ */
function initDashboard () {
if (dashActive) return;
waitFor('ul.divide-y.divide-gray-200').then(() => {
if (!location.pathname.includes('dashboard')) return;
dashActive = true;
injectActionButtons();
injectSettingsCog();
bindCoversToggle();
watchCoversToggle();
refreshBadgeFromAPI();
if (settings.autoRefresh) startAutoRefresh();
// Only auto-enter covers mode if the feature override is enabled.
if (settings.coversOverride && settings.coversMode) enterCoversMode();
}).catch(() => console.warn('[Kenmei+] Dashboard not found'));
}
function teardownDashboard () {
dashActive = false;
stopAutoRefresh();
exitCoversMode();
unwatchCoversToggle();
injectedEls.forEach(el => el.remove());
injectedEls = [];
unreadBadge = null;
cogBtn = null;
}
/* ================================================================
* INJECT ACTION BUTTONS
* ================================================================ */
function injectActionButtons () {
const toolbar = qs('.flex-none.z-5 .space-x-3');
if (!toolbar || qs(`#${PREFIX}-btn-open`)) return;
const btnOpen = h('button', {
id: `${PREFIX}-btn-open`, className: BTN_PRIMARY,
innerHTML: ICON.openUpdated + 'Open Updated',
onClick: openAllUpdated,
});
const btnLatest = h('button', {
id: `${PREFIX}-btn-latest`, className: BTN_OUTLINE,
innerHTML: ICON.setLatest + 'Set All Latest',
onClick: setAllLatest,
});
unreadBadge = h('span', {
id: `${PREFIX}-badge`,
title: 'Unread series',
style: {
display: 'none', alignItems: 'center', padding: '2px 10px',
borderRadius: '9999px', fontSize: '12px', fontWeight: '700',
background: '#ef4444', color: '#fff', alignSelf: 'center', cursor: 'default',
},
});
toolbar.appendChild(btnOpen);
toolbar.appendChild(btnLatest);
toolbar.appendChild(unreadBadge);
injectedEls.push(btnOpen, btnLatest, unreadBadge);
qs('.button--link')?.remove();
}
/* ================================================================
* INJECT SETTINGS COG
* ================================================================ */
function injectSettingsCog () {
const avatar = qs('.btn-avatar');
if (!avatar || qs(`#${PREFIX}-cog`)) return;
cogBtn = h('button', {
id: `${PREFIX}-cog`, className: 'btn--icon', type: 'button',
innerHTML: `<span class="sr-only">Kenmei+ Settings</span>${ICON.cog}`,
onClick () { showModal(buildSettingsPanel()); },
});
updateCogColor();
avatar.parentElement.insertBefore(cogBtn, avatar);
injectedEls.push(cogBtn);
}
function updateCogColor () {
if (!cogBtn) return;
cogBtn.style.color = document.documentElement.classList.contains('dark') ? '#ffffff' : '#6b7280';
}
/* ================================================================
* UNREAD BADGE
* ================================================================ */
async function refreshBadgeFromAPI () {
if (!unreadBadge) return;
if (!authToken) {
const n = qsa('.unread-indicator').length;
setBadge(n);
return;
}
try {
const unread = await fetchUnreadEntries();
lastRefreshAt = Date.now();
setBadge(unread.length);
checkForNewChapters(unread);
} catch (err) {
console.warn('[Kenmei+] Badge error:', err.message);
if (!authExpired) setBadge(qsa('.unread-indicator').length);
}
}
function setBadge (n) {
if (!unreadBadge) return;
unreadBadge.textContent = String(n);
unreadBadge.title = `${n} unread • Last checked ${relativeTime(lastRefreshAt)}`;
unreadBadge.style.display = n > 0 ? 'inline-flex' : 'none';
}
// Periodically refresh tooltip so "5m ago" updates without an API call.
setInterval(() => {
if (unreadBadge && unreadBadge.style.display !== 'none' && lastRefreshAt) {
const n = parseInt(unreadBadge.textContent, 10) || 0;
unreadBadge.title = `${n} unread • Last checked ${relativeTime(lastRefreshAt)}`;
}
}, 30_000);
/* ================================================================
* CORE ACTIONS
* ================================================================ */
async function openAllUpdated () {
const isDark = document.documentElement.classList.contains('dark');
if (!authToken) {
showToast('Auth token not captured yet — refresh the page', { type: 'error' });
return;
}
const btn = qs(`#${PREFIX}-btn-open`);
if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; }
try {
showToast('Looking for updated series…');
const unread = await fetchUnreadEntries();
const filtered = unread.filter(e => !settings.excludedSeries.includes(e.id));
if (filtered.length === 0) {
showToast('No updated series to open', { type: 'info' });
return;
}
const opened = await openInBatches(filtered, settings.batchSize, isDark);
if (settings.autoSetLatestAfterOpen) {
const toSet = filtered
.slice(0, opened)
.map(e => {
const latestId = e.attributes?.latestChapter?.id ?? e.chapters?.last?.id;
return latestId ? setEntryToLatest(e.id, latestId) : null;
})
.filter(Boolean);
if (toSet.length) await Promise.allSettled(toSet);
clickSiteRefresh();
setTimeout(refreshBadgeFromAPI, 500);
}
if (opened > 0) {
showToast(`Opened ${opened} updated series`, { type: 'success' });
}
} catch (err) {
console.error('[Kenmei+] Open Updated error:', err);
showToast(`Open Updated failed: ${err.message}`, { type: 'error' });
} finally {
if (btn) { btn.disabled = false; btn.style.opacity = '1'; }
}
}
function openInBatches (entries, batchSize, isDark) {
return new Promise((resolve) => {
const total = entries.length;
let opened = 0;
const openOne = (entry) => {
const url = entry.chapters?.next?.url;
if (url) window.open(url, '_blank', 'noopener');
};
const nextBatch = () => {
const remaining = total - opened;
if (remaining <= 0) { resolve(opened); return; }
const take = Math.min(batchSize, remaining);
for (let i = 0; i < take; i++) openOne(entries[opened + i]);
opened += take;
if (opened >= total) { resolve(opened); return; }
const nextN = Math.min(batchSize, total - opened);
showModal(buildAlertModal(
`Opened ${opened} of ${total}. Open the next ${nextN}?`,
isDark,
{
title: 'Batch open',
buttons: [
{ label: 'Stop', onClick: () => resolve(opened) },
{ label: `Open ${nextN}`, primary: true, onClick: nextBatch },
],
},
));
};
nextBatch();
});
}
async function setAllLatest () {
if (!authToken) {
showToast('Auth token not captured yet — refresh the page', { type: 'error' });
return;
}
const btn = qs(`#${PREFIX}-btn-latest`);
if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; }
try {
const unread = await fetchUnreadEntries();
if (unread.length === 0) {
showToast('Nothing to update — no unread series', { type: 'info' });
clickSiteRefresh();
setTimeout(refreshBadgeFromAPI, 500);
return;
}
showToast(`Updating ${unread.length} series…`);
const results = await Promise.allSettled(unread.map(entry => {
const latestId = entry.attributes?.latestChapter?.id ?? entry.chapters?.last?.id;
if (!latestId) return Promise.reject(new Error('no latest chapter id'));
return setEntryToLatest(entry.id, latestId);
}));
const ok = results.filter(r => r.status === 'fulfilled').length;
const fails = results.length - ok;
console.log(`[Kenmei+] Set ${ok} series to latest${fails ? ` (${fails} failed)` : ''}`);
// Click the site's Refresh button so both list view and covers grid
// pick up the new state via the API response interceptor.
clickSiteRefresh();
setTimeout(refreshBadgeFromAPI, 500);
if (fails) {
showToast(`Updated ${ok} — ${fails} failed`, { type: 'error' });
} else {
showToast(`Marked ${ok} series as up to date`, { type: 'success' });
}
} catch (err) {
console.error('[Kenmei+] Set All Latest error:', err);
showToast(`Set All Latest failed: ${err.message}`, { type: 'error' });
} finally {
if (btn) { btn.disabled = false; btn.style.opacity = '1'; }
}
}
/* ================================================================
* AUTO-REFRESH
* ================================================================ */
function startAutoRefresh () {
stopAutoRefresh();
if (!settings.autoRefresh) return;
const ms = Math.max(MIN_REFRESH_S, settings.autoRefreshInterval) * 1000;
autoTimer = setInterval(() => {
refreshBadgeFromAPI();
console.log(`[Kenmei+] auto-refresh tick`);
}, ms);
}
function stopAutoRefresh () {
if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
}
// Resume auto-refresh once a fresh token comes through after expiry.
onTokenRestored(() => {
console.log('[Kenmei+] Token restored — resuming.');
refreshBadgeFromAPI();
if (settings.autoRefresh) startAutoRefresh();
});
/* ================================================================
* COVERS MODE
*
* Hijacks the site's `.switch.z-5` toggle (the list/covers
* selector) ONLY when settings.coversOverride is true. When the
* override is disabled, clicks pass through to Vue's own handler
* so Kenmei's premium prompt runs as normal.
* ================================================================ */
const coversState = {
grid: null, // <ul> we inject
list: null, // the native list we hide
entries: [],
pagy: null,
toggleEl: null,
bound: false,
observer: null,
};
function findCoversToggle () { return qs('.switch.z-5'); }
function bindCoversToggle () {
const toggle = findCoversToggle();
// Remove any previously attached listener first so we don't double-bind.
if (coversState.toggleEl) {
coversState.toggleEl.removeEventListener('click', onCoversToggleClick, true);
}
if (!toggle) {
coversState.toggleEl = null;
coversState.bound = false;
return;
}
coversState.toggleEl = toggle;
if (settings.coversOverride) {
// Override is on — intercept and suppress Vue's handler.
toggle.addEventListener('click', onCoversToggleClick, true);
coversState.bound = true;
} else {
// Override is off — do not intercept; leave Vue's handler in place.
coversState.bound = false;
}
syncCoversToggleVisual(toggle);
}
function onCoversToggleClick (e) {
// Only runs when coversOverride is true (we only attach when it is).
e.stopImmediatePropagation();
e.preventDefault();
settings.coversMode = !settings.coversMode;
saveSettings(settings);
settings.coversMode ? enterCoversMode() : exitCoversMode();
syncCoversToggleVisual(coversState.toggleEl);
}
function syncCoversToggleVisual (toggle) {
if (!toggle || !settings.coversOverride) return;
const opts = qsa('.mode-select-btn', toggle);
if (opts.length < 2) return;
// First child = list, second = covers (per site markup).
opts[0].classList.toggle('mode-select-btn__active', !settings.coversMode);
opts[1].classList.toggle('mode-select-btn__active', settings.coversMode);
}
function watchCoversToggle () {
// Re-bind when Vue re-renders (route changes, hot updates).
if (coversState.observer) return;
coversState.observer = new MutationObserver(() => {
const t = findCoversToggle();
if (t && t !== coversState.toggleEl) {
coversState.toggleEl = null;
bindCoversToggle();
}
// If grid disappeared (Vue re-rendered the list region), re-attach it.
if (settings.coversOverride && settings.coversMode
&& coversState.grid && !document.body.contains(coversState.grid)) {
coversState.grid = null;
enterCoversMode();
}
});
coversState.observer.observe(document.body, { childList: true, subtree: true });
}
function unwatchCoversToggle () {
if (coversState.observer) { coversState.observer.disconnect(); coversState.observer = null; }
if (coversState.toggleEl) {
coversState.toggleEl.removeEventListener('click', onCoversToggleClick, true);
coversState.toggleEl = null;
}
coversState.bound = false;
}
function enterCoversMode () {
const list = qs('ul.divide-y.divide-gray-200');
if (!list) return;
coversState.list = list;
// Hide the list off-screen rather than display:none. Vue keeps the rows
// mounted and their @click handlers (Edit, Delete, Share, …) keep firing
// when we relay clicks from cover cards.
list.classList.add(`${PREFIX}-list-offscreen`);
if (!coversState.grid || !document.body.contains(coversState.grid)) {
const grid = hs('ul', SCOPE.LI, {
id: `${PREFIX}-covers-grid`,
role: 'list',
// Matches Kenmei's medium gridSize default (see DashboardGridList).
className: 'grid-cols-2 md_grid-cols-4 md-grid 5xl_grid-cols-6',
});
list.parentElement.insertBefore(grid, list);
coversState.grid = grid;
}
renderCoversGrid();
}
function exitCoversMode () {
if (coversState.grid) {
coversState.grid.remove();
coversState.grid = null;
}
const list = coversState.list || qs('ul.divide-y.divide-gray-200');
if (list) list.classList.remove(`${PREFIX}-list-offscreen`);
coversState.list = null;
}
function onEntriesPageResponse (data) {
if (!data || !Array.isArray(data.entries)) return;
coversState.entries = data.entries;
coversState.pagy = data.pagy;
if (settings.coversOverride && settings.coversMode) {
// The list might have been re-attached after the fetch — re-enter to be safe.
if (!coversState.grid || !document.body.contains(coversState.grid)) enterCoversMode();
else renderCoversGrid();
}
}
function renderCoversGrid () {
const grid = coversState.grid;
if (!grid) return;
grid.replaceChildren();
if (coversState.entries.length === 0) {
grid.appendChild(h('li', {
textContent: 'Loading…',
style: { gridColumn: '1 / -1', textAlign: 'center', padding: '24px', color: '#6b7280' },
}));
return;
}
for (const entry of coversState.entries) grid.appendChild(buildCoverCard(entry));
}
/* ---- Vue scoped-attribute hashes (must match the bundle's CSS) ---- */
const SCOPE = Object.freeze({
LI: 'data-v-039357b4',
CARD: 'data-v-f75b6e10',
OVERLAY: 'data-v-281b8745',
STEPPER1: 'data-v-7573c614', // edit + ellipsis
STEPPER2: 'data-v-b7c83ecc', // minus + plus + check-check
PROGRESS: 'data-v-5f9e6021',
CHECKBOX: 'data-v-51b7852a',
});
// h() pass-through that also sets the data-v scope attributes (one or many).
function hs (tag, scopes, attrs = {}, children = []) {
const el = h(tag, attrs, children);
if (typeof scopes === 'string') el.setAttribute(scopes, '');
else if (Array.isArray(scopes)) scopes.forEach(s => el.setAttribute(s, ''));
return el;
}
/* ---- Lucide icons used by stepper buttons ---- */
function lucide (paths, extraClass = '') {
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide stepper-icon size-4 ${extraClass}" aria-hidden="true">${paths}</svg>`;
}
const LUCIDE = {
squarePen: lucide('<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"></path>', 'lucide-square-pen-icon lucide-square-pen'),
ellipsisVertical: lucide('<circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle>', 'lucide-ellipsis-vertical-icon lucide-ellipsis-vertical'),
minus: lucide('<path d="M5 12h14"></path>', 'lucide-minus-icon lucide-minus'),
plus: lucide('<path d="M5 12h14"></path><path d="M12 5v14"></path>', 'lucide-plus-icon lucide-plus'),
checkCheck: lucide('<path d="M18 6 7 17l-5-5"></path><path d="m22 10-7.5 7.5L13 16"></path>', 'lucide-check-check-icon lucide-check-check'),
check: lucide('<path d="M20 6 9 17l-5-5"></path>', 'lucide-check-icon lucide-check status-icon size-3'),
};
/* ---- Stepper actions (use the same PUT we already built) ---- */
async function stepperSetChapter (entry, kind) {
// kind: 'next' (advance by one)
// 'prev' (rewind by one)
// 'last' (mark all read / set to latest)
let id;
if (kind === 'last') id = entry.chapters?.last?.id || entry.attributes?.latestChapter?.id;
else if (kind === 'next') id = entry.chapters?.next?.id;
else if (kind === 'prev') id = entry.chapters?.prev?.id;
if (!id || !authToken) return;
try {
await setEntryToLatest(entry.id, id);
// Refresh the current page so the grid reflects new chaptersBehind/from.
const data = await fetchEntriesPage(currentEntriesPage(), 1);
onEntriesPageResponse(data);
refreshBadgeFromAPI();
} catch (e) {
console.warn('[Kenmei+] stepper failed:', e.message);
}
}
function currentEntriesPage () { return coversState.pagy?.page || 1; }
/* ---- Refresh-after-mutation (debounced) ---- */
let coversRefreshTimer = null;
function scheduleCoversRefresh () {
if (coversRefreshTimer) return;
coversRefreshTimer = setTimeout(async () => {
coversRefreshTimer = null;
if (!authToken) return;
try {
const data = await fetchEntriesPage(currentEntriesPage(), 1);
onEntriesPageResponse(data);
refreshBadgeFromAPI();
} catch (_) { /* swallow */ }
}, 350); // small delay so a burst of mutations only triggers one refresh
}
/* ---- Delegate to the (hidden) list row ----
Vue's @click handlers fire whether the row is display:none or not, so
clicking the corresponding row button gives us the site's real flows
(edit modal, delete confirm, share clipboard, report dialog, …) for
free. */
function findListRow (entry) {
const list = coversState.list || qs('ul.divide-y.divide-gray-200');
if (!list) return null;
const link = list.querySelector(`a[href$="/series/${entry.slug}"]`);
return link ? link.closest('li') : null;
}
function clickRowAction (entry, content) {
const row = findListRow(entry);
if (!row) return false;
const btn = row.querySelector(`button[content="${content}"]`);
if (!btn) return false;
btn.click();
return true;
}
function clickRowSeriesLink (entry) {
const row = findListRow(entry);
const link = row?.querySelector('a.list-row, a[href*="/series/"]');
if (link) { link.click(); return true; }
location.href = `/series/${entry.slug}`;
return true;
}
/* ---- Ellipsis dropdown ---- */
let openMenuCleanup = null;
const MENU_ICONS = {
Info: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-info-icon lucide-info" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>',
ExternalLink: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-external-link-icon lucide-external-link" aria-hidden="true"><path d="M15 3h6v6"></path><path d="M10 14 21 3"></path><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path></svg>',
Link: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-link-icon lucide-link" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>',
NotebookPen: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-notebook-pen-icon lucide-notebook-pen" aria-hidden="true"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4"/><path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/></svg>',
TriangleAlert: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-triangle-alert-icon lucide-triangle-alert" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>',
Trash: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-trash-icon lucide-trash" aria-hidden="true"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M3 6h18"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>',
};
function openEllipsisMenu (entry, anchorBtn) {
if (openMenuCleanup) openMenuCleanup();
const items = [
{ label: 'Series Details', icon: 'Info', action: () => clickRowSeriesLink(entry) },
{ label: 'Go to Source', icon: 'ExternalLink', action: () => clickRowAction(entry, 'Go to Source') || window.open(entry.links?.series_url, '_blank', 'noopener') },
{ label: 'Share', icon: 'Link', action: () => clickRowAction(entry, 'Share') },
entry.attributes?.notes
? { label: 'View Notes', icon: 'NotebookPen', action: () => clickRowAction(entry, 'View Notes') }
: null,
{ label: 'Report', icon: 'TriangleAlert', action: () => clickRowAction(entry, 'Report') },
{ label: 'Delete', icon: 'Trash', action: () => clickRowAction(entry, 'Delete') },
].filter(Boolean);
const wrap = h('div', {
'data-radix-popper-content-wrapper': '',
style: { position: 'fixed', left: '0', top: '0', zIndex: '50', minWidth: 'max-content' },
});
const menu = h('div', {
role: 'menu', 'aria-orientation': 'vertical',
'data-radix-menu-content': '',
'data-state': 'open', 'data-orientation': 'vertical',
tabindex: '-1', dir: 'ltr',
className: 'z-50 min-w-32 overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-950 shadow-md dark_border-gray-800 dark_bg-gray-700 dark_text-gray-50',
});
for (const item of items) {
const it = hs('div', SCOPE.STEPPER1, {
role: 'menuitem', tabindex: '-1',
className: 'relative flex cursor-pointer select-none items-center rounded-md px-3 py-1.5 text-sm outline-none transition-colors focus_bg-gray-100 focus_text-gray-900 dark_focus_bg-gray-800 dark_focus_text-gray-50 sm_px-2',
innerHTML: MENU_ICONS[item.icon] + item.label,
onClick: (ev) => {
ev.preventDefault(); ev.stopPropagation();
try { item.action(); } catch (e) { console.warn('[Kenmei+] menu action failed:', e); }
if (openMenuCleanup) openMenuCleanup();
},
});
menu.appendChild(it);
}
wrap.appendChild(menu);
document.body.appendChild(wrap);
// Position below-right of anchor button.
const rect = anchorBtn.getBoundingClientRect();
const w = menu.offsetWidth || 160;
const left = Math.max(8, Math.min(window.innerWidth - w - 8, rect.right - w));
const top = Math.min(window.innerHeight - menu.offsetHeight - 8, rect.bottom + 4);
wrap.style.transform = `translate(${left}px, ${top}px)`;
const onDocClick = (ev) => { if (!wrap.contains(ev.target)) openMenuCleanup(); };
const onKey = (ev) => { if (ev.key === 'Escape') openMenuCleanup(); };
openMenuCleanup = () => {
wrap.remove();
document.removeEventListener('mousedown', onDocClick, true);
document.removeEventListener('keydown', onKey, true);
openMenuCleanup = null;
};
setTimeout(() => {
document.addEventListener('mousedown', onDocClick, true);
document.addEventListener('keydown', onKey, true);
}, 0);
}
/* ---- Cover card ---- */
function buildCoverCard (entry) {
const a = entry.attributes || {};
const ch = entry.chapters || {};
const webp = a.cover?.webp?.large;
const jpeg = a.cover?.jpeg?.large || 'https://www.kenmei.co/assets/default_small-DEwRdcqo.jpeg';
const behind = ch.chaptersBehind ?? 0;
const total = ch.count ?? 0;
const upToDate = behind <= 0;
const progress = upToDate ? 100 : Math.max(0, 100 - (behind / (total || 1)) * 100);
const offset = 100 - progress;
const currentChapter = ch.from?.chapter != null
? `Ch. ${ch.from.chapter}`
: 'Not started';
const latestChapter = ch.last?.chapter != null
? `Ch. ${ch.last.chapter}`
: (a.latestChapter?.chapter != null ? `Ch. ${a.latestChapter.chapter}` : '—');
const continueText = ch.next?.chapter != null
? `Continue to Ch. ${ch.next.chapter}`
: 'Up to date';
const continueHref = ch.next?.url || '#';
const canContinue = !!ch.next?.url;
const behindText = upToDate
? 'Up to date'
: `${behind} chapter${behind === 1 ? '' : 's'} behind`;
/* ── <li> wrapper ── */
const li = hs('li', SCOPE.LI, { className: 'relative' });
/* ── flex column wrapper ── */
const wrap = hs('div', [SCOPE.CARD, SCOPE.LI], {
className: 'flex flex-col items-left justify-center relative',
});
/* ── card ── */
const card = hs('div', SCOPE.CARD, { className: 'card group select-none' });
// Card body itself is non-navigational — only the title text below is a link.
/* ── card-cover-container ── */
const cover = hs('div', SCOPE.CARD, { className: 'card-cover-container gradient-visible' });
/* ── aspect / picture ── */
const aspect = hs('div', SCOPE.CARD, {
className: 'aspect-w-2 aspect-h-3 bg-gray-400',
'data-v-wave-boundary': 'true',
});
const pic = hs('picture', SCOPE.CARD);
if (webp) pic.appendChild(hs('source', SCOPE.CARD, { type: 'image/webp', srcset: webp }));
pic.appendChild(hs('img', SCOPE.CARD, {
src: jpeg, loading: 'lazy', alt: a.title || '',
className: 'object-cover h-full w-full',
}));
aspect.appendChild(pic);
cover.appendChild(aspect);
/* ── status indicator ── */
if (upToDate) {
cover.appendChild(hs('div', SCOPE.CARD, {
className: 'inline-flex items-center rounded-full border font-semibold transition-colors focus_outline-none focus_ring-2 focus_ring-gray-950 focus_ring-offset-2 dark_border-gray-800 dark_focus_ring-gray-300 border-transparent bg-emerald-100 text-emerald-800 hover_bg-emerald-100/80 dark_bg-emerald-600/30 dark_text-emerald-300 dark_hover_bg-emerald-50/80 status-indicator up-to-date text-[13px] px-[7px] py-[3px]',
innerHTML: LUCIDE.check,
}));
} else {
cover.appendChild(hs('div', SCOPE.CARD, {
className: 'inline-flex items-center rounded-full border font-semibold transition-colors focus_outline-none focus_ring-2 focus_ring-gray-950 focus_ring-offset-2 dark_border-gray-800 dark_focus_ring-gray-300 border-transparent bg-blue-100 text-blue-800 hover_bg-blue-100/80 dark_bg-blue-600/30 dark_text-blue-300 dark_hover_bg-blue-50/80 status-indicator behind text-[13px] px-[7px] py-[3px]',
}, [
hs('span', SCOPE.CARD, { className: 'status-number h-3 min-w-3', textContent: String(behind) }),
]));
}
/* ── card-info ── */
const titleEl = hs('div', SCOPE.CARD, {
className: 'card-title text-[13px] line-clamp-2 lg_truncate lg_line-clamp-none',
textContent: a.title || '',
style: { cursor: 'pointer' },
});
titleEl.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
clickRowSeriesLink(entry);
});
cover.appendChild(hs('div', SCOPE.CARD, { className: 'card-info' }, [
titleEl,
hs('div', SCOPE.CARD, {
className: 'info-line text-[11px]',
textContent: a.latestChapter?.releasedAt ? `Updated ${relativeTime(Date.parse(a.latestChapter.releasedAt))}` : '',
}),
]));
card.appendChild(cover);
/* ── desktop-only controls (max-lg_hidden) ── */
const ctrlWrap = hs('div', [SCOPE.OVERLAY, SCOPE.CARD], { className: 'max-lg_hidden' });
// top-controls: selection checkbox. Mirrors the same series' checkbox
// in the (off-screen) list row — clicking either toggles both.
const coverCheckbox = hs('input', SCOPE.CHECKBOX, {
type: 'checkbox', className: 'dark_bg-gray-700',
});
const rowCheckbox = findListRow(entry)?.querySelector('input[type="checkbox"]');
if (rowCheckbox) coverCheckbox.checked = !!rowCheckbox.checked;
coverCheckbox.addEventListener('click', (ev) => {
ev.stopPropagation();
const r = findListRow(entry)?.querySelector('input[type="checkbox"]');
if (r) {
// Programmatically click the real checkbox so Vue's @change fires
// and the bulk-select state stays in sync.
r.click();
coverCheckbox.checked = r.checked;
}
});
ctrlWrap.appendChild(hs('div', SCOPE.OVERLAY, { className: 'top-controls' }, [
hs('div', [SCOPE.CHECKBOX, SCOPE.OVERLAY], { className: 'relative flex items-start w-4 hidden sm_flex' }, [
hs('div', SCOPE.CHECKBOX, { className: 'flex h-6 items-center' }, [
coverCheckbox,
]),
]),
]));
// hover-overlay
const hover = hs('div', SCOPE.OVERLAY, { className: 'hover-overlay text-gray-100' });
const controls = hs('div', SCOPE.OVERLAY, { className: 'controls-container' });
// chapter-display-row
controls.appendChild(hs('div', SCOPE.OVERLAY, { className: 'chapter-display-row text-sm' }, [
h('span', { className: 'text-blue-500 dark_text-blue-400', textContent: currentChapter }),
h('span', { className: 'text-gray-500 dark_text-gray-300', textContent: ' / ' }),
h('span', { className: 'text-white dark_text-gray-300 whitespace-nowrap', textContent: latestChapter }),
]));
// control-buttons-row with two steppers
const stepper1 = hs('div', [SCOPE.STEPPER1, SCOPE.OVERLAY], { className: 'stepper' });
stepper1.appendChild(hs('button', SCOPE.STEPPER1, {
className: 'stepper-btn size-7', content: 'Edit', innerHTML: LUCIDE.squarePen,
onClick: (e) => {
e.preventDefault(); e.stopPropagation();
if (!clickRowAction(entry, 'Edit')) {
showToast('Could not open the edit modal — try a manual refresh', { type: 'error' });
}
},
}));
const ellipsisBtn = hs('button', SCOPE.STEPPER1, {
type: 'button',
className: 'outline-none stepper-btn size-7', innerHTML: LUCIDE.ellipsisVertical,
});
ellipsisBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
openEllipsisMenu(entry, ellipsisBtn);
});
stepper1.appendChild(ellipsisBtn);
const stepper2 = hs('div', [SCOPE.STEPPER2, SCOPE.OVERLAY], { className: 'stepper' });
const minusBtn = hs('button', SCOPE.STEPPER2, {
className: 'stepper-btn size-7', innerHTML: LUCIDE.minus,
onClick: async (e) => {
e.preventDefault(); e.stopPropagation();
minusBtn.disabled = true;
await stepperSetChapter(entry, 'prev');
},
});
if (!ch.prev?.id) minusBtn.disabled = true;
stepper2.appendChild(minusBtn);
const plusBtn = hs('button', SCOPE.STEPPER2, {
className: 'stepper-btn size-7', innerHTML: LUCIDE.plus,
onClick: async (e) => {
e.preventDefault(); e.stopPropagation();
plusBtn.disabled = true;
await stepperSetChapter(entry, 'next');
},
});
if (!ch.next?.id) plusBtn.disabled = true;
stepper2.appendChild(plusBtn);
const checkBtn = hs('button', SCOPE.STEPPER2, {
className: 'stepper-btn size-7', content: 'Update last read chapter',
innerHTML: LUCIDE.checkCheck,
onClick: async (e) => {
e.preventDefault(); e.stopPropagation();
checkBtn.disabled = true;
await stepperSetChapter(entry, 'last');
},
});
if (upToDate) checkBtn.disabled = true;
stepper2.appendChild(checkBtn);
controls.appendChild(hs('div', SCOPE.OVERLAY, { className: 'control-buttons-row' }, [stepper1, stepper2]));
// progress bar + status row
const progWrap = hs('div', SCOPE.PROGRESS, { className: 'space-y-1.5' });
const progBar = hs('div', SCOPE.PROGRESS, {
role: 'progressbar',
'aria-valuemax': '100', 'aria-valuemin': '0',
'aria-valuenow': String(progress),
'aria-valuetext': `${Math.round(progress)}%`,
'aria-label': `${Math.round(progress)}%`,
'data-state': 'loading', 'data-value': String(progress), 'data-max': '100',
className: 'relative w-full overflow-hidden rounded-full dark_bg-gray-700 h-1 bg-white/10',
});
progBar.appendChild(h('div', {
'data-state': 'loading', 'data-value': String(progress), 'data-max': '100',
className: `h-full w-full flex-1 ${upToDate ? 'bg-green-500 dark_bg-green-400/90' : 'bg-blue-500 dark_bg-blue-400/90'} rounded-full`,
style: { transform: `translateX(-${offset.toFixed(5)}%)` },
}));
progWrap.appendChild(progBar);
progWrap.appendChild(hs('div', SCOPE.PROGRESS, {
className: `status-row ${upToDate ? 'text-green-600 dark_text-green-400' : 'text-gray-300 dark_text-gray-400'}`,
}, [
upToDate ? h('div', { innerHTML: LUCIDE.check, className: 'inline-flex' }) : null,
hs('span', SCOPE.PROGRESS, { className: 'whitespace-nowrap flex-shrink-0', textContent: behindText }),
]));
controls.appendChild(progWrap);
// Continue to Ch. X button
controls.appendChild(hs('a', SCOPE.OVERLAY, {
href: continueHref, target: '_blank', rel: 'noreferrer',
className: `inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-white transition-colors focus-visible_outline-none focus-visible_ring-2 focus-visible_ring-blue-500 focus-visible_ring-offset-2 disabled_pointer-events-none disabled_opacity-50 dark_ring-offset-0 dark_focus-visible_ring-moon-yellow-400 bg-blue-600 text-white hover_bg-blue-600/90 dark_bg-blue-600/30 dark_text-blue-300 dark_hover_bg-blue-600/40 h-7 rounded px-2 w-full text-xs mt-2 select-none ${canContinue ? '' : 'opacity-50 cursor-not-allowed'}`,
textContent: continueText,
}));
hover.appendChild(controls);
ctrlWrap.appendChild(hover);
card.appendChild(ctrlWrap);
wrap.appendChild(card);
li.appendChild(wrap);
return li;
}
/* ================================================================
* KEYBOARD SHORTCUTS
* ================================================================ */
function runShortcut (action) {
switch (action) {
case 'openUpdated': openAllUpdated(); break;
case 'setLatest': setAllLatest(); break;
case 'toggleSettings':
isModalOpen() ? hideModal() : showModal(buildSettingsPanel());
break;
case 'toggleCovers':
// Only honour the shortcut when the feature override is enabled.
if (settings.coversOverride) coversState.toggleEl?.click();
break;
case 'refresh': refreshBadgeFromAPI(); break;
case 'toggleDark': applyDarkMode(!settings.darkMode); break;
case 'prevPage': {
const a = qs('[aria-label="Previous"]');
if (a && !a.classList.contains('cursor-not-allowed')) a.click();
break;
}
case 'nextPage': {
const a = qs('[aria-label="Next"]');
if (a && !a.classList.contains('cursor-not-allowed')) a.click();
break;
}
}
}
document.addEventListener('keydown', (e) => {
if (isTypingTarget(e.target)) return;
if (e.altKey || e.ctrlKey || e.metaKey) return;
// Ignore key events while a modal is open and the focus belongs inside it
// (so typing in shortcut-rebinder isn't double-handled).
if (isModalOpen() && modalOverlay.contains(e.target)) return;
for (const [action, binding] of Object.entries(settings.shortcuts)) {
if (binding === e.key) {
// Page-nav shortcuts are useful everywhere; others gated on dashboard.
const dashOnly = !['toggleSettings', 'toggleDark'].includes(action);
if (dashOnly && !location.pathname.includes('dashboard')) return;
e.preventDefault();
runShortcut(action);
return;
}
}
});
/* ================================================================
* GLOBAL STYLES
* ================================================================ */
function injectGlobalStyles () {
const s = document.createElement('style');
s.id = `${PREFIX}-global`;
s.textContent = `
/* ── Badge ────────────────────────────────────────────── */
#${PREFIX}-badge {
animation: ${PREFIX}-pulse 2.5s ease-in-out infinite;
min-width: 24px; justify-content: center;
}
@keyframes ${PREFIX}-pulse { 0%,100%{opacity:1} 50%{opacity:.7} }
/* ── Modal overlay ────────────────────────────────────── */
#${PREFIX}-modal-overlay {
position: fixed; inset: 0; z-index: 10000;
display: none; align-items: center; justify-content: center;
background-color: rgba(0,0,0,0);
backdrop-filter: blur(0px);
transition: background-color 0.2s ease, backdrop-filter 0.2s ease;
}
#${PREFIX}-modal-overlay.${PREFIX}-modal-visible { display: flex; }
#${PREFIX}-modal-overlay.${PREFIX}-modal-in {
background-color: rgba(0,0,0,0.55);
backdrop-filter: blur(3px);
}
#${PREFIX}-modal-overlay.${PREFIX}-modal-out {
background-color: rgba(0,0,0,0);
backdrop-filter: blur(0px);
}
.${PREFIX}-modal-panel {
transform: scale(0.92) translateY(8px); opacity: 0;
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.${PREFIX}-modal-in .${PREFIX}-modal-panel { transform: scale(1) translateY(0); opacity: 1; }
.${PREFIX}-modal-out .${PREFIX}-modal-panel { transform: scale(0.95) translateY(4px); opacity: 0; }
#${PREFIX}-series-filter::-webkit-scrollbar { width: 6px; }
#${PREFIX}-series-filter::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.35); border-radius: 3px;
}
/* ── Toasts ───────────────────────────────────────────── */
#${PREFIX}-toast-stack {
position: fixed; bottom: 20px; right: 20px; z-index: 9999;
display: flex; flex-direction: column-reverse; gap: 8px;
pointer-events: none;
}
.${PREFIX}-toast {
padding: 10px 16px; border-radius: 8px;
font-size: 13px; font-weight: 600; color: #fff;
font-family: system-ui, sans-serif;
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
opacity: 0; transform: translateY(8px);
transition: opacity 0.25s, transform 0.25s;
max-width: 360px; pointer-events: auto;
}
.${PREFIX}-toast.${PREFIX}-toast-in { opacity: 1; transform: translateY(0); }
.${PREFIX}-toast-info { background: #1f2937; }
.${PREFIX}-toast-success { background: #059669; }
.${PREFIX}-toast-error { background: #dc2626; }
/* List view banished off-screen while covers are active.
Stays mounted (Vue keeps its handlers attached) but doesn't
steal layout space or visual real estate. */
.${PREFIX}-list-offscreen {
position: absolute !important;
left: -10000px !important; top: auto !important;
width: 1px !important; height: 1px !important;
overflow: hidden !important; opacity: 0 !important;
pointer-events: none !important;
}
/* ── Covers mode ──────────────────────────────────────────
The site's own CSS (data-v-scoped rules in index-CmChCRuR.css)
handles ALL card styling once we apply the matching data-v
attributes. These are just the bits not in scoped CSS:
the grid layout (Tailwind utility) and list-style reset. */
#${PREFIX}-covers-grid {
list-style: none; padding: 0; margin: 0;
display: grid; gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 768px) {
#${PREFIX}-covers-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 1536px) {
#${PREFIX}-covers-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
}
/* Fallback for the 2:3 aspect-ratio box in case the site's
tailwindcss/aspect-ratio plugin classes are not present. */
#${PREFIX}-covers-grid .aspect-w-2.aspect-h-3 {
position: relative; padding-bottom: 150%; overflow: hidden;
}
#${PREFIX}-covers-grid .aspect-w-2.aspect-h-3 > picture,
#${PREFIX}-covers-grid .aspect-w-2.aspect-h-3 > picture > img {
position: absolute; inset: 0; width: 100%; height: 100%;
}
`;
document.head.appendChild(s);
}
/* ================================================================
* BOOT
* ================================================================ */
applyDarkMode(settings.darkMode);
injectGlobalStyles();
ensureModal();
if (settings.notifications) requestNotifPermission();
setupNavDetection((path, prev) => {
// Re-apply dark mode on every SPA navigation — Vue's rehydration on
// certain routes can otherwise wipe the html.dark class.
applyDarkMode(settings.darkMode);
const isDash = path.includes('dashboard');
const wasDash = prev && prev.includes('dashboard');
if (isDash && !wasDash) setTimeout(initDashboard, 300);
else if (isDash && wasDash) refreshBadgeFromAPI();
else if (!isDash && wasDash) teardownDashboard();
else if (isDash && !prev) setTimeout(initDashboard, 300);
});
})();