From 5461cada84754879ac7e61e4ead91524beb24cb9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 03:22:10 +0200 Subject: [PATCH 1/2] refactor(channels): share one ChannelLink + channelYouTubeUrl helper The "avatar + name + open-on-YouTube" cell and the @handle-or-/channel/ URL were copy-pasted across the channel manager, the discovery tab, the subscribe notice and the player. Extract a single ChannelLink component (optional in-app onView, middle-click opens YouTube) and a channelYouTubeUrl helper, and route all four through them. Removes the NameCell / DiscoveryNameCell duplication (the latter introduced with the discovery tab). --- frontend/src/components/ChannelDiscovery.tsx | 32 ++-------- frontend/src/components/ChannelLink.tsx | 64 +++++++++++++++++++ frontend/src/components/Channels.tsx | 52 +++------------ .../src/components/NotificationsPanel.tsx | 3 +- frontend/src/components/PlayerModal.tsx | 4 +- frontend/src/lib/format.ts | 8 +++ 6 files changed, 91 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/ChannelLink.tsx diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index b545f45..ce0d9cf 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -1,11 +1,11 @@ import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ExternalLink, UserPlus } from "lucide-react"; +import { UserPlus } from "lucide-react"; import { api, HttpError, type DiscoveredChannel } from "../lib/api"; import { formatViews } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; -import Avatar from "./Avatar"; +import ChannelLink from "./ChannelLink"; import DataTable, { type Column } from "./DataTable"; // The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but @@ -70,7 +70,9 @@ export default function ChannelDiscovery({ sortValue: (c) => (c.title ?? c.id).toLowerCase(), filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, cardPrimary: true, - render: (c) => , + render: (c) => ( + + ), }, { key: "subs", @@ -153,27 +155,3 @@ export default function ChannelDiscovery({ ); } - -function DiscoveryNameCell({ c }: { c: DiscoveredChannel }) { - const { t } = useTranslation(); - const ytUrl = c.handle - ? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}` - : `https://www.youtube.com/channel/${c.id}`; - return ( -
- - {c.title ?? c.id} - - - - - -
- ); -} diff --git a/frontend/src/components/ChannelLink.tsx b/frontend/src/components/ChannelLink.tsx new file mode 100644 index 0000000..08b78db --- /dev/null +++ b/frontend/src/components/ChannelLink.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import { ExternalLink } from "lucide-react"; +import Avatar from "./Avatar"; +import Tooltip from "./Tooltip"; +import { channelYouTubeUrl } from "../lib/format"; + +// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the +// discovery list (and anywhere else that lists a channel). When `onView` is given the name +// is a button that opens the in-app channel view, with middle-click opening YouTube in a new +// tab; without it the name is plain text. +export default function ChannelLink({ + id, + title, + handle, + thumbnailUrl, + onView, +}: { + id: string; + title: string | null; + handle?: string | null; + thumbnailUrl: string | null; + onView?: () => void; +}) { + const { t } = useTranslation(); + const ytUrl = channelYouTubeUrl(id, handle); + const name = title ?? id; + return ( +
+ + {onView ? ( + + ) : ( + {name} + )} + + + + + +
+ ); +} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 48fe03b..fc1c817 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -5,7 +5,6 @@ import { ArrowDown, ArrowUp, Check, - ExternalLink, Eye, EyeOff, History, @@ -18,8 +17,8 @@ import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { formatEta, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; -import Avatar from "./Avatar"; import DataTable, { type Column } from "./DataTable"; +import ChannelLink from "./ChannelLink"; import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; @@ -242,7 +241,15 @@ export default function Channels({ sortValue: (c) => (c.title ?? c.id).toLowerCase(), filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, cardPrimary: true, - render: (c) => onView(c)} />, + render: (c) => ( + onView(c)} + /> + ), }, { key: "stored", @@ -567,45 +574,6 @@ function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta ); } -function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) { - const { t } = useTranslation(); - const ytUrl = c.handle - ? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}` - : `https://www.youtube.com/channel/${c.id}`; - return ( -
- - - - - - - -
- ); -} // Status only — the backfill *action* lives in the Actions column. When both recent and // full history are in, collapse to a single "fully synced" chip; otherwise show what's diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 1715acd..bac67bf 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -18,6 +18,7 @@ import { type VideoHiddenMeta, type VideoWatchedMeta, } from "../lib/notifications"; +import { channelYouTubeUrl } from "../lib/format"; import type { Page } from "../lib/urlState"; import { LEVEL_STYLE } from "./Toaster"; @@ -380,7 +381,7 @@ function ClientActivityRow({ {t("notifications.openInManager")} form. One place so every channel link across the app stays consistent. +export function channelYouTubeUrl(id: string, handle?: string | null): string { + return handle + ? `https://www.youtube.com/@${handle.replace(/^@/, "")}` + : `https://www.youtube.com/channel/${id}`; +} + export function formatDuration(sec: number | null): string { if (sec == null) return ""; const h = Math.floor(sec / 3600); From 258aa5cc84d6c9be0fa75e0ffba6d240a1f8cefa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 03:24:45 +0200 Subject: [PATCH 2/2] perf(header): poll sync status faster while work is in flight The header polled every 30s, so the live "syncing" state (now gated on a real running job) was usually missed between ticks. Poll every 8s while a job is running or channels are pending, easing back to 30s once everything's settled, via react-query's functional refetchInterval. --- frontend/src/components/SyncStatus.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 56bbb19..39b133d 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; -import { api } from "../lib/api"; +import { api, type MyStatus } from "../lib/api"; import { formatViews } from "../lib/format"; import Tooltip from "./Tooltip"; @@ -22,11 +22,19 @@ export default function SyncStatus({ queryFn: api.myStatus, // Track the scheduler near-real-time: poll even when the tab is backgrounded, and // refresh immediately on tab focus (the global default disables focus refetch), so the - // "N without full history" count keeps ticking down without a manual reload. - refetchInterval: 30_000, + // "N without full history" count keeps ticking down without a manual reload. Poll faster + // while a job is running or work is pending — so the live "syncing" state is actually + // caught — and ease back to a slow idle tick once everything's settled. + refetchInterval: (query) => { + const d = query.state.data as MyStatus | undefined; + const busy = + !!d && + (d.sync_active || d.channels_recent_pending > 0 || d.channels_deep_pending > 0); + return busy ? 8_000 : 30_000; + }, refetchIntervalInBackground: true, refetchOnWindowFocus: true, - staleTime: 25_000, + staleTime: 5_000, }); const toggle = useMutation({