// site-awards.jsx — Awards Night page. Visible in nav only when awardsPage.live === true.
const AW_COLORS = { Fiction: "var(--pink)", Documentary: "var(--purple)", Animation: "var(--yellow)", Experimental: "var(--cyan)", Audience: "#F0EEE9" };
const AW_TEXT = { Fiction: "#fff", Documentary: "#fff", Animation: "#16151a", Experimental: "#16151a", Audience: "#16151a" };
const AW_TROPHY = { Purple: "#784c95", Yellow: "#ffc823", Cyan: "#65c8d0", Pink: "#ff5c70", White: "#f0eee9", Red: "#d6394c", Green: "#3f7d52" };
const AW_LIGHT = new Set(["Yellow", "Cyan", "White"]);
function AwardsPage({ navigate }) {
const d = useSite();
const f = d.festival;
const ap = f.awardsPage || {};
const awards = d.awards || [];
const films = usePublishedFilms();
const people = d.people || [];
const audienceWinner = films.find(fi => fi.prize === "Audience");
// Order jury winners by strand order from the schedule
const _schedStrands = [];
const _seen = new Set();
for (const day of (d.schedule || [])) {
if (day.strand && day.strand !== "Off-night" && day.strand !== "Awards" && !_seen.has(day.strand)) {
_schedStrands.push(day.strand);
_seen.add(day.strand);
}
}
// Fall back to default order if schedule is empty
const _strandOrder = _schedStrands.length ? _schedStrands : ["Documentary","Animation","Experimental","Fiction"];
const juryWinners = _strandOrder
.map(strand => films.find(fi => fi.prize === "Jury" && fi.strand === strand))
.filter(Boolean);
const allWinners = audienceWinner ? [...juryWinners, audienceWinner] : juryWinners;
const gallery = ap.gallery || [];
const winnerDescs = ap.winnerDescs || {};
const juryPeople = people.filter(p => p.group === "jury");
return (
{/* ── HERO — full-bleed image ── */}
{/* String lights image */}
{/* Gradient: image visible at top → fades to ink at bottom */}
{/* Pixel texture overlay */}
{/* Content — anchored to the bottom */}
{ap.eveningLabel || ("Paper Bird Awards \u00b7 " + f.edition + " Edition")}
{ap.heading || "Awards Night."}
{(ap.pageDesc || f.awardsDesc) && (
{ap.pageDesc || f.awardsDesc}
)}
{(ap.eveningDate || ap.eveningTime || ap.venueName) && (
{ap.eveningDate && (
)}
{ap.eveningTime && (
)}
{ap.venueName && (
)}
)}
{/* ── EVENING DESC — yellow splash ── */}
{ap.eveningDesc && (
)}
{/* ── WINNERS CAROUSEL ── */}
{allWinners.length > 0 && (
)}
{/* ── GALLERY ── */}
{gallery.filter(im => im.url).length > 0 && (
)}
{/* ── VIDEO ── */}
{(() => {
const vids = (ap.videos || []).filter(v => v.youTubeId);
const legacy = ap.videoYouTube ? [{ id: "legacy", youTubeId: ap.videoYouTube, label: ap.videoLabel || "Awards Evening · Highlights", desc: "" }] : [];
const allVids = vids.length ? vids : legacy;
return allVids.length > 0 ? : null;
})()}
{/* ── VENUE ── */}
{(ap.venueName || ap.venueDesc) && (
The Venue
{ap.venueName || "The venue."}
{ap.venueAddress && (
{ap.venueAddress}
)}
{ap.venueDesc && (
{ap.venueDesc}
)}
{ap.venuePhoto && (
openLightbox([{ url: ap.venuePhoto, caption: ap.venueName }])}>
)}
)}
);
}
function PhotoCarousel({ images }) {
const items = (images || []).filter(im => im.url);
const total = items.length;
const [cur, setCur] = React.useState(0);
const [deltaX, setDeltaX] = React.useState(0);
const [sliding, setSliding] = React.useState(false);
const [slideDir, setSlideDir] = React.useState(null);
const [incomingCenter, setIncomingCenter] = React.useState(null);
const [exitingCenter, setExitingCenter] = React.useState(null);
const [mobileMode, setMobileMode] = React.useState(() => window.innerWidth < 640);
const trackRef = React.useRef(null);
const pendingRef = React.useRef(null);
const touchStartX = React.useRef(null);
const onTouchStart = (e) => { touchStartX.current = e.touches[0].clientX; };
const onTouchEnd = (e) => {
if (touchStartX.current === null) return;
const delta = e.changedTouches[0].clientX - touchStartX.current;
touchStartX.current = null;
if (Math.abs(delta) < 40) return;
navigate(delta < 0 ? 'next' : 'prev');
};
React.useEffect(() => {
const check = () => setMobileMode(window.innerWidth < 640);
window.addEventListener('resize', check, { passive: true });
return () => window.removeEventListener('resize', check);
}, []);
if (!total) return null;
const lbItems = items.map(im => ({ url: im.url, caption: im.caption }));
const idxAt = n => ((n % total) + total) % total;
// Desktop: 3 cards (33.33% each). Mobile: 80% center with 10% peeks.
const CARD_W = mobileMode ? 80 : 100 / 3;
// BASE_X centers the middle slot (index 2 of 5) in the viewport
const BASE_X = 50 - 2.5 * CARD_W;
const navigate = (dir) => {
if (sliding || total < 2) return;
setSliding(true);
setSlideDir(dir);
const nextIdx = idxAt(cur + (dir === 'next' ? 1 : -1));
setIncomingCenter(nextIdx);
setExitingCenter(cur);
pendingRef.current = nextIdx;
setDeltaX(dir === 'next' ? -CARD_W : CARD_W);
};
const onTransitionEnd = () => {
const track = trackRef.current;
if (!track || pendingRef.current === null) return;
track.style.transition = 'none';
setCur(pendingRef.current);
setDeltaX(0);
setSliding(false);
setSlideDir(null);
setIncomingCenter(null);
setExitingCenter(null);
pendingRef.current = null;
requestAnimationFrame(() => requestAnimationFrame(() => {
if (trackRef.current) trackRef.current.style.transition = '';
}));
};
const slots = [-2, -1, 0, 1, 2].map(offset => ({
idx: idxAt(cur + offset), offset,
isCenter: offset === 0,
isSide: Math.abs(offset) === 1,
}));
const cardStyle = (slot) => {
let op;
if (slot.idx === exitingCenter && sliding) { op = 0.5; }
else if (slot.idx === incomingCenter || slot.isCenter) { op = 1; }
else if (Math.abs(slot.offset) === 2) {
const entering = sliding && (
(slideDir === 'next' && slot.offset === 2) ||
(slideDir === 'prev' && slot.offset === -2)
);
op = entering ? 0.5 : 0;
} else { op = 0.5; }
return {
flexShrink: 0, width: CARD_W + '%', position: 'relative',
padding: '0 clamp(4px,0.6vw,8px)',
opacity: op, transition: 'opacity 0.55s cubic-bezier(0.16,1,0.3,1)',
};
};
const arrowBase = {
position: 'absolute', top: '50%', transform: 'translateY(-50%)',
zIndex: 10, border: 'none', outline: 'none',
background: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', transition: 'background 0.15s',
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
};
// Arrow positions relative to outer wrapper (outside overflow:hidden)
const prevX = mobileMode ? -12 : (CARD_W + '%');
const nextX = mobileMode ? -12 : ((CARD_W * 2) + '%');
const arwSz = mobileMode ? 36 : 48;
const icnSz = mobileMode ? 14 : 18;
return (
The Evening
In pictures.
{total > 1 && (
{String(cur + 1).padStart(2,'0')} / {String(total).padStart(2,'0')}
)}
{/* Outer wrapper: position:relative for arrows, NO overflow:hidden */}
{/* Track clip: overflow:hidden only on this inner div */}
{slots.map((slot) => (
slot.isCenter ? openLightbox(lbItems, slot.idx) : navigate(slot.offset > 0 ? 'next' : 'prev')}
style={{
aspectRatio: '3/4', overflow: 'hidden',
cursor: slot.isCenter ? 'zoom-in' : 'pointer',
transform: (slot.idx === exitingCenter && sliding) ? 'scale(0.92)' : (slot.idx === incomingCenter || slot.isCenter) ? 'scale(1)' : 'scale(0.92)',
transition: 'transform 0.55s cubic-bezier(0.16,1,0.3,1)',
}}>
{slot.isCenter && items[slot.idx].caption && (
{items[slot.idx].caption}
)}
))}
{/* Arrows — outside overflow:hidden, never clipped */}
{total > 1 && (
<>
>
)}
{total > 1 && (
{items.map((_, i) => (
)}
);
}
function DirectorCarousel({ bios }) {
const [idx2, setIdx2] = React.useState(0);
if (!bios || !bios.length) return null;
const dir = bios[idx2];
const prev = () => setIdx2(i => (i - 1 + bios.length) % bios.length);
const next = () => setIdx2(i => (i + 1) % bios.length);
return (
{bios.length > 1 ? "Directors" : "Director"}
{bios.length > 1 && (
{String(idx2 + 1).padStart(2,"0")} / {String(bios.length).padStart(2,"0")}
)}
{dir.name}
{dir.bio && (
{dir.bio}
)}
);
}
function WinnersCarousel({ winners, winnerDescs, navigate }) {
const [active, setActive] = React.useState(0);
if (!winners || !winners.length) return null;
const film = winners[Math.min(active, winners.length - 1)];
const strandKey = film.prize === "Audience" ? "Audience" : film.strand;
const accentHex = { Fiction: "var(--pink)", Documentary: "var(--purple)", Animation: "var(--yellow)", Experimental: "var(--cyan)", Audience: "var(--paper)" }[strandKey] || "var(--yellow)";
const juryDesc = winnerDescs[strandKey] || "";
const dirBios = film.directorBios || [];
// Build carousel entries from the directors string, merging bio data where available
const _dirNames = (film.directors || "").split(",").map(n => n.trim()).filter(Boolean);
const _bioMap = {};
dirBios.forEach(b => { _bioMap[b.name] = b; });
const dirEntries = _dirNames.length > 1
? _dirNames.map(name => _bioMap[name] || { name, bio: "", photo: "" })
: dirBios;
const credits = film.credits || {};
const creditRows = Object.entries(credits).filter(([, v]) => v);
return (
{/* heading */}
This Year’s Winners
The Paper Bird goes to.
{/* tab bar */}
{winners.map((w, i) => {
const sk = w.prize === "Audience" ? "Audience" : w.strand;
const on = i === active;
const tbg = on ? ({ Fiction: "var(--pink)", Documentary: "var(--purple)", Animation: "var(--yellow)", Experimental: "var(--cyan)", Audience: "var(--paper)" }[sk] || "var(--yellow)") : "transparent";
const tfg = on ? ({ Fiction: "#fff", Documentary: "#fff", Animation: "#16151a", Experimental: "#16151a", Audience: "#16151a" }[sk] || "#fff") : "rgba(240,238,233,0.42)";
const tsub = on ? (tfg === "#16151a" ? "rgba(22,21,26,0.55)" : "rgba(240,238,233,0.65)") : "rgba(240,238,233,0.25)";
return (
);
})}
{/* film panel */}
{/* poster 2:3 */}
{/* credits panel */}
{/* award kicker */}
Paper Bird · {strandKey === "Audience" ? "Audience Award" : strandKey + " · Jury Award"}
{/* film title */}
{film.title}
{/* meta strip */}
{film.country && {film.country}}
{film.runtime && {film.runtime}'{String(film.runtimeSecs || "0").padStart(2,"0")}"}
{film.school && {film.school}}
{/* jury statement OR synopsis */}
{(juryDesc || film.synopsis) && (
Jury Statement
{juryDesc ? ("“" + juryDesc + "”") : film.synopsis}
)}
{/* credits grid — film-credits style */}
{creditRows.length > 0 && (
{creditRows.map(([role, name], i) => (
))}
)}
{/* director carousel */}
{dirEntries.length > 0 && (
)}
{/* view film */}
);
}
function AwardsVideoCarousel({ videos, ap }) {
const [active, setActive] = React.useState(0);
const [shown, setShown] = React.useState(0);
const [videoKey, setVideoKey] = React.useState(0);
const [exitStyle, setExitStyle] = React.useState(null);
const [enterAnim, setEnterAnim] = React.useState(null);
const [textVisible, setTextVisible] = React.useState(true);
const inFlight = React.useRef(false);
const navigate = (dir) => {
if (inFlight.current) return;
inFlight.current = true;
const next = (active + (dir === 'next' ? 1 : -1) + videos.length) % videos.length;
const exitTo = dir === 'next' ? 'left' : 'right';
const enterFrom = dir === 'next' ? 'from-right' : 'from-left';
setTextVisible(false);
setExitStyle(exitTo);
setTimeout(() => {
setActive(next);
setShown(next);
setVideoKey(k => k + 1);
setExitStyle(null);
setEnterAnim(enterFrom);
setTimeout(() => setTextVisible(true), 80);
setTimeout(() => { setEnterAnim(null); inFlight.current = false; }, 550);
}, 390);
};
const vid = videos[shown];
const hasMultiple = videos.length > 1;
const vidTouchX = React.useRef(null);
const onVidTouchStart = (e) => { vidTouchX.current = e.touches[0].clientX; };
const onVidTouchEnd = (e) => {
if (vidTouchX.current === null) return;
const delta = e.changedTouches[0].clientX - vidTouchX.current;
vidTouchX.current = null;
if (Math.abs(delta) < 40 || !hasMultiple) return;
navigate(delta < 0 ? 'next' : 'prev');
};
const videoWrapStyle = exitStyle
? { transform: exitStyle === 'right' ? "translateX(55%)" : "translateX(-55%)", opacity: 0, transition: "transform 0.39s ease, opacity 0.33s ease" }
: { transform: "translateX(0)", opacity: 1 };
const enterAnimStyle = enterAnim
? { animation: enterAnim === 'from-left' ? "awards-from-left 0.47s ease forwards" : "awards-from-right 0.47s ease forwards" }
: {};
const arrowBtn = {
width: 48, height: 48, borderRadius: 0,
background: "var(--accent)",
border: "none", outline: "none",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", overflow: "hidden",
position: "absolute", top: "50%", transform: "translateY(-50%)",
zIndex: 4, transition: "background 0.15s",
};
return (
{/* Video + arrows */}
{hasMultiple && (
)}
{hasMultiple && (
)}
{/* Text */}
);
}
Object.assign(window, { AwardsPage });