mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 11:51:13 +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:
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"transmission-rpc",
|
||||
"authlib>=1.6.6,<1.7",
|
||||
"apprise>=1.9.0",
|
||||
"Pillow>=11.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -49,6 +49,9 @@ TRANSIENT_CACHE_TTL = 60
|
||||
|
||||
_MIN_WEBP_HEADER_LENGTH = 12
|
||||
HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
MAX_VARIANT_DIMENSION = 1024
|
||||
WEBP_DEFAULT_QUALITY = 80
|
||||
JPEG_DEFAULT_QUALITY = 85
|
||||
|
||||
|
||||
def _detect_image_type(data: bytes) -> tuple[str, str] | None:
|
||||
@@ -72,6 +75,164 @@ def _detect_image_type(data: bytes) -> tuple[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def normalize_variant_dimension(value: object) -> int | None:
|
||||
"""Normalize a requested variant dimension, clamping to a safe upper bound."""
|
||||
dimension = coerce_int(value, 0)
|
||||
if dimension <= 0:
|
||||
return None
|
||||
return min(dimension, MAX_VARIANT_DIMENSION)
|
||||
|
||||
|
||||
def normalize_variant_format(value: object) -> str | None:
|
||||
"""Normalize a requested output image format."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"jpg", "jpeg"}:
|
||||
return "jpeg"
|
||||
if normalized in {"png", "webp"}:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def build_variant_cache_id(
|
||||
cache_id: str,
|
||||
*,
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
image_format: str | None,
|
||||
) -> str:
|
||||
"""Build a cache key for a derived cover variant."""
|
||||
width_token = str(width) if width is not None else "auto"
|
||||
height_token = str(height) if height is not None else "auto"
|
||||
format_token = image_format or "original"
|
||||
return f"{cache_id}__w{width_token}_h{height_token}_f{format_token}"
|
||||
|
||||
|
||||
def _calculate_variant_size(
|
||||
*,
|
||||
source_width: int,
|
||||
source_height: int,
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
) -> tuple[int, int]:
|
||||
"""Calculate the output size while preserving aspect ratio and avoiding upscaling."""
|
||||
if width is None and height is None:
|
||||
return source_width, source_height
|
||||
|
||||
width_ratio = (width / source_width) if width is not None else None
|
||||
height_ratio = (height / source_height) if height is not None else None
|
||||
|
||||
if width_ratio is not None and height_ratio is not None:
|
||||
scale = min(width_ratio, height_ratio, 1.0)
|
||||
elif width_ratio is not None:
|
||||
scale = min(width_ratio, 1.0)
|
||||
elif height_ratio is not None:
|
||||
scale = min(height_ratio, 1.0)
|
||||
else:
|
||||
scale = 1.0
|
||||
|
||||
return (
|
||||
max(1, round(source_width * scale)),
|
||||
max(1, round(source_height * scale)),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_source_format(image_data: bytes) -> str | None:
|
||||
"""Return the normalized detected source image format."""
|
||||
detected = _detect_image_type(image_data)
|
||||
if not detected:
|
||||
return None
|
||||
|
||||
content_type, _ext = detected
|
||||
if content_type == "image/jpeg":
|
||||
return "jpeg"
|
||||
if content_type == "image/png":
|
||||
return "png"
|
||||
if content_type == "image/webp":
|
||||
return "webp"
|
||||
return None
|
||||
|
||||
|
||||
def create_image_variant(
|
||||
image_data: bytes,
|
||||
*,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
image_format: str | None = None,
|
||||
) -> tuple[bytes, str] | None:
|
||||
"""Create a resized and/or transcoded image variant.
|
||||
|
||||
Returns None when no variant is needed or the image cannot be safely transformed.
|
||||
"""
|
||||
requested_format = normalize_variant_format(image_format)
|
||||
if width is None and height is None and requested_format is None:
|
||||
return None
|
||||
|
||||
source_format = _normalize_source_format(image_data)
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
except ImportError:
|
||||
logger.warning("Pillow is not installed; serving original cover image")
|
||||
return None
|
||||
|
||||
try:
|
||||
with Image.open(BytesIO(image_data)) as source_image:
|
||||
if getattr(source_image, "is_animated", False):
|
||||
return None
|
||||
|
||||
image = ImageOps.exif_transpose(source_image)
|
||||
source_width, source_height = image.size
|
||||
output_width, output_height = _calculate_variant_size(
|
||||
source_width=source_width,
|
||||
source_height=source_height,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
needs_resize = (output_width, output_height) != (source_width, source_height)
|
||||
output_format = requested_format or source_format
|
||||
|
||||
if not needs_resize and output_format == source_format:
|
||||
return None
|
||||
|
||||
if needs_resize:
|
||||
image = image.resize((output_width, output_height), Image.Resampling.LANCZOS)
|
||||
|
||||
if output_format == "jpeg":
|
||||
if image.mode not in {"RGB", "L"}:
|
||||
image = image.convert("RGB")
|
||||
content_type = "image/jpeg"
|
||||
save_kwargs: dict[str, Any] = {
|
||||
"format": "JPEG",
|
||||
"quality": JPEG_DEFAULT_QUALITY,
|
||||
"optimize": True,
|
||||
}
|
||||
elif output_format == "png":
|
||||
if image.mode not in {"1", "L", "LA", "P", "PA", "RGB", "RGBA"}:
|
||||
image = image.convert("RGBA")
|
||||
content_type = "image/png"
|
||||
save_kwargs = {"format": "PNG", "optimize": True}
|
||||
else:
|
||||
if image.mode not in {"RGB", "RGBA"}:
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
content_type = "image/webp"
|
||||
save_kwargs = {
|
||||
"format": "WEBP",
|
||||
"quality": WEBP_DEFAULT_QUALITY,
|
||||
"method": 6,
|
||||
}
|
||||
|
||||
output = BytesIO()
|
||||
image.save(output, **save_kwargs)
|
||||
return output.getvalue(), content_type
|
||||
except (OSError, UnidentifiedImageError, ValueError) as exc:
|
||||
logger.warning("Failed to derive image variant: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
class ImageCacheService:
|
||||
"""Persistent image cache with LRU eviction and TTL support."""
|
||||
|
||||
|
||||
+65
-21
@@ -1597,6 +1597,9 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
||||
|
||||
Query Parameters:
|
||||
url (str): Base64-encoded original image URL (required on first request)
|
||||
w (int): Optional max width for a derived image variant
|
||||
h (int): Optional max height for a derived image variant
|
||||
format (str): Optional output format for a derived image variant (webp/png/jpeg)
|
||||
|
||||
Returns:
|
||||
flask.Response: Binary image data with appropriate Content-Type, or 404.
|
||||
@@ -1606,43 +1609,84 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
||||
import base64
|
||||
|
||||
from shelfmark.config.env import is_covers_cache_enabled
|
||||
from shelfmark.core.image_cache import get_image_cache
|
||||
from shelfmark.core.image_cache import (
|
||||
build_variant_cache_id,
|
||||
create_image_variant,
|
||||
get_image_cache,
|
||||
normalize_variant_dimension,
|
||||
normalize_variant_format,
|
||||
)
|
||||
|
||||
# Check if caching is enabled
|
||||
if not is_covers_cache_enabled():
|
||||
return jsonify({"error": "Cover caching is disabled"}), 404
|
||||
|
||||
cache = get_image_cache()
|
||||
width = normalize_variant_dimension(request.args.get("w"))
|
||||
height = normalize_variant_dimension(request.args.get("h"))
|
||||
image_format = normalize_variant_format(request.args.get("format"))
|
||||
variant_cache_id = (
|
||||
build_variant_cache_id(
|
||||
cover_id,
|
||||
width=width,
|
||||
height=height,
|
||||
image_format=image_format,
|
||||
)
|
||||
if width is not None or height is not None or image_format is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Try to get from cache first
|
||||
cached = cache.get(cover_id)
|
||||
if cached:
|
||||
image_data, content_type = cached
|
||||
def make_cover_response(
|
||||
image_data: bytes,
|
||||
content_type: str,
|
||||
*,
|
||||
cache_status: str,
|
||||
) -> Response:
|
||||
response = app.response_class(response=image_data, status=200, mimetype=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
response.headers["X-Cache"] = "HIT"
|
||||
response.headers["X-Cache"] = cache_status
|
||||
return response
|
||||
|
||||
# Try to get from cache first
|
||||
cache_lookup_id = variant_cache_id or cover_id
|
||||
cached = cache.get(cache_lookup_id)
|
||||
if cached:
|
||||
image_data, content_type = cached
|
||||
return make_cover_response(image_data, content_type, cache_status="HIT")
|
||||
|
||||
# Cache miss - get URL from query parameter
|
||||
encoded_url = request.args.get("url")
|
||||
if not encoded_url:
|
||||
return jsonify({"error": "Cover URL not provided"}), 404
|
||||
original: tuple[bytes, str] | None = cache.get(cover_id) if variant_cache_id else None
|
||||
|
||||
try:
|
||||
original_url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
except (binascii.Error, UnicodeDecodeError) as e:
|
||||
logger.warning("Failed to decode cover URL: %s", e)
|
||||
return jsonify({"error": "Invalid cover URL encoding"}), 400
|
||||
if original is None:
|
||||
if not encoded_url:
|
||||
return jsonify({"error": "Cover URL not provided"}), 404
|
||||
|
||||
# Fetch and cache the image
|
||||
result = cache.fetch_and_cache(cover_id, original_url)
|
||||
if not result:
|
||||
return jsonify({"error": "Failed to fetch cover image"}), 404
|
||||
try:
|
||||
original_url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
except (binascii.Error, UnicodeDecodeError) as e:
|
||||
logger.warning("Failed to decode cover URL: %s", e)
|
||||
return jsonify({"error": "Invalid cover URL encoding"}), 400
|
||||
|
||||
image_data, content_type = result
|
||||
response = app.response_class(response=image_data, status=200, mimetype=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
# Fetch and cache the original image
|
||||
original = cache.fetch_and_cache(cover_id, original_url)
|
||||
if not original:
|
||||
return jsonify({"error": "Failed to fetch cover image"}), 404
|
||||
|
||||
image_data, content_type = original
|
||||
|
||||
if variant_cache_id:
|
||||
variant = create_image_variant(
|
||||
image_data,
|
||||
width=width,
|
||||
height=height,
|
||||
image_format=image_format,
|
||||
)
|
||||
if variant:
|
||||
image_data, content_type = variant
|
||||
cache.put(variant_cache_id, image_data, content_type)
|
||||
|
||||
response = make_cover_response(image_data, content_type, cache_status="MISS")
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Cover fetch error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -1,8 +1,25 @@
|
||||
"""Tests for targeted image cache safety and fetch fallbacks."""
|
||||
|
||||
import requests
|
||||
from io import BytesIO
|
||||
|
||||
from shelfmark.core.image_cache import ImageCacheService
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from shelfmark.core.image_cache import (
|
||||
ImageCacheService,
|
||||
build_variant_cache_id,
|
||||
create_image_variant,
|
||||
normalize_variant_dimension,
|
||||
normalize_variant_format,
|
||||
)
|
||||
|
||||
|
||||
def _make_image_bytes(
|
||||
*, width: int = 400, height: int = 600, image_format: str = "JPEG", color: str = "navy"
|
||||
) -> bytes:
|
||||
buffer = BytesIO()
|
||||
Image.new("RGB", (width, height), color=color).save(buffer, format=image_format)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def test_is_safe_url_rejects_invalid_ipv6_url() -> None:
|
||||
@@ -20,3 +37,58 @@ def test_fetch_and_cache_returns_none_on_request_exception(tmp_path, monkeypatch
|
||||
|
||||
assert cache.fetch_and_cache("cover-1", "https://example.com/cover.jpg") is None
|
||||
assert "cover-1" not in cache._index
|
||||
|
||||
|
||||
def test_create_image_variant_resizes_and_transcodes_to_webp() -> None:
|
||||
variant = create_image_variant(
|
||||
_make_image_bytes(),
|
||||
width=120,
|
||||
height=180,
|
||||
image_format="webp",
|
||||
)
|
||||
|
||||
assert variant is not None
|
||||
variant_bytes, content_type = variant
|
||||
assert content_type == "image/webp"
|
||||
|
||||
with Image.open(BytesIO(variant_bytes)) as image:
|
||||
assert image.size == (120, 180)
|
||||
|
||||
|
||||
def test_create_image_variant_preserves_aspect_ratio_for_single_dimension() -> None:
|
||||
variant = create_image_variant(
|
||||
_make_image_bytes(),
|
||||
width=120,
|
||||
image_format="jpeg",
|
||||
)
|
||||
|
||||
assert variant is not None
|
||||
variant_bytes, content_type = variant
|
||||
assert content_type == "image/jpeg"
|
||||
|
||||
with Image.open(BytesIO(variant_bytes)) as image:
|
||||
assert image.size == (120, 180)
|
||||
|
||||
|
||||
def test_create_image_variant_returns_none_when_no_change_needed() -> None:
|
||||
image_bytes = _make_image_bytes(image_format="WEBP")
|
||||
|
||||
assert create_image_variant(image_bytes, image_format="webp") is None
|
||||
|
||||
|
||||
def test_variant_helpers_normalize_requested_variant_values() -> None:
|
||||
assert normalize_variant_dimension("240") == 240
|
||||
assert normalize_variant_dimension("0") is None
|
||||
assert normalize_variant_dimension("99999") == 1024
|
||||
assert normalize_variant_format("jpg") == "jpeg"
|
||||
assert normalize_variant_format("weBp") == "webp"
|
||||
assert normalize_variant_format("gif") is None
|
||||
assert (
|
||||
build_variant_cache_id(
|
||||
"cover-123",
|
||||
width=120,
|
||||
height=180,
|
||||
image_format="webp",
|
||||
)
|
||||
== "cover-123__w120_h180_fwebp"
|
||||
)
|
||||
|
||||
@@ -714,6 +714,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/fe/53ac0cd932db5dcaf55961bc7cb7afdca8d80d8cc7406ed661f0c7dc111a/pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8", size = 21969, upload-time = "2026-01-14T03:10:27.062Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "26.0.1"
|
||||
@@ -1337,6 +1370,7 @@ dependencies = [
|
||||
{ name = "gevent" },
|
||||
{ name = "gevent-websocket" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psutil" },
|
||||
{ name = "python-socketio" },
|
||||
{ name = "qbittorrent-api" },
|
||||
@@ -1379,6 +1413,7 @@ requires-dist = [
|
||||
{ name = "gevent" },
|
||||
{ name = "gevent-websocket" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "pillow", specifier = ">=11.0.0" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyautogui", marker = "extra == 'browser'" },
|
||||
{ name = "python-socketio" },
|
||||
|
||||
Reference in New Issue
Block a user