// site-ui.jsx — shared building blocks for the public site. // ── Live data: read the SAME store the CMS writes, update on edits ─────────── const SiteData = React.createContext(null); function SiteDataProvider({ children }) { const [data, setData] = React.useState(() => window.loadFestivalData()); React.useEffect(() => { const reload = () => setData(window.loadFestivalData()); const onVis = () => {if (!document.hidden) reload();}; window.addEventListener("storage", reload); // edits from the CMS in another tab window.addEventListener("focus", reload); // returning to this tab document.addEventListener("visibilitychange", onVis); return () => { window.removeEventListener("storage", reload); window.removeEventListener("focus", reload); document.removeEventListener("visibilitychange", onVis); }; }, []); return {children}; } function useSite() {return React.useContext(SiteData);} // published-only helpers — current edition only function usePublishedFilms() { const d = useSite(); const currentYear = d.festival?.year; return d.films.filter((f) => f.status === "published" && String(f.year) === String(currentYear)); } function usePublishedNews() { const d = useSite(); return [...d.news.filter((n) => n.status === "published")].sort((a, b) => (b.date || "").localeCompare(a.date || "")); } // ── Icons ───────────────────────────────────────────────────────────────── const SIcon = ({ name, style }) => { const p = { arrowR: "M5 12h14M13 6l6 6-6 6", arrowL: "M19 12H5M11 18l-6-6 6-6", arrowUpR: "M7 17L17 7M9 7h8v8", play: "M8 5v14l11-7z", menu: "M3 6h18M3 12h18M3 18h18", x: "M6 6l12 12M18 6L6 18", pin: "M12 21s-7-6.4-7-11a7 7 0 0114 0c0 4.6-7 11-7 11zM12 12a2.5 2.5 0 100-5 2.5 2.5 0 000 5z", clock: "M12 22a10 10 0 100-20 10 10 0 000 20zM12 7v5l3 2", check: "M5 12l5 5L20 7", plus: "M12 5v14M5 12h14", cal: "M4 5h16v16H4zM4 9h16M8 3v4M16 3v4", ticket: "M3 8a2 2 0 012-2h14a2 2 0 012 2 2 2 0 000 4 2 2 0 00-2 2H5a2 2 0 01-2-2 2 2 0 000-4zM13 6v12" }; return ( ); }; // ── Grain frame (image placeholder) ───────────────────────────────────────── const DUOS = ["d-ink", "d-pink", "d-cyan", "d-purple", "d-yellow"]; function hashStr(s) {let h = 0;for (let i = 0; i < (s || "").length; i++) h = h * 31 + s.charCodeAt(i) >>> 0;return h;} // auto-derive a thematic image URL from the frame's seed. // - kind="portrait" → portrait service (pravatar) // - default → seeded random photo service (picsum, real Unsplash backend) function autoSrc(seed, kind, ratio) { if (!seed) return null; const slug = String(seed).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 48) || "x"; if (kind === "portrait") { return `https://i.pravatar.cc/600?u=${encodeURIComponent(slug)}`; } // pick dimensions by ratio let w = 1200,h = 800; if (ratio) { const parts = String(ratio).split("/").map(Number); if (parts.length === 2 && parts[0] && parts[1]) { const base = 1200; const scale = base / Math.max(parts[0], parts[1]); w = Math.round(parts[0] * scale); h = Math.round(parts[1] * scale); } } return `https://picsum.photos/seed/${encodeURIComponent(slug)}/${w}/${h}`; } function Frame({ seed = "", src, video, kind, label, corner, play, ratio, className = "", style = {}, duo }) { const resolvedSrc = src !== undefined ? src : autoSrc(seed, kind, ratio); // When a media asset is present, use a neutral dark backdrop so brand-colour // flashes don't peek through while the image loads. const variant = duo || (resolvedSrc || video ? "d-ink" : DUOS[hashStr(seed) % DUOS.length]); const [vidOn, setVidOn] = React.useState(false); const vidRef = React.useRef(null); const startVideo = () => { setVidOn(true); requestAnimationFrame(() => {vidRef.current?.play?.().catch(() => {});}); }; const showPlay = play || video; return (
{video ?
); } // ── Navigation ────────────────────────────────────────────────────────────── const BASE_NAV = [ ["home", "Home"], ["about", "About"], ["news", "News"], ["programme", "Programme"], ["jury", "Jury & Team"], ["archive", "Archive"], ["contact", "Contact"], ]; function Nav({ route, navigate }) { const d = useSite(); const [open, setOpen] = React.useState(false); const top = route.split("/")[1] || "home"; const go = (to) => {setOpen(false);navigate(to);}; const awardsLive = !!(d.festival && d.festival.awardsPage && d.festival.awardsPage.live); const navItems = awardsLive ? [...BASE_NAV.slice(0, 6), ["awards", "Awards"], BASE_NAV[6]] : BASE_NAV; return (
go("/")}> {d.festival.logoImage ? {d.festival.name} : {d.festival.logoText} } {d.festival.logoSubtitle && ( {d.festival.logoSubtitle} )}
setOpen(false)} />
); } // ── Footer ────────────────────────────────────────────────────────────────── // ── Accent colour injector ────────────────────────────────────────────────── function ColorInjector() { const d = useSite(); React.useEffect(() => { const c1 = d.festival.accentColor || "#ff5c70"; const c2 = d.festival.accentColor2 || "#784c95"; let el = document.getElementById("__site-accent"); if (!el) { el = document.createElement("style"); el.id = "__site-accent"; document.head.appendChild(el); } el.textContent = `:root { --accent: ${c1}; --accent-bright: ${c1}; --coral: ${c1}; --coral-bright: ${c1}; --accent2: ${c2}; }`; }, [d.festival.accentColor, d.festival.accentColor2]); return null; } function Footer({ navigate }) { const d = useSite(); const columns = d.festival.footer?.columns || []; const handleLink = (link) => { if (link.type === "url") { if (link.url) window.open(link.url, "_blank", "noopener"); } else { if (link.page) navigate(link.page); } }; return ( ); } // ── Kicker row (section label with rule) ───────────────────────────────────── function Kicker({ children, n, color }) { // Replace § with ▸ (right-facing triangle) globally const label = n ? String(n).replace(/§/g, "▸") : n; return (
{label && {label}} {children}
); } // ── Page transition wrapper ─────────────────────────────────────────────── function Page({ children, k }) { return
{children}
; } // ── YouTubeThumb ───────────────────────────────────────────────────────────── // Click-to-load YouTube embed. Shows thumbnail + play button initially; on // click swaps to a plain youtube.com/embed iframe with autoplay. Origin is // only passed when the page has a real http(s) origin (never "null"), since // passing origin=null is what triggers YouTube's 153 embed error. function YouTubeThumb({ id, title, style }) { const [playing, setPlaying] = React.useState(false); if (!id) return ; if (playing) { const o = typeof window !== "undefined" && window.location && /^https?:/.test(window.location.origin) ? window.location.origin : ""; const embedSrc = "https://www.youtube.com/embed/" + id + "?autoplay=1&rel=0&modestbranding=1&playsinline=1" + ( o ? "&origin=" + encodeURIComponent(o) : ""); return (