siftlode/frontend/src/components/Feed.tsx
npeter83 9c61bd898d feat(i18n): translate remaining components (HU/EN/DE)
Feed, VideoCard, Sidebar, PlayerModal, Channels, Stats, SettingsPanel,
OnboardingWizard, NotificationCenter, Toaster, ErrorBoundary and the relativeTime
helper are now fully translated in Hungarian, English and German, each with its own
locale area file (auto-loaded). Key parity verified across all three languages.
2026-06-15 00:47:04 +02:00

220 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}