144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
|
|
import { useCallback, useEffect, useState } from 'react';
|
|||
|
|
import type { ShipImageInfo } from '../../entities/shipImage/model/types';
|
|||
|
|
import { fetchShipImagesByImo, toHighResUrl } from '../../entities/shipImage/api/fetchShipImages';
|
|||
|
|
|
|||
|
|
interface ShipImageModalProps {
|
|||
|
|
/** 갤러리에서 전달받은 전체 이미지 목록 (있으면 API 호출 생략) */
|
|||
|
|
images?: ShipImageInfo[];
|
|||
|
|
/** 시작 인덱스 */
|
|||
|
|
initialIndex?: number;
|
|||
|
|
/** fallback: 첫 번째 이미지 경로 (images 없을 때) */
|
|||
|
|
initialImagePath?: string;
|
|||
|
|
/** 전체 이미지 수 (images 없을 때 표시용) */
|
|||
|
|
totalCount?: number;
|
|||
|
|
/** IMO — images 없을 때 API 호출용 */
|
|||
|
|
imo?: number;
|
|||
|
|
vesselName?: string;
|
|||
|
|
onClose: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ShipImageModal = ({
|
|||
|
|
images: preloadedImages,
|
|||
|
|
initialIndex = 0,
|
|||
|
|
initialImagePath,
|
|||
|
|
totalCount,
|
|||
|
|
imo,
|
|||
|
|
vesselName,
|
|||
|
|
onClose,
|
|||
|
|
}: ShipImageModalProps) => {
|
|||
|
|
const [index, setIndex] = useState(initialIndex);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [error, setError] = useState(false);
|
|||
|
|
|
|||
|
|
// 전체 이미지 목록: preloaded가 있으면 그것을 사용, 없으면 API로 로드
|
|||
|
|
const [fetchedImages, setFetchedImages] = useState<ShipImageInfo[] | null>(null);
|
|||
|
|
const needsFetch = !preloadedImages && !!imo && imo > 0;
|
|||
|
|
const [fetchingAll, setFetchingAll] = useState(needsFetch);
|
|||
|
|
|
|||
|
|
const allImages = preloadedImages ?? fetchedImages;
|
|||
|
|
const total = allImages ? allImages.length : (totalCount ?? 1);
|
|||
|
|
const hasPrev = index > 0;
|
|||
|
|
const hasNext = index < total - 1;
|
|||
|
|
|
|||
|
|
// 현재 이미지 URL 결정 (모달은 항상 고화질)
|
|||
|
|
const currentImageUrl = (() => {
|
|||
|
|
if (allImages && allImages[index]) return toHighResUrl(allImages[index].path);
|
|||
|
|
if (index === 0 && initialImagePath) return toHighResUrl(initialImagePath);
|
|||
|
|
return null;
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
// preloaded 없을 때만 API 호출
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!needsFetch) return;
|
|||
|
|
const ac = new AbortController();
|
|||
|
|
fetchShipImagesByImo(imo!, ac.signal).then((result) => {
|
|||
|
|
if (ac.signal.aborted) return;
|
|||
|
|
setFetchedImages(result.length > 0 ? result : null);
|
|||
|
|
setFetchingAll(false);
|
|||
|
|
});
|
|||
|
|
return () => ac.abort();
|
|||
|
|
}, [needsFetch, imo]);
|
|||
|
|
|
|||
|
|
const goPrev = useCallback(() => {
|
|||
|
|
if (hasPrev) { setIndex((i) => i - 1); setLoading(true); setError(false); }
|
|||
|
|
}, [hasPrev]);
|
|||
|
|
|
|||
|
|
const goNext = useCallback(() => {
|
|||
|
|
if (!hasNext) return;
|
|||
|
|
setIndex((i) => i + 1);
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(false);
|
|||
|
|
}, [hasNext]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const onKey = (e: KeyboardEvent) => {
|
|||
|
|
if (e.key === 'Escape') onClose();
|
|||
|
|
else if (e.key === 'ArrowLeft') goPrev();
|
|||
|
|
else if (e.key === 'ArrowRight') goNext();
|
|||
|
|
};
|
|||
|
|
window.addEventListener('keydown', onKey);
|
|||
|
|
return () => window.removeEventListener('keydown', onKey);
|
|||
|
|
}, [onClose, goPrev, goNext]);
|
|||
|
|
|
|||
|
|
// 현재 이미지 메타데이터
|
|||
|
|
const currentMeta = allImages?.[index] ?? null;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="ship-image-modal" onClick={onClose}>
|
|||
|
|
<div className="ship-image-modal__content" onClick={(e) => e.stopPropagation()}>
|
|||
|
|
{/* 헤더 */}
|
|||
|
|
<div className="ship-image-modal__header">
|
|||
|
|
<span className="ship-image-modal__title">
|
|||
|
|
{vesselName && <strong>{vesselName}</strong>}
|
|||
|
|
{total > 1 && <span className="ship-image-modal__counter">{index + 1} / {total}</span>}
|
|||
|
|
</span>
|
|||
|
|
<button className="ship-image-modal__close" onClick={onClose} aria-label="닫기">
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 이미지 영역 */}
|
|||
|
|
<div className="ship-image-modal__body">
|
|||
|
|
{hasPrev && (
|
|||
|
|
<button className="ship-image-modal__nav ship-image-modal__nav--prev" onClick={goPrev} aria-label="이전">
|
|||
|
|
‹
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="ship-image-modal__img-wrap">
|
|||
|
|
{(loading || fetchingAll) && !error && <div className="ship-image-modal__spinner" />}
|
|||
|
|
{error && <div className="ship-image-modal__error">이미지를 불러올 수 없습니다</div>}
|
|||
|
|
{currentImageUrl ? (
|
|||
|
|
<img
|
|||
|
|
key={currentImageUrl}
|
|||
|
|
src={currentImageUrl}
|
|||
|
|
alt={vesselName || '선박 사진'}
|
|||
|
|
className="ship-image-modal__img"
|
|||
|
|
style={{ opacity: loading ? 0 : 1 }}
|
|||
|
|
onLoad={() => setLoading(false)}
|
|||
|
|
onError={() => { setLoading(false); setError(true); }}
|
|||
|
|
/>
|
|||
|
|
) : fetchingAll ? null : (
|
|||
|
|
<div className="ship-image-modal__error">이미지를 불러올 수 없습니다</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{hasNext && (
|
|||
|
|
<button className="ship-image-modal__nav ship-image-modal__nav--next" onClick={goNext} aria-label="다음">
|
|||
|
|
›
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 푸터 */}
|
|||
|
|
<div className="ship-image-modal__footer">
|
|||
|
|
{currentMeta?.copyright && <span>{currentMeta.copyright}</span>}
|
|||
|
|
{currentMeta?.date && <span>{currentMeta.date}</span>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default ShipImageModal;
|