Før mer endring av detaljvisninger
This commit is contained in:
parent
336d9342dd
commit
2160d8a9f6
2 changed files with 206 additions and 43 deletions
BIN
2026-05-19 19.35.50 teeoff.no b0df9e23eb81.jpg
Normal file
BIN
2026-05-19 19.35.50 teeoff.no b0df9e23eb81.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
|
|
@ -13,7 +13,7 @@
|
|||
* ---------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from "next/dynamic";
|
||||
import { STATUS_MAP, FALLBACK_IMAGE } from "@/config/constants";
|
||||
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"/></>,
|
||||
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"/></>,
|
||||
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> = {
|
||||
|
|
@ -336,6 +339,8 @@ export default function FacilityDetailView({
|
|||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
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) => {
|
||||
return parseSharedJson(val, fallback);
|
||||
|
|
@ -498,7 +503,7 @@ export default function FacilityDetailView({
|
|||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setActiveMedia(null);
|
||||
closeMedia();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +550,7 @@ export default function FacilityDetailView({
|
|||
const activeImage = activeMedia?.type === 'image' ? imageGalleryItems[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 canCycleActiveMedia = activeMediaCount > 1;
|
||||
|
||||
const cycleActiveMedia = (direction: -1 | 1) => {
|
||||
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 (
|
||||
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
|
||||
|
||||
|
|
@ -899,7 +968,7 @@ export default function FacilityDetailView({
|
|||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMedia({ type: 'image', index: 0 })}
|
||||
onClick={() => openMedia({ type: 'image', index: 0 })}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Åpne galleri
|
||||
|
|
@ -914,7 +983,7 @@ export default function FacilityDetailView({
|
|||
<button
|
||||
key={`${image}-${index}`}
|
||||
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]"
|
||||
>
|
||||
<div className="aspect-[4/3]">
|
||||
|
|
@ -937,9 +1006,6 @@ export default function FacilityDetailView({
|
|||
})}
|
||||
</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, så denne seksjonen holder seg lett på initial last.
|
||||
</p>
|
||||
</article>
|
||||
)}
|
||||
|
||||
|
|
@ -962,7 +1028,7 @@ export default function FacilityDetailView({
|
|||
<button
|
||||
key={`${video.url}-${index}`}
|
||||
type="button"
|
||||
onClick={() => setActiveMedia({ type: 'video', index })}
|
||||
onClick={() => openMedia({ type: 'video', index })}
|
||||
className="group text-left"
|
||||
>
|
||||
<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 && (
|
||||
<div
|
||||
className="fixed inset-0 z-[105] bg-[#11280f]/82 px-4 py-5 backdrop-blur-sm md:px-8 md:py-8"
|
||||
onClick={() => setActiveMedia(null)}
|
||||
className="fixed inset-0 z-[105] bg-[#081107]/92 backdrop-blur-md"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
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">
|
||||
<div className="w-full" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-white">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">
|
||||
{activeMedia.type === 'image' ? 'Bilder' : 'Video'}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-black md:text-2xl">
|
||||
{activeMedia.type === 'image'
|
||||
? `${activeMedia.index + 1} av ${activeMediaCount}`
|
||||
: activeVideo?.title || `Video ${activeMedia.index + 1} av ${activeMediaCount}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{activeMediaCount > 1 ? (
|
||||
<>
|
||||
<button type="button" onClick={() => cycleActiveMedia(-1)} className="btn btn-secondary">
|
||||
Forrige
|
||||
</button>
|
||||
<button type="button" onClick={() => cycleActiveMedia(1)} className="btn btn-secondary">
|
||||
Neste
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setActiveMedia(null)} className="btn btn-primary">
|
||||
Lukk
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-0 cursor-zoom-out"
|
||||
onClick={closeMedia}
|
||||
aria-label="Lukk galleri"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none relative z-10 flex h-[100dvh] w-full items-center justify-center"
|
||||
onTouchStart={handleLightboxTouchStart}
|
||||
onTouchEnd={handleLightboxTouchEnd}
|
||||
onTouchCancel={resetLightboxTouchTracking}
|
||||
>
|
||||
<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">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-white/60">
|
||||
{activeMedia.type === 'image' ? 'Bilder' : getFacilityVideoProviderLabel(activeVideo?.provider || 'external')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-black md:text-base">
|
||||
{activeMedia.type === 'image'
|
||||
? `${activeMedia.index + 1} / ${activeMediaCount}`
|
||||
: activeVideo?.title || `Video ${activeMedia.index + 1} / ${activeMediaCount}`}
|
||||
</p>
|
||||
</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 ? (
|
||||
<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
|
||||
src={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"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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">
|
||||
<iframe
|
||||
src={activeVideo.embedUrl}
|
||||
|
|
@ -1293,6 +1416,46 @@ export default function FacilityDetailView({
|
|||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue