// 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
)}
TheProgramme.
{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}
)}
navigate("/programme/" + strand.toLowerCase())}>
Browse {strand}
);
}
// ── 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 (
navigate("/programme")} style={{ background: "none", border: "none", cursor: "pointer", color: "inherit", opacity: .7, fontFamily: "var(--mono)", fontSize: 11, letterSpacing: ".14em", textTransform: "uppercase", display: "flex", alignItems: "center", gap: 6, marginBottom: 24, padding: 0 }}>
All strands
{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 ?
:
}
{getAwardLabel(film) && (
<>
>
)}
{film.premiere || film.strand}
{fmtRuntime(film.runtime, film.runtimeSecs)}
{film.title}
{film.directors} · {film.country}
{showVote && (
{ e.stopPropagation(); window.open(film.voteLink, "_blank"); }}
style={{ marginTop: 12, width: "100%", background: m.hex, color: m.textOn, border: "none", padding: "9px 12px", fontFamily: "var(--mono)", fontSize: 11, letterSpacing: ".1em", textTransform: "uppercase", fontWeight: 700, cursor: "pointer", transition: "filter .15s" }}
onMouseEnter={(e) => e.currentTarget.style.filter = "brightness(1.12)"}
onMouseLeave={(e) => e.currentTarget.style.filter = "none"}>
Vote For This Film
)}
);
}
// ── 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
navigate("/programme")} style={{ marginTop: 16 }}>Back to programme
);
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 ?
:
}
{/* Gradient: solid strand colour on left → transparent on right */}
{/* Bottom fade into strand colour so it bleeds into next section */}
{/* Content layer */}
{/* Back link */}
navigate("/programme/" + (film.strand || "").toLowerCase())}
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit", opacity: .8, fontFamily: "var(--mono)", fontSize: 11, letterSpacing: ".14em", textTransform: "uppercase", display: "inline-flex", alignItems: "center", gap: 6, padding: 0, marginBottom: "clamp(120px,20vh,280px)" }}>
{film.strand}
{/* 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) &&
}
{/* ── Synopsis + meta ── */}
Synopsis
{film.synopsis ?
{film.synopsis}
:
Synopsis to be added.
}
{d.festival.votingOpen && String(film.year) === String(d.festival.year) && film.voteLink && (
window.open(film.voteLink, "_blank")}>
Vote for this Film
)}
{[["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)}>
)}
);
})()}
{/* ── 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 });