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 (
);
};