Etter justering av sidetitler

This commit is contained in:
Erol Haagenrud 2026-04-29 17:43:57 +02:00
parent 8a8690ed92
commit d49aa7b211
5 changed files with 472 additions and 36 deletions

View file

@ -914,7 +914,7 @@ FACILITY_ALLOWED_FIELDS = [
'address', 'zipcode', 'city', 'county', 'lat', 'lng', 'address', 'zipcode', 'city', 'county', 'lat', 'lng',
'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url', 'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_url',
'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url', 'weather_url', 'webcam_url', 'video_url', 'baneguide_url', 'flyfoto_url',
'image_url', 'logo_url', 'front_image_url', 'gallery', 'image_url', 'logo_url', 'front_image_url', 'gallery', 'videos',
'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee', 'amenities', 'greenfee', 'golfpakker', 'rabattert_greenfee',
'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data', 'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data',
'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer',
@ -1010,7 +1010,7 @@ def format_row(row):
d[key] = d[key].isoformat() d[key] = d[key].isoformat()
json_list_fields = [ json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee', 'course_statuses', 'courses', 'gallery', 'videos', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer', 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer',
'weather_forecast', 'vtg_courses_draft' 'weather_forecast', 'vtg_courses_draft'
] ]
@ -3253,6 +3253,7 @@ async def ensure_facility_columns(conn):
ADD COLUMN IF NOT EXISTS meta_title TEXT, ADD COLUMN IF NOT EXISTS meta_title TEXT,
ADD COLUMN IF NOT EXISTS meta_description TEXT, ADD COLUMN IF NOT EXISTS meta_description TEXT,
ADD COLUMN IF NOT EXISTS golfamore_url TEXT, ADD COLUMN IF NOT EXISTS golfamore_url TEXT,
ADD COLUMN IF NOT EXISTS videos JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS golfpakker_url TEXT, ADD COLUMN IF NOT EXISTS golfpakker_url TEXT,
ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB, ADD COLUMN IF NOT EXISTS golfpakker_draft JSONB,
ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ,

View file

