mono/packages/ui/src/hooks/useResponsiveImage.ts
2026-04-11 21:06:22 +02:00

162 lines
6.6 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import { ResponsiveData } from '@/components/ResponsiveImage';
import { serverUrl } from '@/lib/db';
interface UseResponsiveImageProps {
src: string | File | null;
responsiveSizes?: number[];
formats?: string[];
enabled?: boolean;
apiUrl?: string;
}
const DEFAULT_SIZES = [180, 640, 1024];
const DEFAULT_FORMATS = ['avif', 'webp', 'jpeg'];
// Module-level cache to deduplicate requests
// Key: stringified request params, Value: Promise<ResponsiveData>
const requestCache = new Map<string, Promise<ResponsiveData>>();
export const useResponsiveImage = ({
src,
responsiveSizes = DEFAULT_SIZES,
formats = DEFAULT_FORMATS,
enabled = true,
apiUrl = serverUrl,
}: UseResponsiveImageProps) => {
const [data, setData] = useState<ResponsiveData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Stable key for deps — only changes when array contents actually change
const sizesKey = useMemo(() => JSON.stringify(responsiveSizes), [responsiveSizes]);
const formatsKey = useMemo(() => JSON.stringify(formats), [formats]);
useEffect(() => {
let isMounted = true;
const generateResponsiveImages = async () => {
if (!src || !enabled) {
if (isMounted) {
// Only reset if disabled or no src, but if we already have data we might want to keep it?
// actually if distinct src change, reset. if just disabled, maybe do nothing?
// For now, if disabled, we just don't start.
if (!src) {
setData(null);
setLoading(false);
}
}
return;
}
if (isMounted) {
setLoading(true);
setError(null);
}
try {
// Only cache string URLs (remote images)
// File objects are harder to cache reliably and usually come from user input anyway
if (typeof src === 'string') {
// Check for Data URI
if (src.startsWith('data:')) {
if (isMounted) {
setData({
img: {
src: src,
width: 0, // Unknown dimensions without parsing
height: 0,
format: 'unknown'
},
sources: [] // No alternative sources for raw data URI
});
setLoading(false);
}
return;
}
const cacheKey = JSON.stringify({ src, sizes: responsiveSizes, formats, apiUrl });
if (!requestCache.has(cacheKey)) {
const requestPromise = (async () => {
const serverBase = apiUrl;
// Resolve relative URLs to absolute so the server-side API can fetch them
let resolvedSrc = src;
if (src.startsWith('/')) {
resolvedSrc = `${window.location.origin}${src}`;
} else if (src.startsWith('./')) {
// For files like ./image033.jpg, resolve relative to current path or origin
// Standard URL constructor resolves './' against the base URL
resolvedSrc = new URL(src, window.location.href).href;
} else if (!src.startsWith('http://') && !src.startsWith('https://')) {
resolvedSrc = new URL(src, window.location.href).href;
}
const params = new URLSearchParams({
url: resolvedSrc,
sizes: JSON.stringify(responsiveSizes),
formats: JSON.stringify(formats),
});
const response = await fetch(`${serverBase}/api/images/responsive?${params.toString()}`);
if (!response.ok) {
const txt = await response.text();
// Remove from cache on error so it can be retried
requestCache.delete(cacheKey);
throw new Error(txt || `Failed to generate responsive images for ${src}`);
}
return response.json();
})();
requestCache.set(cacheKey, requestPromise);
}
const result = await requestCache.get(cacheKey)!;
if (isMounted) {
setData(result);
}
} else {
// Handle File objects (no caching)
const formData = new FormData();
formData.append('file', src);
formData.append('sizes', JSON.stringify(responsiveSizes));
formData.append('formats', JSON.stringify(formats));
const response = await fetch(`${serverUrl}/api/images/responsive`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const txt = await response.text();
throw new Error(txt || 'Failed to generate responsive images');
}
const result = await response.json();
if (isMounted) {
setData(result);
}
}
} catch (err: any) {
console.error('Error generating responsive images:', err);
if (isMounted) {
setError(err.message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
generateResponsiveImages();
return () => {
isMounted = false;
};
}, [src, sizesKey, formatsKey, enabled]);
return { data, loading, error };
};