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
This commit is contained in:
Alex
2026-03-15 10:17:37 +00:00
committed by GitHub
parent 3295be82a7
commit fec9d31c8a
11 changed files with 185 additions and 113 deletions

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 = ({
</p>
</div>
)}
</div>
{/* Other display fields (pages, editions) - Universal mode only */}
{otherDisplayFields && otherDisplayFields.length > 0 && (
<div className="flex flex-wrap gap-4 text-sm">
{otherDisplayFields.map(field => (
<span key={field.label} className="flex items-center gap-1.5">
{field.icon === 'book' && (
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
)}
{field.icon === 'editions' && (
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.878V6a2.25 2.25 0 0 1 2.25-2.25h7.5A2.25 2.25 0 0 1 18 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 0 0 4.5 9v.878m13.5-3A2.25 2.25 0 0 1 19.5 9v.878m0 0a2.246 2.246 0 0 0-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0 1 21 12v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6c0-.98.626-1.813 1.5-2.122" />
</svg>
)}
<span className="text-gray-500 dark:text-gray-400">{field.label}:</span>
<span className="text-gray-900 dark:text-gray-100">{field.value}</span>
</span>
))}
</div>
)}
{/* Other display fields (length, narrator, format, etc.) - Universal mode only */}
{otherDisplayFields && otherDisplayFields.map(field => (
<div key={field.label} className={`${infoCardClass} space-y-1`}>
<p className={infoLabelClass}>{field.label}</p>
<p className={infoValueClass}>{field.value}</p>
</div>
))}
</div>
{/* ISBN - Universal mode only */}
{isMetadata && (book.isbn_13 || book.isbn_10) && (

View File

@@ -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 = ({
<img
src={book.preview}
alt=""
width={46}
width={book.cover_aspect === 'square' ? 68 : 46}
height={68}
className="rounded-sm shadow-md object-cover object-top"
style={{ width: 46, height: 68, minWidth: 46 }}
className={`rounded-sm shadow-md object-cover ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
style={{ width: book.cover_aspect === 'square' ? 68 : 46, height: 68, minWidth: book.cover_aspect === 'square' ? 68 : 46 }}
/>
) : (
<div
className="rounded-sm border border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
style={{ width: 46, height: 68, minWidth: 46 }}
style={{ width: book.cover_aspect === 'square' ? 68 : 46, height: 68, minWidth: book.cover_aspect === 'square' ? 68 : 46 }}
>
No cover
</div>
@@ -1338,7 +1340,7 @@ export const ReleaseModal = ({
<div
className="hidden sm:block shrink-0 overflow-hidden transition-[width,margin] duration-300 ease-out"
style={{
width: showHeaderThumb ? 46 : 0,
width: showHeaderThumb ? (book.cover_aspect === 'square' ? 68 : 46) : 0,
marginRight: showHeaderThumb ? 0 : -12,
}}
>
@@ -1350,15 +1352,15 @@ export const ReleaseModal = ({
<img
src={book.preview}
alt=""
width={46}
width={book.cover_aspect === 'square' ? 68 : 46}
height={68}
className="rounded-sm shadow-md object-cover object-top"
style={{ width: 46, height: 68, minWidth: 46 }}
className={`rounded-sm shadow-md object-cover ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
style={{ width: book.cover_aspect === 'square' ? 68 : 46, height: 68, minWidth: book.cover_aspect === 'square' ? 68 : 46 }}
/>
) : (
<div
className="rounded-sm border border-dashed border-(--border-muted) bg-(--bg)/60 flex items-center justify-center text-[7px] text-zinc-500"
style={{ width: 46, height: 68, minWidth: 46 }}
style={{ width: book.cover_aspect === 'square' ? 68 : 46, height: 68, minWidth: book.cover_aspect === 'square' ? 68 : 46 }}
>
No cover
</div>
@@ -1402,14 +1404,14 @@ export const ReleaseModal = ({
<img
src={book.preview}
alt="Book cover"
className={`hidden sm:block rounded-lg shadow-md object-cover object-top shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}
className={`hidden sm:block rounded-lg shadow-md object-cover shrink-0 ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'} ${book.cover_aspect === 'square' ? (book.series_name ? 'w-[144px] h-[144px]' : 'w-[120px] h-[120px]') : (book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]')}`}
/>
) : (
<div className={`hidden sm:flex rounded-lg border border-dashed border-(--border-muted) bg-(--bg)/60 items-center justify-center text-[10px] text-zinc-500 shrink-0 ${book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]'}`}>
<div className={`hidden sm:flex rounded-lg border border-dashed border-(--border-muted) bg-(--bg)/60 items-center justify-center text-[10px] text-zinc-500 shrink-0 ${book.cover_aspect === 'square' ? (book.series_name ? 'w-[144px] h-[144px]' : 'w-[120px] h-[120px]') : (book.series_name ? 'w-24 h-[144px]' : 'w-20 h-[120px]')}`}>
No cover
</div>
)}
<div className="flex-1 min-w-0 space-y-2">
<div className="flex-1 min-w-0 flex flex-col gap-2">
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-zinc-600 dark:text-zinc-400">
{book.year && <span>{book.year}</span>}
@@ -1433,6 +1435,22 @@ export const ReleaseModal = ({
{displayFields?.pagesField && (
<span>{displayFields.pagesField.value} pages</span>
)}
{displayFields?.lengthField && (
<span className="flex items-center gap-1">
<svg className="h-3.5 w-3.5 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{displayFields.lengthField.value}
</span>
)}
{displayFields?.narratorField && (
<span className="flex items-center gap-1">
<svg className="h-3.5 w-3.5 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
</svg>
{displayFields.narratorField.value}
</span>
)}
</div>
{/* Series info */}
@@ -1494,7 +1512,7 @@ export const ReleaseModal = ({
)}
{/* Links row */}
<div className="flex flex-wrap items-center gap-3 text-xs">
<div className="flex flex-wrap items-center gap-3 text-xs mt-auto">
{(book.isbn_13 || book.isbn_10) && (
<span className="text-zinc-500 dark:text-zinc-400">
ISBN: {book.isbn_13 || book.isbn_10}

View File

@@ -651,14 +651,48 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<div className="max-h-[min(24rem,calc(100vh-8rem))] overflow-y-auto p-3">
{showContentTypeSelector && (
<div className="border-b pb-3" style={{ borderColor: 'var(--border-muted)' }}>
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
Content
<div className="flex items-center justify-between px-1 pb-2">
<span className="text-xs font-medium uppercase tracking-wide opacity-60">Content</span>
{onAdvancedToggle && (
<button
type="button"
onClick={() => {
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)' }}
>
<svg
className="w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
Options
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleContentTypeSelect('ebook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'ebook' ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'ebook'
@@ -671,7 +705,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
<button
type="button"
onClick={() => handleContentTypeSelect('audiobook')}
className={`flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium transition-colors ${
contentType === 'audiobook' ? 'bg-emerald-600 text-white' : 'hover-surface'
}`}
style={contentType !== 'audiobook'
@@ -685,9 +719,43 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
</div>
)}
<div className={showContentTypeSelector ? 'pt-3' : ''}>
<div className="px-1 pb-2 text-xs font-medium uppercase tracking-wide opacity-60">
Search By
<div className={showContentTypeSelector ? 'pt-2' : ''}>
<div className="flex items-center justify-between px-1 pb-1.5">
<span className="text-xs font-medium uppercase tracking-wide opacity-60">Search By</span>
{!showContentTypeSelector && onAdvancedToggle && (
<button
type="button"
onClick={() => {
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)' }}
>
<svg
className="w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
Options
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{queryTargets.map((target) => {
@@ -699,7 +767,7 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
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<SearchBarHandle, SearchBarProps>(({
</div>
</div>
{onAdvancedToggle && (
<div className="border-t pt-3 mt-3" style={{ borderColor: 'var(--border-muted)' }}>
<button
type="button"
onClick={() => {
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 ? (
<CheckIcon />
) : (
<svg
className="w-4 h-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
)}
Options & Filters
</button>
</div>
)}
</div>
</div>
)}

View File

@@ -62,7 +62,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:shrink-0" style={{ aspectRatio: '2/3' }}>
<div className="relative w-full sm:w-full max-sm:w-[120px] max-sm:h-full max-sm:shrink-0" style={{ aspectRatio: book.cover_aspect === 'square' ? '1/1' : '2/3' }}>
<div className="absolute inset-0 overflow-hidden sm:rounded-t-[.75rem] max-sm:rounded-l-[.75rem]">
{/* Series position badge */}
{showSeriesPosition && book.series_position != null && (
@@ -156,7 +156,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span></span>
<DisplayFieldBadges fields={book.display_fields} />
<DisplayFieldBadges fields={book.display_fields.filter(f => f.icon !== 'editions')} />
</div>
) : (
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">

View File

@@ -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 = () => (
<div className="w-full h-full bg-linear-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
@@ -159,7 +159,15 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
<div className="mt-auto flex flex-col gap-2">
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
<DisplayFieldBadges fields={book.display_fields} className="text-xs opacity-70" />
<>
<DisplayFieldBadges fields={book.display_fields.filter(f => f.icon !== 'editions' && f.icon !== 'microphone')} className="text-xs opacity-70" />
{book.display_fields.find(f => f.icon === 'microphone') && (
<div className="flex items-center gap-0.5 text-xs opacity-70">
<DisplayFieldIcon icon="microphone" />
<span>{book.display_fields.find(f => f.icon === 'microphone')!.value}</span>
</div>
)}
</>
) : (
<div className="text-xs opacity-70 flex flex-wrap gap-1">
<span>{book.language || '-'}</span>

View File

@@ -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 (
<div
className="w-7 h-10 sm:w-10 sm:h-14 rounded-sm bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300"
className={`${sizeClass} rounded-sm bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] sm:text-[9px] font-medium text-gray-500 dark:text-gray-300`}
aria-label="No cover available"
>
No Cover
@@ -34,14 +36,14 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
}
return (
<div className="relative w-7 h-10 sm:w-10 sm:h-14 rounded-sm overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70">
<div className={`relative ${sizeClass} rounded-sm overflow-hidden bg-gray-100 dark:bg-gray-800 border border-white/40 dark:border-gray-700/70`}>
{!imageLoaded && (
<div className="absolute inset-0 bg-linear-to-r from-gray-200 via-gray-100 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
)}
<img
src={preview}
alt={title || 'Book cover'}
className="w-full h-full object-cover object-top"
className={`w-full h-full object-cover ${isSquare ? 'object-center' : 'object-top'}`}
loading="lazy"
onLoad={() => 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 */}
<div className={`grid items-center gap-2 sm:gap-y-1 sm:gap-x-0.5 w-full ${
searchMode === 'universal'
? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(80px,0.4fr)_minmax(80px,0.4fr)_auto]'
? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(90px,0.5fr)_minmax(90px,0.5fr)_minmax(120px,0.7fr)_auto]'
: 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto]'
}`}>
{/* Thumbnail */}
<div className="flex items-center pl-1 sm:pl-3">
<ListViewThumbnail preview={book.preview} title={book.title} />
<ListViewThumbnail preview={book.preview} title={book.title} coverAspect={book.cover_aspect} />
</div>
{/* Title and Author */}
@@ -149,7 +151,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto
{/* Mobile universal mode info */}
<div className="flex sm:hidden flex-col items-end text-[10px] opacity-70 leading-tight">
{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) => (
<span key={idx} className="flex items-center gap-0.5" title={field.label}>
<DisplayFieldIcon icon={field.icon} />
<span>{field.value}</span>
@@ -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 */}
<div className="hidden sm:flex justify-center">
{book.display_fields && book.display_fields[0] ? (
<DisplayFieldBadge field={book.display_fields[0]} />
{/* Rating column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'star') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'star')!} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
</div>
{/* Second display field column */}
<div className="hidden sm:flex justify-center">
{book.display_fields && book.display_fields[1] ? (
<DisplayFieldBadge field={book.display_fields[1]} />
{/* Length column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'clock' || f.icon === 'book') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'clock' || f.icon === 'book')!} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
</div>
{/* Narrator column */}
<div className="hidden sm:flex justify-start">
{book.display_fields?.find(f => f.icon === 'microphone') ? (
<DisplayFieldBadge field={book.display_fields.find(f => f.icon === 'microphone')!} />
) : (
<span className="text-xs text-gray-500">-</span>
)}

View File

@@ -25,6 +25,18 @@ export function DisplayFieldIcon({ icon, className = 'w-3 h-3' }: DisplayFieldIc
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
);
case 'microphone':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z" />
</svg>
);
case 'clock':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
);
case 'editions':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -43,6 +43,7 @@ export interface Book {
genres?: string[];
source_url?: string; // Link to book on provider's site
display_fields?: DisplayField[]; // Provider-specific display data
cover_aspect?: 'portrait' | 'square'; // Cover art aspect ratio hint
// Series info (if book is part of a series)
series_id?: string; // Provider-specific series ID
series_name?: string; // Name of the series

View File

@@ -13,6 +13,7 @@ export interface MetadataBookData {
isbn_10?: string;
isbn_13?: string;
cover_url?: string;
cover_aspect?: 'portrait' | 'square';
description?: string;
publisher?: string;
publish_year?: number;
@@ -79,6 +80,7 @@ export function transformMetadataToBook(data: MetadataBookData): Book {
year: data.publish_year?.toString(),
language: data.language,
preview: data.cover_url,
cover_aspect: data.cover_aspect,
publisher: data.publisher,
description: data.description,
provider: data.provider,