UI enhancement pass: Image streaming, dropdown UI fixes (#909)

- Added max image sizes and compression for frontend cached images
- Fixed various UI issues with dropdown placement and animation
This commit is contained in:
Alex
2026-04-23 20:57:15 +01:00
committed by GitHub
parent e0980a84d9
commit 819d794039
24 changed files with 684 additions and 154 deletions
@@ -296,6 +296,7 @@ const BookTargetDropdownSession = ({
multiple
showCheckboxes
keepOpenOnSelect
positionStrategy={variant === 'icon' ? 'fixed' : 'absolute'}
summaryFormatter={(selectedOptions) => renderSummary(selectedOptions)}
renderTrigger={customTrigger}
onOpenChange={onOpenChange}
@@ -72,7 +72,7 @@ export const ConfigSetupBanner = ({
{/* Backdrop */}
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/60 transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
aria-label="Close settings setup dialog"
/>
+10 -2
View File
@@ -7,6 +7,7 @@ import { useMountEffect } from '../hooks/useMountEffect';
import type { Book, ButtonStateInfo } from '../types';
import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { getSizedCoverUrl } from '../utils/covers';
import { isUserCancelledError } from '../utils/errors';
import { BookTargetDropdown } from './BookTargetDropdown';
@@ -136,6 +137,10 @@ export const DetailsModal = ({
const artworkMaxWidth = isSquareCover
? 'min(45vw, 400px, calc(90vh - 220px))'
: 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
const optimizedPreview = getSizedCoverUrl(book.preview, {
width: isSquareCover ? 640 : 480,
height: isSquareCover ? 640 : 720,
});
const additionalInfo =
book.info && Object.keys(book.info).length > 0
? Object.entries(book.info).filter(([key]) => {
@@ -201,14 +206,17 @@ export const DetailsModal = ({
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-6">
<div className="flex flex-col gap-6 lg:min-h-0 lg:flex-row lg:items-stretch lg:gap-8">
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
{book.preview ? (
{optimizedPreview ? (
<div
className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
>
<img
src={book.preview}
src={optimizedPreview}
alt="Book cover"
width={isSquareCover ? 640 : 480}
height={isSquareCover ? 640 : 720}
decoding="async"
className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg"
style={{ maxHeight: '100%', maxWidth: '100%' }}
/>
+142 -80
View File
@@ -4,30 +4,22 @@ import { createPortal } from 'react-dom';
import { useDismiss } from '../hooks/useDismiss';
// Simple throttle function to limit how often a function can be called
function throttle<Args extends unknown[]>(
fn: (...args: Args) => void,
delay: number,
): (...args: Args) => void {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const FIXED_DROPDOWN_Z_INDEX = 1050;
const DROPDOWN_GAP_PX = 8;
return (...args: Args) => {
const now = Date.now();
const timeSinceLastCall = now - lastCall;
function getPositioningAncestor(element: HTMLElement | null): HTMLElement | null {
let current = element?.parentElement;
if (timeSinceLastCall >= delay) {
lastCall = now;
fn(...args);
} else if (!timeoutId) {
// Schedule a trailing call
timeoutId = setTimeout(() => {
lastCall = Date.now();
timeoutId = null;
fn(...args);
}, delay - timeSinceLastCall);
while (current) {
const style = getComputedStyle(current);
const overflowY = style.overflowY;
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'hidden') {
return current;
}
};
current = current.parentElement;
}
return null;
}
interface DropdownProps {
@@ -43,6 +35,7 @@ interface DropdownProps {
/** Disable max-height and overflow scrolling (for panels with nested dropdowns) */
noScrollLimit?: boolean;
triggerChrome?: 'default' | 'minimal';
positionStrategy?: 'absolute' | 'fixed';
onOpenChange?: (isOpen: boolean) => void;
}
@@ -58,6 +51,7 @@ export const Dropdown = ({
renderTrigger,
noScrollLimit = false,
triggerChrome = 'default',
positionStrategy = 'absolute',
onOpenChange,
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
@@ -65,7 +59,9 @@ export const Dropdown = ({
const triggerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
const [panelPos, setPanelPos] = useState({ top: 0, left: 0, width: 0 });
const [resolvedAlign, setResolvedAlign] = useState<'left' | 'right'>(
align === 'right' ? 'right' : 'left',
);
let triggerBorderRadius = '0.5rem';
if (triggerChrome === 'minimal') {
@@ -79,6 +75,13 @@ export const Dropdown = ({
panelBorderRadius = panelDirection === 'down' ? '0 0 0.5rem 0.5rem' : '0.5rem 0.5rem 0 0';
}
let panelOffsetClassName = 'top-full -mt-px';
if (panelDirection === 'down') {
panelOffsetClassName = renderTrigger ? 'top-full mt-2' : 'top-full -mt-px';
} else {
panelOffsetClassName = renderTrigger ? 'bottom-full mb-2' : 'bottom-full -mb-px';
}
const toggleOpen = () => {
if (disabled) return;
setIsOpen((prev) => {
@@ -95,63 +98,143 @@ export const Dropdown = ({
useDismiss(isOpen, [containerRef, panelRef], close);
// Compute panel direction and fixed position relative to the trigger
const updatePanelPosition = useCallback(() => {
const updatePanelLayout = useCallback(() => {
if (!triggerRef.current || !panelRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
const panelHeight = panelRef.current.offsetHeight || panelRef.current.scrollHeight;
const panelWidth = panelRef.current.offsetWidth || panelRef.current.scrollWidth;
const panelElement = panelRef.current;
const boundaryRect =
positionStrategy === 'absolute'
? getPositioningAncestor(containerRef.current)?.getBoundingClientRect()
: undefined;
// Direction: flip up if not enough space below but enough above
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
setPanelDirection(shouldOpenUp ? 'up' : 'down');
// Vertical: seamless trigger uses -1px border overlap, custom trigger uses 8px gap
let top: number;
if (shouldOpenUp) {
top = renderTrigger ? rect.top - panelHeight - 8 : rect.top - panelHeight + 1;
if (positionStrategy === 'fixed' && !panelClassName) {
panelElement.style.width = `${rect.width}px`;
} else {
top = renderTrigger ? rect.bottom + 8 : rect.bottom - 1;
panelElement.style.removeProperty('width');
}
// Horizontal alignment
let left: number;
const panelHeight = panelElement.offsetHeight || panelElement.scrollHeight;
const panelWidth = panelElement.offsetWidth || panelElement.scrollWidth;
const boundaryTop = boundaryRect?.top ?? 0;
const boundaryRight = boundaryRect?.right ?? window.innerWidth;
const boundaryBottom = boundaryRect?.bottom ?? window.innerHeight;
const boundaryLeft = boundaryRect?.left ?? 0;
// Direction: flip up if not enough space below but enough above
const spaceBelow = boundaryBottom - rect.bottom - DROPDOWN_GAP_PX;
const spaceAbove = rect.top - boundaryTop - DROPDOWN_GAP_PX;
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
const nextDirection = shouldOpenUp ? 'up' : 'down';
setPanelDirection((current) => (current === nextDirection ? current : nextDirection));
let nextAlign: 'left' | 'right';
if (align === 'auto') {
const overflowsRight = rect.left + panelWidth > window.innerWidth - 8;
const overflowsLeft = rect.right - panelWidth < 8;
left =
overflowsRight && !overflowsLeft
? rect.right - Math.max(panelWidth, rect.width)
: rect.left;
} else if (align === 'right') {
const overflowsRight = rect.left + panelWidth > boundaryRight - DROPDOWN_GAP_PX;
const overflowsLeft = rect.right - panelWidth < boundaryLeft + DROPDOWN_GAP_PX;
nextAlign = overflowsRight && !overflowsLeft ? 'right' : 'left';
} else {
nextAlign = align === 'right' ? 'right' : 'left';
}
setResolvedAlign((current) => (current === nextAlign ? current : nextAlign));
if (positionStrategy !== 'fixed') {
panelElement.style.removeProperty('top');
panelElement.style.removeProperty('left');
return;
}
// Fixed-position panels need explicit viewport coordinates.
let top: number;
if (shouldOpenUp) {
top = renderTrigger ? rect.top - panelHeight - DROPDOWN_GAP_PX : rect.top - panelHeight + 1;
} else {
top = renderTrigger ? rect.bottom + DROPDOWN_GAP_PX : rect.bottom - 1;
}
let left: number;
if (nextAlign === 'right') {
left = rect.right - Math.max(panelWidth, rect.width);
} else {
left = rect.left;
}
setPanelPos({ top, left, width: rect.width });
}, [align, renderTrigger]);
panelElement.style.top = `${top}px`;
panelElement.style.left = `${left}px`;
}, [align, panelClassName, positionStrategy, renderTrigger]);
useLayoutEffect(() => {
if (!isOpen) {
return undefined;
}
// Throttle scroll/resize handlers to reduce layout thrashing
const throttledUpdate = throttle(updatePanelPosition, 100);
updatePanelLayout();
window.addEventListener('resize', updatePanelLayout);
window.addEventListener('scroll', updatePanelLayout, true);
updatePanelPosition();
window.addEventListener('resize', throttledUpdate);
window.addEventListener('scroll', throttledUpdate, true);
const resizeObserver =
typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(() => {
updatePanelLayout();
});
if (resizeObserver) {
if (triggerRef.current) {
resizeObserver.observe(triggerRef.current);
}
if (panelRef.current) {
resizeObserver.observe(panelRef.current);
}
}
return () => {
window.removeEventListener('resize', throttledUpdate);
window.removeEventListener('scroll', throttledUpdate, true);
resizeObserver?.disconnect();
window.removeEventListener('resize', updatePanelLayout);
window.removeEventListener('scroll', updatePanelLayout, true);
};
}, [isOpen, updatePanelPosition]);
}, [isOpen, updatePanelLayout]);
const panelChromeClassName = `border ${panelDirection === 'down' ? 'shadow-lg' : ''}`;
const panelContent = (
<div className={noScrollLimit ? '' : 'max-h-64 overflow-auto'}>{children({ close })}</div>
);
const absolutePanel =
isOpen && positionStrategy === 'absolute' ? (
<div
ref={panelRef}
className={`absolute ${resolvedAlign === 'right' ? 'right-0' : 'left-0'} ${panelOffsetClassName} z-50 ${panelChromeClassName} ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
borderRadius: panelBorderRadius,
}}
>
{panelContent}
</div>
) : null;
const fixedPanel =
isOpen && positionStrategy === 'fixed' && typeof document !== 'undefined'
? createPortal(
<div
ref={panelRef}
className={`${panelChromeClassName} ${panelClassName ?? ''}`}
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: FIXED_DROPDOWN_Z_INDEX,
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
borderRadius: panelBorderRadius,
}}
>
{panelContent}
</div>,
document.body,
)
: null;
return (
<div className={widthClassName} ref={containerRef}>
@@ -163,7 +246,7 @@ export const Dropdown = ({
{label}
</label>
)}
<div ref={triggerRef}>
<div ref={triggerRef} className="relative">
{renderTrigger ? (
renderTrigger({ isOpen, toggle: toggleOpen })
) : (
@@ -193,29 +276,8 @@ export const Dropdown = ({
</svg>
</button>
)}
{isOpen &&
createPortal(
<div
ref={panelRef}
className={`border ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName ?? ''}`}
style={{
position: 'fixed',
top: panelPos.top,
left: panelPos.left,
width: panelClassName ? undefined : panelPos.width,
zIndex: 100,
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
borderRadius: panelBorderRadius,
}}
>
<div className={noScrollLimit ? '' : 'max-h-64 overflow-auto'}>
{children({ close })}
</div>
</div>,
document.body,
)}
{absolutePanel}
{fixedPanel}
</div>
</div>
);
@@ -26,6 +26,7 @@ interface DropdownListProps {
summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode;
keepOpenOnSelect?: boolean;
triggerChrome?: 'default' | 'minimal';
positionStrategy?: 'absolute' | 'fixed';
renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode;
onOpenChange?: (isOpen: boolean) => void;
}
@@ -45,6 +46,7 @@ export const DropdownList = ({
summaryFormatter,
keepOpenOnSelect,
triggerChrome = 'default',
positionStrategy,
renderTrigger,
onOpenChange,
}: DropdownListProps) => {
@@ -114,6 +116,7 @@ export const DropdownList = ({
buttonClassName={buttonClassName}
panelClassName={panelClassName}
triggerChrome={triggerChrome}
positionStrategy={positionStrategy}
renderTrigger={renderTrigger}
onOpenChange={onOpenChange}
>
+1 -1
View File
@@ -646,7 +646,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(
return (
<header
className="sticky top-0 z-40 w-full backdrop-blur-xs"
className="sticky top-0 z-40 w-full"
style={{ background: 'var(--bg)', paddingTop: 'env(safe-area-inset-top)' }}
>
<div className="mx-auto max-w-full px-4 py-4 sm:px-6 lg:px-8">
@@ -61,7 +61,7 @@ export const OnBehalfConfirmationModal = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/60 transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
tabIndex={-1}
aria-label="Close download confirmation"
@@ -339,7 +339,7 @@ const OnboardingModalSession = ({
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-xs" />
<div className="absolute inset-0 bg-black/60" />
<div className="relative rounded-xl p-8 shadow-2xl" style={{ background: 'var(--bg)' }}>
<div className="flex items-center gap-3">
<svg className="h-5 w-5 animate-spin" viewBox="0 0 24 24">
@@ -371,7 +371,7 @@ const OnboardingModalSession = ({
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-xs"
className="absolute inset-0 bg-black/60"
onClick={handleClose}
tabIndex={-1}
aria-label="Close setup wizard"
@@ -419,7 +419,7 @@ const OnboardingModalSession = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/60 transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
/>
{/* Modal */}
+24 -10
View File
@@ -22,6 +22,7 @@ import type {
import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { getSizedCoverUrl } from '../utils/covers';
import {
LANGUAGE_OPTION_DEFAULT,
getLanguageFilterValues,
@@ -210,8 +211,9 @@ function StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: num
const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const optimizedPreview = getSizedCoverUrl(preview, { width: 32, height: 48 });
if (!preview || imageError) {
if (!optimizedPreview || imageError) {
return (
<div
className="flex h-10 w-7 shrink-0 items-center justify-center rounded-sm bg-zinc-200 text-[7px] font-medium text-zinc-500 sm:h-12 sm:w-8 sm:text-[8px] dark:bg-zinc-700 dark:text-zinc-400"
@@ -228,10 +230,13 @@ const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string
<div className="absolute inset-0 animate-pulse 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" />
)}
<img
src={preview}
src={optimizedPreview}
alt={title || 'Book cover'}
className="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
width={32}
height={48}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
@@ -454,7 +459,7 @@ const ReleaseRow = ({
return (
<div
className={`hover-row animate-pop-up py-2 pr-4 pl-5 transition-colors duration-200 will-change-transform sm:pr-5 ${
className={`hover-row animate-pop-up py-2 pr-4 pl-5 transition-colors duration-200 sm:pr-5 ${
selectionMode ? 'cursor-pointer' : ''
} ${isSelected ? 'bg-emerald-50/50 dark:bg-emerald-900/10' : ''}`}
style={{
@@ -1237,6 +1242,10 @@ const ReleaseModalSession = ({
} else if (book.series_name) {
coverSizeClassName = 'h-[144px] w-24';
}
const modalPreview = getSizedCoverUrl(book.preview, {
width: book.cover_aspect === 'square' ? 144 : 96,
height: 144,
});
let combinedFooterEbookMode = combinedEbookMode;
if (combinedPhase === 'ebook') {
@@ -1325,13 +1334,14 @@ const ReleaseModalSession = ({
{/* Mobile: static thumbnail always visible */}
{!isRequestMode && (
<div className="shrink-0 sm:hidden">
{book.preview ? (
{modalPreview ? (
<img
src={book.preview}
src={modalPreview}
alt=""
width={book.cover_aspect === 'square' ? 68 : 46}
height={68}
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
decoding="async"
style={{
width: book.cover_aspect === 'square' ? 68 : 46,
height: 68,
@@ -1365,13 +1375,14 @@ const ReleaseModalSession = ({
className="transition-opacity duration-300 ease-out"
style={{ opacity: showHeaderThumb ? 1 : 0 }}
>
{book.preview ? (
{modalPreview ? (
<img
src={book.preview}
src={modalPreview}
alt=""
width={book.cover_aspect === 'square' ? 68 : 46}
height={68}
className={`rounded-sm object-cover shadow-md ${book.cover_aspect === 'square' ? 'object-center' : 'object-top'}`}
decoding="async"
style={{
width: book.cover_aspect === 'square' ? 68 : 46,
height: 68,
@@ -1434,10 +1445,13 @@ const ReleaseModalSession = ({
ref={bookSummaryRef}
className="flex gap-4 border-b border-(--border-muted) px-5 py-4"
>
{book.preview ? (
{modalPreview ? (
<img
src={book.preview}
src={modalPreview}
alt="Book cover"
width={book.cover_aspect === 'square' ? (book.series_name ? 144 : 120) : 96}
height={book.series_name ? 144 : 120}
decoding="async"
className={`hidden shrink-0 rounded-lg object-cover shadow-md sm:block ${coverAspectClassName} ${coverSizeClassName}`}
/>
) : (
@@ -2197,7 +2211,7 @@ const ReleaseModalSession = ({
'',
))) && (
<div
className="animate-pop-up py-3 text-center will-change-transform"
className="animate-pop-up py-3 text-center"
style={{
animationDelay: `${filteredReleases.length * 30}ms`,
animationFillMode: 'both',
@@ -5,6 +5,7 @@ import { useEscapeKey } from '../hooks/useEscapeKey';
import { useMountEffect } from '../hooks/useMountEffect';
import { getMetadataBookInfo } from '../services/api';
import type { CreateRequestPayload } from '../types';
import { getSizedCoverUrl } from '../utils/covers';
import type { RequestConfirmationPreview } from '../utils/requestConfirmation';
import {
applyRequestNoteToPayload,
@@ -175,6 +176,7 @@ function RequestConfirmationModalSession({
const titleId = 'request-confirmation-modal-title';
const confirmDisabled = isSubmitting || (allowNotes && note.length > MAX_REQUEST_NOTE_LENGTH);
const previewImage = getSizedCoverUrl(preview.preview, { width: 64, height: 96 });
const submit = async () => {
if (confirmDisabled) {
@@ -200,7 +202,7 @@ function RequestConfirmationModalSession({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/60 transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
tabIndex={-1}
aria-label="Close request confirmation"
@@ -240,11 +242,15 @@ function RequestConfirmationModalSession({
<div className="rounded-xl border border-(--border-muted) bg-(--bg-soft) px-4 py-4">
<div className="flex gap-4">
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-lg border border-(--border-muted) bg-(--bg)">
{preview.preview ? (
{previewImage ? (
<img
src={preview.preview}
src={previewImage}
alt={`${preview.title} cover`}
className="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
width={64}
height={96}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] opacity-60">
@@ -3,6 +3,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { RequestRecord } from '../../types';
import { withBasePath } from '../../utils/basePath';
import { getSizedCoverUrl } from '../../utils/covers';
import { Tooltip } from '../shared/Tooltip';
import type { ActivityCardAction } from './activityCardModel';
import { buildActivityCardModel } from './activityCardModel';
@@ -499,6 +500,7 @@ export const ActivityCard = ({
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
const [titleOverflow, setTitleOverflow] = useState(false);
const previewImage = getSizedCoverUrl(item.preview, { width: 48, height: 72 });
useLayoutEffect(() => {
const measureBadgeOverflow = () => {
@@ -709,11 +711,15 @@ export const ActivityCard = ({
<div className="flex items-start gap-3">
{/* Artwork */}
<div className="h-18 w-12 shrink-0 overflow-hidden rounded-sm bg-gray-200 dark:bg-gray-700">
{item.preview ? (
{previewImage ? (
<img
src={item.preview}
src={previewImage}
alt={`${item.title} cover`}
className="h-full w-full object-cover object-top"
loading="lazy"
decoding="async"
width={48}
height={72}
/>
) : (
<BookFallback />
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { getSizedCoverUrl } from '../../utils/covers';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { DisplayFieldBadges } from '../shared';
@@ -41,6 +42,11 @@ export const CardView = ({
const [dropdownOpen, setDropdownOpen] = useState(false);
const targetProvider = book.provider;
const targetBookId = book.provider_id;
const isSquareCover = book.cover_aspect === 'square';
const optimizedPreview = getSizedCoverUrl(book.preview, {
width: 292,
height: isSquareCover ? 292 : 438,
});
let zIndex: number | undefined;
if (dropdownOpen) {
zIndex = 20;
@@ -68,7 +74,7 @@ export const CardView = ({
return (
<article
className="book-card space-between animate-pop-up relative flex h-full w-full flex-col transition-shadow duration-300 will-change-transform max-sm:h-[180px] max-sm:flex-row sm:max-w-[292px] sm:flex-col"
className="book-card space-between animate-pop-up relative flex h-full w-full flex-col transition-shadow duration-300 max-sm:h-[180px] max-sm:flex-row sm:max-w-[292px] sm:flex-col"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -97,7 +103,7 @@ export const CardView = ({
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
{optimizedPreview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
@@ -105,9 +111,13 @@ export const CardView = ({
</div>
)}
<img
src={book.preview}
src={optimizedPreview}
alt={book.title || 'Book cover'}
className="h-full w-full"
loading="lazy"
decoding="async"
width={292}
height={isSquareCover ? 292 : 438}
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
@@ -146,13 +156,13 @@ export const CardView = ({
bookId={targetBookId}
onShowToast={onShowToast}
variant="icon"
className="h-8 w-8 bg-white/90 shadow-lg backdrop-blur-xs hover:scale-110 dark:bg-neutral-800/90"
className="h-8 w-8 bg-white/95 shadow-lg hover:scale-110 dark:bg-neutral-800/95"
onOpenChange={setDropdownOpen}
/>
)}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 shadow-lg backdrop-blur-xs transition-all duration-300 hover:scale-110 dark:bg-neutral-800/90"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/95 shadow-lg transition-all duration-300 hover:scale-110 dark:bg-neutral-800/95"
onClick={(e) => {
e.stopPropagation();
void handleDetails(book.id);
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { getSizedCoverUrl } from '../../utils/covers';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { DisplayFieldBadges, DisplayFieldIcon } from '../shared';
@@ -44,6 +45,11 @@ export const CompactView = ({
const targetProvider = book.provider;
const targetBookId = book.provider_id;
const microphoneField = book.display_fields?.find((field) => field.icon === 'microphone');
const isSquareCover = book.cover_aspect === 'square';
const optimizedPreview = getSizedCoverUrl(book.preview, {
width: 120,
height: isSquareCover ? 120 : 180,
});
let zIndex: number | undefined;
if (dropdownOpen) {
zIndex = 20;
@@ -71,7 +77,7 @@ export const CompactView = ({
return (
<article
className="book-card animate-pop-up relative flex! h-[180px]! w-full flex-row! transition-shadow duration-300 will-change-transform"
className="book-card animate-pop-up relative flex! h-[180px]! w-full flex-row! transition-shadow duration-300"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -97,7 +103,7 @@ export const CompactView = ({
#{book.series_position}
</div>
)}
{book.preview && !imageError ? (
{optimizedPreview && !imageError ? (
<>
{!imageLoaded && (
<div className="absolute inset-0">
@@ -105,9 +111,13 @@ export const CompactView = ({
</div>
)}
<img
src={book.preview}
src={optimizedPreview}
alt={book.title || 'Book cover'}
className="h-full w-full"
loading="lazy"
decoding="async"
width={120}
height={isSquareCover ? 120 : 180}
style={{
opacity: imageLoaded ? 1 : 0,
transition: 'opacity 0.3s ease-in-out',
@@ -147,13 +157,13 @@ export const CompactView = ({
bookId={targetBookId}
onShowToast={onShowToast}
variant="icon"
className="h-8 w-8 bg-white/90 shadow-lg backdrop-blur-xs hover:scale-110 dark:bg-neutral-800/90"
className="h-8 w-8 bg-white/95 shadow-lg hover:scale-110 dark:bg-neutral-800/95"
onOpenChange={setDropdownOpen}
/>
)}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 shadow-lg backdrop-blur-xs transition-all duration-300 hover:scale-110 dark:bg-neutral-800/90"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/95 shadow-lg transition-all duration-300 hover:scale-110 dark:bg-neutral-800/95"
onClick={(e) => {
e.stopPropagation();
void handleDetails(book.id);
@@ -4,6 +4,7 @@ import { useSearchMode } from '../../contexts/SearchModeContext';
import type { Book, ButtonStateInfo, DisplayField } from '../../types';
import { bookSupportsTargets } from '../../utils/bookTargetLoader';
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
import { getSizedCoverUrl } from '../../utils/covers';
import { BookActionButton } from '../BookActionButton';
import { BookTargetDropdown } from '../BookTargetDropdown';
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
@@ -47,8 +48,12 @@ const ListViewThumbnail = ({
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';
const optimizedPreview = getSizedCoverUrl(preview, {
width: isSquare ? 56 : 40,
height: isSquare ? 56 : 56,
});
if (!preview || imageError) {
if (!optimizedPreview || imageError) {
return (
<div
className={`${sizeClass} flex items-center justify-center rounded-sm bg-gray-200 text-[8px] font-medium text-gray-500 sm:text-[9px] dark:bg-gray-700 dark:text-gray-300`}
@@ -67,10 +72,13 @@ const ListViewThumbnail = ({
<div className="absolute inset-0 animate-pulse 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" />
)}
<img
src={preview}
src={optimizedPreview}
alt={title || 'Book cover'}
className={`h-full w-full object-cover ${isSquare ? 'object-center' : 'object-top'}`}
loading="lazy"
decoding="async"
width={isSquare ? 56 : 40}
height={isSquare ? 56 : 56}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }}
@@ -153,7 +161,7 @@ export const ListView = ({
return (
<div
key={book.id}
className="hover-row animate-pop-up relative w-full px-1.5 py-1.5 transition-colors duration-200 will-change-transform sm:px-2 sm:py-2"
className="hover-row animate-pop-up relative w-full px-1.5 py-1.5 transition-colors duration-200 sm:px-2 sm:py-2"
style={{
zIndex: openDropdownBookId === book.id ? 30 : undefined,
animationDelay: `${index * 50}ms`,
@@ -265,7 +265,7 @@ const SelfSettingsModalSession = ({
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/60 transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
tabIndex={-1}
aria-label="Close account settings"
@@ -388,7 +388,7 @@ const SettingsModalSession = ({
{tabSync}
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
className="absolute inset-0 bg-black/60"
style={{ willChange: 'opacity', contain: 'strict' }}
onClick={handleClose}
tabIndex={-1}
@@ -428,7 +428,7 @@ const SettingsModalSession = ({
{tabSync}
<button
type="button"
className="absolute inset-0 bg-black/50 backdrop-blur-[2px]"
className="absolute inset-0 bg-black/60"
style={{ willChange: 'opacity', contain: 'strict' }}
onClick={handleClose}
tabIndex={-1}
@@ -509,7 +509,7 @@ const SettingsModalSession = ({
{tabSync}
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-[2px] transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/60 transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
style={{ willChange: 'opacity', contain: 'strict' }}
onClick={handleClose}
tabIndex={-1}
+1 -11
View File
@@ -360,7 +360,6 @@
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.animate-pop-up {
@@ -369,7 +368,6 @@
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.animate-fade-in-down {
@@ -378,7 +376,6 @@
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.animate-fade-out-up {
@@ -387,7 +384,6 @@
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
/* Button spinner styles */
@@ -427,13 +423,11 @@
inset: 0;
width: 100%;
height: 100%;
background: var(--loading-overlay);
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
padding: 1.5rem;
justify-content: center;
align-items: center;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.modal-overlay.active {
@@ -586,7 +580,6 @@
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.release-modal-no-step-animation,
@@ -600,17 +593,14 @@
backface-visibility: hidden;
-webkit-transform: translateZ(0);
transform: translateZ(0);
will-change: transform, opacity;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
will-change: opacity;
}
.animate-fade-out {
animation: fade-out 0.15s ease-in;
will-change: opacity;
}
/* ============================================
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { getSizedCoverUrl } from '../utils/covers';
describe('getSizedCoverUrl', () => {
it('adds size and format params to local cover proxy URLs', () => {
expect(
getSizedCoverUrl('/api/covers/book-1?url=abc', {
width: 120,
height: 180,
}),
).toBe('/api/covers/book-1?url=abc&w=120&h=180&format=webp');
});
it('leaves external preview URLs alone', () => {
expect(
getSizedCoverUrl('https://covers.example.com/book.jpg', {
width: 120,
height: 180,
}),
).toBe('https://covers.example.com/book.jpg');
});
it('preserves absolute proxy URLs', () => {
expect(
getSizedCoverUrl('https://bookrequest.example.com/api/covers/book-1?url=abc', {
width: 56,
height: 56,
format: 'png',
}),
).toBe('https://bookrequest.example.com/api/covers/book-1?url=abc&w=56&h=56&format=png');
});
});
+66
View File
@@ -0,0 +1,66 @@
const COVER_PROXY_PATH = '/api/covers/';
const LOCAL_URL_BASE = 'http://shelfmark.local';
const DEFAULT_COVER_FORMAT = 'webp';
const MAX_COVER_DIMENSION = 1024;
type CoverFormat = 'jpeg' | 'png' | 'webp';
interface SizedCoverUrlOptions {
width?: number;
height?: number;
format?: CoverFormat;
}
const normalizeDimension = (value?: number) => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
}
const rounded = Math.round(value);
if (rounded <= 0) {
return undefined;
}
return Math.min(rounded, MAX_COVER_DIMENSION);
};
export const getSizedCoverUrl = (
preview?: string,
{ width, height, format = DEFAULT_COVER_FORMAT }: SizedCoverUrlOptions = {},
) => {
if (!preview) {
return preview;
}
const isRelativeUrl = preview.startsWith('/');
let url: URL;
try {
url = new URL(preview, LOCAL_URL_BASE);
} catch {
return preview;
}
if (!url.pathname.includes(COVER_PROXY_PATH)) {
return preview;
}
const normalizedWidth = normalizeDimension(width);
const normalizedHeight = normalizeDimension(height);
if (normalizedWidth !== undefined) {
url.searchParams.set('w', String(normalizedWidth));
}
if (normalizedHeight !== undefined) {
url.searchParams.set('h', String(normalizedHeight));
}
if (format) {
url.searchParams.set('format', format);
}
const search = url.searchParams.toString();
const relativeUrl = `${url.pathname}${search ? `?${search}` : ''}${url.hash}`;
return isRelativeUrl ? relativeUrl : url.toString();
};