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 collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set, Tuple
from urllib.parse import urljoin, urlparse from urllib.parse import quote, urljoin, urlparse
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
import httpx import httpx
@@ -202,13 +202,26 @@ class CalibreOPDSClient:
cleaned = (query or "").strip() cleaned = (query or "").strip()
if not cleaned: if not cleaned:
return self.fetch_feed() 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", {"query": cleaned}),
("search", {"q": cleaned}), ("search", {"q": cleaned}),
(None, {"search": cleaned}), (None, {"search": cleaned}),
] ])
last_error: Optional[Exception] = None last_error: Optional[Exception] = None
base_feed: Optional[OPDSFeed] = None
base_combined: Optional[OPDSFeed] = None base_combined: Optional[OPDSFeed] = None
for path, params in candidates: for path, params in candidates:
try: try:
@@ -1037,6 +1050,20 @@ class CalibreOPDSClient:
return dataclasses.replace(template, entries=collected) return dataclasses.replace(template, entries=collected)
return dataclasses.replace(template, entries=[]) 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]: def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]:
"""Helper used by APIs to convert a feed into JSON-serialisable payloads.""" """Helper used by APIs to convert a feed into JSON-serialisable payloads."""
+34 -27
View File
@@ -5,6 +5,7 @@ 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 navBottomEl = browser.querySelector('[data-role="opds-nav-bottom"]');
const alphaPickerEl = browser.querySelector('[data-role="opds-alpha-picker"]'); const alphaPickerEl = browser.querySelector('[data-role="opds-alpha-picker"]');
const tabsEl = modal.querySelector('[data-role="opds-tabs"]'); const tabsEl = modal.querySelector('[data-role="opds-tabs"]');
const searchForm = modal.querySelector('[data-role="opds-search"]'); const searchForm = modal.querySelector('[data-role="opds-search"]');
@@ -247,17 +248,10 @@ if (modal && browser) {
if (!alphaPickerEl || state.query) { if (!alphaPickerEl || state.query) {
return false; return false;
} }
if (!Array.isArray(entries) || entries.length < 6) { if (!Array.isArray(entries) || entries.length === 0) {
return false; return false;
} }
const mode = deriveBrowseMode(); return true;
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;
}; };
const refreshAlphabetActiveState = () => { const refreshAlphabetActiveState = () => {
@@ -542,10 +536,14 @@ if (modal && browser) {
}; };
const renderNav = (links) => { const renderNav = (links) => {
if (!navEl) { const targets = [navEl, navBottomEl].filter(Boolean);
if (!targets.length) {
return; return;
} }
navEl.innerHTML = ''; targets.forEach((el) => {
el.innerHTML = '';
});
const descriptors = [ const descriptors = [
{ key: 'up', label: 'Up one level' }, { key: 'up', label: 'Up one level' },
{ key: 'previous', label: 'Previous page' }, { key: 'previous', label: 'Previous page' },
@@ -556,26 +554,35 @@ if (modal && browser) {
return; return;
} }
const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`); const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`);
const button = document.createElement('button');
button.type = 'button';
button.className = 'button button--ghost';
button.textContent = label;
const hasLink = Boolean(link && link.href); const hasLink = Boolean(link && link.href);
if (hasLink) {
button.addEventListener('click', () => { if (!hasLink && key !== 'previous') {
const targetQuery = key === 'up' ? '' : state.query;
const tabId = resolveTabIdForHref(link.href);
loadFeed({ href: link.href, query: targetQuery, activeTab: tabId || (targetQuery ? TabIds.SEARCH : TabIds.CUSTOM) });
});
} else if (key === 'previous') {
button.disabled = true;
button.setAttribute('aria-disabled', 'true');
} else {
return; return;
} }
navEl.appendChild(button);
targets.forEach((targetEl) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'button button--ghost';
button.textContent = label;
if (hasLink) {
button.addEventListener('click', () => {
const targetQuery = key === 'up' ? '' : state.query;
const tabId = resolveTabIdForHref(link.href);
loadFeed({ href: link.href, query: targetQuery, activeTab: tabId || (targetQuery ? TabIds.SEARCH : TabIds.CUSTOM) });
});
} else if (key === 'previous') {
button.disabled = true;
button.setAttribute('aria-disabled', 'true');
}
targetEl.appendChild(button);
});
});
targets.forEach((el) => {
el.hidden = !el.childElementCount;
}); });
navEl.hidden = !navEl.childElementCount;
}; };
const createEntry = (entry) => { const createEntry = (entry) => {
+16 -1
View File
@@ -654,7 +654,22 @@ body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.35rem; 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 { .opds-alpha-picker__button {
+1
View File
@@ -76,6 +76,7 @@
<div class="opds-modal__results-wrap"> <div class="opds-modal__results-wrap">
<ul class="opds-modal__results" data-role="opds-results"></ul> <ul class="opds-modal__results" data-role="opds-results"></ul>
</div> </div>
<div class="opds-modal__nav opds-modal__nav--bottom" data-role="opds-nav-bottom"></div>
</div> </div>
</div> </div>
</div> </div>