Etter justering av sidetitler
This commit is contained in:
parent
8a8690ed92
commit
d49aa7b211
5 changed files with 472 additions and 36 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<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">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 på 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
|
||||
label="Sosiale Medier (Legg inn f.eks facebook, instagram, linkedin)"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ export type FacilityRecord = {
|
|||
county?: string | null;
|
||||
banetype?: string | null;
|
||||
image_url?: string | null;
|
||||
logo_url?: string | null;
|
||||
gallery?: unknown;
|
||||
video_url?: string | null;
|
||||
videos?: unknown;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
website_url?: string | null;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,19 @@ import CourseDisplay from './CourseDisplay';
|
|||
import FacilityEditorialHub from './FacilityEditorialHub';
|
||||
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"), {
|
||||
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<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 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<ActiveMedia | null>(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 (
|
||||
<main className="min-h-screen bg-[#f1f7ed] pb-20 relative font-sans text-[#11280f]">
|
||||
|
||||
{/* 1. HERO SLIDER */}
|
||||
<div className="h-[55vh] min-h-[450px] relative overflow-hidden bg-[#11280f]">
|
||||
{gallery.map((img: string, i: number) => (
|
||||
<img
|
||||
key={i}
|
||||
src={img}
|
||||
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(img)}
|
||||
/>
|
||||
))}
|
||||
<img
|
||||
key={activeHeroImage}
|
||||
src={activeHeroImage}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
alt={getGalleryImageAlt(activeHeroImage)}
|
||||
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" />
|
||||
|
||||
{/* BANESTATUS BADGES */}
|
||||
|
|
@ -661,19 +836,127 @@ export default function FacilityDetailView({
|
|||
</section>
|
||||
)}
|
||||
|
||||
{/* 7. VIDEO SEKSJON */}
|
||||
{videoEmbedUrl && (
|
||||
<section id="video" 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>
|
||||
<div className="w-full md:rounded-[3rem] overflow-hidden shadow-2xl aspect-video bg-black border-y-4 md:border-[12px] border-white">
|
||||
<iframe
|
||||
src={videoEmbedUrl}
|
||||
title={`Video fra ${facility.name}`}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
{/* 7. MEDIA */}
|
||||
{hasMediaSection && (
|
||||
<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">Media <span className="h-1 flex-grow bg-gray-100 rounded-full" /></h2>
|
||||
|
||||
<div className={`grid grid-cols-1 gap-6 lg:gap-8 ${hasImageGallery && hasVideoGallery ? 'xl:grid-cols-2' : ''}`}>
|
||||
{hasImageGallery && (
|
||||
<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]">Bilder</p>
|
||||
<h3 className="mt-1 text-2xl font-black tracking-tight text-[#11280f]">Galleriet til {facility.name}</h3>
|
||||
</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, så denne seksjonen holder seg lett på 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>
|
||||
)}
|
||||
|
||||
|
|
@ -896,6 +1179,74 @@ export default function FacilityDetailView({
|
|||
/>
|
||||
</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 && (
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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="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">
|
||||
<h1 className="text-4xl font-black tracking-[-0.05em] text-white sm:text-5xl lg:text-7xl">
|
||||
{normalizedTitle}
|
||||
|
|
|
|||
Loading…
Reference in a new issue