// site-programme.jsx — strand landing, per-strand film grid, and film detail. const STRAND_META = { Documentary: { color: "var(--purple)", hex: "#784c95", textOn: "#fff", colorName: "Purple", italic: "of reality.", desc: "Films rooted in reality — observational, investigative, personal. Work that bears witness to the world as it is, whether intimate or expansive." }, Animation: { color: "var(--yellow)", hex: "#ffc823", textOn: "#16151a", colorName: "Yellow", italic: "of imagination.",desc: "Films where drawing, modelling and digital craft meet storytelling. From hand-drawn gesture to stop-motion to CGI — animation as a form in its own right." }, Experimental: { color: "var(--cyan)", hex: "#65c8d0", textOn: "#16151a", colorName: "Cyan", italic: "of form.", desc: "Films that question what cinema can be. Structural, essayistic, formally adventurous — work that doesn't fit neatly anywhere else, and doesn't pretend to." }, Fiction: { color: "var(--pink)", hex: "#ff5c70", textOn: "#fff", colorName: "Pink", italic: "of story.", desc: "Scripted, acted, directed — stories imagined and put on screen. From intimate drama to dark comedy to genre film. The oldest tradition in cinema, still finding new ground." }, }; // Derive programmer names from the students list for a given strand function getProgrammers(people, strand) { const names = (people || []) .filter(p => p.group === "student" && p.strand === strand && p.name) .map(p => p.name); if (!names.length) return null; if (names.length === 1) return names[0]; return names.slice(0, -1).join(", ") + " & " + names[names.length - 1]; } // Runtime formatting helpers function fmtRuntime(mins, secs) { const m = parseInt(mins) || 0; const s = parseInt(secs) || 0; if (!m && !s) return ""; return s ? m + "'" + String(s).padStart(2,"0") + "\"" : m + "'"; } function fmtRuntimeLong(mins, secs) { const m = parseInt(mins) || 0; const s = parseInt(secs) || 0; if (!m && !s) return ""; return s ? m + " MIN " + s + " SEC" : m + " MIN"; } function getAwardLabel(film) { const prize = film.prize || ""; const strand = film.strand || ""; if (prize === "Jury") return "Paper Bird — " + strand + " Winner"; if (prize === "Audience") return "Paper Bird — Audience Award"; if (prize === "Special Mention") return "Special Mention — " + strand; return ""; } // Merge hardcoded defaults with CMS overrides function mergeStrandMeta(overrides) { const out = {}; Object.keys(STRAND_META).forEach(s => { out[s] = { ...STRAND_META[s], ...(overrides?.[s] || {}) }; }); return out; } // ── Programme landing: hero + stacked editorial strand rows ────────────────── function ProgrammeLanding({ navigate }) { const d = useSite(); const films = usePublishedFilms(); const allStrandMeta = mergeStrandMeta(d.festival?.strandMeta); const order = ["Documentary", "Animation", "Experimental", "Fiction"]; const countOf = (s) => films.filter((f) => f.strand === s).length; const pg = d.festival?.pages?.programme || {}; return ( {/* ── Hero ── */}
{pg.heroEyebrow || ("Official Selection · " + d.festival.year)} {d.festival.votingOpen && (
Voting Live
)}

The
Programme.

{films.length} short films across four strands. {pg.heroLead || "Each strand programmes and presents independently, culminating in the Paper Bird Awards on the closing night."}

{order.map((s, i) =>
0{i + 1} · {s} {countOf(s)} films
)}
{/* ── Strand rows, stacked vertically ── */} {order.map((strand, i) => )}
); } // One strand presented as a full-width editorial section with alternating bg. function StrandRow({ strand, count, idx, navigate, meta: metaProp }) { const d = useSite(); const meta = metaProp || STRAND_META[strand]; // 0 = paper, 1 = dark, 2 = paper-2, 3 = dark — gives strong light/dark rhythm const isDark = idx % 2 === 1; const bg = isDark ? "var(--ink)" : idx === 0 ? "var(--paper)" : "var(--paper-2)"; const fg = isDark ? "var(--paper)" : "var(--ink)"; const muted = isDark ? "var(--muted-d)" : "var(--muted)"; const line = isDark ? "var(--line-d)" : "var(--line)"; return (
{/* Top kicker bar */}
▸ 0{idx + 1} · Strand {count} films · Edition VI
{/* Full-width strand name — sized so even "EXPERIMENTAL" (longest) never overflows */}

{strand}
{meta.italic}

{/* Two-column lower row: description (left) + meta table + CTA (right) */}

{meta.desc}

{[["Films", count], ["Programmers", getProgrammers(d.people, strand)], ["Award", "Paper Bird \u00b7 " + meta.colorName]].filter(([_k, v]) => v).map(([k, v], j) =>
{k} {v}
)}
); } // ── Strand page ─────────────────────────────────────────────────────────────── function StrandPage({ strand, navigate }) { const d = useSite(); const key = strand ? strand.charAt(0).toUpperCase() + strand.slice(1).toLowerCase() : ""; const meta = { ...STRAND_META[key], ...d.festival?.strandMeta?.[key] }; const films = usePublishedFilms().filter((f) => f.strand === key); const [q, setQ] = React.useState(""); const shown = q ? films.filter((f) => (f.title + f.directors + f.country).toLowerCase().includes(q.toLowerCase())) : films; if (!meta) return ; return (

{key}

{meta.desc}
Curated by {getProgrammers(d.people, key)}

