116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
"use client";
|
|
import React from "react";
|
|
import Image from "next/image";
|
|
import { Sparkles } from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
import { useSettingsStore } from "../lib/store/settingsStore";
|
|
import { getMediaUrl } from "../lib/media";
|
|
|
|
interface SafeImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
|
src?: string;
|
|
alt?: string;
|
|
className?: string;
|
|
imgClassName?: string;
|
|
fallbackText?: string;
|
|
priority?: boolean;
|
|
sizes?: string;
|
|
quality?: number;
|
|
width?: number;
|
|
height?: number;
|
|
}
|
|
|
|
export default function SafeImage({
|
|
src: rawSrc,
|
|
alt = "تصویر مکمل کنینا",
|
|
className,
|
|
imgClassName,
|
|
fallbackText = "در حال بروزرسانی تصویر...",
|
|
priority = false,
|
|
sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 400px",
|
|
quality = 85,
|
|
width = 500,
|
|
height = 500,
|
|
}: SafeImageProps) {
|
|
const [error, setError] = React.useState(false);
|
|
const [loading, setLoading] = React.useState(!priority);
|
|
const logoUrl = useSettingsStore(state => state.getText('site_logo', ''));
|
|
|
|
const src = React.useMemo(() => getMediaUrl(rawSrc), [rawSrc]);
|
|
|
|
// Check if external domain is in allowed remote patterns or relative path
|
|
const isExternal = typeof src === 'string' && (src.startsWith('http://') || src.startsWith('https://'));
|
|
const isBlobOrData = typeof src === 'string' && (src.startsWith('blob:') || src.startsWith('data:'));
|
|
const isAllowedHost = React.useMemo(() => {
|
|
if (!isExternal || !src) return true;
|
|
try {
|
|
const url = new URL(src);
|
|
const allowedHosts = [
|
|
'canina.ir', 'api.canina.ir', 'stage.canina.ir', 'stageapi.canina.ir',
|
|
'apicanina.parsaaghayi.ir', 'canina.de', 'www.canina.de',
|
|
'images.unsplash.com', 'plus.unsplash.com', 'localhost', '127.0.0.1'
|
|
];
|
|
return allowedHosts.some(h => url.hostname === h || url.hostname.endsWith(`.${h}`));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, [src, isExternal]);
|
|
|
|
if (!src || error) {
|
|
return (
|
|
<div className={cn(
|
|
"flex flex-col items-center justify-center bg-medical-gray-50 border border-medical-gray-200/80 p-2 text-center w-full h-full rounded-2xl relative overflow-hidden select-none aspect-square",
|
|
className
|
|
)}>
|
|
{logoUrl ? (
|
|
<Image
|
|
src={logoUrl}
|
|
alt={alt || "Canina"}
|
|
width={80}
|
|
height={80}
|
|
className="w-12 h-12 object-contain opacity-70 mb-1"
|
|
unoptimized={typeof logoUrl === 'string' && (logoUrl.startsWith('blob:') || logoUrl.startsWith('data:'))}
|
|
/>
|
|
) : (
|
|
<div className="w-10 h-10 rounded-2xl bg-canina-blue/10 border border-canina-blue/20 text-canina-blue flex items-center justify-center font-black text-sm font-mono shadow-xs mb-1">
|
|
CANINA
|
|
</div>
|
|
)}
|
|
<span className="text-[10px] font-black text-medical-gray-800 font-vazir leading-tight">
|
|
Canina Pharma
|
|
</span>
|
|
<span className="text-[8px] font-bold text-canina-blue font-vazir leading-tight mt-0.5 bg-white border border-medical-gray-200 px-2 py-0.5 rounded-full shadow-2xs">
|
|
{fallbackText}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("relative overflow-hidden group flex items-center justify-center", className)}>
|
|
{loading && !priority && (
|
|
<div className="absolute inset-0 bg-medical-gray-50 animate-pulse flex items-center justify-center z-10">
|
|
<Sparkles className="w-6 h-6 text-canina-blue/20" />
|
|
</div>
|
|
)}
|
|
<Image
|
|
src={src}
|
|
alt={alt}
|
|
width={width}
|
|
height={height}
|
|
priority={priority}
|
|
loading={priority ? undefined : "lazy"}
|
|
sizes={sizes}
|
|
quality={quality}
|
|
unoptimized={isBlobOrData || !isAllowedHost}
|
|
className={cn(
|
|
"transition-all duration-500 w-full h-full object-contain",
|
|
imgClassName,
|
|
loading && !priority ? "opacity-0 scale-105" : "opacity-100 scale-100"
|
|
)}
|
|
onLoad={() => setLoading(false)}
|
|
onError={() => setError(true)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|