mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-19 21:39:17 -04:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user