2026-06-11 02:19:47 +02:00
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-06-11 04:15:25 +02:00
|
|
|
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-06-11 02:19:47 +02:00
|
|
|
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
|
import { toast } from "../lib/toast";
|
2026-06-11 02:19:47 +02:00
|
|
|
|
import VideoCard from "./VideoCard";
|
|
|
|
|
|
|
|
|
|
|
|
const PAGE = 60;
|
|
|
|
|
|
|
2026-06-11 03:47:51 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
|
export default function Feed({
|
|
|
|
|
|
filters,
|
2026-06-11 03:47:51 +02:00
|
|
|
|
setFilters,
|
2026-06-11 02:19:47 +02:00
|
|
|
|
view,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
filters: FeedFilters;
|
2026-06-11 03:47:51 +02:00
|
|
|
|
setFilters: (f: FeedFilters) => void;
|
2026-06-11 02:19:47 +02:00
|
|
|
|
view: "grid" | "list";
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
2026-06-11 03:47:51 +02:00
|
|
|
|
const qc = useQueryClient();
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => setOverrides({}), [filters]);
|
|
|
|
|
|
|
2026-06-11 04:15:25 +02:00
|
|
|
|
const countQuery = useQuery({
|
|
|
|
|
|
queryKey: ["feed-count", filters],
|
|
|
|
|
|
queryFn: () => api.feedCount(filters),
|
|
|
|
|
|
staleTime: 30_000,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
|
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: "800px" }
|
|
|
|
|
|
);
|
|
|
|
|
|
io.observe(el);
|
|
|
|
|
|
return () => io.disconnect();
|
|
|
|
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
|
|
|
|
|
|
|
|
function onState(id: string, status: string) {
|
|
|
|
|
|
setOverrides((o) => ({ ...o, [id]: status }));
|
2026-06-11 03:47:51 +02:00
|
|
|
|
// 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(() => {});
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
|
if (status === "hidden") {
|
|
|
|
|
|
toast("Video hidden", { label: "Undo", onClick: () => onState(id, "new") });
|
|
|
|
|
|
}
|
2026-06-11 02:19:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 03:47:51 +02:00
|
|
|
|
function onChannelFilter(channelId: string, channelName: string) {
|
|
|
|
|
|
setFilters({ ...filters, channelId, channelName });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
|
const items: Video[] = (query.data?.pages ?? [])
|
|
|
|
|
|
.flatMap((p) => p.items)
|
|
|
|
|
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
2026-06-11 03:47:51 +02:00
|
|
|
|
.filter((v) => matchesView(v.status, filters.show));
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
return <div className="p-8 text-muted">No videos match these filters.</div>;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="p-4">
|
2026-06-11 04:15:25 +02:00
|
|
|
|
<div className="pb-3 text-sm text-muted">
|
|
|
|
|
|
{countQuery.data
|
|
|
|
|
|
? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}`
|
|
|
|
|
|
: " "}
|
|
|
|
|
|
</div>
|
2026-06-11 02:19:47 +02:00
|
|
|
|
{view === "grid" ? (
|
2026-06-11 03:47:51 +02:00
|
|
|
|
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
2026-06-11 02:19:47 +02:00
|
|
|
|
{items.map((v) => (
|
2026-06-11 03:47:51 +02:00
|
|
|
|
<VideoCard
|
|
|
|
|
|
key={v.id}
|
|
|
|
|
|
video={v}
|
|
|
|
|
|
view="grid"
|
|
|
|
|
|
onState={onState}
|
|
|
|
|
|
onChannelFilter={onChannelFilter}
|
|
|
|
|
|
/>
|
2026-06-11 02:19:47 +02:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="max-w-4xl mx-auto flex flex-col gap-1">
|
|
|
|
|
|
{items.map((v) => (
|
2026-06-11 03:47:51 +02:00
|
|
|
|
<VideoCard
|
|
|
|
|
|
key={v.id}
|
|
|
|
|
|
video={v}
|
|
|
|
|
|
view="list"
|
|
|
|
|
|
onState={onState}
|
|
|
|
|
|
onChannelFilter={onChannelFilter}
|
|
|
|
|
|
/>
|
2026-06-11 02:19:47 +02:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div ref={sentinel} className="h-10" />
|
|
|
|
|
|
{isFetchingNextPage && (
|
|
|
|
|
|
<div className="text-center text-muted py-4">Loading more…</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|