// Modern UI script: search, cards, details, downloads, status, theme
// Reuses existing API endpoints. Keeps logic minimal and accessible.
(function () {
// ---- DOM ----
const el = {
searchInput: document.getElementById('search-input'),
searchBtn: document.getElementById('search-button'),
advToggle: document.getElementById('toggle-advanced'),
filtersForm: document.getElementById('search-filters'),
isbn: document.getElementById('isbn-input'),
author: document.getElementById('author-input'),
title: document.getElementById('title-input'),
lang: document.getElementById('lang-input'),
sort: document.getElementById('sort-input'),
content: document.getElementById('content-input'),
resultsGrid: document.getElementById('results-grid'),
noResults: document.getElementById('no-results'),
searchLoading: document.getElementById('search-loading'),
modalOverlay: document.getElementById('modal-overlay'),
detailsContainer: document.getElementById('details-container'),
refreshStatusBtn: document.getElementById('refresh-status-button'),
clearCompletedBtn: document.getElementById('clear-completed-button'),
statusLoading: document.getElementById('status-loading'),
statusList: document.getElementById('status-list'),
activeDownloadsCount: document.getElementById('active-downloads-count'),
// Active downloads (top section under search)
activeTopSec: document.getElementById('active-downloads-top'),
activeTopList: document.getElementById('active-downloads-list'),
activeTopRefreshBtn: document.getElementById('active-refresh-button'),
themeToggle: document.getElementById('theme-toggle'),
themeText: document.getElementById('theme-text'),
themeMenu: document.getElementById('theme-menu')
};
// ---- Constants ----
const API = {
search: '/request/api/search',
info: '/request/api/info',
download: '/request/api/download',
status: '/request/api/status',
cancelDownload: '/request/api/download',
setPriority: '/request/api/queue',
clearCompleted: '/request/api/queue/clear',
activeDownloads: '/request/api/downloads/active'
};
const FILTERS = ['isbn', 'author', 'title', 'lang', 'sort', 'content', 'format'];
// ---- Utils ----
const utils = {
show(node) { node && node.classList.remove('hidden'); },
hide(node) { node && node.classList.add('hidden'); },
async j(url, opts = {}) {
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
},
// Build query string from basic + advanced filters
buildQuery() {
const q = [];
const basic = el.searchInput?.value?.trim();
if (basic) q.push(`query=${encodeURIComponent(basic)}`);
if (!el.filtersForm || el.filtersForm.classList.contains('hidden')) {
return q.join('&');
}
FILTERS.forEach((name) => {
if (name === 'format') {
const checked = Array.from(document.querySelectorAll('[id^="format-"]:checked'));
checked.forEach((cb) => q.push(`format=${encodeURIComponent(cb.value)}`));
} else {
const input = document.querySelectorAll(`[id^="${name}-input"]`);
input.forEach((node) => {
const val = node.value?.trim();
if (val) q.push(`${name}=${encodeURIComponent(val)}`);
});
}
});
return q.join('&');
},
// Simple notification via alert fallback
toast(msg) { try { console.info(msg); } catch (_) {} },
// Escapes text for safe HTML injection
e(text) { return (text ?? '').toString(); }
};
// ---- Modal ----
const modal = {
open() { el.modalOverlay?.classList.add('active'); },
close() { el.modalOverlay?.classList.remove('active'); el.detailsContainer.innerHTML = ''; }
};
// ---- Cards ----
function renderCard(book) {
const cover = book.preview ? `
` :
`
No Cover
`;
const html = `
${cover}
${utils.e(book.title) || 'Untitled'}
${utils.e(book.author) || 'Unknown author'}
${utils.e(book.year) || '-'}
•
${utils.e(book.language) || '-'}
•
${utils.e(book.format) || '-'}
${book.size ? `•${utils.e(book.size)}` : ''}
`;
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
// Bind actions
const detailsBtn = wrapper.querySelector('[data-action="details"]');
const downloadBtn = wrapper.querySelector('[data-action="download"]');
detailsBtn?.addEventListener('click', () => bookDetails.show(book.id));
downloadBtn?.addEventListener('click', () => bookDetails.download(book));
return wrapper.firstElementChild;
}
function renderCards(books) {
el.resultsGrid.innerHTML = '';
if (!books || books.length === 0) {
utils.show(el.noResults);
return;
}
utils.hide(el.noResults);
const frag = document.createDocumentFragment();
books.forEach((b) => frag.appendChild(renderCard(b)));
el.resultsGrid.appendChild(frag);
}
// ---- Search ----
const search = {
async run() {
const qs = utils.buildQuery();
if (!qs) { renderCards([]); return; }
utils.show(el.searchLoading);
try {
const data = await utils.j(`${API.search}?${qs}`);
renderCards(data);
} catch (e) {
renderCards([]);
} finally {
utils.hide(el.searchLoading);
}
}
};
// ---- Details ----
const bookDetails = {
async show(id) {
try {
modal.open();
el.detailsContainer.innerHTML = 'Loading…
';
const book = await utils.j(`${API.info}?id=${encodeURIComponent(id)}`);
el.detailsContainer.innerHTML = this.tpl(book);
document.getElementById('close-details')?.addEventListener('click', modal.close);
document.getElementById('download-button')?.addEventListener('click', () => this.download(book));
} catch (e) {
el.detailsContainer.innerHTML = 'Failed to load details.
';
}
},
tpl(book) {
const cover = book.preview ? `
` : '';
const infoList = book.info ? Object.entries(book.info).map(([k, v]) => `${utils.e(k)}: ${utils.e((v||[]).join
? v.join(', ') : v)}`).join('') : '';
return `
${cover}
${utils.e(book.title) || 'Untitled'}
${utils.e(book.author) || 'Unknown author'}
Publisher: ${utils.e(book.publisher) || '-'}
Year: ${utils.e(book.year) || '-'}
Language: ${utils.e(book.language) || '-'}
Format: ${utils.e(book.format) || '-'}
Size: ${utils.e(book.size) || '-'}
${infoList ? `
` : ''}
`;
},
async download(book) {
if (!book) return;
try {
await utils.j(`${API.download}?id=${encodeURIComponent(book.id)}`);
utils.toast('Queued for download');
modal.close();
status.fetch();
} catch (_){}
}
};
// ---- Status ----
const status = {
async fetch() {
try {
utils.show(el.statusLoading);
const data = await utils.j(API.status);
this.render(data);
// Also reflect active downloads in the top section
this.renderTop(data);
this.updateActive();
} catch (e) {
el.statusList.innerHTML = 'Error loading status.
';
} finally { utils.hide(el.statusLoading); }
},
render(data) {
// data shape: {queued: {...}, downloading: {...}, completed: {...}, error: {...}}
const sections = [];
for (const [name, items] of Object.entries(data || {})) {
if (!items || Object.keys(items).length === 0) continue;
const rows = Object.values(items).map((b) => {
const titleText = utils.e(b.title) || '-';
const maybeLinkedTitle = b.download_path
? `${titleText}`
: titleText;
const actions = (name === 'queued' || name === 'downloading')
? ``
: '';
const progress = (name === 'downloading' && typeof b.progress === 'number')
? ``
: '';
return `
${utils.e(name)} • ${maybeLinkedTitle}
${progress}
${actions}
`;
}).join('');
sections.push(`
${name.charAt(0).toUpperCase() + name.slice(1)}
`);
}
el.statusList.innerHTML = sections.join('') || 'No items.
';
// Bind cancel buttons
el.statusList.querySelectorAll('[data-cancel]')?.forEach((btn) => {
btn.addEventListener('click', () => queue.cancel(btn.getAttribute('data-cancel')));
});
},
// Render compact active downloads list near the search bar
renderTop(data) {
try {
const downloading = (data && data.downloading) ? Object.values(data.downloading) : [];
if (!el.activeTopSec || !el.activeTopList) return;
if (!downloading.length) {
el.activeTopList.innerHTML = '';
el.activeTopSec.classList.add('hidden');
return;
}
// Build compact rows with title and progress bar + cancel
const rows = downloading.map((b) => {
const prog = (typeof b.progress === 'number')
? ``
: '';
const cancel = ``;
return `
${utils.e(b.title || '-') }
${cancel}
${prog}
`;
}).join('');
el.activeTopList.innerHTML = rows;
el.activeTopSec.classList.remove('hidden');
// Bind cancel handlers for the top section
el.activeTopList.querySelectorAll('[data-cancel]')?.forEach((btn) => {
btn.addEventListener('click', () => queue.cancel(btn.getAttribute('data-cancel')));
});
} catch (_) {}
},
async updateActive() {
try {
const d = await utils.j(API.activeDownloads);
const n = Array.isArray(d.active_downloads) ? d.active_downloads.length : 0;
if (el.activeDownloadsCount) el.activeDownloadsCount.textContent = `Active: ${n}`;
} catch (_) {}
}
};
// ---- Queue ----
const queue = {
async cancel(id) {
try {
await fetch(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
status.fetch();
} catch (_){}
}
};
// ---- Theme ----
const theme = {
KEY: 'preferred-theme',
init() {
const saved = localStorage.getItem(this.KEY) || 'auto';
this.apply(saved);
this.updateLabel(saved);
// toggle dropdown
el.themeToggle?.addEventListener('click', (e) => {
e.preventDefault();
if (!el.themeMenu) return;
el.themeMenu.classList.toggle('hidden');
});
// outside click to close
document.addEventListener('click', (ev) => {
if (!el.themeMenu || !el.themeToggle) return;
if (el.themeMenu.contains(ev.target) || el.themeToggle.contains(ev.target)) return;
el.themeMenu.classList.add('hidden');
});
// selection
el.themeMenu?.querySelectorAll('a[data-theme]')?.forEach((a) => {
a.addEventListener('click', (ev) => {
ev.preventDefault();
const pref = a.getAttribute('data-theme');
localStorage.setItem(theme.KEY, pref);
theme.apply(pref);
theme.updateLabel(pref);
el.themeMenu.classList.add('hidden');
});
});
// react to system change if auto
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', (e) => {
if ((localStorage.getItem(theme.KEY) || 'auto') === 'auto') {
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
}
});
},
apply(pref) {
if (pref === 'auto') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', pref);
}
},
updateLabel(pref) { if (el.themeText) el.themeText.textContent = `Theme (${pref})`; }
};
// ---- Wire up ----
function initEvents() {
el.searchBtn?.addEventListener('click', () => search.run());
el.searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') search.run(); });
document.getElementById('adv-search-button')?.addEventListener('click', () => search.run());
if (el.advToggle && el.filtersForm) {
el.advToggle.addEventListener('click', (e) => {
e.preventDefault();
el.filtersForm.classList.toggle('hidden');
});
}
el.refreshStatusBtn?.addEventListener('click', () => status.fetch());
el.activeTopRefreshBtn?.addEventListener('click', () => status.fetch());
el.clearCompletedBtn?.addEventListener('click', async () => {
try { await fetch(API.clearCompleted, { method: 'DELETE' }); status.fetch(); } catch (_) {}
});
// Close modal on overlay click
el.modalOverlay?.addEventListener('click', (e) => { if (e.target === el.modalOverlay) modal.close(); });
}
// ---- Init ----
theme.init();
initEvents();
status.fetch();
})();