Før mer endring av detaljvisninger

This commit is contained in:
Erol Haagenrud 2026-05-20 07:38:43 +02:00
parent 336d9342dd
commit 2160d8a9f6
2 changed files with 206 additions and 43 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

View file

@ -13,7 +13,7 @@
* --------------------------------------------------------------------------- * ---------------------------------------------------------------------------
*/ */
import { useState, useEffect } from 'react'; import { useEffect, useRef, useState } from 'react';
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants"; import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, getPublicCourseDisplayName, parseJson as parseSharedJson, slugify } from "@/app/facilityData"; import { STATUS_ICON_PATHS, buildMapUrl, getPrimaryStatus, getPublicCourseDisplayName, parseJson as parseSharedJson, slugify } from "@/app/facilityData";
@ -307,7 +307,10 @@ const ICONS = {
camera: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></>, camera: <><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></>,
webcam: <><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></>, webcam: <><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></>,
chart: <><path d="M18 20V10M12 20V4M6 20v-6"/></>, chart: <><path d="M18 20V10M12 20V4M6 20v-6"/></>,
weather: <><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></> weather: <><path d="M12 2v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="M20 12h2"/><path d="m19.07 4.93-1.41 1.41"/><path d="M15.947 12.65a4 4 0 0 0-5.925-4.128"/><path d="M13 22H7a5 5 0 1 1 4.9-6H13a3 3 0 0 1 0 6Z"/></>,
chevronLeft: <path d="m15 18-6-6 6-6" />,
chevronRight: <path d="m9 18 6-6-6-6" />,
close: <><path d="M18 6 6 18" /><path d="m6 6 12 12" /></>,
}; };
const SOCIAL_ICONS: Record<string, React.ReactNode> = { const SOCIAL_ICONS: Record<string, React.ReactNode> = {
@ -336,6 +339,8 @@ export default function FacilityDetailView({
const [showBackToTop, setShowBackToTop] = useState(false); const [showBackToTop, setShowBackToTop] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0); const [currentSlide, setCurrentSlide] = useState(0);
const [activeMedia, setActiveMedia] = useState<ActiveMedia | null>(null); const [activeMedia, setActiveMedia] = useState<ActiveMedia | null>(null);
const lightboxTouchStartX = useRef<number | null>(null);
const lightboxTouchStartY = useRef<number | null>(null);
const parseJson = (val: any, fallback: any) => { const parseJson = (val: any, fallback: any) => {
return parseSharedJson(val, fallback); return parseSharedJson(val, fallback);
@ -498,7 +503,7 @@ export default function FacilityDetailView({
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setActiveMedia(null); closeMedia();
return; return;
} }
@ -545,6 +550,7 @@ export default function FacilityDetailView({
const activeImage = activeMedia?.type === 'image' ? imageGalleryItems[activeMedia.index] : null; const activeImage = activeMedia?.type === 'image' ? imageGalleryItems[activeMedia.index] : null;
const activeVideo = activeMedia?.type === 'video' ? facilityVideos[activeMedia.index] : null; const activeVideo = activeMedia?.type === 'video' ? facilityVideos[activeMedia.index] : null;
const activeMediaCount = activeMedia?.type === 'image' ? imageGalleryItems.length : activeMedia?.type === 'video' ? facilityVideos.length : 0; const activeMediaCount = activeMedia?.type === 'image' ? imageGalleryItems.length : activeMedia?.type === 'video' ? facilityVideos.length : 0;
const canCycleActiveMedia = activeMediaCount > 1;
const cycleActiveMedia = (direction: -1 | 1) => { const cycleActiveMedia = (direction: -1 | 1) => {
setActiveMedia((current) => { setActiveMedia((current) => {
@ -556,6 +562,69 @@ export default function FacilityDetailView({
}); });
}; };
const shouldUseNativeFullscreen = () => {
if (typeof window === "undefined") return false;
return window.matchMedia("(max-width: 767px)").matches;
};
const requestNativeFullscreen = () => {
if (typeof document === "undefined" || !shouldUseNativeFullscreen()) return;
const root = document.documentElement;
if (document.fullscreenElement || !root.requestFullscreen) return;
void root.requestFullscreen().catch(() => {});
};
const exitNativeFullscreen = () => {
if (typeof document === "undefined" || !document.fullscreenElement || !document.exitFullscreen) return;
void document.exitFullscreen().catch(() => {});
};
const openMedia = (media: ActiveMedia) => {
requestNativeFullscreen();
setActiveMedia(media);
};
const closeMedia = () => {
setActiveMedia(null);
exitNativeFullscreen();
};
const resetLightboxTouchTracking = () => {
lightboxTouchStartX.current = null;
lightboxTouchStartY.current = null;
};
const handleLightboxTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
const touch = event.changedTouches[0];
lightboxTouchStartX.current = touch?.clientX ?? null;
lightboxTouchStartY.current = touch?.clientY ?? null;
};
const handleLightboxTouchEnd = (event: React.TouchEvent<HTMLDivElement>) => {
if (!canCycleActiveMedia) {
resetLightboxTouchTracking();
return;
}
const startX = lightboxTouchStartX.current;
const startY = lightboxTouchStartY.current;
const touch = event.changedTouches[0];
if (startX === null || startY === null || !touch) {
resetLightboxTouchTracking();
return;
}
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
resetLightboxTouchTracking();
if (Math.abs(deltaX) < 48 || Math.abs(deltaX) <= Math.abs(deltaY)) {
return;
}
cycleActiveMedia(deltaX < 0 ? 1 : -1);
};
return ( return (
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]"> <main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
@ -899,7 +968,7 @@ export default function FacilityDetailView({
</div> </div>
<button <button
type="button" type="button"
onClick={() => setActiveMedia({ type: 'image', index: 0 })} onClick={() => openMedia({ type: 'image', index: 0 })}
className="btn btn-secondary" className="btn btn-secondary"
> >
Åpne galleri Åpne galleri
@ -914,7 +983,7 @@ export default function FacilityDetailView({
<button <button
key={`${image}-${index}`} key={`${image}-${index}`}
type="button" type="button"
onClick={() => setActiveMedia({ type: 'image', index })} onClick={() => openMedia({ type: 'image', index })}
className="group relative overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]" className="group relative overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]"
> >
<div className="aspect-[4/3]"> <div className="aspect-[4/3]">
@ -937,9 +1006,6 @@ export default function FacilityDetailView({
})} })}
</div> </div>
<p className="mt-4 text-sm text-[#536256]">
{imageGalleryItems.length} bilde{imageGalleryItems.length === 1 ? '' : 'r'} tilgjengelig. Lysboksen åpner først når brukeren klikker, denne seksjonen holder seg lett initial last.
</p>
</article> </article>
)} )}
@ -962,7 +1028,7 @@ export default function FacilityDetailView({
<button <button
key={`${video.url}-${index}`} key={`${video.url}-${index}`}
type="button" type="button"
onClick={() => setActiveMedia({ type: 'video', index })} onClick={() => openMedia({ type: 'video', index })}
className="group text-left" className="group text-left"
> >
<div className="relative overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f] shadow-sm"> <div className="relative overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f] shadow-sm">
@ -1231,56 +1297,113 @@ export default function FacilityDetailView({
{activeMedia && ( {activeMedia && (
<div <div
className="fixed inset-0 z-[105] bg-[#11280f]/82 px-4 py-5 backdrop-blur-sm md:px-8 md:py-8" className="fixed inset-0 z-[105] bg-[#081107]/92 backdrop-blur-md"
onClick={() => setActiveMedia(null)}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={activeMedia.type === 'image' ? `Bildegalleri for ${facility.name}` : `Videogalleri for ${facility.name}`} aria-label={activeMedia.type === 'image' ? `Bildegalleri for ${facility.name}` : `Videogalleri for ${facility.name}`}
> >
<div className="mx-auto flex h-full max-w-6xl items-center justify-center"> <button
<div className="w-full" onClick={(event) => event.stopPropagation()}> type="button"
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-white"> className="absolute inset-0 z-0 cursor-zoom-out"
<div> onClick={closeMedia}
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60"> aria-label="Lukk galleri"
{activeMedia.type === 'image' ? 'Bilder' : 'Video'} />
</p> <div
<p className="mt-1 text-lg font-black md:text-2xl"> className="pointer-events-none relative z-10 flex h-[100dvh] w-full items-center justify-center"
{activeMedia.type === 'image' onTouchStart={handleLightboxTouchStart}
? `${activeMedia.index + 1} av ${activeMediaCount}` onTouchEnd={handleLightboxTouchEnd}
: activeVideo?.title || `Video ${activeMedia.index + 1} av ${activeMediaCount}`} onTouchCancel={resetLightboxTouchTracking}
</p> >
</div> <div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 px-3 pt-3 text-white md:px-6 md:pt-6">
<div className="pointer-events-auto hidden rounded-full border border-white/10 bg-black/45 px-3 py-2 shadow-lg backdrop-blur-md md:block md:px-4">
<div className="flex flex-wrap items-center gap-2"> <p className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">
{activeMediaCount > 1 ? ( {activeMedia.type === 'image' ? 'Bilder' : getFacilityVideoProviderLabel(activeVideo?.provider || 'external')}
<> </p>
<button type="button" onClick={() => cycleActiveMedia(-1)} className="btn btn-secondary"> <p className="mt-1 text-sm font-black md:text-base">
Forrige {activeMedia.type === 'image'
</button> ? `${activeMedia.index + 1} / ${activeMediaCount}`
<button type="button" onClick={() => cycleActiveMedia(1)} className="btn btn-secondary"> : activeVideo?.title || `Video ${activeMedia.index + 1} / ${activeMediaCount}`}
Neste </p>
</button>
</>
) : null}
<button type="button" onClick={() => setActiveMedia(null)} className="btn btn-primary">
Lukk
</button>
</div>
</div> </div>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
closeMedia();
}}
className="pointer-events-auto inline-flex h-11 w-11 items-center justify-center rounded-full border border-white/12 bg-black/45 text-white shadow-lg backdrop-blur-md transition hover:bg-black/65 md:h-12 md:w-12"
aria-label="Lukk galleri"
>
<Icon className="h-5 w-5" children={ICONS.close} />
</button>
</div>
{canCycleActiveMedia ? (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
cycleActiveMedia(-1);
}}
className="pointer-events-auto absolute inset-y-0 left-0 z-10 block w-1/4 min-w-[88px] md:hidden"
aria-label="Forrige element"
/>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
cycleActiveMedia(1);
}}
className="pointer-events-auto absolute inset-y-0 right-0 z-10 block w-1/4 min-w-[88px] md:hidden"
aria-label="Neste element"
/>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
cycleActiveMedia(-1);
}}
className="pointer-events-auto absolute left-2 top-1/2 z-20 hidden h-14 w-14 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-black/45 text-white shadow-lg backdrop-blur-md transition hover:bg-black/65 md:inline-flex md:left-6"
aria-label="Forrige element"
>
<Icon className="h-5 w-5 md:h-6 md:w-6" children={ICONS.chevronLeft} />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
cycleActiveMedia(1);
}}
className="pointer-events-auto absolute right-2 top-1/2 z-20 hidden h-14 w-14 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-black/45 text-white shadow-lg backdrop-blur-md transition hover:bg-black/65 md:inline-flex md:right-6"
aria-label="Neste element"
>
<Icon className="h-5 w-5 md:h-6 md:w-6" children={ICONS.chevronRight} />
</button>
</>
) : null}
<div className="flex h-[100dvh] w-full items-center justify-center px-0 pb-0 pt-0 md:px-16 md:pb-8 md:pt-8">
{activeMedia.type === 'image' && activeImage ? ( {activeMedia.type === 'image' && activeImage ? (
<div className="overflow-hidden rounded-[2rem] border border-white/10 bg-black shadow-2xl"> <div
className="pointer-events-auto flex h-full w-full items-center justify-center"
onClick={(event) => event.stopPropagation()}
>
<img <img
src={activeImage} src={activeImage}
alt={getGalleryImageAlt(activeImage)} alt={getGalleryImageAlt(activeImage)}
className="max-h-[78vh] w-full object-contain" className="block max-h-[100dvh] max-w-[100vw] object-contain shadow-[0_25px_80px_rgba(0,0,0,0.42)] md:max-h-[calc(100dvh-4rem)] md:max-w-[calc(100vw-8rem)] md:rounded-[1.5rem]"
decoding="async" decoding="async"
/> />
</div> </div>
) : null} ) : null}
{activeMedia.type === 'video' && activeVideo ? ( {activeMedia.type === 'video' && activeVideo ? (
<div className="overflow-hidden rounded-[2rem] border border-white/10 bg-black shadow-2xl"> <div
className="pointer-events-auto w-full max-w-[min(96vw,1200px)] overflow-hidden rounded-[1.6rem] border border-white/10 bg-black shadow-[0_25px_80px_rgba(0,0,0,0.42)] md:max-w-[min(92vw,1360px)]"
onClick={(event) => event.stopPropagation()}
>
<div className="aspect-video w-full"> <div className="aspect-video w-full">
<iframe <iframe
src={activeVideo.embedUrl} src={activeVideo.embedUrl}
@ -1293,6 +1416,46 @@ export default function FacilityDetailView({
</div> </div>
) : null} ) : null}
</div> </div>
{activeMedia.type === 'image' && imageGalleryItems.length > 1 ? (
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-20 hidden justify-center px-6 md:flex">
<div className="pointer-events-auto flex max-w-[min(92vw,1040px)] gap-2 overflow-x-auto rounded-[1.25rem] border border-white/10 bg-black/40 px-3 py-3 shadow-lg backdrop-blur-md [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{imageGalleryItems.map((image, index) => {
const isActive = index === activeMedia.index;
return (
<button
key={`${image}-${index}`}
type="button"
onClick={(event) => {
event.stopPropagation();
openMedia({ type: 'image', index });
}}
className={`relative h-14 w-20 shrink-0 overflow-hidden rounded-xl border transition ${
isActive
? 'border-white/90 ring-2 ring-white/60'
: 'border-white/10 opacity-75 hover:opacity-100'
}`}
aria-label={`Vis bilde ${index + 1}`}
>
<img
src={image}
alt={getGalleryImageAlt(image)}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</button>
);
})}
</div>
</div>
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-20 flex justify-center px-4 md:hidden">
<div className="pointer-events-auto rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs font-black tracking-[0.18em] text-white shadow-lg backdrop-blur-md">
{activeMedia.index + 1} / {activeMediaCount}
</div>
</div>
</div> </div>
</div> </div>
)} )}