Improve gallery loading speed with image optimization and lazy loading

Implement a thumbnail proxy endpoint and lazy loading for gallery images to significantly reduce page load times and improve user experience.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 1488855f-6772-48e1-9244-282d1cc91352
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/VgutZ7W
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sebastjanartic 2026-03-01 18:52:46 +00:00
parent 97d09ba27e
commit 39d43cd876
4 changed files with 616 additions and 535 deletions

View File

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react"; import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
interface GalleryImage { interface GalleryImage {
@ -9,6 +9,45 @@ interface GalleryImage {
large: string; large: string;
} }
function thumbUrl(src: string) {
return `/api/gallery/thumb?src=${encodeURIComponent(src)}`;
}
function LazyImage({ src, alt, className, onClick }: { src: string; alt: string; className?: string; onClick?: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setVisible(true); obs.disconnect(); } },
{ rootMargin: "200px" }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div ref={ref} className={className} onClick={onClick}>
{visible ? (
<>
{!loaded && <div className="absolute inset-0 bg-muted animate-pulse" />}
<img
src={src}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
onLoad={() => setLoaded(true)}
/>
</>
) : (
<div className="w-full h-full bg-muted" />
)}
</div>
);
}
function Lightbox({ function Lightbox({
images, images,
startIndex, startIndex,
@ -242,11 +281,10 @@ export default function GalleryPage() {
className="group relative aspect-square rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer" className="group relative aspect-square rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer"
data-testid={`button-gallery-image-${i}`} data-testid={`button-gallery-image-${i}`}
> >
<img <LazyImage
src={img.thumb} src={thumbUrl(img.thumb)}
alt={img.fileName} alt={img.fileName}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" className="absolute inset-0 w-full h-full transition-transform duration-500 group-hover:scale-110"
loading="lazy"
/> />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" /> <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<div className="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@ -114,7 +114,7 @@ async function refreshAccessToken(refreshToken: string): Promise<DropboxTokens>
return tokens; return tokens;
} }
async function getValidAccessToken(): Promise<string | null> { export async function getValidAccessToken(): Promise<string | null> {
let tokens = loadTokens(); let tokens = loadTokens();
if (!tokens) return null; if (!tokens) return null;
@ -200,6 +200,7 @@ async function getTemporaryLinks(accessToken: string, paths: string[]): Promise<
return results; return results;
} }
const galleryCache: { data: GalleryImage[]; timestamp: number } = { data: [], timestamp: 0 }; const galleryCache: { data: GalleryImage[]; timestamp: number } = { data: [], timestamp: 0 };
const CACHE_DURATION = 30 * 60 * 1000; const CACHE_DURATION = 30 * 60 * 1000;

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ import { seedDatabase } from "./seed";
import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope } from "./horoscope-generator"; import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope } from "./horoscope-generator";
import { analyzeAllArticleImages, getCachedFocalPoints } from "./focal-point"; import { analyzeAllArticleImages, getCachedFocalPoints } from "./focal-point";
import { optimizeImage } from "./image-optimizer"; import { optimizeImage } from "./image-optimizer";
import { getAuthUrl, exchangeCodeForTokens, isConnected, fetchGalleryFromDropbox } from "./dropbox"; import { getAuthUrl, exchangeCodeForTokens, isConnected, fetchGalleryFromDropbox, getValidAccessToken } from "./dropbox";
import multer from "multer"; import multer from "multer";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
@ -233,6 +233,48 @@ export async function registerRoutes(
res.json({ connected: isConnected() }); res.json({ connected: isConnected() });
}); });
const thumbCache = new Map<string, { data: Buffer; timestamp: number }>();
const THUMB_CACHE_TTL = 30 * 60 * 1000;
app.get("/api/gallery/thumb", async (req, res) => {
const src = req.query.src as string;
if (!src) return res.status(400).send("Missing src");
const cached = thumbCache.get(src);
if (cached && Date.now() - cached.timestamp < THUMB_CACHE_TTL) {
res.set("Content-Type", "image/jpeg");
res.set("Cache-Control", "public, max-age=1800");
return res.send(cached.data);
}
try {
const resp = await fetch(src);
if (!resp.ok) return res.status(502).send("Upstream error");
const arrayBuf = await resp.arrayBuffer();
const fullBuf = Buffer.from(arrayBuf);
const sharp = (await import("sharp")).default;
const thumbBuf = await sharp(fullBuf)
.resize(400, 400, { fit: "cover", position: "attention" })
.jpeg({ quality: 70 })
.toBuffer();
thumbCache.set(src, { data: thumbBuf, timestamp: Date.now() });
if (thumbCache.size > 300) {
const oldest = [...thumbCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
for (let i = 0; i < 50; i++) thumbCache.delete(oldest[i][0]);
}
res.set("Content-Type", "image/jpeg");
res.set("Cache-Control", "public, max-age=1800");
res.send(thumbBuf);
} catch (err: any) {
res.status(500).send(err.message);
}
});
app.post("/api/dropbox/refresh-gallery", async (_req, res) => { app.post("/api/dropbox/refresh-gallery", async (_req, res) => {
try { try {
const images = await fetchGalleryFromDropbox(); const images = await fetchGalleryFromDropbox();