After read access is granted the wizard now imports the user's YouTube subscriptions automatically (with a "Building your feed…" progress state), so a new user lands on a populated feed — channels already in the shared catalog show up instantly, new ones backfill in the background. The empty feed now prompts users without read access to set up via the wizard instead of a bare message.
200 lines
6.7 KiB
TypeScript
200 lines
6.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { api, type FeedFilters, type Video } from "../lib/api";
|
||
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":
|
||
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 [overrides, setOverrides] = useState<Record<string, string>>({});
|
||
const [activeVideo, setActiveVideo] = useState<Video | null>(null);
|
||
const qc = useQueryClient();
|
||
|
||
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 ? `Hidden “${v.title}”` : "Video hidden",
|
||
action: { label: "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 ? `Marked watched “${v.title}”` : "Marked watched",
|
||
action: { label: "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">Loading feed…</div>;
|
||
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</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">Your feed is empty</h2>
|
||
<p className="text-sm text-muted mt-2 mb-4">
|
||
Connect your YouTube account to import your subscriptions and build your feed.
|
||
</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"
|
||
>
|
||
Set up my feed
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
return <div className="p-8 text-muted">No videos match these filters.</div>;
|
||
}
|
||
|
||
return (
|
||
<div className="p-4">
|
||
<div className="pb-3 text-sm text-muted">
|
||
{countQuery.data
|
||
? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}`
|
||
: " "}
|
||
</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={setActiveVideo}
|
||
/>
|
||
))}
|
||
</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={setActiveVideo}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{activeVideo && (
|
||
<PlayerModal
|
||
video={activeVideo}
|
||
onClose={() => setActiveVideo(null)}
|
||
onState={onState}
|
||
/>
|
||
)}
|
||
<div ref={sentinel} className="h-10" />
|
||
{isFetchingNextPage && (
|
||
<div className="text-center text-muted py-4">Loading more…</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|