Files
004.erp.generic/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx
mariomitte 4e3bf6dab0
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
patch oko teksta PDFa i UI tablica
2026-07-12 03:25:03 +02:00

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