mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Revamp Calibre OPDS integration with improved UI, search functionality, and modal handling
This commit is contained in:
+275
-65
@@ -1,49 +1,42 @@
|
|||||||
const browser = document.querySelector('[data-role="opds-browser"]');
|
const modal = document.querySelector('[data-role="opds-modal"]');
|
||||||
|
const browser = modal?.querySelector('[data-role="opds-browser"]') || null;
|
||||||
|
|
||||||
if (browser) {
|
if (modal && browser) {
|
||||||
const statusEl = browser.querySelector('[data-role="opds-status"]');
|
const statusEl = browser.querySelector('[data-role="opds-status"]');
|
||||||
const resultsEl = browser.querySelector('[data-role="opds-results"]');
|
const resultsEl = browser.querySelector('[data-role="opds-results"]');
|
||||||
const navEl = browser.querySelector('[data-role="opds-nav"]');
|
const navEl = browser.querySelector('[data-role="opds-nav"]');
|
||||||
const searchForm = browser.querySelector('[data-role="opds-search"]');
|
const tabsEl = modal.querySelector('[data-role="opds-tabs"]');
|
||||||
|
const searchForm = modal.querySelector('[data-role="opds-search"]');
|
||||||
const searchInput = searchForm?.querySelector('input[name="q"]');
|
const searchInput = searchForm?.querySelector('input[name="q"]');
|
||||||
const refreshButton = browser.querySelector('[data-action="opds-refresh"]');
|
const refreshButton = searchForm?.querySelector('[data-action="opds-refresh"]');
|
||||||
|
const openButtons = document.querySelectorAll('[data-action="open-opds-modal"]');
|
||||||
|
const closeTargets = modal.querySelectorAll('[data-role="opds-modal-close"]');
|
||||||
|
|
||||||
const state = {
|
const TabIds = {
|
||||||
query: '',
|
ROOT: 'root',
|
||||||
|
SEARCH: 'search',
|
||||||
|
CUSTOM: 'custom',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ENTRY_TYPES = {
|
const EntryTypes = {
|
||||||
BOOK: 'book',
|
BOOK: 'book',
|
||||||
NAVIGATION: 'navigation',
|
NAVIGATION: 'navigation',
|
||||||
OTHER: 'other',
|
OTHER: 'other',
|
||||||
};
|
};
|
||||||
|
|
||||||
const setStatus = (message, level) => {
|
const state = {
|
||||||
if (!statusEl) {
|
query: '',
|
||||||
return;
|
currentHref: '',
|
||||||
}
|
activeTab: TabIds.ROOT,
|
||||||
statusEl.textContent = message || '';
|
tabs: [],
|
||||||
if (level) {
|
tabsReady: false,
|
||||||
statusEl.dataset.state = level;
|
requestToken: 0,
|
||||||
} else {
|
|
||||||
delete statusEl.dataset.state;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearStatus = () => setStatus('', null);
|
let isOpen = false;
|
||||||
|
let lastTrigger = null;
|
||||||
|
|
||||||
const resolveRelLink = (links, rel) => {
|
const truncate = (text, limit = 160) => {
|
||||||
if (!links) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (links[rel]) {
|
|
||||||
return links[rel];
|
|
||||||
}
|
|
||||||
const key = Object.keys(links).find((entry) => entry === rel || entry.endsWith(rel));
|
|
||||||
return key ? links[key] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const truncate = (text, limit = 320) => {
|
|
||||||
if (!text || typeof text !== 'string') {
|
if (!text || typeof text !== 'string') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -60,12 +53,51 @@ if (browser) {
|
|||||||
return authors.filter((author) => !!author).join(', ');
|
return authors.filter((author) => !!author).join(', ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setStatus = (message, level) => {
|
||||||
|
if (!statusEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusEl.textContent = message || '';
|
||||||
|
if (level) {
|
||||||
|
statusEl.dataset.state = level;
|
||||||
|
} else {
|
||||||
|
delete statusEl.dataset.state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStatus = () => setStatus('', null);
|
||||||
|
|
||||||
|
const focusSearch = () => {
|
||||||
|
if (!searchInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
searchInput.focus({ preventScroll: true });
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore focus issues
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRelLink = (links, rel) => {
|
||||||
|
if (!links) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (links[rel]) {
|
||||||
|
return links[rel];
|
||||||
|
}
|
||||||
|
const key = Object.keys(links).find((entry) => entry === rel || entry.endsWith(rel));
|
||||||
|
return key ? links[key] : null;
|
||||||
|
};
|
||||||
|
|
||||||
const findNavigationLink = (entry) => {
|
const findNavigationLink = (entry) => {
|
||||||
if (!entry || !Array.isArray(entry.links)) {
|
if (!entry || !Array.isArray(entry.links)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const candidates = entry.links.filter((link) => link && link.href);
|
const candidates = entry.links.filter((link) => link && link.href);
|
||||||
return candidates.find((link) => {
|
return (
|
||||||
|
candidates.find((link) => {
|
||||||
const rel = (link.rel || '').toLowerCase();
|
const rel = (link.rel || '').toLowerCase();
|
||||||
const type = (link.type || '').toLowerCase();
|
const type = (link.type || '').toLowerCase();
|
||||||
if (!link.href) {
|
if (!link.href) {
|
||||||
@@ -87,7 +119,86 @@ if (browser) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}) || null;
|
}) || null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTabIdForHref = (href) => {
|
||||||
|
if (!href) {
|
||||||
|
return TabIds.ROOT;
|
||||||
|
}
|
||||||
|
const matching = state.tabs.find((tab) => tab.href === href);
|
||||||
|
return matching ? matching.id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTabsFromFeed = (feed) => {
|
||||||
|
if (!feed || !Array.isArray(feed.entries)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
const nextTabs = [];
|
||||||
|
feed.entries.forEach((entry) => {
|
||||||
|
const navLink = findNavigationLink(entry);
|
||||||
|
if (!navLink || !navLink.href) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seen.has(navLink.href)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(navLink.href);
|
||||||
|
const label = entry.title || navLink.title || 'Catalog view';
|
||||||
|
nextTabs.push({
|
||||||
|
id: navLink.href,
|
||||||
|
label,
|
||||||
|
href: navLink.href,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
state.tabs = nextTabs;
|
||||||
|
state.tabsReady = true;
|
||||||
|
renderTabs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTabs = () => {
|
||||||
|
if (!tabsEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabsEl.innerHTML = '';
|
||||||
|
const tabs = [];
|
||||||
|
tabs.push({ id: TabIds.ROOT, label: 'Catalog home', href: '' });
|
||||||
|
state.tabs.forEach((tab) => tabs.push(tab));
|
||||||
|
if (state.activeTab === TabIds.SEARCH && state.query) {
|
||||||
|
tabs.push({
|
||||||
|
id: TabIds.SEARCH,
|
||||||
|
label: `Search: "${truncate(state.query, 32)}"`,
|
||||||
|
href: '',
|
||||||
|
isSearch: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'opds-tab';
|
||||||
|
if (tab.isSearch) {
|
||||||
|
button.classList.add('opds-tab--search');
|
||||||
|
}
|
||||||
|
if (state.activeTab === tab.id || (tab.id !== TabIds.SEARCH && state.activeTab === tab.href)) {
|
||||||
|
button.classList.add('is-active');
|
||||||
|
}
|
||||||
|
button.textContent = tab.label;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
if (tab.id === TabIds.SEARCH) {
|
||||||
|
loadFeed({ href: '', query: state.query, activeTab: TabIds.SEARCH });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tab.id === TabIds.ROOT) {
|
||||||
|
loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadFeed({ href: tab.href, query: '', activeTab: tab.id });
|
||||||
|
});
|
||||||
|
tabsEl.appendChild(button);
|
||||||
|
});
|
||||||
|
tabsEl.classList.toggle('is-empty', tabs.length <= 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderNav = (links) => {
|
const renderNav = (links) => {
|
||||||
@@ -96,9 +207,9 @@ if (browser) {
|
|||||||
}
|
}
|
||||||
navEl.innerHTML = '';
|
navEl.innerHTML = '';
|
||||||
const descriptors = [
|
const descriptors = [
|
||||||
{ key: 'up', label: 'Up' },
|
{ key: 'up', label: 'Up one level' },
|
||||||
{ key: 'previous', label: 'Previous' },
|
{ key: 'previous', label: 'Previous page' },
|
||||||
{ key: 'next', label: 'Next' },
|
{ key: 'next', label: 'Next page' },
|
||||||
];
|
];
|
||||||
descriptors.forEach(({ key, label }) => {
|
descriptors.forEach(({ key, label }) => {
|
||||||
const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`);
|
const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`);
|
||||||
@@ -108,15 +219,15 @@ if (browser) {
|
|||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'button button--ghost';
|
button.className = 'button button--ghost';
|
||||||
button.dataset.href = link.href;
|
|
||||||
button.dataset.rel = key;
|
|
||||||
button.textContent = label;
|
button.textContent = label;
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
clearStatus();
|
const targetQuery = key === 'up' ? '' : state.query;
|
||||||
loadFeed({ href: link.href, query: state.query });
|
const tabId = resolveTabIdForHref(link.href);
|
||||||
|
loadFeed({ href: link.href, query: targetQuery, activeTab: tabId || (targetQuery ? TabIds.SEARCH : TabIds.CUSTOM) });
|
||||||
});
|
});
|
||||||
navEl.appendChild(button);
|
navEl.appendChild(button);
|
||||||
});
|
});
|
||||||
|
navEl.hidden = !navEl.childElementCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createEntry = (entry) => {
|
const createEntry = (entry) => {
|
||||||
@@ -141,10 +252,11 @@ if (browser) {
|
|||||||
|
|
||||||
item.appendChild(header);
|
item.appendChild(header);
|
||||||
|
|
||||||
if (entry.summary) {
|
const summarySource = entry.summary || entry?.alternate?.title || entry?.download?.title || '';
|
||||||
|
if (summarySource) {
|
||||||
const summary = document.createElement('p');
|
const summary = document.createElement('p');
|
||||||
summary.className = 'opds-browser__summary';
|
summary.className = 'opds-browser__summary';
|
||||||
summary.textContent = truncate(entry.summary, 380);
|
summary.textContent = truncate(summarySource, 420);
|
||||||
item.appendChild(summary);
|
item.appendChild(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,34 +267,35 @@ if (browser) {
|
|||||||
const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null;
|
const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null;
|
||||||
const navigationLink = findNavigationLink(entry);
|
const navigationLink = findNavigationLink(entry);
|
||||||
|
|
||||||
let entryType = ENTRY_TYPES.OTHER;
|
let entryType = EntryTypes.OTHER;
|
||||||
if (downloadLink) {
|
if (downloadLink) {
|
||||||
entryType = ENTRY_TYPES.BOOK;
|
entryType = EntryTypes.BOOK;
|
||||||
} else if (navigationLink && navigationLink.href) {
|
} else if (navigationLink && navigationLink.href) {
|
||||||
entryType = ENTRY_TYPES.NAVIGATION;
|
entryType = EntryTypes.NAVIGATION;
|
||||||
item.classList.add('opds-browser__entry--navigation');
|
item.classList.add('opds-browser__entry--navigation');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entryType === ENTRY_TYPES.BOOK) {
|
if (entryType === EntryTypes.BOOK) {
|
||||||
const queueButton = document.createElement('button');
|
const queueButton = document.createElement('button');
|
||||||
queueButton.type = 'button';
|
queueButton.type = 'button';
|
||||||
queueButton.className = 'button';
|
queueButton.className = 'button';
|
||||||
queueButton.textContent = 'Queue for conversion';
|
queueButton.textContent = 'Queue for conversion';
|
||||||
queueButton.addEventListener('click', () => importEntry(entry, queueButton));
|
queueButton.addEventListener('click', () => importEntry(entry, queueButton));
|
||||||
actions.appendChild(queueButton);
|
actions.appendChild(queueButton);
|
||||||
} else if (entryType === ENTRY_TYPES.NAVIGATION && navigationLink) {
|
} else if (entryType === EntryTypes.NAVIGATION && navigationLink) {
|
||||||
const browseButton = document.createElement('button');
|
const browseButton = document.createElement('button');
|
||||||
browseButton.type = 'button';
|
browseButton.type = 'button';
|
||||||
browseButton.className = 'button button--ghost';
|
browseButton.className = 'button button--ghost';
|
||||||
browseButton.textContent = 'Browse view';
|
browseButton.textContent = 'Browse view';
|
||||||
browseButton.addEventListener('click', () => {
|
browseButton.addEventListener('click', () => {
|
||||||
clearStatus();
|
clearStatus();
|
||||||
loadFeed({ href: navigationLink.href, query: '' });
|
const tabId = resolveTabIdForHref(navigationLink.href);
|
||||||
|
loadFeed({ href: navigationLink.href, query: '', activeTab: tabId || TabIds.CUSTOM });
|
||||||
});
|
});
|
||||||
actions.appendChild(browseButton);
|
actions.appendChild(browseButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (alternateLink && entryType !== ENTRY_TYPES.NAVIGATION) {
|
if (alternateLink && entryType !== EntryTypes.NAVIGATION) {
|
||||||
const previewLink = document.createElement('a');
|
const previewLink = document.createElement('a');
|
||||||
previewLink.className = 'button button--ghost';
|
previewLink.className = 'button button--ghost';
|
||||||
previewLink.href = alternateLink;
|
previewLink.href = alternateLink;
|
||||||
@@ -205,21 +318,21 @@ if (browser) {
|
|||||||
|
|
||||||
const renderEntries = (entries) => {
|
const renderEntries = (entries) => {
|
||||||
if (!resultsEl) {
|
if (!resultsEl) {
|
||||||
return { [ENTRY_TYPES.BOOK]: 0, [ENTRY_TYPES.NAVIGATION]: 0, [ENTRY_TYPES.OTHER]: 0 };
|
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
|
||||||
}
|
}
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
if (!Array.isArray(entries) || !entries.length) {
|
if (!Array.isArray(entries) || !entries.length) {
|
||||||
const empty = document.createElement('li');
|
const empty = document.createElement('li');
|
||||||
empty.className = 'opds-browser__empty';
|
empty.className = 'opds-browser__empty';
|
||||||
empty.textContent = 'No books found in this view.';
|
empty.textContent = 'No catalog entries found here yet.';
|
||||||
resultsEl.appendChild(empty);
|
resultsEl.appendChild(empty);
|
||||||
return { [ENTRY_TYPES.BOOK]: 0, [ENTRY_TYPES.NAVIGATION]: 0, [ENTRY_TYPES.OTHER]: 0 };
|
return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 };
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
const stats = {
|
const stats = {
|
||||||
[ENTRY_TYPES.BOOK]: 0,
|
[EntryTypes.BOOK]: 0,
|
||||||
[ENTRY_TYPES.NAVIGATION]: 0,
|
[EntryTypes.NAVIGATION]: 0,
|
||||||
[ENTRY_TYPES.OTHER]: 0,
|
[EntryTypes.OTHER]: 0,
|
||||||
};
|
};
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
const { element, type } = createEntry(entry);
|
const { element, type } = createEntry(entry);
|
||||||
@@ -240,7 +353,7 @@ if (browser) {
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.dataset.loading = 'true';
|
button.dataset.loading = 'true';
|
||||||
}
|
}
|
||||||
setStatus(`Queueing “${entry.title || 'Untitled'}”…`, 'loading');
|
setStatus(`Queueing "${entry.title || 'Untitled'}"…`, 'loading');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/integrations/calibre-opds/import', {
|
const response = await fetch('/api/integrations/calibre-opds/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -265,7 +378,7 @@ if (browser) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadFeed = async ({ href = '', query = '' } = {}) => {
|
const loadFeed = async ({ href = '', query = '', activeTab = null, updateTabs = false } = {}) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (href) {
|
if (href) {
|
||||||
params.set('href', href);
|
params.set('href', href);
|
||||||
@@ -273,23 +386,59 @@ if (browser) {
|
|||||||
if (query) {
|
if (query) {
|
||||||
params.set('q', query);
|
params.set('q', query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestId = ++state.requestToken;
|
||||||
setStatus('Loading catalog…', 'loading');
|
setStatus('Loading catalog…', 'loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/api/integrations/calibre-opds/feed${params.toString() ? `?${params.toString()}` : ''}`;
|
const url = `/api/integrations/calibre-opds/feed${params.toString() ? `?${params.toString()}` : ''}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
|
if (requestId !== state.requestToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(payload.error || 'Unable to load the Calibre catalog.');
|
throw new Error(payload.error || 'Unable to load the Calibre catalog.');
|
||||||
}
|
}
|
||||||
const feed = payload.feed || {};
|
const feed = payload.feed || {};
|
||||||
|
state.currentHref = href;
|
||||||
state.query = query;
|
state.query = query;
|
||||||
if (searchInput && typeof query === 'string') {
|
if (typeof activeTab === 'string') {
|
||||||
searchInput.value = query;
|
state.activeTab = activeTab;
|
||||||
|
} else if (query) {
|
||||||
|
state.activeTab = TabIds.SEARCH;
|
||||||
|
} else if (href) {
|
||||||
|
state.activeTab = resolveTabIdForHref(href) || TabIds.CUSTOM;
|
||||||
|
} else {
|
||||||
|
state.activeTab = TabIds.ROOT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = query || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateTabs || !state.tabsReady) {
|
||||||
|
buildTabsFromFeed(feed);
|
||||||
|
} else {
|
||||||
|
renderTabs();
|
||||||
|
}
|
||||||
|
|
||||||
renderNav(feed.links);
|
renderNav(feed.links);
|
||||||
const stats = renderEntries(feed.entries || []);
|
const stats = renderEntries(feed.entries || []);
|
||||||
const books = stats?.[ENTRY_TYPES.BOOK] || 0;
|
const books = stats?.[EntryTypes.BOOK] || 0;
|
||||||
const views = stats?.[ENTRY_TYPES.NAVIGATION] || 0;
|
const views = stats?.[EntryTypes.NAVIGATION] || 0;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
if (books) {
|
||||||
|
setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${query}".`, 'success');
|
||||||
|
} else if (views) {
|
||||||
|
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${query}".`, 'info');
|
||||||
|
} else {
|
||||||
|
setStatus(`No results for "${query}".`, 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (books && views) {
|
if (books && views) {
|
||||||
setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success');
|
setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success');
|
||||||
} else if (books) {
|
} else if (books) {
|
||||||
@@ -297,9 +446,12 @@ if (browser) {
|
|||||||
} else if (views) {
|
} else if (views) {
|
||||||
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info');
|
setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info');
|
||||||
} else {
|
} else {
|
||||||
setStatus('No books found in this view.', 'info');
|
setStatus('No catalog entries found here yet.', 'info');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId !== state.requestToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error');
|
setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error');
|
||||||
renderEntries([]);
|
renderEntries([]);
|
||||||
if (navEl) {
|
if (navEl) {
|
||||||
@@ -308,20 +460,78 @@ if (browser) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openModal = (trigger) => {
|
||||||
|
if (isOpen) {
|
||||||
|
focusSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isOpen = true;
|
||||||
|
lastTrigger = trigger || null;
|
||||||
|
modal.hidden = false;
|
||||||
|
modal.dataset.open = 'true';
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
focusSearch();
|
||||||
|
loadFeed({ href: state.currentHref || '', query: state.query || '', activeTab: state.activeTab || TabIds.ROOT, updateTabs: !state.tabsReady });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isOpen = false;
|
||||||
|
modal.hidden = true;
|
||||||
|
delete modal.dataset.open;
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
if (lastTrigger instanceof HTMLElement) {
|
||||||
|
lastTrigger.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (event) => {
|
||||||
|
if (event.key === 'Escape' && isOpen) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
|
openButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
openModal(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
closeTargets.forEach((target) => {
|
||||||
|
target.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
closeModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.addEventListener('click', (event) => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (searchForm && searchInput) {
|
if (searchForm && searchInput) {
|
||||||
searchForm.addEventListener('submit', (event) => {
|
searchForm.addEventListener('submit', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const query = searchInput.value.trim();
|
const query = searchInput.value.trim();
|
||||||
loadFeed({ query });
|
if (!query) {
|
||||||
|
loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true });
|
||||||
|
} else {
|
||||||
|
loadFeed({ href: '', query, activeTab: TabIds.SEARCH });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refreshButton && searchInput) {
|
if (refreshButton && searchInput) {
|
||||||
refreshButton.addEventListener('click', () => {
|
refreshButton.addEventListener('click', () => {
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
loadFeed({});
|
loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFeed({});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,6 +342,292 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.find-books__body {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.find-books__body {
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.find-books__resources {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tile {
|
||||||
|
background: rgba(15, 23, 42, 0.32);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
box-shadow: 0 18px 40px rgba(8, 15, 32, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tile h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tile p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tile .button {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tile .hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.find-books__opds {
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.22);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
background: rgba(15, 23, 42, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.find-books__opds h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.find-books__opds .hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.find-books__opds .button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal {
|
||||||
|
width: min(1020px, 95vw);
|
||||||
|
max-height: 88vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__header {
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-search__field {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 280px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-search__field input[type="search"] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-search__field input[type="search"]::placeholder {
|
||||||
|
color: rgba(148, 163, 184, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-search__icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: rgba(148, 163, 184, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-search__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__close {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0 2rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__tabs.is-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-tab {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.95rem;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-tab:hover {
|
||||||
|
border-color: rgba(56, 189, 248, 0.4);
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-tab.is-active {
|
||||||
|
border-color: rgba(56, 189, 248, 0.6);
|
||||||
|
background: rgba(56, 189, 248, 0.18);
|
||||||
|
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-tab--search {
|
||||||
|
background: rgba(79, 70, 229, 0.16);
|
||||||
|
border-color: rgba(129, 140, 248, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__body {
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__status {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__status[data-state="loading"] {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__status[data-state="success"] {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__status[data-state="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__status[data-state="info"] {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__results-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-modal__results {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__entry {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
box-shadow: 0 14px 30px rgba(7, 14, 32, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__entry--navigation {
|
||||||
|
border-style: dashed;
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__entry-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__meta {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__summary {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__hint {
|
||||||
|
color: rgba(148, 163, 184, 0.75);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opds-browser__empty {
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.24);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(148, 163, 184, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
.reader-modal__body {
|
.reader-modal__body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
@@ -31,27 +31,55 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="find-books__opds">
|
||||||
|
<h2>Calibre catalog</h2>
|
||||||
|
<p class="hint">Wire abogen to your Calibre OPDS server to browse, search, and import titles without leaving the app.</p>
|
||||||
{% if not opds_available %}
|
{% if not opds_available %}
|
||||||
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
|
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="find-books__opds">
|
<button type="button" class="button" data-action="open-opds-modal">Browse Calibre catalog</button>
|
||||||
<h2>Calibre catalog</h2>
|
|
||||||
<p class="hint">Navigate Calibre shelves and queue downloadable titles directly into the conversion wizard.</p>
|
|
||||||
<div class="opds-browser" data-role="opds-browser">
|
|
||||||
<form class="opds-browser__search" data-role="opds-search">
|
|
||||||
<label for="opds-search-input">Search your catalog</label>
|
|
||||||
<input type="search" id="opds-search-input" name="q" placeholder="Title, author, or keyword">
|
|
||||||
<button type="submit" class="button">Search</button>
|
|
||||||
<button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button>
|
|
||||||
</form>
|
|
||||||
<div class="opds-browser__status" data-role="opds-status"></div>
|
|
||||||
<div class="opds-browser__nav" data-role="opds-nav"></div>
|
|
||||||
<ul class="opds-browser__results" data-role="opds-results"></ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if opds_available %}
|
||||||
|
<div class="modal" data-role="opds-modal" hidden>
|
||||||
|
<div class="modal__overlay" data-role="opds-modal-close" tabindex="-1"></div>
|
||||||
|
<div class="modal__content card card--modal opds-modal" role="dialog" aria-modal="true" aria-labelledby="opds-modal-title">
|
||||||
|
<header class="modal__header opds-modal__header">
|
||||||
|
<div class="modal__heading">
|
||||||
|
<p class="modal__eyebrow">Calibre OPDS</p>
|
||||||
|
<h2 class="modal__title" id="opds-modal-title">Browse your catalog</h2>
|
||||||
|
</div>
|
||||||
|
<form class="opds-modal__search" data-role="opds-search">
|
||||||
|
<label class="visually-hidden" for="opds-search-input">Search your Calibre catalog</label>
|
||||||
|
<div class="opds-search__field">
|
||||||
|
<svg class="opds-search__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M15.5 14h-.79l-.28-.27A6.5 6.5 0 0016 9.5 6.5 6.5 0 109.5 16a6.5 6.5 0 004.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1114 9.5 4.5 4.5 0 019.5 14z" />
|
||||||
|
</svg>
|
||||||
|
<input type="search" id="opds-search-input" name="q" placeholder="Search by title, author, or keyword" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="opds-search__actions">
|
||||||
|
<button type="submit" class="button">Search</button>
|
||||||
|
<button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<button type="button" class="button button--ghost opds-modal__close" data-role="opds-modal-close">Close</button>
|
||||||
|
</header>
|
||||||
|
<div class="opds-modal__tabs" data-role="opds-tabs"></div>
|
||||||
|
<div class="modal__body opds-modal__body">
|
||||||
|
<div class="opds-browser" data-role="opds-browser">
|
||||||
|
<div class="opds-modal__status" data-role="opds-status"></div>
|
||||||
|
<div class="opds-modal__nav" data-role="opds-nav"></div>
|
||||||
|
<div class="opds-modal__results-wrap">
|
||||||
|
<ul class="opds-modal__results" data-role="opds-results"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
Reference in New Issue
Block a user