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
Flyfoto URL handleChange('flyfoto_url', e.target.value)} />
Vær URL (YR) handleChange('weather_url', e.target.value)} />
Webkamera URL handleChange('webcam_url', e.target.value)} />
- Video URL (YouTube/Vimeo) 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}
+
+
+
Legacy video URL (fallback)
+
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) => (
-
- ))}
+
{/* BANESTATUS BADGES */}
@@ -661,19 +836,127 @@ export default function FacilityDetailView({
)}
- {/* 7. VIDEO SEKSJON */}
- {videoEmbedUrl && (
-
- Video
-
-
-
+ {/* 7. MEDIA */}
+ {hasMediaSection && (
+
)}
@@ -896,6 +1179,74 @@ export default function FacilityDetailView({
/>
+ {activeMedia && (
+ setActiveMedia(null)}
+ role="dialog"
+ aria-modal="true"
+ aria-label={activeMedia.type === 'image' ? `Bildegalleri for ${facility.name}` : `Videogalleri for ${facility.name}`}
+ >
+
+
event.stopPropagation()}>
+
+
+
+ {activeMedia.type === 'image' ? 'Bilder' : 'Video'}
+
+
+ {activeMedia.type === 'image'
+ ? `${activeMedia.index + 1} av ${activeMediaCount}`
+ : activeVideo?.title || `Video ${activeMedia.index + 1} av ${activeMediaCount}`}
+
+
+
+
+ {activeMediaCount > 1 ? (
+ <>
+ cycleActiveMedia(-1)} className="btn btn-secondary">
+ Forrige
+
+ cycleActiveMedia(1)} className="btn btn-secondary">
+ Neste
+
+ >
+ ) : null}
+ setActiveMedia(null)} className="btn btn-primary">
+ Lukk
+
+
+
+
+ {activeMedia.type === 'image' && activeImage ? (
+
+
+
+ ) : null}
+
+ {activeMedia.type === 'video' && activeVideo ? (
+
+ ) : null}
+
+
+
+ )}
+
{showBackToTop && (
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">↑
)}
diff --git a/frontend/src/components/SitePageTemplate.tsx b/frontend/src/components/SitePageTemplate.tsx
index 1e46f15..9e7fc9b 100644
--- a/frontend/src/components/SitePageTemplate.tsx
+++ b/frontend/src/components/SitePageTemplate.tsx
@@ -36,7 +36,7 @@ export default function SitePageTemplate({