import { useState, useEffect, type ImgHTMLAttributes, type ReactElement, } from "react"; import { fileService } from "@/services/file-service"; import apiClient from "@/services/api-client"; import { Loader2, ImageIcon } from "lucide-react"; import { useEnabledServices } from "@/hooks/useEnabledServices"; // Global cache to persist blob URLs between component remounts (prevents re-fetching on every page click) const BLOB_CACHE = new Map(); const PENDING_REQUESTS = new Map>(); interface AuthenticatedImageProps extends Omit< ImgHTMLAttributes, "src" > { fileId?: string | null; src?: string | null; fallback?: ReactElement; tenantId?: string | null; } export const AuthenticatedImage = ({ fileId, src, fallback, className, alt = "Image", tenantId, ...props }: AuthenticatedImageProps): ReactElement => { const { isServiceEnabled } = useEnabledServices(); const isFileServiceEnabled = isServiceEnabled("file_attachment"); // Helper to extract fileId from backend preview URL const extractFileIdFromUrl = (url: string | null | undefined): string | null => { if (!url) return null; const match = url.match(/\/files\/([a-f0-9-]{36})\/preview/i); return match ? match[1] : null; }; const extractedFileId = fileId || extractFileIdFromUrl(src); const cacheKey = extractedFileId || src; // 1. Initialize state from cache immediately to prevent blank flash or redundant requests on remount const [blobUrl, setBlobUrl] = useState(() => { return cacheKey ? BLOB_CACHE.get(cacheKey) || null : null; }); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(false); // Helper to check if URL is a backend URL that needs authentication const getIsBackendUrl = (url: string | null | undefined) => { if (!url) return false; const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000/api/v1"; const cleanBase = baseUrl.replace(/\/+$/, ""); // 1. Precise match with configured base URL if (url.includes(`${cleanBase}/files/`) && url.includes("/preview")) return true; // 2. Fallback: Catch URLs pointing to any host with the expected API path structure // This handles cases where backend returns 'localhost' but frontend uses IP, or vice versa. const hasApiPath = url.includes("/api/v1/files/") && url.includes("/preview"); // Also check if it's a relative path const isRelative = url.startsWith("/") && url.includes("/files/") && url.includes("/preview"); return hasApiPath || isRelative; }; const isBackendUrl = getIsBackendUrl(src); const isAuthRequired = !!(extractedFileId || isBackendUrl); useEffect(() => { const currentCachedUrl = cacheKey ? BLOB_CACHE.get(cacheKey) || null : null; // If it's already a blob URL (local preview) or a data URL, use it directly if (src && (src.startsWith("blob:") || src.startsWith("data:"))) { setBlobUrl(src); return; } if (!cacheKey) { setBlobUrl(null); return; } // 2. If we have a fileId or a backend URL, fetch it via authenticated request if (isAuthRequired) { if (!isFileServiceEnabled) { setError(true); return; } // If we already have the blobUrl for this cacheKey, use it and don't fetch if (currentCachedUrl) { setBlobUrl(currentCachedUrl); return; } // Otherwise, we need to fetch. Reset blobUrl to null first to show loading/fallback setBlobUrl(null); let isMounted = true; const fetchImage = async () => { // 3. Check if there's already a pending request for this same image if (PENDING_REQUESTS.has(cacheKey)) { try { const url = await PENDING_REQUESTS.get(cacheKey)!; if (isMounted) setBlobUrl(url); return; } catch (err) { if (isMounted) setError(true); return; } } setIsLoading(true); setError(false); try { const fetchPromise = (async () => { let url: string; if (extractedFileId) { url = await fileService.getPreview(extractedFileId, tenantId || undefined); } else { // If useBackendUrl is true, src is guaranteed to be non-null const headers = tenantId ? { "x-tenant-id": tenantId } : undefined; const response = await apiClient.get(src!, { responseType: "blob", headers, }); url = URL.createObjectURL(response.data); } BLOB_CACHE.set(cacheKey, url); return url; })(); PENDING_REQUESTS.set(cacheKey, fetchPromise); const url = await fetchPromise; PENDING_REQUESTS.delete(cacheKey); if (isMounted) { setBlobUrl(url); } } catch (err) { console.error("Failed to fetch authenticated image:", err); PENDING_REQUESTS.delete(cacheKey); if (isMounted) { setError(true); } } finally { if (isMounted) { setIsLoading(false); } } }; fetchImage(); return () => { isMounted = false; }; } else if (src) { // For other external URLs, use them directly setBlobUrl(src); } }, [fileId, src, cacheKey, isAuthRequired, tenantId, isFileServiceEnabled]); if (isLoading) { return (
); } if (error || (!blobUrl && !src)) { return ( fallback || (
) ); } // IMPORTANT: For authenticated images, never use the raw 'src' in the tag. // We only render if we have a valid blobUrl. const imageSrc = isAuthRequired ? blobUrl : blobUrl || src; if (isAuthRequired && !imageSrc) { return (
); } return ( {alt} ); };