SPB Git

Repository Public

Feed.tsx 6.9 KB 105 lines tsx

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
Files main
Menu

Theme