Repository Public
1"use client";23import Link from "next/link";4import { useCallback, useEffect, useRef, useState } from "react";5import { Player, type PlayerLabels } from "./Player";6import { FilmActions, track, type ActionLabels } from "./FilmActions";78export interface FeedFilm {9 id: string; slug: string; title: string; synopsis: string; takeaway: string; duration: number; video: string; videoLow: string | null; poster: string;10 captions: { en: string | null; fr: string | null }; series: { slug: string; title: string; accent: string } | null; episode: number | null;11 tags: { slug: string; kind: string; label: string }[]; likes: number; kind: string;12}13export interface FeedLabels { player: PlayerLabels; actions: ActionLabels; next: string; previous: string; details: string; end: string; back: string; empty: string; hint: string; ai: string }1415/** Full-screen vertical feed: scroll-snap, swipe, keyboard, preloading of the next film, cursor pagination. */16export function Feed({ initial, nextCursor, locale, labels, base, query = "" }: { initial: FeedFilm[]; nextCursor: string | null; locale: "en" | "fr"; labels: FeedLabels; base: string; query?: string }) {17 const [films, setFilms] = useState(initial);18 const [cursor, setCursor] = useState(nextCursor);19 const [active, setActive] = useState(0);20 const scroller = useRef<HTMLDivElement>(null);21 const loading = useRef(false);2223 const go = useCallback((i: number) => {24 const el = scroller.current?.children[i] as HTMLElement | undefined;25 el?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" });26 }, []);2728 useEffect(() => {29 const root = scroller.current;30 if (!root) return;31 const io = new IntersectionObserver((entries) => {32 for (const e of entries) if (e.isIntersecting && e.intersectionRatio > 0.6) setActive(Number((e.target as HTMLElement).dataset.index));33 }, { root, threshold: [0.6] });34 [...root.children].forEach((c) => io.observe(c));35 return () => io.disconnect();36 }, [films.length]);3738 useEffect(() => {39 if (!cursor || loading.current || active < films.length - 3) return;40 loading.current = true;41 fetch(`/api/films?locale=${locale}&limit=6&cursor=${encodeURIComponent(cursor)}${query}`)42 .then((r) => r.json()).then((d: { films: FeedFilm[]; nextCursor: string | null }) => { setFilms((f) => [...f, ...d.films.filter((x) => !f.some((y) => y.id === x.id))]); setCursor(d.nextCursor); })43 .catch(() => {}).finally(() => { loading.current = false; });44 }, [active, cursor, films.length, locale, query]);4546 useEffect(() => {47 const onKey = (e: KeyboardEvent) => {48 if ((e.target as HTMLElement).closest("input, textarea, select")) return;49 if (e.key === "ArrowDown" || e.key === "j" || e.key === "PageDown") { e.preventDefault(); go(Math.min(active + 1, films.length)); }50 if (e.key === "ArrowUp" || e.key === "PageUp") { e.preventDefault(); go(Math.max(active - 1, 0)); }51 };52 window.addEventListener("keydown", onKey);53 return () => window.removeEventListener("keydown", onKey);54 }, [active, films.length, go]);5556 if (!films.length) return <div className="grid h-[70dvh] place-items-center px-6 text-center text-[var(--color-muted)]">{labels.empty}</div>;5758 return (59 <div ref={scroller} className="feed" aria-label={labels.hint}>60 {films.map((f, i) => (61 <section key={f.id} data-index={i} className="feed-item relative flex items-center justify-center lg:gap-12 lg:px-10" aria-label={f.title}>62 {/* stage: 9:16 frame, full-bleed on phones, a framed screen on desktop */}63 <div className="relative h-full w-full overflow-hidden bg-black lg:h-[min(92dvh,calc((100vw-520px)*16/9))] lg:w-auto lg:aspect-[9/16] lg:rounded-[14px] lg:shadow-[0_0_0_1px_var(--color-line-soft),0_40px_120px_-40px_#000]">64 {Math.abs(i - active) <= 1 ? (65 <Player film={f} labels={labels.player} locale={locale} active={i === active} preload={i === active ? "auto" : "metadata"} chromeClass="top-16 lg:top-3" captionClass="bottom-[31%] lg:bottom-[13%]"66 onFirstPlay={() => track(f.id, "view", locale)} onComplete={() => track(f.id, "complete", locale)} onEnded={() => { if (i < films.length - 1) go(i + 1); }} />67 ) : (68 69 <img src={f.poster} alt="" className="h-full w-full object-contain" loading="lazy" />70 )}71 {/* phone overlay */}72 <div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/40 to-transparent px-4 pb-8 pt-24 lg:hidden">73 <div className="pointer-events-auto max-w-[78%]">74 {f.series && <p className="kicker mb-1" style={{ color: f.series.accent }}>{f.series.title}{f.episode ? ` · ${f.episode}` : ""}</p>}75 <h2 className="display text-2xl"><Link href={`${base}/film/${f.slug}`}>{f.title}</Link></h2>76 <p className="mt-1 line-clamp-2 text-sm text-white/75">{f.synopsis}</p>77 <p className="mt-2 text-[0.68rem] uppercase tracking-[0.16em] text-white/55">{labels.ai}</p>78 </div>79 </div>80 <div className="absolute bottom-24 right-3 z-30 lg:hidden"><FilmActions film={f} labels={labels.actions} layout="column" detailsHref={`${base}/film/${f.slug}`} detailsLabel={labels.details} /></div>81 </div>8283 {/* desktop side panel */}84 <aside className="hidden w-[400px] shrink-0 lg:block">85 {f.series && <p className="kicker accent-text mb-3" style={{ "--a": f.series.accent } as React.CSSProperties}>{f.series.title}{f.episode ? ` · ${f.episode}` : ""}</p>}86 <h2 className="display text-5xl">{f.title}</h2>87 <p className="prose-film mt-5">{f.synopsis}</p>88 {f.takeaway && <p className="mt-5 border-l border-[var(--color-lamp)] pl-4 quote text-xl text-[var(--color-text)]">{f.takeaway}</p>}89 <div className="mt-6 flex flex-wrap gap-2">{f.tags.slice(0, 6).map((t) => <Link key={t.slug} href={`${base}/explore?tag=${t.slug}`} className="chip">{t.label}</Link>)}</div>90 <div className="mt-8"><FilmActions film={f} labels={labels.actions} layout="row" detailsHref={`${base}/film/${f.slug}`} detailsLabel={labels.details} /></div>91 <p className="label mt-8">{labels.ai}</p>92 <div className="mt-8 flex gap-2">93 <button type="button" className="btn btn-ghost btn-sm" onClick={() => go(Math.max(i - 1, 0))} disabled={i === 0}>↑ {labels.previous}</button>94 <button type="button" className="btn btn-ghost btn-sm" onClick={() => go(i + 1)}>↓ {labels.next}</button>95 </div>96 </aside>97 </section>98 ))}99 <section data-index={films.length} className="feed-item grid place-items-center px-6 text-center">100 <div><p className="display text-3xl">{labels.end}</p><button type="button" onClick={() => go(0)} className="btn btn-ghost mt-6">{labels.back}</button></div>101 </section>102 </div>103 );104}105