2026-06-12 18:17:03 +02:00
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
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
|
|
|
import { useTranslation } from "react-i18next";
|
2026-06-11 04:15:25 +02:00
|
|
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-06-16 02:34:13 +02:00
|
|
|
import { RefreshCw } from "lucide-react";
|
2026-06-11 02:19:47 +02:00
|
|
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
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
|
|
|
import i18n from "../i18n";
|
2026-06-11 19:46:58 +02:00
|
|
|
import { notify } from "../lib/notifications";
|
2026-06-11 02:19:47 +02:00
|
|
|
import VideoCard from "./VideoCard";
|
2026-06-12 17:38:45 +02:00
|
|
|
import PlayerModal from "./PlayerModal";
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
const PAGE = 60;
|
|
|
|
|
|
2026-06-16 02:34:13 +02:00
|
|
|
// Sort + content-type live in the feed toolbar (above the cards), not the filter sidebar.
|
|
|
|
|
const SORT_IDS = [
|
|
|
|
|
"newest",
|
|
|
|
|
"oldest",
|
|
|
|
|
"views",
|
|
|
|
|
"duration_desc",
|
|
|
|
|
"duration_asc",
|
|
|
|
|
"title",
|
|
|
|
|
"subscribers",
|
|
|
|
|
"priority",
|
|
|
|
|
"shuffle",
|
|
|
|
|
];
|
|
|
|
|
const CONTENT = [
|
|
|
|
|
{ key: "includeNormal", label: "sidebar.content.normal" },
|
|
|
|
|
{ key: "includeShorts", label: "sidebar.content.shorts" },
|
|
|
|
|
{ key: "includeLive", label: "sidebar.content.live" },
|
|
|
|
|
] as const;
|
|
|
|
|
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
|
|
|
|
|
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 "unwatched":
|
2026-06-14 18:40:12 +02:00
|
|
|
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.)
|
2026-06-11 03:47:51 +02:00
|
|
|
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,
|
2026-06-14 06:36:12 +02:00
|
|
|
canRead,
|
|
|
|
|
onOpenWizard,
|
2026-06-11 02:19:47 +02:00
|
|
|
}: {
|
|
|
|
|
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";
|
2026-06-14 06:36:12 +02:00
|
|
|
canRead: boolean;
|
|
|
|
|
onOpenWizard: () => void;
|
2026-06-11 02:19:47 +02:00
|
|
|
}) {
|
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
|
|
|
const { t } = useTranslation();
|
2026-06-11 02:19:47 +02:00
|
|
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
2026-06-15 15:33:53 +02:00
|
|
|
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
2026-06-14 18:40:12 +02:00
|
|
|
// 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
|
|
|
|
|
);
|
2026-06-11 03:47:51 +02:00
|
|
|
const qc = useQueryClient();
|
2026-06-11 02:19:47 +02:00
|
|
|
|
2026-06-14 18:40:12 +02:00
|
|
|
const openVideo = useCallback(
|
|
|
|
|
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
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),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-12 17:39:28 +02:00
|
|
|
// 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.
|
2026-06-15 15:33:53 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
setOverrides({});
|
|
|
|
|
setSavedOverrides({});
|
|
|
|
|
}, [filters]);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setOverrides({});
|
|
|
|
|
setSavedOverrides({});
|
|
|
|
|
}, [query.dataUpdatedAt]);
|
2026-06-11 02:19:47 +02:00
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-06-12 18:17:03 +02:00
|
|
|
{ rootMargin: "1500px" } // prefetch the next page well before the bottom is in view
|
2026-06-11 02:19:47 +02:00
|
|
|
);
|
|
|
|
|
io.observe(el);
|
|
|
|
|
return () => io.disconnect();
|
|
|
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
|
|
2026-06-12 18:17:03 +02:00
|
|
|
// 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[]>([]);
|
2026-06-11 02:19:47 +02:00
|
|
|
|
2026-06-12 18:17:03 +02:00
|
|
|
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({
|
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
|
|
|
message: v?.title
|
|
|
|
|
? i18n.t("feed.hiddenNamed", { title: v.title })
|
|
|
|
|
: i18n.t("feed.hidden"),
|
|
|
|
|
action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
|
2026-06-12 18:17:03 +02:00
|
|
|
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({
|
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
|
|
|
message: v?.title
|
|
|
|
|
? i18n.t("feed.markedWatchedNamed", { title: v.title })
|
|
|
|
|
: i18n.t("feed.markedWatched"),
|
|
|
|
|
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
|
2026-06-12 18:17:03 +02:00
|
|
|
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]
|
|
|
|
|
);
|
2026-06-11 03:47:51 +02:00
|
|
|
|
2026-06-15 15:33:53 +02:00
|
|
|
const onToggleSave = useCallback(
|
|
|
|
|
(id: string, saved: boolean) => {
|
|
|
|
|
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
|
|
|
|
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
|
|
|
|
.then(() => {
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["playlist"] });
|
|
|
|
|
})
|
|
|
|
|
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
|
|
|
|
|
},
|
|
|
|
|
[qc]
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-12 18:17:03 +02:00
|
|
|
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
|
|
|
|
loadedRef.current = loaded;
|
|
|
|
|
const items: Video[] = loaded
|
2026-06-11 02:19:47 +02:00
|
|
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
2026-06-15 15:33:53 +02:00
|
|
|
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[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
|
|
|
|
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
|
|
|
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>;
|
2026-06-16 02:34:13 +02:00
|
|
|
// Onboarding empty state (no YouTube + own subscriptions): a dedicated full-page prompt,
|
|
|
|
|
// with no toolbar (content-type/sort would be meaningless here).
|
|
|
|
|
if (items.length === 0 && !canRead && filters.scope === "my")
|
|
|
|
|
return (
|
2026-06-14 06:36:12 +02:00
|
|
|
<div className="p-8 grid place-items-center text-center">
|
|
|
|
|
<div className="max-w-sm">
|
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
|
|
|
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
|
|
|
|
|
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
|
2026-06-14 06:36:12 +02:00
|
|
|
<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"
|
|
|
|
|
>
|
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
|
|
|
{t("feed.setUp")}
|
2026-06-14 06:36:12 +02:00
|
|
|
</button>
|
2026-06-15 04:06:22 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
|
|
|
|
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
|
|
|
|
>
|
|
|
|
|
{t("feed.browseShared")}
|
|
|
|
|
</button>
|
2026-06-14 06:36:12 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-06-16 02:34:13 +02:00
|
|
|
|
|
|
|
|
const toolbar = (
|
|
|
|
|
<div className="pb-3">
|
|
|
|
|
<div className="flex items-center gap-1.5 flex-wrap mb-2.5">
|
|
|
|
|
{CONTENT.map((c) => {
|
|
|
|
|
const on = filters[c.key];
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={c.key}
|
|
|
|
|
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
|
|
|
|
aria-pressed={on}
|
|
|
|
|
className={`text-xs px-3 py-1.5 rounded-full border transition ${
|
|
|
|
|
on
|
|
|
|
|
? "bg-accent text-accent-fg border-accent"
|
|
|
|
|
: "border-border text-muted hover:text-fg hover:border-accent"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{t(c.label)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2 border-t border-border pt-2.5">
|
|
|
|
|
<span className="text-sm text-muted">
|
|
|
|
|
{countQuery.data
|
|
|
|
|
? t("feed.videoCount", {
|
|
|
|
|
count: countQuery.data.count,
|
|
|
|
|
formattedCount: countQuery.data.count.toLocaleString(),
|
|
|
|
|
})
|
|
|
|
|
: " "}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex-1" />
|
|
|
|
|
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
|
|
|
|
<select
|
|
|
|
|
value={filters.sort}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const sort = e.target.value;
|
|
|
|
|
setFilters({ ...filters, sort, seed: sort === "shuffle" ? rollSeed() : undefined });
|
|
|
|
|
}}
|
|
|
|
|
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
|
|
|
|
>
|
|
|
|
|
{SORT_IDS.map((id) => (
|
|
|
|
|
<option key={id} value={id}>
|
|
|
|
|
{t("sidebar.sort." + id)}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
{filters.sort === "shuffle" && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
|
|
|
|
title={t("sidebar.reshuffle")}
|
|
|
|
|
aria-label={t("sidebar.reshuffle")}
|
|
|
|
|
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-4">
|
2026-06-16 02:34:13 +02:00
|
|
|
{toolbar}
|
|
|
|
|
{items.length === 0 ? (
|
|
|
|
|
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div>
|
|
|
|
|
) : 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}
|
2026-06-15 15:33:53 +02:00
|
|
|
onToggleSave={onToggleSave}
|
2026-06-11 03:47:51 +02:00
|
|
|
onChannelFilter={onChannelFilter}
|
2026-06-14 18:40:12 +02:00
|
|
|
onOpen={openVideo}
|
2026-06-11 03:47:51 +02:00
|
|
|
/>
|
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}
|
2026-06-15 15:33:53 +02:00
|
|
|
onToggleSave={onToggleSave}
|
2026-06-11 03:47:51 +02:00
|
|
|
onChannelFilter={onChannelFilter}
|
2026-06-14 18:40:12 +02:00
|
|
|
onOpen={openVideo}
|
2026-06-11 03:47:51 +02:00
|
|
|
/>
|
2026-06-11 02:19:47 +02:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-12 17:38:45 +02:00
|
|
|
{activeVideo && (
|
2026-06-12 17:39:01 +02:00
|
|
|
<PlayerModal
|
2026-06-14 18:40:12 +02:00
|
|
|
video={activeVideo.video}
|
|
|
|
|
startAt={activeVideo.startAt}
|
2026-06-12 17:39:01 +02:00
|
|
|
onClose={() => setActiveVideo(null)}
|
|
|
|
|
onState={onState}
|
|
|
|
|
/>
|
2026-06-12 17:38:45 +02:00
|
|
|
)}
|
2026-06-11 02:19:47 +02:00
|
|
|
<div ref={sentinel} className="h-10" />
|
|
|
|
|
{isFetchingNextPage && (
|
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
|
|
|
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
|
2026-06-11 02:19:47 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|