258 lines
9.9 KiB
JavaScript
258 lines
9.9 KiB
JavaScript
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
import { animated, useTransition } from '@react-spring/web';
|
|
import { api } from '../../services/apiClient';
|
|
|
|
function getBrowserApiBase() {
|
|
const apiUrl = import.meta.env.PUBLIC_API_URL || '';
|
|
if (typeof window === 'undefined') {
|
|
return apiUrl || 'http://localhost:8001/api/';
|
|
}
|
|
|
|
try {
|
|
if (apiUrl) {
|
|
const parsed = new URL(apiUrl);
|
|
const internalHosts = new Set(['backend', 'localhost', '127.0.0.1']);
|
|
const isInternalDockerHost = parsed.hostname === 'backend';
|
|
if (!isInternalDockerHost) {
|
|
return parsed.toString();
|
|
}
|
|
}
|
|
} catch {
|
|
// fallback is handled below
|
|
}
|
|
|
|
return `${window.location.protocol}//${window.location.hostname}:8001/api/`;
|
|
}
|
|
|
|
export function resolveMediaUrl(pathOrUrl) {
|
|
if (!pathOrUrl || typeof window === 'undefined') return '';
|
|
|
|
let finalSrc = String(pathOrUrl);
|
|
const apiUrl = getBrowserApiBase();
|
|
const baseUrl = apiUrl.replace(/api\/?$/, '');
|
|
|
|
if (/^https?:\/\//i.test(finalSrc)) {
|
|
if (finalSrc.includes('backend:8000')) {
|
|
return finalSrc.replace('http://backend:8000/', baseUrl);
|
|
}
|
|
return finalSrc;
|
|
}
|
|
|
|
if (finalSrc.startsWith('/')) {
|
|
return `${baseUrl.replace(/\/$/, '')}${finalSrc}`;
|
|
}
|
|
|
|
return new URL(finalSrc, apiUrl).toString();
|
|
}
|
|
|
|
function isProtectedMediaUrl(url) {
|
|
return typeof url === 'string' && /\/api\/fleet\//i.test(url);
|
|
}
|
|
|
|
function revokeObjectUrls(entries = {}) {
|
|
Object.values(entries).forEach((url) => {
|
|
if (typeof url === 'string' && url.startsWith('blob:')) {
|
|
window.URL.revokeObjectURL(url);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function useAuthenticatedMediaSources(mediaPaths = []) {
|
|
const [authenticatedSources, setAuthenticatedSources] = useState({});
|
|
const authenticatedSourcesRef = useRef({});
|
|
|
|
const resolvedMediaPaths = useMemo(() => {
|
|
const unique = new Set();
|
|
if (!Array.isArray(mediaPaths)) {
|
|
return [];
|
|
}
|
|
mediaPaths.forEach((item) => {
|
|
const resolved = resolveMediaUrl(item);
|
|
if (resolved) {
|
|
unique.add(resolved);
|
|
}
|
|
});
|
|
return Array.from(unique);
|
|
}, [mediaPaths]);
|
|
const resolvedMediaPathsKey = useMemo(() => resolvedMediaPaths.join('|'), [resolvedMediaPaths]);
|
|
|
|
useEffect(() => {
|
|
authenticatedSourcesRef.current = authenticatedSources;
|
|
}, [authenticatedSources]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const protectedPaths = resolvedMediaPaths.filter((url) => isProtectedMediaUrl(url));
|
|
|
|
if (protectedPaths.length === 0) {
|
|
setAuthenticatedSources((previous) => {
|
|
revokeObjectUrls(previous);
|
|
return {};
|
|
});
|
|
return undefined;
|
|
}
|
|
|
|
(async () => {
|
|
const nextSources = {};
|
|
const createdObjectUrls = [];
|
|
|
|
const fetched = await Promise.all(
|
|
protectedPaths.map(async (url) => {
|
|
try {
|
|
const blob = await api.get(url, { responseType: 'blob' });
|
|
const blobUrl = window.URL.createObjectURL(blob);
|
|
createdObjectUrls.push(blobUrl);
|
|
return [url, blobUrl];
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
fetched.forEach((entry) => {
|
|
if (!entry) return;
|
|
const [url, blobUrl] = entry;
|
|
nextSources[url] = blobUrl;
|
|
});
|
|
|
|
if (cancelled) {
|
|
createdObjectUrls.forEach((blobUrl) => window.URL.revokeObjectURL(blobUrl));
|
|
return;
|
|
}
|
|
|
|
setAuthenticatedSources((previous) => {
|
|
revokeObjectUrls(previous);
|
|
return nextSources;
|
|
});
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [resolvedMediaPathsKey]);
|
|
|
|
useEffect(() => () => {
|
|
revokeObjectUrls(authenticatedSourcesRef.current);
|
|
}, []);
|
|
|
|
const getAuthenticatedMediaSrc = (pathOrUrl) => {
|
|
const resolved = resolveMediaUrl(pathOrUrl);
|
|
if (!resolved) {
|
|
return '';
|
|
}
|
|
return authenticatedSources[resolved] || resolved;
|
|
};
|
|
|
|
return {
|
|
getAuthenticatedMediaSrc,
|
|
};
|
|
}
|
|
|
|
export default function WorkOrderImageCarousel({ images = [], editMode = false, emptyLabel = 'Nema priložene tehničke dokumentacije' }) {
|
|
const normalizedImages = useMemo(() => (
|
|
Array.isArray(images)
|
|
? images.filter(Boolean)
|
|
: (typeof images === 'string' && images ? [images] : [])
|
|
), [images]);
|
|
const [currentImgIdx, setCurrentImgIdx] = useState(0);
|
|
const { getAuthenticatedMediaSrc } = useAuthenticatedMediaSources(normalizedImages);
|
|
|
|
useEffect(() => {
|
|
setCurrentImgIdx(0);
|
|
}, [normalizedImages.length]);
|
|
|
|
useEffect(() => {
|
|
if (editMode || normalizedImages.length <= 1) return undefined;
|
|
|
|
const interval = window.setInterval(() => {
|
|
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
|
|
}, 3000);
|
|
|
|
return () => window.clearInterval(interval);
|
|
}, [normalizedImages.length, editMode]);
|
|
|
|
if (normalizedImages.length === 0) {
|
|
return (
|
|
<div className="flex h-64 w-full flex-col items-center justify-center border-b border-border-hairline bg-canvas-deep font-mono text-[11px] uppercase tracking-wider text-text-muted/40">
|
|
<svg className="mb-2 h-8 w-8 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="1.5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
|
</svg>
|
|
{emptyLabel}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const prevSlide = (event) => {
|
|
event.preventDefault();
|
|
setCurrentImgIdx((prev) => (prev - 1 + normalizedImages.length) % normalizedImages.length);
|
|
};
|
|
|
|
const nextSlide = (event) => {
|
|
event.preventDefault();
|
|
setCurrentImgIdx((prev) => (prev + 1) % normalizedImages.length);
|
|
};
|
|
|
|
const finalSrc = getAuthenticatedMediaSrc(normalizedImages[currentImgIdx] || '');
|
|
const imageTransitions = useTransition(currentImgIdx, {
|
|
from: { opacity: 0, transform: 'translate3d(8%,0,0) scale(1.01)' },
|
|
enter: { opacity: 1, transform: 'translate3d(0%,0,0) scale(1)' },
|
|
leave: { opacity: 0, transform: 'translate3d(-8%,0,0) scale(1.01)' },
|
|
config: { tension: 220, friction: 26 },
|
|
});
|
|
|
|
return (
|
|
<div className="group relative h-72 w-full overflow-hidden border-b border-border-hairline bg-canvas-base sm:h-80">
|
|
<div className="relative h-full w-full overflow-hidden">
|
|
{imageTransitions((style, idx) => (
|
|
<animated.img
|
|
key={idx}
|
|
src={getAuthenticatedMediaSrc(normalizedImages[idx] || finalSrc)}
|
|
alt={`Dokumentacija s terena ${idx + 1}`}
|
|
style={style}
|
|
className="absolute inset-0 h-full w-full object-cover will-change-transform"
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/40 to-transparent" />
|
|
|
|
{normalizedImages.length > 1 && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={prevSlide}
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full border border-border-hairline bg-canvas-elevated/80 p-2 text-text-main opacity-0 shadow-lg transition-opacity duration-200 hover:bg-canvas-base group-hover:opacity-100"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={nextSlide}
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full border border-border-hairline bg-canvas-elevated/80 p-2 text-text-main opacity-0 shadow-lg transition-opacity duration-200 hover:bg-canvas-base group-hover:opacity-100"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
|
|
<div className="absolute inset-x-0 bottom-3 z-10 flex justify-center gap-1.5">
|
|
{normalizedImages.map((_, idx) => (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
setCurrentImgIdx(idx);
|
|
}}
|
|
className={`h-1.5 rounded-full transition-all duration-300 ${idx === currentImgIdx ? 'w-4 bg-brand-accent' : 'w-1.5 bg-white/40 hover:bg-white/70'}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|