Merge pull request 'feat: implement imgproxy integration with responsive image handling and loading states' (#39) from feat/image-optimization into main

Reviewed-on: #39
This commit is contained in:
rov 2026-04-27 09:30:11 -03:00
commit 30c893cc6f
5 changed files with 89 additions and 25 deletions

View File

@ -0,0 +1,37 @@
import { buildImgproxyUrl, generateSrcSet } from "@/utils/imgproxy.ts";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useState } from "react";
interface ChapterImageProps {
imageKey: string;
pageNumber: number;
infiniteScroll?: boolean;
}
export const ChapterImage = ({ imageKey, pageNumber, infiniteScroll }: ChapterImageProps) => {
const [isLoading, setIsLoading] = useState(true);
return (
<div className={cn(
"relative w-full overflow-hidden",
isLoading && (infiniteScroll ? "min-h-[600px] aspect-[2/3]" : "aspect-[2/3]")
)}>
{isLoading && (
<Skeleton className="absolute inset-0 z-10 h-full w-full" />
)}
<img
src={buildImgproxyUrl(imageKey, 1200)}
srcSet={generateSrcSet(imageKey)}
sizes="(max-width: 896px) 100vw, 896px"
className={cn(
"w-full h-auto block m-0 p-0 border-none transition-opacity duration-300",
isLoading ? "opacity-0" : "opacity-100"
)}
alt={`Page ${pageNumber}`}
loading="lazy"
onLoad={() => setIsLoading(false)}
/>
</div>
);
};

View File

@ -17,6 +17,8 @@ import { Skeleton } from "@/components/ui/skeleton.tsx";
import { useAuth } from "@/contexts/AuthContext.tsx";
import { cn } from "@/lib/utils.ts";
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
import { buildImgproxyUrl } from "@/utils/imgproxy.ts";
interface MangaCardProps {
manga: MangaListDTO;
@ -89,11 +91,9 @@ export const MangaCard = ({ manga, queryKey }: MangaCardProps) => {
<Link to={`/manga/${manga.id}`}>
<img
src={
(manga.coverImageKey &&
import.meta.env.VITE_OMV_BASE_URL +
"/" +
manga.coverImageKey) ||
"/placeholder.svg"
manga.coverImageKey
? buildImgproxyUrl(manga.coverImageKey)
: "/placeholder.svg"
}
alt={manga.title}
onLoad={() => setIsImageLoading(false)}

View File

@ -14,6 +14,7 @@ import {
} from "@/components/ui/dialog";
import { useReadingProgressSync } from "@/features/chapter/hooks/useReadingProgressSync.ts";
import { MangaLoadingState } from "@/components/MangaLoadingState.tsx";
import { ChapterImage } from "@/features/chapter/components/ChapterImage";
const Chapter = () => {
const navigate = useNavigate();
@ -420,11 +421,10 @@ const Chapter = () => {
target.style.aspectRatio = "auto";
}}
>
<img
src={`${import.meta.env.VITE_OMV_BASE_URL}/${key}`}
className="w-full h-auto block m-0 p-0 border-none"
alt={`Page ${idx + 1}`}
loading="lazy"
<ChapterImage
imageKey={key}
pageNumber={idx + 1}
infiniteScroll
/>
</div>
))}
@ -468,16 +468,9 @@ const Chapter = () => {
/* ------------------------------------------------------------------ */
<>
<div className="relative mx-auto mb-8 overflow-hidden rounded-lg border border-border bg-muted">
<img
src={
import.meta.env.VITE_OMV_BASE_URL +
"/" +
images[currentPage - 1] || "/placeholder.svg"
}
alt={`Page ${currentPage}`}
width={1000}
height={1400}
className="h-auto w-full"
<ChapterImage
imageKey={images[currentPage - 1]}
pageNumber={currentPage}
/>
</div>

View File

@ -43,6 +43,8 @@ import { MangaChapter } from "@/features/manga/MangaChapter.tsx";
import { useScrollPersistence } from "@/hooks/useScrollPersistence.ts";
import { formatToTwoDigitsDateRange } from "@/utils/dateFormatter.ts";
import { Skeleton } from "@/components/ui/skeleton";
import { buildImgproxyUrl } from "@/utils/imgproxy.ts";
import { Spinner } from "@/components/ui/spinner";
import { useGetProgress } from "@/api/generated/reading-progress/reading-progress.ts";
@ -265,12 +267,11 @@ const Manga = () => {
<div className="relative aspect-2/3 overflow-hidden rounded-lg border border-border bg-muted w-full">
<img
src={
(mangaData.data?.coverImageKey &&
import.meta.env.VITE_OMV_BASE_URL +
"/" +
mangaData.data?.coverImageKey) ||
"/placeholder.svg"
mangaData.data?.coverImageKey
? buildImgproxyUrl(mangaData.data?.coverImageKey)
: "/placeholder.svg"
}
alt={mangaData.data?.title ?? ""}
className="absolute inset-0 w-full h-full object-cover"
/>

33
src/utils/imgproxy.ts Normal file
View File

@ -0,0 +1,33 @@
export const buildImgproxyUrl = (sourceUrl: string, width?: number): string => {
const baseUrl = import.meta.env.VITE_IMGPROXY_BASE_URL;
if (!baseUrl) {
// Fallback to original URL logic
const originalBase = import.meta.env.VITE_OMV_BASE_URL;
if (sourceUrl.startsWith("http")) {
return sourceUrl;
}
return `${originalBase}/${sourceUrl}`;
}
// rs:fit:${width}:0:0 to resize preserving aspect ratio, capping width at `width`
// If width is provided, we resize, otherwise we keep original dimensions.
const parts = [];
if (width) {
parts.push(`rs:fit:${width}:0:0`);
}
parts.push("f:avif");
const options = parts.join("/");
return `${baseUrl}/insecure/${options}/plain/s3://${import.meta.env.VITE_S3_BUCKET}/${sourceUrl}`;
};
export const generateSrcSet = (
sourceUrl: string,
widths: number[] = [400, 800, 1200, 1800],
): string => {
return widths
.map((width) => `${buildImgproxyUrl(sourceUrl, width)} ${width}w`)
.join(", ");
};