@ -557,6 +557,50 @@ const getMediaFieldLabel = (field: string) => {
return 'bildet'; return 'bildet';
}; };
const getYouTubeVideoId = (value: string) => {
const match = value.match(
/(?:youtube\.com\/watch\?v=|youtube\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/i,
);
return match ? match[1] : "";
};
const getVimeoVideoId = (value: string) => {
const match = value.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
return match ? match[1] : "";
};
const isSupportedFacilityVideoUrl = (value: string) => {
const trimmed = String(value || "").trim();
if (!trimmed) return false;
return Boolean(getYouTubeVideoId(trimmed) || getVimeoVideoId(trimmed));
};
const normalizeFacilityVideos = (value: any): Array<{ title: string; url: string; poster: string }> => {
const rawItems = Array.isArray(value)
? value
: typeof value === 'string'
? (() => {
try {
return JSON.parse(value);
} catch {
return [];
}
})()
: [];
if (!Array.isArray(rawItems)) {
return [];
}
return rawItems
.map((item) => ({
title: String(item?.title || "").trim(),
url: String(item?.url || "").trim(),
poster: String(item?.poster || "").trim(),
}))
.filter((item) => item.title || item.url || item.poster);
};
export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) { export default function EditFacilityClient({ initialData, allFacilities }: { initialData: any, allFacilities: any[] }) {
const router = useRouter(); const router = useRouter();
const isCreateMode = typeof initialData?.id !== 'number'; const isCreateMode = typeof initialData?.id !== 'number';
@ -564,6 +608,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
...initialData, ...initialData,
is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false, is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false,
courses: Array.isArray(initialData?.courses) ? initialData.courses : [], courses: Array.isArray(initialData?.courses) ? initialData.courses : [],
videos: normalizeFacilityVideos(initialData?.videos),
}); });
const [activeTab, setActiveTab] = useState('generelt'); const [activeTab, setActiveTab] = useState('generelt');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -656,6 +701,8 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
}; };
const galleryImages = normalizeStringList(formData.gallery); const galleryImages = normalizeStringList(formData.gallery);
const facilityVideos = normalizeFacilityVideos(formData.videos);
const unsupportedFacilityVideos = facilityVideos.filter((video) => video.url && !isSupportedFacilityVideoUrl(video.url));
const setGalleryImages = (images: string[]) => { const setGalleryImages = (images: string[]) => {
handleChange('gallery', Array.from(new Set(images.map((entry) => String(entry || "").trim()).filter(Boolean)))); handleChange('gallery', Array.from(new Set(images.map((entry) => String(entry || "").trim()).filter(Boolean))));
@ -1247,7 +1294,40 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Flyfoto URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('flyfoto_url', 'text')} onChange={e => handleChange('flyfoto_url', e.target.value)} /></div> <div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Flyfoto URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('flyfoto_url', 'text')} onChange={e => handleChange('flyfoto_url', e.target.value)} /></div>
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Vær URL (YR)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('weather_url', 'text')} onChange={e => handleChange('weather_url', e.target.value)} /></div> <div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Vær URL (YR)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('weather_url', 'text')} onChange={e => handleChange('weather_url', e.target.value)} /></div>
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Webkamera URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('webcam_url', 'text')} onChange={e => handleChange('webcam_url', e.target.value)} /></div> <div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Webkamera URL</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('webcam_url', 'text')} onChange={e => handleChange('webcam_url', e.target.value)} /></div>
<div className="flex flex-col gap-2 mb-8"><label className="text-xs font-black uppercase tracking-widest text-gray-600">Video URL (YouTube/Vimeo)</label><input className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none" value={getValue('video_url', 'text')} onChange={e => handleChange('video_url', e.target.value)} /></div> <div className="mb-8 rounded-[2rem] border border-[#11280f]/8 bg-[#F7F9F2] p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-black uppercase tracking-[0.18em] text-[#11280f]">Videoer</p>
<p className="mt-2 max-w-3xl text-sm text-[#536256]">Legg inn én eller flere YouTube- eller Vimeo-lenker. Detaljsiden viser lette forhåndsvisninger og laster selve spilleren først når brukeren åpner videoen.</p>
</div>
<span className="rounded-full bg-white px-3 py-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#536256] shadow-sm">
{facilityVideos.length} videoer
</span>
</div>
</div>
<ListObjectEditor
label="Videogalleri"
value={facilityVideos}
templateKeys={['title', 'url', 'poster']}
onChange={(v) => handleChange('videos', normalizeFacilityVideos(v))}
/>
{unsupportedFacilityVideos.length > 0 ? (
<div className="mb-8 rounded-2xl border border-[#ffb46a] bg-[#fff5e8] px-4 py-3 text-sm font-medium text-[#8a4b08]">
{unsupportedFacilityVideos.length} video{unsupportedFacilityVideos.length === 1 ? '' : 'er'} ser ikke ut som YouTube eller Vimeo. De blir ikke automatisk embeddet detaljsiden før URL-en er korrigert.
</div>
) : null}
<div className="flex flex-col gap-2 mb-8">
<label className="text-xs font-black uppercase tracking-widest text-gray-600">Legacy video URL (fallback)</label>
<input
className="p-4 rounded-2xl border-2 border-gray-300 bg-white text-black text-base font-bold shadow-sm focus:border-[#8bc34a] outline-none"
value={getValue('video_url', 'text')}
onChange={e => handleChange('video_url', e.target.value)}
/>
<p className="text-sm text-[#536256]">Brukes bare hvis videogalleriet over er tomt. Feltet kan beholdes midlertidig mens eldre anlegg migreres.</p>
</div>
<ListObjectEditor <ListObjectEditor
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)" label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"

View file

