'use strict'; /* Library overview: searchable covers with combinable metadata filters. */ (async function () { let me, data; try { [me, data] = await Promise.all([api('api/me'), api('api/library')]); } catch (e) { return showError(e); } topNav(me); const main = $('main'); main.textContent = ''; if (!data.mangas.length) { main.append(h('div', { class: 'empty' }, h('h1', {}, data.scanning ? 'Scanning the library…' : 'The library is empty'), h('p', { class: 'muted' }, data.scanning ? 'This page refreshes by itself.' : 'Put manga folders into the library folder (see README.md for the format)' + (me.admin ? ', then rescan it in the settings.' : '.')))); if (data.scanning) setTimeout(() => location.reload(), 2000); return; } const storyAvailable = !!(data.reading && data.reading.activeMs > 0); const allTags = [...new Set(data.mangas.flatMap(m => m.tags || []).filter(Boolean))].sort((a, b) => a.localeCompare(b)); const allStatuses = [...new Set(data.mangas.map(m => m.status).filter(Boolean))].sort((a, b) => a.localeCompare(b)); const requestedTags = new URLSearchParams(location.search).getAll('tag').filter(tag => allTags.includes(tag)); let rememberedTags = []; try { rememberedTags = JSON.parse(store.get('tags') || '[]'); } catch (_) { /* ignore old invalid state */ } const selectedTags = new Set((requestedTags.length ? requestedTags : rememberedTags).filter(tag => allTags.includes(tag))); const filter = h('input', { type: 'search', placeholder: 'Search titles or authors…', 'aria-label': 'Search library', value: requestedTags.length ? '' : (store.get('filter') || '') }); const status = h('select', { 'aria-label': 'Filter by publication status' }, h('option', { value: '' }, 'All statuses'), allStatuses.map(value => h('option', { value }, value))); status.value = !requestedTags.length && allStatuses.includes(store.get('status')) ? store.get('status') : ''; const sort = h('select', { 'aria-label': 'Sort library' }, h('option', { value: 'title' }, 'Title A–Z'), h('option', { value: 'title-desc' }, 'Title Z–A'), h('option', { value: 'release-desc' }, 'First release · newest'), h('option', { value: 'release-asc' }, 'First release · oldest'), h('option', { value: 'updated' }, 'Recently updated'), h('option', { value: 'read' }, 'Recently read'), storyAvailable ? h('option', { value: 'timed' }, 'Recently timed') : null, storyAvailable ? h('option', { value: 'time' }, 'Time together') : null); const allowedSorts = ['title', 'title-desc', 'release-desc', 'release-asc', 'updated', 'read', ...(storyAvailable ? ['timed', 'time'] : [])]; sort.value = allowedSorts.includes(store.get('sort')) ? store.get('sort') : 'title'; const resultCount = h('div', { class: 'muted small result-count', 'aria-live': 'polite' }); const tagButtons = new Map(); const tagBar = h('div', { class: 'tag-filter', 'aria-label': 'Filter by tags' }); const clearTags = h('button', { class: 'filter-chip', type: 'button' }, 'All tags'); clearTags.onclick = () => { selectedTags.clear(); syncTags(); render(); }; tagBar.append(clearTags); for (const tag of allTags) { const button = h('button', { class: 'filter-chip', type: 'button' }, tag); button.onclick = () => { selectedTags.has(tag) ? selectedTags.delete(tag) : selectedTags.add(tag); syncTags(); render(); }; tagButtons.set(tag, button); tagBar.append(button); } const grid = h('div', { class: 'grid' }); const grouping = h('select', { 'aria-label': 'Collection display' }, h('option', { value: 'grouped' }, 'Group collections'), h('option', { value: 'expanded' }, 'Expand collections'), h('option', { value: 'individual' }, 'Individual manga')); grouping.value = ['grouped', 'expanded', 'individual'].includes(store.get('grouping')) ? store.get('grouping') : 'grouped'; grouping.onchange = render; // Started but unfinished manga, most recently read first; the same manga still appear in the grid. const continueItems = data.mangas.filter(m => m.lastRead && m.read < m.chapters) .sort((a, b) => b.lastRead - a.lastRead).slice(0, 5); const continueRow = h('section', { class: 'continue-row', 'aria-label': 'Continue where you left off', hidden: true }, h('div', { class: 'continue-banner' }, h('span', { 'aria-hidden': 'true' }, '▶'), 'Continue where you left off'), h('div', { class: 'continue-tiles' }, continueItems.map(m => tile(m)))); if (storyAvailable) { const most = data.mangas.find(manga => manga.id === data.reading.mostTimeMangaId); const latest = data.mangas.find(manga => manga.id === data.reading.lastMangaId); main.append(h('section', { class: 'reading-summary' }, h('div', { class: 'reading-summary-title' }, h('span', { 'aria-hidden': 'true' }, '⏱'), h('b', {}, 'Your Reading Story')), h('div', { class: 'reading-summary-stats' }, h('strong', {}, readingDuration(data.reading.activeMs)), h('span', { class: 'muted' }, `together · ${data.reading.timedChapters} chapter${data.reading.timedChapters === 1 ? '' : 's'} timed`), data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null), h('div', { class: 'reading-summary-links' }, most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null, latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId) }, `Continue ${latest.title} →`) : null))); } main.append( h('div', { class: 'library-controls' }, h('div', { class: 'toolbar' }, filter, status, sort, grouping, me.admin ? h('a', { class: 'btn', href: 'collections.html' }, 'Manage collections') : null), allTags.length ? h('div', { class: 'filter-group' }, h('div', { class: 'filter-heading' }, h('b', {}, 'Tags'), h('span', { class: 'muted small' }, 'Manga must match every selected tag')), tagBar) : null, resultCount), continueRow, grid); function syncTags() { store.set('tags', JSON.stringify([...selectedTags])); const params = new URLSearchParams(location.search); params.delete('tag'); for (const tag of selectedTags) params.append('tag', tag); const query = params.toString(); history.replaceState(null, '', location.pathname + (query ? '?' + query : '')); } function releaseSort(a, b, direction) { const at = +a.firstReleaseAt || 0, bt = +b.firstReleaseAt || 0; if (!at && !bt) return a.title.localeCompare(b.title); if (!at) return 1; if (!bt) return -1; return direction * (at - bt) || a.title.localeCompare(b.title); } function tile(m) { const cover = m.isCollection && m.coverMode === 'mosaic' && m.covers.length ? h('div', { class: 'cv collection-mosaic' }, m.covers.map(src => h('img', { loading: 'lazy', src, alt: '' }))) : h('div', { class: 'cv' }, m.cover ? h('img', { loading: 'lazy', src: m.cover, alt: '' }) : null); return h('a', { class: 'tile', href: m.isCollection ? 'collection.html?' + qsa({ id: m.id }) : mangaUrl(m.id) }, cover, m.isCollection ? h('span', { class: 'collection-count' }, `${m.members.length} manga`) : null, m.unread ? h('span', { class: 'badge' }, m.unread + ' new') : null, h('div', { class: 't' }, m.title), h('div', { class: 's' }, [m.firstRelease, m.status, m.read ? `${m.read} / ${m.chapters} read` : `${m.chapters} chapters`].filter(Boolean).join(' · ')), m.read ? h('div', { class: 'prog' }, h('i', { style: `width:${Math.round((100 * m.read) / Math.max(1, m.chapters))}%` })) : null); } function render() { store.set('filter', filter.value); store.set('sort', sort.value); store.set('status', status.value); store.set('grouping', grouping.value); // Only on the plain overview: a search or status filter means the reader is looking for something else, // and "Recently read" already puts these first. Tags and grouping keep it. continueRow.hidden = !me.settings.continueRow || !continueItems.length || sort.value === 'read' || !!filter.value.trim() || !!status.value; const q = filter.value.trim().toLowerCase(); const groups = (data.collections || []).filter(c => c.grouped); const membership = new Set(groups.flatMap(c => c.members.map(m => m.id))); const candidates = grouping.value === 'grouped' ? [...data.mangas.filter(m => !membership.has(m.id)), ...groups.map(c => ({ ...c, isCollection: true }))] : [...data.mangas]; const matches = m => { const haystack = [m.title, ...(m.altTitles || []), ...(m.authors || [])].join(' ').toLowerCase(); return (!q || haystack.includes(q)) && (!status.value || m.status === status.value) && [...selectedTags].every(tag => (m.tags || []).includes(tag)); }; // Match complete tag combinations on a single member, not unrelated tags spread across a group. const list = candidates.filter(m => m.isCollection ? m.members.some(matches) || (q && m.title.toLowerCase().includes(q) && !status.value && !selectedTags.size) : matches(m)); if (sort.value === 'title') list.sort((a, b) => a.title.localeCompare(b.title)); if (sort.value === 'title-desc') list.sort((a, b) => b.title.localeCompare(a.title)); if (sort.value === 'release-asc') list.sort((a, b) => releaseSort(a, b, 1)); if (sort.value === 'release-desc') list.sort((a, b) => releaseSort(a, b, -1)); if (sort.value === 'updated') list.sort((a, b) => b.updated - a.updated); if (sort.value === 'read') list.sort((a, b) => b.lastRead - a.lastRead); if (sort.value === 'timed') list.sort((a, b) => (b.reading?.updatedAt || 0) - (a.reading?.updatedAt || 0)); if (sort.value === 'time') list.sort((a, b) => (b.reading?.activeMs || 0) - (a.reading?.activeMs || 0)); clearTags.classList.toggle('on', !selectedTags.size); clearTags.setAttribute('aria-pressed', String(!selectedTags.size)); for (const [tag, button] of tagButtons) { button.classList.toggle('on', selectedTags.has(tag)); button.setAttribute('aria-pressed', String(selectedTags.has(tag))); } resultCount.textContent = `${list.length} results · ${data.mangas.length} manga in the library`; grid.textContent = ''; if (grouping.value === 'expanded') { const visible = new Set(list.map(m => m.id)); for (const c of groups) { const members = c.members.filter(m => visible.has(m.id)); if (!members.length) continue; grid.append(h('h2', { class: 'collection-heading' }, h('a', { href: 'collection.html?' + qsa({ id: c.id }) }, c.title))); members.forEach(m => { grid.append(tile(m)); visible.delete(m.id); }); } list.filter(m => visible.has(m.id)).forEach(m => grid.append(tile(m))); return; } for (const m of list) { grid.append(tile(m)); } if (!list.length) grid.append(h('p', { class: 'muted' }, 'Nothing matches these filters.')); } filter.addEventListener('input', render); status.addEventListener('change', render); sort.addEventListener('change', render); syncTags(); render(); })();