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 ? (
+
{
+ // Middle-click opens the channel's YouTube page in a new tab; left-click keeps
+ // opening the in-app channel detail.
+ if (e.button === 1) {
+ e.preventDefault();
+ window.open(ytUrl, "_blank", "noopener,noreferrer");
+ }
+ }}
+ onMouseDown={(e) => {
+ if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
+ }}
+ className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
+ >
+ {name}
+
+ ) : (
+
{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 (
-
-
-
{
- // Middle-click opens the channel's YouTube page in a new tab; left-click
- // keeps opening the in-app channel detail.
- if (e.button === 1) {
- e.preventDefault();
- window.open(ytUrl, "_blank", "noopener,noreferrer");
- }
- }}
- onMouseDown={(e) => {
- if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
- }}
- className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
- >
- {c.title ?? c.id}
-
-
-
-
-
-
-
- );
-}
// 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")}
{
+ 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({
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 3e8b470..5aa8ac2 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -27,6 +27,14 @@ export function relativeTime(iso: string | null): string {
return i18n.t("time.monthsAgo", { count: months });
}
+// The canonical YouTube URL for a channel: its @handle when known (nicer), else the
+// /channel/ 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);