setQ(e.target.value)} style={{ maxWidth: 220, color: "#000", background: "rgba(255,255,255,0.2)", border: "1px solid rgba(255,255,255,0.3)", color: meta.textOn, fontSize: 13 }} />
{shown.length === 0 ?

No films match your search.

:
{shown.map((film, i) => )}
}
); } // ── Film card (shared between strand grid + "more from strand") ─────────────── function FilmCard({ film, meta, idx, navigate }) { const d = useSite(); const m = meta || STRAND_META[film.strand] || {}; const showVote = d.festival.votingOpen && String(film.year) === String(d.festival.year) && film.voteLink; return (
navigate("/film/" + film.id)}>
{film.poster ? {film.title} : } {getAwardLabel(film) && ( <>
{getAwardLabel(film)} )}
{film.premiere || film.strand} {fmtRuntime(film.runtime, film.runtimeSecs)}
{film.title}
{film.directors} · {film.country}
{showVote && ( )}
); } // ── Film detail ─────────────────────────────────────────────────────────────── function FilmDetail({ filmId, navigate }) { const d = useSite(); const film = d.films.find((f) => f.id === filmId); const others = d.films.filter((f) => f.status === "published" && f.id !== filmId && f.strand === film?.strand).slice(0, 4); const meta = film ? STRAND_META[film.strand] || {} : {}; // Build per-director bio list — prefer film.directorBios array, fall back to parsed names const dirBios = film?.directorBios && film.directorBios.length > 0 ? film.directorBios : film ? film.directors.split(/\s*[&,]\s*/).map((n) => ({ name: n.trim(), bio: "" })).filter((d) => d.name) : []; const credits = film?.credits || {}; const creditFields = [ ["Director", film?.directors || "—"], ["Country", film?.country || "—"], ["Runtime", film ? fmtRuntimeLong(film.runtime, film.runtimeSecs) || "—" : "—"], ["Strand", film?.strand || "—"], ["Film School", film?.school || "—"], ...(film?.additionalCredits?.length ? film.additionalCredits.filter(c => c.name).map(c => [c.role, c.name]) : [ ["Producer", credits.producer || "—"], ["Cinematography", credits.cinematography || "—"], ["Editor", credits.editor || "—"], ["Sound", credits.sound || "—"], ["Cast", credits.cast || "—"], ["Production", credits.production || film?.school || "—"], ] ), ].filter(([, v]) => v && v !== "—"); if (!film) return (

Film not found

); const bannerImg = film.stillsImages?.[0]?.url || film.bannerUrl || film.poster; return ( {/* ── Hero: first still as banner + gradient overlay ── */}
{/* Banner — first still, falls back to poster */} {bannerImg ? {film.title} :
} {/* Gradient: solid strand colour on left → transparent on right */}
{/* Bottom fade into strand colour so it bleeds into next section */}
{/* Content layer */}
{/* Back link */} {/* Title block — left side, max 62% width so poster shows through on right */}

{film.title}

{film.directors}
{[film.country, fmtRuntimeLong(film.runtime, film.runtimeSecs) || null, film.school].filter(Boolean).join(" · ")}
{getAwardLabel(film) &&
{getAwardLabel(film)}
}
{/* ── Synopsis + meta ── */}
Synopsis {film.synopsis ?

{film.synopsis}

:

Synopsis to be added.

}
{d.festival.votingOpen && String(film.year) === String(d.festival.year) && film.voteLink && ( )}
{[["Country", film.country], ["Runtime", fmtRuntimeLong(film.runtime, film.runtimeSecs)], ["Strand", film.strand], ...(film.school ? [["School", film.school]] : []), ...(film.premiere ? [["Screening", film.premiere]] : [])]. map(([k, v]) =>
{k} {v}
)}
{/* ── Director(s) — bigger photo, full bio ── */}
{dirBios.length > 1 ? "Directors" : "Director"}
{dirBios.map((dir, i) =>
{/* Large circle portrait */}
(dir.photo || film.directorPhoto) && openLightbox([{ url: dir.photo || film.directorPhoto, caption: dir.name }])}>
{/* Name + bio */}
{dir.name}
Director · {film.country}{film.school ? " · " + film.school : ""}
{dir.bio ?

{dir.bio}

:

Director biography to be added.

}
)}
{/* ── Stills ── */} {(() => { const real = (film.stillsImages || []).filter(s => s && s.url).map(s => s.url); const urls = real.length ? real : (film.stills > 0 ? Array.from({ length: Math.min(film.stills, 6) }, (_, i) => autoSrc(film.title + "s" + i, undefined, "16/9")) : []); if (!urls.length) return null; const items = urls.map(u => ({ url: u, caption: film.title })); return (
Stills
{urls.map((u, i) =>
openLightbox(items, i)}> {film.title
)}
); })()} {/* ── Full credits ── */}
{film.specsEnabled && film.specs && film.specs.length > 0 && (
Specifications
{film.specs.map((s, i) => (
{s.label}: {s.value}
))}
)} Full credits
{creditFields.map(([k, v]) =>
{k} {v}
)}
{/* ── More from this strand ── */} {others.length > 0 &&

More {film.strand}

navigate("/programme/" + film.strand.toLowerCase())}>All {film.strand}
{others.map((o, i) => )}
}
); } function Programme({ navigate, strand }) { if (strand) return ; return ; } Object.assign(window, { Programme, FilmDetail, FilmCard, fmtRuntime, fmtRuntimeLong, getAwardLabel });