siftlode/frontend/src/components/Feed.tsx

221 lines
7.4 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify } from "../lib/notifications";
import VideoCard from "./VideoCard";
import PlayerModal from "./PlayerModal";
const PAGE = 60;
function matchesView(status: string, show: string): boolean {
switch (show) {
case "hidden":
return status === "hidden";
case "watched":
return status === "watched";
case "saved":
return status === "saved";
case "unwatched":
case "in_progress":
// (in_progress is further narrowed server-side by resume position; here we only
// need to drop a card once it's optimistically marked watched/hidden.)
return status !== "watched" && status !== "hidden";
default:
return status !== "hidden"; // all
}
}
export default function Feed({
filters,
setFilters,
view,
canRead,
onOpenWizard,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
view: "grid" | "list";
canRead: boolean;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({});
// The open player: which video and where to start (null = resume from saved position).
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
null
);
const qc = useQueryClient();
const openVideo = useCallback(
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
[]
);
const query = useInfiniteQuery({
queryKey: ["feed", filters],
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE),
initialPageParam: 0,
getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined),
});
// Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override
// (e.g. a video reverted to "new" from the notification center) won't keep it
// filtered out of the current view.
useEffect(() => setOverrides({}), [filters]);
useEffect(() => setOverrides({}), [query.dataUpdatedAt]);
const countQuery = useQuery({
queryKey: ["feed-count", filters],
queryFn: () => api.feedCount(filters),
staleTime: 30_000,
});
const sentinel = useRef<HTMLDivElement | null>(null);
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
useEffect(() => {
const el = sentinel.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "1500px" } // prefetch the next page well before the bottom is in view
);
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Keep the loaded videos in a ref so onState can stay referentially stable
// (it needs the list only to look up a title for the notification). A stable
// onState lets memo(VideoCard) skip re-rendering existing cards on page append.
const loadedRef = useRef<Video[]>([]);
const onState = useCallback(
(id: string, status: string) => {
setOverrides((o) => ({ ...o, [id]: status }));
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
api
.setState(id, status)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.catch(() => {});
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.hiddenNamed", { title: v.title })
: i18n.t("feed.hidden"),
action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
meta: {
kind: "video-hidden",
videoId: id,
title: v?.title ?? "this video",
channelId: v?.channel_id ?? "",
channelName: v?.channel_title ?? "",
},
});
} else if (status === "watched") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
message: v?.title
? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"),
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" },
});
}
},
[qc]
);
const onChannelFilter = useCallback(
(channelId: string, channelName: string) => {
setFilters({ ...filters, channelId, channelName });
},
[filters, setFilters]
);
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
loadedRef.current = loaded;
const items: Video[] = loaded
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.filter((v) => matchesView(v.status, filters.show));
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
if (items.length === 0) {
if (!canRead)
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
<button
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
{t("feed.setUp")}
</button>
</div>
</div>
);
return <div className="p-8 text-muted">{t("feed.noMatches")}</div>;
}
return (
<div className="p-4">
<div className="pb-3 text-sm text-muted">
{countQuery.data
? t("feed.videoCount", {
count: countQuery.data.count,
formattedCount: countQuery.data.count.toLocaleString(),
})
: " "}
</div>
{view === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => (
<VideoCard
key={v.id}
video={v}
view="grid"
onState={onState}
onChannelFilter={onChannelFilter}
onOpen={openVideo}
/>
))}
</div>
) : (
<div className="max-w-4xl mx-auto flex flex-col gap-1">
{items.map((v) => (
<VideoCard
key={v.id}
video={v}
view="list"
onState={onState}
onChannelFilter={onChannelFilter}
onOpen={openVideo}
/>
))}
</div>
)}
{activeVideo && (
<PlayerModal
video={activeVideo.video}
startAt={activeVideo.startAt}
onClose={() => setActiveVideo(null)}
onState={onState}
/>
)}
<div ref={sentinel} className="h-10" />
{isFetchingNextPage && (
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
)}
</div>
);
}