perf: route- and modal-level code splitting (React.lazy)
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
DownloadDialog (DownloadButton — kept out of the feed chunk), and the
VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
callers can pre-select the admin tab without statically importing the now
lazy-loaded AdminUsers page (which would defeat the split).
Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
2026-07-04 19:43:50 +02:00
|
|
|
import { lazy, Suspense, 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-29 02:19:18 +02:00
|
|
|
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-07-01 01:00:32 +02:00
|
|
|
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
|
2026-06-29 02:01:44 +02:00
|
|
|
import { api, HttpError, 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-18 04:37:20 +02:00
|
|
|
import { notify, resolveVideo } from "../lib/notifications";
|
2026-06-29 02:19:18 +02:00
|
|
|
import { useDebounced } from "../lib/useDebounced";
|
2026-06-25 19:54:40 +02:00
|
|
|
import VirtualFeed from "./VirtualFeed";
|
perf: route- and modal-level code splitting (React.lazy)
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
DownloadDialog (DownloadButton — kept out of the feed chunk), and the
VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
callers can pre-select the admin tab without statically importing the now
lazy-loaded AdminUsers page (which would defeat the split).
Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
2026-07-04 19:43:50 +02:00
|
|
|
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
|
|
|
|
const PlayerModal = lazy(() => import("./PlayerModal"));
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
const PAGE = 60;
|
|
|
|
|
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
|
|
|
|
|
// (above the cards), not the filter sidebar.
|
|
|
|
|
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
2026-06-16 02:34:13 +02:00
|
|
|
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);
|
|
|
|
|
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
|
|
|
|
|
// (which encode both). One entry per concept; a single arrow flips the direction.
|
2026-06-30 00:39:21 +02:00
|
|
|
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
|
|
|
|
|
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle" | "relevance";
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
|
2026-06-30 00:39:21 +02:00
|
|
|
const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
|
|
|
|
|
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
date: { desc: "newest", asc: "oldest" },
|
|
|
|
|
popular: { desc: "views", asc: "views_asc" },
|
|
|
|
|
duration: { desc: "duration_desc", asc: "duration_asc" },
|
|
|
|
|
title: { asc: "title", desc: "title_desc" },
|
|
|
|
|
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
|
|
|
|
|
priority: { desc: "priority", asc: "priority_asc" },
|
|
|
|
|
};
|
2026-06-30 00:39:21 +02:00
|
|
|
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
|
|
|
|
|
};
|
|
|
|
|
function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
|
2026-06-30 00:39:21 +02:00
|
|
|
if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" };
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
|
|
|
|
|
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
|
|
|
|
|
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
|
|
|
|
|
}
|
|
|
|
|
return { key: "date", dir: "desc" };
|
|
|
|
|
}
|
|
|
|
|
function buildSort(key: SortKey, dir: "asc" | "desc"): string {
|
2026-06-30 00:39:21 +02:00
|
|
|
if (key === "shuffle" || key === "relevance") return key;
|
|
|
|
|
return SORT_MAP[key][dir];
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
}
|
|
|
|
|
|
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,
|
2026-06-16 09:27:34 +02:00
|
|
|
isDemo = false,
|
2026-06-14 06:36:12 +02:00
|
|
|
onOpenWizard,
|
2026-06-29 02:01:44 +02:00
|
|
|
ytSearch,
|
2026-06-29 23:03:25 +02:00
|
|
|
onYtSearch,
|
|
|
|
|
onExitYtSearch,
|
2026-06-30 03:08:52 +02:00
|
|
|
onOpenChannel,
|
2026-06-30 04:37:00 +02:00
|
|
|
channelScoped,
|
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;
|
2026-06-16 09:27:34 +02:00
|
|
|
isDemo?: boolean;
|
2026-06-14 06:36:12 +02:00
|
|
|
onOpenWizard: () => void;
|
2026-06-29 23:03:25 +02:00
|
|
|
// Live YouTube search: the active search term (null = normal feed). Entering a search and
|
|
|
|
|
// leaving it both step browser history (the search is a feed sub-view with its own history
|
|
|
|
|
// entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
|
|
|
|
|
// onExitYtSearch. Search affordances are hidden for demo.
|
2026-06-29 02:01:44 +02:00
|
|
|
ytSearch: string | null;
|
2026-06-29 23:03:25 +02:00
|
|
|
onYtSearch: (q: string) => void;
|
|
|
|
|
onExitYtSearch: () => void;
|
2026-06-30 03:08:52 +02:00
|
|
|
// Open a channel's dedicated page (from a card's channel name / avatar). Threaded down to
|
|
|
|
|
// the cards via VirtualFeed.
|
|
|
|
|
onOpenChannel?: (channelId: string, channelName?: string) => void;
|
2026-06-30 04:37:00 +02:00
|
|
|
// True when this feed is scoped to a single channel (the channel page). Drops sort options
|
|
|
|
|
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
|
|
|
|
|
channelScoped?: boolean;
|
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-30 04:37:00 +02:00
|
|
|
const sortKeys = channelScoped
|
|
|
|
|
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
|
|
|
|
: SORT_KEYS;
|
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-29 02:01:44 +02:00
|
|
|
const ytActive = !!ytSearch;
|
2026-07-01 01:00:32 +02:00
|
|
|
// How many live-search results to gather (the count selector replaces a manual "load more";
|
|
|
|
|
// the free scrape source pages through continuations until this many are collected).
|
|
|
|
|
const [ytCount, setYtCount] = useState(20);
|
2026-06-29 02:01:44 +02:00
|
|
|
|
2026-06-29 02:19:18 +02:00
|
|
|
// Debounce only the search term feeding the queries: the input still updates instantly (it
|
|
|
|
|
// owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
|
|
|
|
|
// and re-fetched pages keep the previous results on screen, so the feed never blanks/flickers.
|
|
|
|
|
const debouncedQ = useDebounced(filters.q, 300);
|
|
|
|
|
const queryFilters: FeedFilters = { ...filters, q: debouncedQ };
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
const query = useInfiniteQuery({
|
2026-06-29 02:19:18 +02:00
|
|
|
queryKey: ["feed", queryFilters],
|
|
|
|
|
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
|
2026-06-25 19:54:40 +02:00
|
|
|
initialPageParam: null as string | null,
|
|
|
|
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
2026-06-29 02:01:44 +02:00
|
|
|
enabled: !ytActive, // don't load the normal feed while showing live YouTube results
|
2026-06-29 02:19:18 +02:00
|
|
|
placeholderData: keepPreviousData,
|
2026-06-29 02:01:44 +02:00
|
|
|
});
|
|
|
|
|
|
2026-06-29 22:30:03 +02:00
|
|
|
// Live YouTube search results. We never auto-paginate on scroll — the user pulls more with an
|
|
|
|
|
// explicit button (each api-source page spends 100 quota units; scrape-source pages are free).
|
|
|
|
|
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
|
|
|
|
|
// revisited search from re-spending.
|
2026-06-29 02:01:44 +02:00
|
|
|
const ytQuery = useInfiniteQuery({
|
2026-07-01 01:00:32 +02:00
|
|
|
queryKey: ["yt-search", ytSearch, ytCount],
|
|
|
|
|
queryFn: ({ pageParam }) => api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
|
2026-06-29 02:01:44 +02:00
|
|
|
initialPageParam: null as string | null,
|
|
|
|
|
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
|
|
|
|
enabled: ytActive,
|
|
|
|
|
staleTime: 5 * 60_000,
|
|
|
|
|
retry: false,
|
2026-06-11 02:19:47 +02:00
|
|
|
});
|
|
|
|
|
|
2026-07-01 01:55:05 +02:00
|
|
|
// Remember (in history.state) that this live search was served by the zero-quota scrape source,
|
|
|
|
|
// so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
|
|
|
|
|
// costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!ytActive) return;
|
|
|
|
|
const st = window.history.state;
|
|
|
|
|
if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
|
|
|
|
|
if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
|
|
|
|
|
window.history.replaceState({ ...st, _ytScrape: true }, "");
|
|
|
|
|
}
|
|
|
|
|
}, [ytActive, ytSearch, ytQuery.data]);
|
|
|
|
|
|
2026-06-30 01:36:12 +02:00
|
|
|
// Switching to the relevance sort when a search starts happens atomically in the header's
|
|
|
|
|
// input onChange (race-free with the query update). Here we only handle the reverse: when
|
|
|
|
|
// the term is cleared, fall back to the default sort — relevance has no dropdown option and
|
|
|
|
|
// doesn't rank without a query.
|
2026-06-30 00:39:21 +02:00
|
|
|
const qEmpty = !filters.q.trim();
|
|
|
|
|
useEffect(() => {
|
2026-06-30 01:36:12 +02:00
|
|
|
if (qEmpty && parseSort(filters.sort).key === "relevance") {
|
2026-06-30 00:39:21 +02:00
|
|
|
setFilters({ ...filters, sort: buildSort("date", "desc") });
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [qEmpty]);
|
|
|
|
|
|
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({
|
2026-06-29 02:19:18 +02:00
|
|
|
queryKey: ["feed-count", queryFilters],
|
|
|
|
|
queryFn: () => api.feedCount(queryFilters),
|
2026-06-11 04:15:25 +02:00
|
|
|
staleTime: 30_000,
|
2026-06-29 02:01:44 +02:00
|
|
|
enabled: !ytActive,
|
2026-06-29 02:19:18 +02:00
|
|
|
placeholderData: keepPreviousData,
|
2026-06-11 04:15:25 +02:00
|
|
|
});
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
|
|
|
|
|
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.
|
2026-06-18 23:27:59 +02:00
|
|
|
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
|
|
|
|
|
// notice (or resolving a stale one) is a claim of success, so firing it optimistically
|
|
|
|
|
// would falsely report a change that the .catch below is about to roll back (e.g. when
|
|
|
|
|
// the API is unreachable).
|
2026-06-12 18:17:03 +02:00
|
|
|
api
|
|
|
|
|
.setState(id, status)
|
2026-06-18 23:27:59 +02:00
|
|
|
.then(() => {
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
|
|
|
|
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") },
|
2026-06-19 03:05:01 +02:00
|
|
|
meta: {
|
|
|
|
|
kind: "video-watched",
|
|
|
|
|
videoId: id,
|
|
|
|
|
title: v?.title ?? "this video",
|
|
|
|
|
channelId: v?.channel_id ?? "",
|
|
|
|
|
channelName: v?.channel_title ?? "",
|
|
|
|
|
},
|
2026-06-18 23:27:59 +02:00
|
|
|
});
|
|
|
|
|
} else if (status === "new") {
|
|
|
|
|
// Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
|
|
|
|
|
// any stale hide/watch notice for this video so it doesn't linger with a dead action.
|
|
|
|
|
resolveVideo(id);
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-06-17 13:38:47 +02:00
|
|
|
.catch(() =>
|
|
|
|
|
// Server rejected the change — drop the optimistic override so the card
|
|
|
|
|
// reverts to the real (unchanged) state instead of staying phantom-hidden.
|
|
|
|
|
setOverrides((o) => {
|
|
|
|
|
const next = { ...o };
|
|
|
|
|
delete next[id];
|
|
|
|
|
return next;
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-06-12 18:17:03 +02:00
|
|
|
},
|
|
|
|
|
[qc]
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-18 01:17:31 +02:00
|
|
|
const onResetState = useCallback(
|
|
|
|
|
(id: string) => {
|
|
|
|
|
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
|
|
|
|
|
// un-watch toggle can't clear an in-progress position, this can.
|
|
|
|
|
setOverrides((o) => ({ ...o, [id]: "new" }));
|
|
|
|
|
api
|
|
|
|
|
.clearState(id)
|
|
|
|
|
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
|
|
|
|
|
.catch(() =>
|
|
|
|
|
setOverrides((o) => {
|
|
|
|
|
const next = { ...o };
|
|
|
|
|
delete next[id];
|
|
|
|
|
return next;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[qc]
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-30 03:08:52 +02:00
|
|
|
const handleOpenChannel = useCallback(
|
2026-06-12 18:17:03 +02:00
|
|
|
(channelId: string, channelName: string) => {
|
2026-06-30 03:08:52 +02:00
|
|
|
onOpenChannel?.(channelId, channelName);
|
2026-06-12 18:17:03 +02:00
|
|
|
},
|
2026-06-30 03:08:52 +02:00
|
|
|
[onOpenChannel]
|
2026-06-12 18:17:03 +02:00
|
|
|
);
|
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
|
|
|
|
2026-06-29 02:01:44 +02:00
|
|
|
// --- Live YouTube search mode -------------------------------------------------------------
|
|
|
|
|
// A separate data source rendered in the same cards/player. No watch-state view filter (the
|
|
|
|
|
// user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
|
|
|
|
|
if (ytActive) {
|
|
|
|
|
const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
|
|
|
|
|
loadedRef.current = ytLoaded;
|
|
|
|
|
const ytItems: Video[] = ytLoaded
|
|
|
|
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
|
|
|
|
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v));
|
|
|
|
|
const ytError =
|
|
|
|
|
ytQuery.error instanceof HttpError
|
|
|
|
|
? ytQuery.error.detail || t("feed.yt.error")
|
|
|
|
|
: ytQuery.isError
|
|
|
|
|
? t("feed.yt.error")
|
|
|
|
|
: null;
|
2026-06-29 22:30:03 +02:00
|
|
|
// The backend reports which source served the search; scrape spends no search quota, so we
|
|
|
|
|
// drop the quota warning + "uses quota" wording in that mode.
|
|
|
|
|
const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";
|
2026-06-29 02:01:44 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-4">
|
2026-07-01 01:00:32 +02:00
|
|
|
<div className="pb-3 mb-1 border-b border-border">
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
// The search just ingested new catalog videos, so the normal feed (which was
|
|
|
|
|
// disabled and still holds its pre-search cache) is stale. Drop those caches so
|
|
|
|
|
// it re-fetches fresh on return instead of showing the old (often empty) result.
|
|
|
|
|
// Surface the just-searched videos in the feed you return to: switch the Source
|
|
|
|
|
// filter to "search" (your own search finds) and rank them by relevance.
|
|
|
|
|
setFilters({ ...filters, librarySource: "search", sort: "relevance" });
|
|
|
|
|
onExitYtSearch();
|
|
|
|
|
qc.removeQueries({ queryKey: ["feed"] });
|
|
|
|
|
qc.removeQueries({ queryKey: ["feed-count"] });
|
|
|
|
|
qc.removeQueries({ queryKey: ["facets"] });
|
|
|
|
|
}}
|
|
|
|
|
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<ArrowLeft className="w-4 h-4" />
|
|
|
|
|
{t("feed.yt.back")}
|
|
|
|
|
</button>
|
|
|
|
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
|
|
|
|
<Youtube className="w-4 h-4 text-accent shrink-0" />
|
|
|
|
|
<span className="text-sm font-semibold truncate">
|
|
|
|
|
{t("feed.yt.resultsFor", { query: ytSearch })}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-xs text-muted">
|
|
|
|
|
{zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
|
|
|
|
|
</span>
|
|
|
|
|
<label className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
|
|
|
|
{t("feed.yt.show")}
|
|
|
|
|
<select
|
|
|
|
|
value={ytCount}
|
|
|
|
|
onChange={(e) => setYtCount(Number(e.target.value))}
|
2026-07-01 01:17:04 +02:00
|
|
|
title={t("feed.yt.showHint")}
|
2026-07-01 01:00:32 +02:00
|
|
|
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
|
|
|
|
>
|
|
|
|
|
{[20, 40, 60, 100].map((n) => (
|
|
|
|
|
<option key={n} value={n}>
|
|
|
|
|
{n}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
{ytItems.length > 0 && (
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
|
|
|
|
<Info className="w-3.5 h-3.5 shrink-0" />
|
|
|
|
|
<span>{t("feed.yt.ephemeralNote")}</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const ids = ytItems.map((v) => v.id);
|
|
|
|
|
api
|
|
|
|
|
.clearSearch(ids)
|
|
|
|
|
.then((r) => notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }))
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
|
|
|
|
onExitYtSearch();
|
|
|
|
|
qc.removeQueries({ queryKey: ["feed"] });
|
|
|
|
|
qc.removeQueries({ queryKey: ["feed-count"] });
|
|
|
|
|
qc.removeQueries({ queryKey: ["facets"] });
|
|
|
|
|
qc.removeQueries({ queryKey: ["yt-search"] });
|
|
|
|
|
}}
|
|
|
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="w-3 h-3" />
|
|
|
|
|
{t("feed.yt.clearNow")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-29 02:01:44 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{ytQuery.isLoading ? (
|
|
|
|
|
<div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
|
|
|
|
|
) : ytError ? (
|
|
|
|
|
<div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
|
|
|
|
|
) : ytItems.length === 0 ? (
|
|
|
|
|
<div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<VirtualFeed
|
|
|
|
|
items={ytItems}
|
|
|
|
|
view={view}
|
|
|
|
|
onState={onState}
|
|
|
|
|
onToggleSave={onToggleSave}
|
2026-06-30 03:08:52 +02:00
|
|
|
onOpenChannel={handleOpenChannel}
|
2026-06-29 02:01:44 +02:00
|
|
|
onResetState={onResetState}
|
|
|
|
|
onOpen={openVideo}
|
|
|
|
|
hasNextPage={false}
|
|
|
|
|
isFetchingNextPage={false}
|
|
|
|
|
fetchNextPage={() => {}}
|
|
|
|
|
/>
|
2026-07-01 01:17:04 +02:00
|
|
|
{ytQuery.hasNextPage && (
|
|
|
|
|
<div className="text-center pt-4">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => ytQuery.fetchNextPage()}
|
|
|
|
|
disabled={ytQuery.isFetchingNextPage}
|
|
|
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
|
|
|
|
|
>
|
|
|
|
|
{ytQuery.isFetchingNextPage
|
|
|
|
|
? t("feed.loadingMore")
|
|
|
|
|
: zeroQuota
|
|
|
|
|
? t("feed.yt.loadMoreFree")
|
|
|
|
|
: t("feed.yt.loadMore")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-29 02:01:44 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{activeVideo && (
|
perf: route- and modal-level code splitting (React.lazy)
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
DownloadDialog (DownloadButton — kept out of the feed chunk), and the
VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
callers can pre-select the admin tab without statically importing the now
lazy-loaded AdminUsers page (which would defeat the split).
Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
2026-07-04 19:43:50 +02:00
|
|
|
<Suspense fallback={null}>
|
|
|
|
|
<PlayerModal
|
|
|
|
|
video={activeVideo.video}
|
|
|
|
|
startAt={activeVideo.startAt}
|
|
|
|
|
onClose={() => setActiveVideo(null)}
|
|
|
|
|
onState={onState}
|
|
|
|
|
onOpenChannel={onOpenChannel}
|
|
|
|
|
/>
|
|
|
|
|
</Suspense>
|
2026-06-29 02:01:44 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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 09:27:34 +02:00
|
|
|
// Empty "my" feed with no YouTube access. The shared demo account can never connect
|
|
|
|
|
// YouTube, so it gets a gentle nudge into the shared library instead of the connect
|
|
|
|
|
// wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
|
2026-06-16 02:34:13 +02:00
|
|
|
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">
|
2026-06-16 09:27:34 +02:00
|
|
|
<h2 className="text-lg font-semibold">
|
|
|
|
|
{isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="text-sm text-muted mt-2 mb-4">
|
|
|
|
|
{isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
|
|
|
|
|
</p>
|
|
|
|
|
{isDemo ? (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setFilters({ ...filters, scope: "all" })}
|
|
|
|
|
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.browseLibrary")}
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<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>
|
|
|
|
|
<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
|
|
|
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
|
|
|
|
|
|
2026-06-16 02:34:13 +02:00
|
|
|
const toolbar = (
|
|
|
|
|
<div className="pb-3">
|
2026-06-30 04:56:08 +02:00
|
|
|
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
{SHOW_IDS.map((id) => (
|
|
|
|
|
<button
|
|
|
|
|
key={id}
|
|
|
|
|
onClick={() => setFilters({ ...filters, show: id })}
|
|
|
|
|
aria-pressed={filters.show === id}
|
|
|
|
|
className={`text-xs px-3 py-1.5 rounded-full border transition ${
|
|
|
|
|
filters.show === id
|
|
|
|
|
? "bg-accent text-accent-fg border-accent"
|
|
|
|
|
: "border-border text-muted hover:text-fg hover:border-accent"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{t("sidebar.show." + id)}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
2026-06-16 02:34:13 +02:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-06-30 00:39:21 +02:00
|
|
|
{(
|
2026-06-29 02:01:44 +02:00
|
|
|
<>
|
|
|
|
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
2026-06-29 02:11:53 +02:00
|
|
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
|
|
|
|
<span>{t("feed.source.label")}</span>
|
|
|
|
|
<select
|
|
|
|
|
value={filters.librarySource ?? "organic"}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setFilters({
|
|
|
|
|
...filters,
|
|
|
|
|
librarySource: e.target.value as FeedFilters["librarySource"],
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
title={t("feed.source.tip")}
|
|
|
|
|
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
|
|
|
|
>
|
|
|
|
|
<option value="organic">{t("feed.source.organic")}</option>
|
|
|
|
|
<option value="all">{t("feed.source.all")}</option>
|
|
|
|
|
<option value="search">{t("feed.source.only")}</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
2026-06-29 02:01:44 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
2026-06-30 04:56:08 +02:00
|
|
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
|
|
|
|
<span className="text-xs text-muted whitespace-nowrap">
|
2026-06-16 02:34:13 +02:00
|
|
|
{countQuery.data
|
|
|
|
|
? t("feed.videoCount", {
|
|
|
|
|
count: countQuery.data.count,
|
|
|
|
|
formattedCount: countQuery.data.count.toLocaleString(),
|
|
|
|
|
})
|
|
|
|
|
: " "}
|
|
|
|
|
</span>
|
2026-06-30 04:56:08 +02:00
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
|
|
|
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
|
2026-06-16 02:34:13 +02:00
|
|
|
<select
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
value={sortKey}
|
2026-06-16 02:34:13 +02:00
|
|
|
onChange={(e) => {
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
const key = e.target.value as SortKey;
|
2026-06-30 00:39:21 +02:00
|
|
|
const dir = DIRECTIONLESS.includes(key)
|
|
|
|
|
? "desc"
|
|
|
|
|
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
setFilters({
|
|
|
|
|
...filters,
|
|
|
|
|
sort: buildSort(key, dir),
|
|
|
|
|
seed: key === "shuffle" ? rollSeed() : undefined,
|
|
|
|
|
});
|
2026-06-16 02:34:13 +02:00
|
|
|
}}
|
2026-07-04 19:13:11 +02:00
|
|
|
aria-label={t("feed.sortLabel")}
|
2026-06-16 02:34:13 +02:00
|
|
|
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
|
|
|
|
>
|
2026-06-30 00:39:21 +02:00
|
|
|
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
|
2026-06-30 04:37:00 +02:00
|
|
|
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
<option key={k} value={k}>
|
|
|
|
|
{t("feed.sortKey." + k)}
|
2026-06-16 02:34:13 +02:00
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2026-06-30 00:39:21 +02:00
|
|
|
{!DIRECTIONLESS.includes(sortKey) && (
|
feat(feed): Show chips in the toolbar + key+direction sort
1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
toolbar chip row as its own single-select group, divided from the content-type chips;
removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
2026-06-16 02:46:26 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setFilters({ ...filters, sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc") })
|
|
|
|
|
}
|
|
|
|
|
title={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
|
|
|
|
|
aria-label={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
{sortDir === "asc" ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{sortKey === "shuffle" && (
|
2026-06-16 02:34:13 +02:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-06-30 04:56:08 +02:00
|
|
|
</div>
|
2026-06-16 02:34:13 +02:00
|
|
|
</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 ? (
|
2026-06-29 02:01:44 +02:00
|
|
|
<div className="py-16 text-center text-muted">
|
|
|
|
|
<p>{t("feed.noMatches")}</p>
|
|
|
|
|
{filters.q.trim() && !isDemo && (
|
|
|
|
|
<button
|
2026-06-29 23:03:25 +02:00
|
|
|
onClick={() => onYtSearch(filters.q.trim())}
|
2026-06-29 02:01:44 +02:00
|
|
|
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
|
|
|
|
|
>
|
|
|
|
|
<Youtube className="w-4 h-4" />
|
|
|
|
|
{t("feed.yt.searchFor", { query: filters.q.trim() })}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-11 02:19:47 +02:00
|
|
|
) : (
|
2026-06-25 19:54:40 +02:00
|
|
|
<VirtualFeed
|
|
|
|
|
items={items}
|
|
|
|
|
view={view}
|
|
|
|
|
onState={onState}
|
|
|
|
|
onToggleSave={onToggleSave}
|
2026-06-30 03:08:52 +02:00
|
|
|
onOpenChannel={handleOpenChannel}
|
2026-06-25 19:54:40 +02:00
|
|
|
onResetState={onResetState}
|
|
|
|
|
onOpen={openVideo}
|
|
|
|
|
hasNextPage={!!hasNextPage}
|
|
|
|
|
isFetchingNextPage={isFetchingNextPage}
|
|
|
|
|
fetchNextPage={fetchNextPage}
|
|
|
|
|
/>
|
2026-06-11 02:19:47 +02:00
|
|
|
)}
|
2026-06-12 17:38:45 +02:00
|
|
|
{activeVideo && (
|
perf: route- and modal-level code splitting (React.lazy)
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
DownloadDialog (DownloadButton — kept out of the feed chunk), and the
VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
callers can pre-select the admin tab without statically importing the now
lazy-loaded AdminUsers page (which would defeat the split).
Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
2026-07-04 19:43:50 +02:00
|
|
|
<Suspense fallback={null}>
|
|
|
|
|
<PlayerModal
|
|
|
|
|
video={activeVideo.video}
|
|
|
|
|
startAt={activeVideo.startAt}
|
|
|
|
|
onClose={() => setActiveVideo(null)}
|
|
|
|
|
onState={onState}
|
|
|
|
|
onOpenChannel={onOpenChannel}
|
|
|
|
|
/>
|
|
|
|
|
</Suspense>
|
2026-06-12 17:38:45 +02:00
|
|
|
)}
|
2026-06-11 02:19:47 +02:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|