feat: Add bottom navigation for OPDS modal and enhance styling for better visibility

This commit is contained in:
JB
2025-11-28 12:13:37 -08:00
parent 775e05f94c
commit 7cc60aacf6
4 changed files with 83 additions and 33 deletions
+32 -5
View File
@@ -6,8 +6,8 @@ import re
from collections import deque
from dataclasses import dataclass, field
from pathlib import PurePosixPath
from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set
from urllib.parse import urljoin, urlparse
from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set, Tuple
from urllib.parse import quote, urljoin, urlparse
from xml.etree import ElementTree as ET
import httpx
@@ -202,13 +202,26 @@ class CalibreOPDSClient:
cleaned = (query or "").strip()
if not cleaned:
return self.fetch_feed()
candidates = [
base_feed: Optional[OPDSFeed] = None
try:
base_feed = self.fetch_feed()
except CalibreOPDSError:
pass
candidates: List[Tuple[Optional[str], Optional[Mapping[str, Any]]]] = []
if base_feed:
search_url = self._resolve_search_url(base_feed, cleaned)
if search_url:
candidates.append((search_url, None))
candidates.extend([
("search", {"query": cleaned}),
("search", {"q": cleaned}),
(None, {"search": cleaned}),
]
])
last_error: Optional[Exception] = None
base_feed: Optional[OPDSFeed] = None
base_combined: Optional[OPDSFeed] = None
for path, params in candidates:
try:
@@ -1037,6 +1050,20 @@ class CalibreOPDSClient:
return dataclasses.replace(template, entries=collected)
return dataclasses.replace(template, entries=[])
def _resolve_search_url(self, feed: OPDSFeed, query: str) -> Optional[str]:
link = self._resolve_link(feed.links, "search")
if not link:
link = self._resolve_link(feed.links, "http://opds-spec.org/search")
if not link or not link.href:
return None
href = link.href.strip()
if "{searchTerms}" in href:
return href.replace("{searchTerms}", quote(query))
return href
def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
"""Helper used by APIs to convert a feed into JSON-serialisable payloads."""
+23 -16
View File
@@ -5,6 +5,7 @@ if (modal && browser) {
const statusEl = browser.querySelector('[data-role="opds-status"]');
const resultsEl = browser.querySelector('[data-role="opds-results"]');
const navEl = browser.querySelector('[data-role="opds-nav"]');
const navBottomEl = browser.querySelector('[data-role="opds-nav-bottom"]');
const alphaPickerEl = browser.querySelector('[data-role="opds-alpha-picker"]');
const tabsEl = modal.querySelector('[data-role="opds-tabs"]');
const searchForm = modal.querySelector('[data-role="opds-search"]');
@@ -247,17 +248,10 @@ if (modal && browser) {
if (!alphaPickerEl || state.query) {
return false;
}
if (!Array.isArray(entries) || entries.length < 6) {
if (!Array.isArray(entries) || entries.length === 0) {
return false;
}
const mode = deriveBrowseMode();
const counts = stats || computeEntryStats(entries);
const hasNavigation = counts[EntryTypes.NAVIGATION] > 0;
if (mode === 'generic' && !hasNavigation) {
return false;
}
const alphabetCounts = collectAlphabetCounts(entries);
return alphabetCounts.size > 1;
return true;
};
const refreshAlphabetActiveState = () => {
@@ -542,10 +536,14 @@ if (modal && browser) {
};
const renderNav = (links) => {
if (!navEl) {
const targets = [navEl, navBottomEl].filter(Boolean);
if (!targets.length) {
return;
}
navEl.innerHTML = '';
targets.forEach((el) => {
el.innerHTML = '';
});
const descriptors = [
{ key: 'up', label: 'Up one level' },
{ key: 'previous', label: 'Previous page' },
@@ -556,11 +554,18 @@ if (modal && browser) {
return;
}
const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`);
const hasLink = Boolean(link && link.href);
if (!hasLink && key !== 'previous') {
return;
}
targets.forEach((targetEl) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'button button--ghost';
button.textContent = label;
const hasLink = Boolean(link && link.href);
if (hasLink) {
button.addEventListener('click', () => {
const targetQuery = key === 'up' ? '' : state.query;
@@ -570,12 +575,14 @@ if (modal && browser) {
} else if (key === 'previous') {
button.disabled = true;
button.setAttribute('aria-disabled', 'true');
} else {
return;
}
navEl.appendChild(button);
targetEl.appendChild(button);
});
});
targets.forEach((el) => {
el.hidden = !el.childElementCount;
});
navEl.hidden = !navEl.childElementCount;
};
const createEntry = (entry) => {
+16 -1
View File
@@ -654,7 +654,22 @@ body {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.35rem 0 0.65rem;
margin: 0;
padding: 0.75rem 0 1rem;
position: sticky;
top: 0;
z-index: 20;
background: var(--bg);
box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.2);
margin-bottom: 1rem;
border-bottom: 1px solid var(--panel-border);
}
.opds-modal__nav--bottom {
margin-top: 1rem;
padding-top: 1.5rem;
border-top: 1px solid var(--panel-border);
justify-content: center;
}
.opds-alpha-picker__button {
+1
View File
@@ -76,6 +76,7 @@
<div class="opds-modal__results-wrap">
<ul class="opds-modal__results" data-role="opds-results"></ul>
</div>
<div class="opds-modal__nav opds-modal__nav--bottom" data-role="opds-nav-bottom"></div>
</div>
</div>
</div>