From d49aa7b2111a1104d055d1a40a4ea16332b1404e Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 29 Apr 2026 17:43:57 +0200 Subject: [PATCH] Etter justering av sidetitler --- backend/main.py | 5 +- .../rediger/[slug]/EditFacilityClient.tsx | 82 +++- frontend/src/app/facilityData.ts | 4 + .../golfbaner/[slug]/FacilityDetailView.tsx | 415 ++++++++++++++++-- frontend/src/components/SitePageTemplate.tsx | 2 +- 5 files changed, 472 insertions(+), 36 deletions(-) diff --git a/backend/main.py b/backend/main.py index 9d9e278..cf5b322 100644 --- a/backend/main.py +++ b/backend/main.py @@ -914,7 +914,7 @@ FACILITY_ALLOWED_FIELDS = [ 'address', 'zipcode', 'city', 'county', 'lat', 'lng', 'email', 'phone', 'website_url', 'golfbox_booking_url', 'golfbox_tournament_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', 'nsg_url', 'nsg_data', 'golfamore', 'golfamore_url', 'golfamore_data', 'navn_standard_medlemskap', 'standard_medlemskap', 'standard_medlemskap_kommentarer', @@ -1010,7 +1010,7 @@ def format_row(row): d[key] = d[key].isoformat() json_list_fields = [ - 'course_statuses', 'courses', 'gallery', 'greenfee', + 'course_statuses', 'courses', 'gallery', 'videos', 'greenfee', 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer', '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_description 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_draft JSONB, ADD COLUMN IF NOT EXISTS golfpakker_updated_at TIMESTAMPTZ, diff --git a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx index 3f37f5c..30e712e 100644 --- a/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx +++ b/frontend/src/app/admin/rediger/[slug]/EditFacilityClient.tsx @@ -557,6 +557,50 @@ const getMediaFieldLabel = (field: string) => { 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[] }) { const router = useRouter(); const isCreateMode = typeof initialData?.id !== 'number'; @@ -564,6 +608,7 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini ...initialData, is_published: isCreateMode ? Boolean(initialData?.is_published) : initialData?.is_published !== false, courses: Array.isArray(initialData?.courses) ? initialData.courses : [], + videos: normalizeFacilityVideos(initialData?.videos), }); const [activeTab, setActiveTab] = useState('generelt'); const [saving, setSaving] = useState(false); @@ -656,6 +701,8 @@ export default function EditFacilityClient({ initialData, allFacilities }: { ini }; const galleryImages = normalizeStringList(formData.gallery); + const facilityVideos = normalizeFacilityVideos(formData.videos); + const unsupportedFacilityVideos = facilityVideos.filter((video) => video.url && !isSupportedFacilityVideoUrl(video.url)); const setGalleryImages = (images: string[]) => { 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
handleChange('flyfoto_url', e.target.value)} />
handleChange('weather_url', e.target.value)} />
handleChange('webcam_url', e.target.value)} />
-
handleChange('video_url', e.target.value)} />
+
+
+
+

Videoer

+

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.

+
+ + {facilityVideos.length} videoer + +
+
+ + handleChange('videos', normalizeFacilityVideos(v))} + /> + + {unsupportedFacilityVideos.length > 0 ? ( +
+ {unsupportedFacilityVideos.length} video{unsupportedFacilityVideos.length === 1 ? '' : 'er'} ser ikke ut som YouTube eller Vimeo. De blir ikke automatisk embeddet på detaljsiden før URL-en er korrigert. +
+ ) : null} + +
+ + handleChange('video_url', e.target.value)} + /> +

Brukes bare hvis videogalleriet over er tomt. Feltet kan beholdes midlertidig mens eldre anlegg migreres.

+
import("./FacilityDetailLeafletMap"), { ssr: false, loading: () => ( @@ -37,16 +50,26 @@ const formatPhoneForUrl = (phone: string) => { return normalized.startsWith("00") ? `+${normalized.slice(2)}` : normalized; }; -const getYouTubeEmbedUrl = (url: string) => { +const getYouTubeVideoId = (url: string) => { const match = url.match( /(?: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 match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/i); - return match ? `https://player.vimeo.com/video/${match[1]}` : null; + const videoId = getVimeoVideoId(url); + return videoId ? `https://player.vimeo.com/video/${videoId}` : null; }; const getFacilityVideoEmbedUrl = (url: string | null | undefined) => { @@ -55,6 +78,93 @@ const getFacilityVideoEmbedUrl = (url: string | null | undefined) => { 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) => { + 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(); + 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(); + 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 raw = String(url || "").trim(); if (!raw) return null; @@ -225,6 +335,7 @@ export default function FacilityDetailView({ }) { const [showBackToTop, setShowBackToTop] = useState(false); const [currentSlide, setCurrentSlide] = useState(0); + const [activeMedia, setActiveMedia] = useState(null); const parseJson = (val: any, fallback: any) => { return parseSharedJson(val, fallback); @@ -235,12 +346,21 @@ export default function FacilityDetailView({ const amenities = parseJson(facility.amenities, {}); const camperParking = String(facility.camper_parking || "").trim(); 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 facilitySlug = String(facility.slug || "").trim(); const facilityIllustrationAlt = facilitySlug ? `Illustrasjonsbilde fra ${facilitySlug}` : `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 const greenfeeRaw = parseJson(facility.greenfee, []); @@ -294,7 +414,7 @@ export default function FacilityDetailView({ { id: 'weather', label: 'Vær', showOnMobile: false }, { id: 'details', label: 'Detaljer', showOnMobile: true }, 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 }, hasVtg ? { id: 'vtg', label: 'VTG', showOnMobile: true } : null, { id: 'scorecards', label: 'Scorekort', showOnMobile: true }, @@ -304,10 +424,17 @@ export default function FacilityDetailView({ const mobileSectionNavItems = sectionNavItems.filter((item) => item.showOnMobile); useEffect(() => { - if (gallery.length <= 1) return; - const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % gallery.length), 5000); + if (heroImages.length <= 1) return; + const timer = setInterval(() => setCurrentSlide((p) => (p + 1) % heroImages.length), 5000); return () => clearInterval(timer); - }, [gallery.length]); + }, [heroImages.length]); + + useEffect(() => { + setCurrentSlide((previousSlide) => { + if (heroImages.length <= 0) return 0; + return previousSlide % heroImages.length; + }); + }, [heroImages.length]); useEffect(() => { const handleScroll = () => setShowBackToTop(window.scrollY > 500); @@ -315,6 +442,40 @@ export default function FacilityDetailView({ 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 el = document.getElementById(id); 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 weatherImg = getYrMeteogramUrl(facility.weather_url); - const videoEmbedUrl = getFacilityVideoEmbedUrl(facility.video_url); const getGalleryImageAlt = (imageUrl: string) => { const normalized = String(imageUrl || "").trim().toLowerCase(); if (!normalized) { return "Generisk bilde fra en golfbane"; } - if (/\/toppbilde-standard\.jpg(?:\?.*)?$/i.test(normalized)) { + if (isFallbackFacilityImage(normalized)) { return "Generisk bilde fra en golfbane"; } 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 (
{/* 1. HERO SLIDER */}
- {gallery.map((img: string, i: number) => ( - {getGalleryImageAlt(img)} - ))} + {getGalleryImageAlt(activeHeroImage)}
{/* BANESTATUS BADGES */} @@ -661,19 +836,127 @@ export default function FacilityDetailView({ )} - {/* 7. VIDEO SEKSJON */} - {videoEmbedUrl && ( -
-

Video

-
-