@ -15,6 +15,10 @@ export type FacilityRecord = {
county?: string | null; county?: string | null;
banetype?: string | null; banetype?: string | null;
image_url?: string | null; image_url?: string | null;
logo_url?: string | null;
gallery?: unknown;
video_url?: string | null;
videos?: unknown;
phone?: string | null; phone?: string | null;
email?: string | null; email?: string | null;
website_url?: string | null; website_url?: string | null;

View file

@ -22,6 +22,19 @@ import CourseDisplay from './CourseDisplay';
import FacilityEditorialHub from './FacilityEditorialHub'; import FacilityEditorialHub from './FacilityEditorialHub';
import FacilityFeedbackForm from './FacilityFeedbackForm'; import FacilityFeedbackForm from './FacilityFeedbackForm';
type FacilityVideo = {
title: string;
url: string;
poster: string;
embedUrl: string;
provider: "youtube" | "vimeo" | "external";
};
type ActiveMedia = {
type: "image" | "video";
index: number;
};
const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), { const FacilityDetailLeafletMap = dynamic(() => import("./FacilityDetailLeafletMap"), {
ssr: false, ssr: false,
loading: () => ( loading: () => (
@ -37,16 +50,26 @@ const formatPhoneForUrl = (phone: string) => {
return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized; return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized;
}; };
const getYouTubeEmbedUrl = (url: string) => { const getYouTubeVideoId = (url: string) => {
const match = url.match( const match = url.match(
/(?:youtube\.com\/watch\?v=|youtube\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/i, /(?:youtube\.com\/watch\?v=|youtube\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,})/i,
); );
return match ? `https://www.youtube.com/embed/${match[1]}?rel=0` : null; return match ? match[1] : null;
};
const getYouTubeEmbedUrl = (url: string) => {
const videoId = getYouTubeVideoId(url);
return videoId ? `https://www.youtube-nocookie.com/embed/${videoId}?rel=0` : null;
};
const getVimeoVideoId = (url: string) => {
const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i);
return match ? match[1] : null;
}; };
const getVimeoEmbedUrl = (url: string) => { const getVimeoEmbedUrl = (url: string) => {
const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i); const videoId = getVimeoVideoId(url);
return match ? `https://player.vimeo.com/video/${match[1]}` : null; return videoId ? `https://player.vimeo.com/video/${videoId}` : null;
}; };
const getFacilityVideoEmbedUrl = (url: string | null | undefined) => { const getFacilityVideoEmbedUrl = (url: string | null | undefined) => {
@ -55,6 +78,93 @@ const getFacilityVideoEmbedUrl = (url: string | null | undefined) => {
return getYouTubeEmbedUrl(raw) || getVimeoEmbedUrl(raw) || raw; return getYouTubeEmbedUrl(raw) || getVimeoEmbedUrl(raw) || raw;
}; };
const getFacilityVideoProvider = (url: string): FacilityVideo["provider"] => {
if (getYouTubeVideoId(url)) return "youtube";
if (getVimeoVideoId(url)) return "vimeo";
return "external";
};
const getFacilityVideoProviderLabel = (provider: FacilityVideo["provider"]) => {
if (provider === "youtube") return "YouTube";
if (provider === "vimeo") return "Vimeo";
return "Video";
};
const getFacilityVideoPoster = (video: Pick<FacilityVideo, "url" | "poster">) => {
const explicitPoster = String(video.poster || "").trim();
if (explicitPoster) return explicitPoster;
const youtubeId = getYouTubeVideoId(video.url);
if (youtubeId) {
return `https://i.ytimg.com/vi_webp/${youtubeId}/hqdefault.webp`;
}
return "";
};
const normalizeFacilityVideos = (value: unknown, legacyUrl?: string | null): FacilityVideo[] => {
const rawVideos = Array.isArray(value) ? value : [];
const seen = new Set<string>();
const normalized = rawVideos
.map((item) => {
const title = String(item && typeof item === "object" ? item.title || "" : "").trim();
const url = String(item && typeof item === "object" ? item.url || "" : "").trim();
const poster = String(item && typeof item === "object" ? item.poster || "" : "").trim();
const embedUrl = getFacilityVideoEmbedUrl(url);
const provider = getFacilityVideoProvider(url);
return { title, url, poster, embedUrl, provider };
})
.filter((item) => item.url && item.embedUrl && !seen.has(item.url) && seen.add(item.url))
.map((item) => ({
title: item.title,
url: item.url,
poster: item.poster,
embedUrl: item.embedUrl!,
provider: item.provider,
}));
if (normalized.length > 0) {
return normalized;
}
const fallbackUrl = String(legacyUrl || "").trim();
const fallbackEmbedUrl = getFacilityVideoEmbedUrl(fallbackUrl);
if (!fallbackUrl || !fallbackEmbedUrl) {
return [];
}
return [
{
title: "",
url: fallbackUrl,
poster: "",
embedUrl: fallbackEmbedUrl,
provider: getFacilityVideoProvider(fallbackUrl),
},
];
};
const isFallbackFacilityImage = (value: string | null | undefined) =>
/\/toppbilde-standard\.jpg(?:\?.*)?$/i.test(String(value || "").trim().toLowerCase());
const normalizeFacilityGallery = (galleryValue: unknown) => {
const seen = new Set<string>();
const normalized: string[] = [];
const addImage = (value: unknown) => {
const url = String(value || "").trim();
if (!url || seen.has(url)) return;
seen.add(url);
normalized.push(url);
};
if (Array.isArray(galleryValue)) {
galleryValue.forEach(addImage);
}
return normalized;
};
const getYrMeteogramUrl = (url: string | null | undefined) => { const getYrMeteogramUrl = (url: string | null | undefined) => {
const raw = String(url || "").trim(); const raw = String(url || "").trim();
if (!raw) return null; if (!raw) return null;
@ -225,6 +335,7 @@ 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 parseJson = (val: any, fallback: any) => { const parseJson = (val: any, fallback: any) => {
return parseSharedJson(val, fallback); return parseSharedJson(val, fallback);
@ -235,12 +346,21 @@ export default function FacilityDetailView({
const amenities = parseJson(facility.amenities, {}); const amenities = parseJson(facility.amenities, {});
const camperParking = String(facility.camper_parking || "").trim(); const camperParking = String(facility.camper_parking || "").trim();
const galleryRaw = parseJson(facility.gallery, []); const galleryRaw = parseJson(facility.gallery, []);
const gallery = galleryRaw.length > 0 ? galleryRaw : [facility.image_url || FALLBACK_IMAGE]; const galleryImages = normalizeFacilityGallery(galleryRaw);
const heroImageUrl = String(facility.image_url || "").trim();
const heroImages = heroImageUrl ? [heroImageUrl] : galleryImages.length > 0 ? galleryImages : [FALLBACK_IMAGE];
const facilityVideos = normalizeFacilityVideos(parseJson(facility.videos, []), facility.video_url);
const shotzoom = parseJson(facility.shotzoom, []); const shotzoom = parseJson(facility.shotzoom, []);
const facilitySlug = String(facility.slug || "").trim(); const facilitySlug = String(facility.slug || "").trim();
const facilityIllustrationAlt = facilitySlug const facilityIllustrationAlt = facilitySlug
? `Illustrasjonsbilde fra ${facilitySlug}` ? `Illustrasjonsbilde fra ${facilitySlug}`
: `Illustrasjonsbilde fra ${facility.name || "golfanlegget"}`; : `Illustrasjonsbilde fra ${facility.name || "golfanlegget"}`;
const hasImageGallery = galleryImages.some((image) => !isFallbackFacilityImage(image));
const hasVideoGallery = facilityVideos.length > 0;
const hasMediaSection = hasImageGallery || hasVideoGallery;
const previewGalleryImages = hasImageGallery ? galleryImages.filter((image) => !isFallbackFacilityImage(image)).slice(0, 4) : [];
const previewVideos = facilityVideos.slice(0, hasImageGallery ? 2 : 3);
const activeHeroImage = heroImages[currentSlide] || FALLBACK_IMAGE;
// Pris og kurs-arrays // Pris og kurs-arrays
const greenfeeRaw = parseJson(facility.greenfee, []); const greenfeeRaw = parseJson(facility.greenfee, []);
@ -294,7 +414,7 @@ export default function FacilityDetailView({
{ id: 'weather', label: 'Vær', showOnMobile: false }, { id: 'weather', label: 'Vær', showOnMobile: false },
{ id: 'details', label: 'Detaljer', showOnMobile: true }, { id: 'details', label: 'Detaljer', showOnMobile: true },
mapUrl ? { id: 'map', label: 'Kart', showOnMobile: true } : null, mapUrl ? { id: 'map', label: 'Kart', showOnMobile: true } : null,
facility.video_url ? { id: 'video', label: 'Video', showOnMobile: true } : null, hasMediaSection ? { id: 'media', label: 'Media', showOnMobile: true } : null,
{ id: 'prices', label: 'Priser', showOnMobile: true }, { id: 'prices', label: 'Priser', showOnMobile: true },
hasVtg ? { id: 'vtg', label: 'VTG', showOnMobile: true } : null, hasVtg ? { id: 'vtg', label: 'VTG', showOnMobile: true } : null,
{ id: 'scorecards', label: 'Scorekort', showOnMobile: true }, { id: 'scorecards', label: 'Scorekort', showOnMobile: true },
@ -304,10 +424,17 @@ export default function FacilityDetailView({
const mobileSectionNavItems = sectionNavItems.filter((item) => item.showOnMobile); const mobileSectionNavItems = sectionNavItems.filter((item) => item.showOnMobile);
useEffect(() => { useEffect(() => {
if (gallery.length <= 1) return; if (heroImages.length <= 1) return;
const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000); const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % heroImages.length), 5000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [gallery.length]); }, [heroImages.length]);
useEffect(() => {
setCurrentSlide((previousSlide) => {
if (heroImages.length <= 0) return 0;
return previousSlide % heroImages.length;
});
}, [heroImages.length]);
useEffect(() => { useEffect(() => {
const handleScroll = () => setShowBackToTop(window.scrollY > 500); const handleScroll = () => setShowBackToTop(window.scrollY > 500);
@ -315,6 +442,40 @@ export default function FacilityDetailView({
return () => window.removeEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll);
}, []); }, []);
useEffect(() => {
if (!activeMedia) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setActiveMedia(null);
return;
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') {
return;
}
setActiveMedia((current) => {
if (!current) return current;
const items = current.type === 'image' ? galleryImages.filter((image) => !isFallbackFacilityImage(image)) : facilityVideos;
if (items.length <= 1) return current;
const direction = event.key === 'ArrowRight' ? 1 : -1;
const nextIndex = (current.index + direction + items.length) % items.length;
return { ...current, index: nextIndex };
});
};
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [activeMedia, facilityVideos, galleryImages]);
const scrollTo = (id: string) => { const scrollTo = (id: string) => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' }); if (el) window.scrollTo({ top: el.getBoundingClientRect().top + window.pageYOffset - 80, behavior: 'smooth' });
@ -322,31 +483,45 @@ export default function FacilityDetailView({
const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null; const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'long', year: 'numeric' }) : null;
const weatherImg = getYrMeteogramUrl(facility.weather_url); const weatherImg = getYrMeteogramUrl(facility.weather_url);
const videoEmbedUrl = getFacilityVideoEmbedUrl(facility.video_url);
const getGalleryImageAlt = (imageUrl: string) => { const getGalleryImageAlt = (imageUrl: string) => {
const normalized = String(imageUrl || "").trim().toLowerCase(); const normalized = String(imageUrl || "").trim().toLowerCase();
if (!normalized) { if (!normalized) {
return "Generisk bilde fra en golfbane"; return "Generisk bilde fra en golfbane";
} }
if (/\/toppbilde-standard\.jpg(?:\?.*)?$/i.test(normalized)) { if (isFallbackFacilityImage(normalized)) {
return "Generisk bilde fra en golfbane"; return "Generisk bilde fra en golfbane";
} }
return facilityIllustrationAlt; return facilityIllustrationAlt;
}; };
const imageGalleryItems = galleryImages.filter((image) => !isFallbackFacilityImage(image));
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 cycleActiveMedia = (direction: -1 | 1) => {
setActiveMedia((current) => {
if (!current) return current;
const items = current.type === 'image' ? imageGalleryItems : facilityVideos;
if (items.length <= 1) return current;
const nextIndex = (current.index + direction + items.length) % items.length;
return { ...current, index: nextIndex };
});
};
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]">
{/* 1. HERO SLIDER */} {/* 1. HERO SLIDER */}
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]"> <div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
{gallery.map((img: string, i: number) => ( <img
<img key={activeHeroImage}
key={i} src={activeHeroImage}
src={img} className="absolute inset-0 h-full w-full object-cover"
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${i === currentSlide ? 'opacity-100 z-10' : 'opacity-0 z-0'}`} alt={getGalleryImageAlt(activeHeroImage)}
alt={getGalleryImageAlt(img)} loading="eager"
/> fetchPriority="high"
))} decoding="async"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" /> <div className="absolute inset-0 bg-gradient-to-t from-[#11280f]/90 via-transparent to-black/10 z-20" />
{/* BANESTATUS BADGES */} {/* BANESTATUS BADGES */}
@ -661,19 +836,127 @@ export default function FacilityDetailView({
</section> </section>
)} )}
{/* 7. VIDEO SEKSJON */} {/* 7. MEDIA */}
{videoEmbedUrl && ( {hasMediaSection && (
<section id="video" className="space-y-6"> <section id="media" className="space-y-6">
<h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Video <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2> <h2 className="text-3xl md:text-4xl font-black uppercase tracking-tighter flex items-center gap-5 ml-6 md:ml-0">Media <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
<iframe <div className={`grid grid-cols-1 gap-6 lg:gap-8 ${hasImageGallery && hasVideoGallery ? 'xl:grid-cols-2' : ''}`}>
src={videoEmbedUrl} {hasImageGallery && (
title={`Video fra ${facility.name}`} <article className="rounded-[2rem] border border-[#11280f]/8 bg-white p-5 shadow-sm md:p-6">
className="w-full h-full" <div className="flex flex-wrap items-center justify-between gap-3">
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" <div>
allowFullScreen <p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#7ca982]">Bilder</p>
/> <h3 className="mt-1 text-2xl font-black tracking-tight text-[#11280f]">Galleriet til {facility.name}</h3>
</div> </div>
<button
type="button"
onClick={() => setActiveMedia({ type: 'image', index: 0 })}
className="btn btn-secondary"
>
Åpne galleri
</button>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
{previewGalleryImages.map((image, index) => {
const remainingImages = imageGalleryItems.length - previewGalleryImages.length;
const showRemainingOverlay = remainingImages > 0 && index === previewGalleryImages.length - 1;
return (
<button
key={`${image}-${index}`}
type="button"
onClick={() => setActiveMedia({ type: 'image', index })}
className="group relative overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f]"
>
<div className="aspect-[4/3]">
<img
src={image}
alt={getGalleryImageAlt(image)}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
/>
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-[#11280f]/55 via-transparent to-transparent" />
{showRemainingOverlay ? (
<div className="absolute inset-0 flex items-center justify-center bg-[#11280f]/55 text-2xl font-black text-white">
+{remainingImages}
</div>
) : null}
</button>
);
})}
</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>
)}
{hasVideoGallery && (
<article className="rounded-[2rem] border border-[#11280f]/8 bg-white p-5 shadow-sm md:p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#7ca982]">Video</p>
<h3 className="mt-1 text-2xl font-black tracking-tight text-[#11280f]">Se banen i bevegelse</h3>
</div>
<span className="rounded-full bg-[#f1f7ed] px-3 py-2 text-[10px] font-black uppercase tracking-[0.18em] text-[#536256]">
{facilityVideos.length} video{facilityVideos.length === 1 ? '' : 'er'}
</span>
</div>
<div className="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2">
{previewVideos.map((video, index) => {
const poster = getFacilityVideoPoster(video);
return (
<button
key={`${video.url}-${index}`}
type="button"
onClick={() => setActiveMedia({ type: 'video', index })}
className="group text-left"
>
<div className="relative overflow-hidden rounded-[1.5rem] border border-[#11280f]/8 bg-[#11280f] shadow-sm">
<div className="aspect-video">
{poster ? (
<img
src={poster}
alt={video.title || `Video fra ${facility.name}`}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
/>
) : (
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,_#385332,_#11280f_65%)] px-6 text-center text-sm font-black uppercase tracking-[0.22em] text-white/75">
{getFacilityVideoProviderLabel(video.provider)}
</div>
)}
</div>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-[#11280f]/80 via-[#11280f]/15 to-transparent" />
<div className="absolute bottom-4 left-4 flex h-12 w-12 items-center justify-center rounded-full bg-white text-[#11280f] shadow-lg transition-transform duration-300 group-hover:scale-105">
</div>
</div>
<div className="mt-3">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-[#7ca982]">
{getFacilityVideoProviderLabel(video.provider)}
</p>
<p className="mt-1 text-base font-black text-[#11280f]">
{video.title || `Video ${index + 1} fra ${facility.name}`}
</p>
</div>
</button>
);
})}
</div>
<p className="mt-4 text-sm text-[#536256]">
Videoene bruker forhåndsvisning i vanlig sidevisning og laster ikke inn YouTube- eller Vimeo-spilleren før brukeren åpner modalvinduet.
</p>
</article>
)}
</div>
</section> </section>
)} )}
@ -896,6 +1179,74 @@ export default function FacilityDetailView({
/> />
</div> </div>
{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)}
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>
</div>
{activeMedia.type === 'image' && activeImage ? (
<div className="overflow-hidden rounded-[2rem] border border-white/10 bg-black shadow-2xl">
<img
src={activeImage}
alt={getGalleryImageAlt(activeImage)}
className="max-h-[78vh] w-full object-contain"
decoding="async"
/>
</div>
) : null}
{activeMedia.type === 'video' && activeVideo ? (
<div className="overflow-hidden rounded-[2rem] border border-white/10 bg-black shadow-2xl">
<div className="aspect-video w-full">
<iframe
src={activeVideo.embedUrl}
title={activeVideo.title || `Video fra ${facility.name}`}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
</div>
) : null}
</div>
</div>
</div>
)}
{showBackToTop && ( {showBackToTop && (
<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="btn btn-ink fixed bottom-8 right-8 z-[100] flex h-14 w-14 items-center justify-center rounded-full border-4 border-white/20 p-0 text-2xl shadow-2xl"></button> <button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="btn btn-ink fixed bottom-8 right-8 z-[100] flex h-14 w-14 items-center justify-center rounded-full border-4 border-white/20 p-0 text-2xl shadow-2xl"></button>
)} )}

View file

@ -36,7 +36,7 @@ export default function SitePageTemplate({
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(214,240,173,0.22),transparent_28%)]" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(214,240,173,0.22),transparent_28%)]" />
<div className="relative mx-auto flex min-h-[18rem] max-w-[1320px] items-end px-4 pb-4 pt-20 sm:min-h-[22rem] sm:px-6 sm:pb-6 lg:min-h-[31rem] lg:px-8 lg:pb-8 lg:pt-24"> <div className="relative mx-auto flex min-h-[18rem] max-w-[1320px] items-end px-4 pb-4 pt-20 sm:min-h-[22rem] sm:px-6 sm:pb-6 lg:min-h-[31rem] lg:px-8 lg:pb-8 lg:pt-24">
<div className="max-w-4xl"> <div className="max-w-full lg:max-w-[1100px] xl:max-w-[1200px]">
<div className="inline-block max-w-full rounded-[1.75rem] bg-[linear-gradient(180deg,rgba(12,20,15,0.42),rgba(12,20,15,0.74))] px-5 py-4 shadow-[0_26px_55px_rgba(0,0,0,0.24)] backdrop-blur-md sm:px-7 sm:py-5 lg:px-9 lg:py-7"> <div className="inline-block max-w-full rounded-[1.75rem] bg-[linear-gradient(180deg,rgba(12,20,15,0.42),rgba(12,20,15,0.74))] px-5 py-4 shadow-[0_26px_55px_rgba(0,0,0,0.24)] backdrop-blur-md sm:px-7 sm:py-5 lg:px-9 lg:py-7">
<h1 className="text-4xl font-black tracking-[-0.05em] text-white sm:text-5xl lg:text-7xl"> <h1 className="text-4xl font-black tracking-[-0.05em] text-white sm:text-5xl lg:text-7xl">
{normalizedTitle} {normalizedTitle}