From fec9d31c8a45bf11a9210ec76e2f8b189b2f7eb8 Mon Sep 17 00:00:00 2001
From: Alex <25013571+alexhb1@users.noreply.github.com>
Date: Sun, 15 Mar 2026 10:17:37 +0000
Subject: [PATCH] Frontend improvements for Audible provider (#769)
- Added square artwork support
- Added dedicated length and narrator icons
- Added audiobook info to release and details modals
- Moved search options button to accommodate larger Audible search
fields
---
shelfmark/metadata_providers/__init__.py | 3 +
shelfmark/metadata_providers/audible.py | 19 +--
src/frontend/src/components/DetailsModal.tsx | 35 ++---
src/frontend/src/components/ReleaseModal.tsx | 46 +++++--
src/frontend/src/components/SearchBar.tsx | 124 +++++++++++-------
.../src/components/resultsViews/CardView.tsx | 4 +-
.../components/resultsViews/CompactView.tsx | 12 +-
.../src/components/resultsViews/ListView.tsx | 40 +++---
.../components/shared/DisplayFieldIcon.tsx | 12 ++
src/frontend/src/types/index.ts | 1 +
src/frontend/src/utils/bookTransformers.ts | 2 +
11 files changed, 185 insertions(+), 113 deletions(-)
diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py
index 5d61ede..95beb53 100644
--- a/shelfmark/metadata_providers/__init__.py
+++ b/shelfmark/metadata_providers/__init__.py
@@ -194,6 +194,9 @@ class BookMetadata:
search_title: Optional[str] = None # Cleaner title for search queries (provider-specific)
search_author: Optional[str] = None # Cleaner author for search queries (provider-specific)
+ # Cover aspect ratio hint for the frontend ("portrait" or "square")
+ cover_aspect: Optional[str] = None
+
# Provider-specific display fields for cards/lists
display_fields: List[DisplayField] = field(default_factory=list)
diff --git a/shelfmark/metadata_providers/audible.py b/shelfmark/metadata_providers/audible.py
index ee99c67..a07f55e 100644
--- a/shelfmark/metadata_providers/audible.py
+++ b/shelfmark/metadata_providers/audible.py
@@ -270,6 +270,13 @@ class AudibleProvider(MetadataProvider):
label="Author",
description="Search Audible by author name",
),
+ TextSearchField(
+ key="series",
+ label="Series",
+ description="Browse a series in reading order",
+ suggestions_endpoint="/api/metadata/field-options?provider=audible&field=series",
+ suggestions_min_query_length=2,
+ ),
TextSearchField(
key="title",
label="Title",
@@ -290,13 +297,6 @@ class AudibleProvider(MetadataProvider):
label="Keywords",
description="Search Audible by keywords",
),
- TextSearchField(
- key="series",
- label="Series",
- description="Browse a series in reading order",
- suggestions_endpoint="/api/metadata/field-options?provider=audible&field=series",
- suggestions_min_query_length=2,
- ),
]
capabilities = [
MetadataCapability(
@@ -764,12 +764,12 @@ class AudibleProvider(MetadataProvider):
runtime_value = _format_runtime(item.get("lengthMinutes"))
if runtime_value:
- display_fields.append(DisplayField(label="Length", value=runtime_value, icon="book"))
+ display_fields.append(DisplayField(label="Length", value=runtime_value, icon="clock"))
narrator_value = _format_narrator_value(narrators)
if narrator_value:
label = "Narrator" if len(narrators) == 1 else "Narrators"
- display_fields.append(DisplayField(label=label, value=narrator_value, icon="users"))
+ display_fields.append(DisplayField(label=label, value=narrator_value, icon="microphone"))
book_format = str(item.get("bookFormat") or "").strip()
if book_format:
@@ -786,6 +786,7 @@ class AudibleProvider(MetadataProvider):
isbn_10=isbn_10,
isbn_13=isbn_13,
cover_url=str(item.get("imageUrl") or "").strip() or None,
+ cover_aspect="square",
description=_sanitize_description(item.get("description"))
or _sanitize_description(item.get("summary")),
publisher=str(item.get("publisher") or "").strip() or None,
diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx
index 1b92c89..da7d45c 100644
--- a/src/frontend/src/components/DetailsModal.tsx
+++ b/src/frontend/src/components/DetailsModal.tsx
@@ -128,8 +128,11 @@ export const DetailsModal = ({
// Use provider display name from backend, fall back to capitalized provider name
const providerDisplay = book.provider_display_name
|| (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : '');
+ const isSquareCover = book.cover_aspect === 'square';
const artworkMaxHeight = 'calc(90vh - 220px)';
- const artworkMaxWidth = 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
+ const artworkMaxWidth = isSquareCover
+ ? 'min(45vw, 400px, calc(90vh - 220px))'
+ : 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
const additionalInfo =
book.info && Object.keys(book.info).length > 0
? Object.entries(book.info).filter(([key]) => {
@@ -251,29 +254,15 @@ export const DetailsModal = ({
)}
-
- {/* Other display fields (pages, editions) - Universal mode only */}
- {otherDisplayFields && otherDisplayFields.length > 0 && (
-
- {otherDisplayFields.map(field => (
-
- {field.icon === 'book' && (
-
- )}
- {field.icon === 'editions' && (
-
- )}
- {field.label}:
- {field.value}
-
- ))}
-
- )}
+ {/* Other display fields (length, narrator, format, etc.) - Universal mode only */}
+ {otherDisplayFields && otherDisplayFields.map(field => (
+
+
{field.label}
+
{field.value}
+
+ ))}
+
{/* ISBN - Universal mode only */}
{isMetadata && (book.isbn_13 || book.isbn_10) && (
diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx
index c8933b2..e2113e5 100644
--- a/src/frontend/src/components/ReleaseModal.tsx
+++ b/src/frontend/src/components/ReleaseModal.tsx
@@ -1196,8 +1196,10 @@ export const ReleaseModal = ({
const ratingsField = book.display_fields.find(f => f.icon === 'ratings');
const usersField = book.display_fields.find(f => f.icon === 'users');
const pagesField = book.display_fields.find(f => f.icon === 'book');
+ const lengthField = book.display_fields.find(f => f.icon === 'clock');
+ const narratorField = book.display_fields.find(f => f.icon === 'microphone');
- return { starField, ratingsField, usersField, pagesField };
+ return { starField, ratingsField, usersField, pagesField, lengthField, narratorField };
}, [book?.display_fields]);
const getReleaseActionMode = useCallback(
@@ -1318,15 +1320,15 @@ export const ReleaseModal = ({
) : (
No cover
@@ -1338,7 +1340,7 @@ export const ReleaseModal = ({
@@ -1350,15 +1352,15 @@ export const ReleaseModal = ({

) : (
No cover
@@ -1402,14 +1404,14 @@ export const ReleaseModal = ({

) : (
-
+
No cover
)}
-
+
{/* Metadata row */}
{book.year &&
{book.year}}
@@ -1433,6 +1435,22 @@ export const ReleaseModal = ({
{displayFields?.pagesField && (
{displayFields.pagesField.value} pages
)}
+ {displayFields?.lengthField && (
+
+
+ {displayFields.lengthField.value}
+
+ )}
+ {displayFields?.narratorField && (
+
+
+ {displayFields.narratorField.value}
+
+ )}
{/* Series info */}
@@ -1494,7 +1512,7 @@ export const ReleaseModal = ({
)}
{/* Links row */}
-
+
{(book.isbn_13 || book.isbn_10) && (
ISBN: {book.isbn_13 || book.isbn_10}
diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx
index ea69942..046b264 100644
--- a/src/frontend/src/components/SearchBar.tsx
+++ b/src/frontend/src/components/SearchBar.tsx
@@ -651,14 +651,48 @@ export const SearchBar = forwardRef(({
{showContentTypeSelector && (
-
- Content
+
+
Content
+ {onAdvancedToggle && (
+
+ )}
)}
-
-
- Search By
+
+
+
Search By
+ {!showContentTypeSelector && onAdvancedToggle && (
+
{
+ setIsSelectorOpen(false);
+ onAdvancedToggle();
+ }}
+ className={`flex items-center gap-1.5 px-4 py-2.5 -mt-1.5 -mb-0.5 -mr-1 text-xs font-medium rounded-xl transition-colors ${
+ isAdvancedActive
+ ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
+ : 'hover-surface'
+ }`}
+ style={isAdvancedActive
+ ? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
+ : { color: 'var(--text-muted)' }}
+ >
+
+ Options
+
+ )}
{queryTargets.map((target) => {
@@ -699,7 +767,7 @@ export const SearchBar = forwardRef(({
onClick={() => handleQueryTargetSelect(target.key)}
title={target.description || target.label}
aria-label={target.label}
- className={`min-w-0 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors flex items-center gap-2 ${
+ className={`min-w-0 rounded-xl border px-3 py-2 text-sm font-medium transition-colors flex items-center gap-2 ${
isActive ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` : 'hover-surface'
}`}
style={isActive
@@ -714,46 +782,6 @@ export const SearchBar = forwardRef(({
- {onAdvancedToggle && (
-
-
{
- setIsSelectorOpen(false);
- onAdvancedToggle();
- }}
- className={`w-full px-3 py-2.5 text-sm font-medium rounded-xl transition-colors flex items-center justify-center gap-2 ${
- isAdvancedActive
- ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white`
- : 'hover-surface'
- }`}
- style={isAdvancedActive
- ? { borderColor: searchMode === 'direct' ? 'rgb(3 105 161 / 0.7)' : 'rgb(16 185 129 / 0.7)' }
- : { color: 'var(--text-muted)' }}
- >
- {isAdvancedActive ? (
-
- ) : (
-
- )}
- Options & Filters
-
-
- )}
)}
diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx
index 1a85c89..7d0666a 100644
--- a/src/frontend/src/components/resultsViews/CardView.tsx
+++ b/src/frontend/src/components/resultsViews/CardView.tsx
@@ -62,7 +62,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
-
+
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
@@ -156,7 +156,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
{book.year || '-'}
•
-
+ f.icon !== 'editions')} />
) : (
diff --git a/src/frontend/src/components/resultsViews/CompactView.tsx b/src/frontend/src/components/resultsViews/CompactView.tsx
index 04b996c..2d95ee7 100644
--- a/src/frontend/src/components/resultsViews/CompactView.tsx
+++ b/src/frontend/src/components/resultsViews/CompactView.tsx
@@ -4,7 +4,7 @@ import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
-import { DisplayFieldBadges } from '../shared';
+import { DisplayFieldBadges, DisplayFieldIcon } from '../shared';
const SkeletonLoader = () => (
@@ -159,7 +159,15 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
-
+ <>
+
f.icon !== 'editions' && f.icon !== 'microphone')} className="text-xs opacity-70" />
+ {book.display_fields.find(f => f.icon === 'microphone') && (
+
+
+ {book.display_fields.find(f => f.icon === 'microphone')!.value}
+
+ )}
+ >
) : (
{book.language || '-'}
diff --git a/src/frontend/src/components/resultsViews/ListView.tsx b/src/frontend/src/components/resultsViews/ListView.tsx
index 4826e5f..bce9eab 100644
--- a/src/frontend/src/components/resultsViews/ListView.tsx
+++ b/src/frontend/src/components/resultsViews/ListView.tsx
@@ -18,14 +18,16 @@ interface ListViewProps {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
-const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
+const ListViewThumbnail = ({ preview, title, coverAspect }: { preview?: string; title?: string; coverAspect?: string }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
+ const isSquare = coverAspect === 'square';
+ const sizeClass = isSquare ? 'w-10 h-10 sm:w-14 sm:h-14' : 'w-7 h-10 sm:w-10 sm:h-14';
if (!preview || imageError) {
return (
No Cover
@@ -34,14 +36,14 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
}
return (
-
+
{!imageLoaded && (
)}

setImageLoaded(true)}
onError={() => setImageError(true)}
@@ -116,12 +118,12 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Universal mode uses separate columns for each display field, direct mode uses language/format/size */}
{/* Thumbnail */}
-
+
{/* Title and Author */}
@@ -149,7 +151,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Mobile universal mode info */}
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
- book.display_fields.slice(0, 2).map((field, idx) => (
+ book.display_fields.filter(f => f.icon !== 'editions').slice(0, 2).map((field, idx) => (
{field.value}
@@ -171,18 +173,26 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Universal mode: Display fields as separate columns - Desktop only */}
{searchMode === 'universal' && (
<>
- {/* First display field column */}
-
- {book.display_fields && book.display_fields[0] ? (
-
+ {/* Rating column */}
+
+ {book.display_fields?.find(f => f.icon === 'star') ? (
+ f.icon === 'star')!} />
) : (
-
)}
- {/* Second display field column */}
-
- {book.display_fields && book.display_fields[1] ? (
-
+ {/* Length column */}
+
+ {book.display_fields?.find(f => f.icon === 'clock' || f.icon === 'book') ? (
+ f.icon === 'clock' || f.icon === 'book')!} />
+ ) : (
+ -
+ )}
+
+ {/* Narrator column */}
+
+ {book.display_fields?.find(f => f.icon === 'microphone') ? (
+
f.icon === 'microphone')!} />
) : (
-
)}
diff --git a/src/frontend/src/components/shared/DisplayFieldIcon.tsx b/src/frontend/src/components/shared/DisplayFieldIcon.tsx
index 21620b8..116a20f 100644
--- a/src/frontend/src/components/shared/DisplayFieldIcon.tsx
+++ b/src/frontend/src/components/shared/DisplayFieldIcon.tsx
@@ -25,6 +25,18 @@ export function DisplayFieldIcon({ icon, className = 'w-3 h-3' }: DisplayFieldIc
);
+ case 'microphone':
+ return (
+
+ );
+ case 'clock':
+ return (
+
+ );
case 'editions':
return (