mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
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:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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%' }}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user