feat: Revamp Calibre OPDS integration with improved UI, search functionality, and modal handling

This commit is contained in:
JB
2025-10-29 06:52:23 -07:00
parent eb4704bc26
commit cf6ccf0171
3 changed files with 624 additions and 100 deletions
+291 -81
View File
@@ -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,34 +53,152 @@ 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 (
const rel = (link.rel || '').toLowerCase(); candidates.find((link) => {
const type = (link.type || '').toLowerCase(); const rel = (link.rel || '').toLowerCase();
if (!link.href) { const type = (link.type || '').toLowerCase();
if (!link.href) {
return false;
}
if (rel.includes('acquisition')) {
return false;
}
if (rel === 'self') {
return false;
}
if (type.includes('opds-catalog')) {
return true;
}
if (rel.includes('subsection') || rel.includes('collection')) {
return true;
}
if (rel.startsWith('http://opds-spec.org/sort') || rel.startsWith('http://opds-spec.org/group')) {
return true;
}
return false; return false;
}) || 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 (rel.includes('acquisition')) { if (seen.has(navLink.href)) {
return false; return;
} }
if (rel === 'self') { seen.add(navLink.href);
return false; 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 (type.includes('opds-catalog')) { if (state.activeTab === tab.id || (tab.id !== TabIds.SEARCH && state.activeTab === tab.href)) {
return true; button.classList.add('is-active');
} }
if (rel.includes('subsection') || rel.includes('collection')) { button.textContent = tab.label;
return true; button.addEventListener('click', () => {
} if (tab.id === TabIds.SEARCH) {
if (rel.startsWith('http://opds-spec.org/sort') || rel.startsWith('http://opds-spec.org/group')) { loadFeed({ href: '', query: state.query, activeTab: TabIds.SEARCH });
return true; return;
} }
return false; if (tab.id === TabIds.ROOT) {
}) || null; 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({});
} }
+286
View File
@@ -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;
+47 -19
View File
@@ -31,27 +31,55 @@
</article> </article>
</div> </div>
{% if not opds_available %} <div class="find-books__opds">
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div> <h2>Calibre catalog</h2>
{% else %} <p class="hint">Wire abogen to your Calibre OPDS server to browse, search, and import titles without leaving the app.</p>
<div class="find-books__opds"> {% if not opds_available %}
<h2>Calibre catalog</h2> <div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
<p class="hint">Navigate Calibre shelves and queue downloadable titles directly into the conversion wizard.</p> {% else %}
<div class="opds-browser" data-role="opds-browser"> <button type="button" class="button" data-action="open-opds-modal">Browse Calibre catalog</button>
<form class="opds-browser__search" data-role="opds-search"> {% endif %}
<label for="opds-search-input">Search your catalog</label> </div>
<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 %}
</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 %}