refactor(channels): share one ChannelLink + channelYouTubeUrl helper
The "avatar + name + open-on-YouTube" cell and the @handle-or-/channel/<id> 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).
This commit is contained in:
parent
96f303b0cf
commit
5461cada84
6 changed files with 91 additions and 72 deletions
|
|
@ -1,11 +1,11 @@
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, HttpError, type DiscoveredChannel } from "../lib/api";
|
||||||
import { formatViews } from "../lib/format";
|
import { formatViews } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Avatar from "./Avatar";
|
import ChannelLink from "./ChannelLink";
|
||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
|
||||||
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
// 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(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
render: (c) => <DiscoveryNameCell c={c} />,
|
render: (c) => (
|
||||||
|
<ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "subs",
|
key: "subs",
|
||||||
|
|
@ -153,27 +155,3 @@ export default function ChannelDiscovery({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Avatar src={c.thumbnail_url} fallback={c.title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
|
||||||
<span className="text-sm font-medium truncate min-w-0">{c.title ?? c.id}</span>
|
|
||||||
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
|
||||||
<a
|
|
||||||
href={ytUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted hover:text-accent shrink-0"
|
|
||||||
aria-label={t("channels.row.openOnYouTube")}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
64
frontend/src/components/ChannelLink.tsx
Normal file
64
frontend/src/components/ChannelLink.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
||||||
|
{onView ? (
|
||||||
|
<button
|
||||||
|
onClick={onView}
|
||||||
|
onAuxClick={(e) => {
|
||||||
|
// 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}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium truncate min-w-0">{name}</span>
|
||||||
|
)}
|
||||||
|
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
||||||
|
<a
|
||||||
|
href={ytUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted hover:text-accent shrink-0"
|
||||||
|
aria-label={t("channels.row.openOnYouTube")}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
History,
|
History,
|
||||||
|
|
@ -18,8 +17,8 @@ import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
import { formatEta, formatViews, relativeTime } from "../lib/format";
|
import { formatEta, formatViews, relativeTime } from "../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
|
import ChannelLink from "./ChannelLink";
|
||||||
import ChannelDiscovery from "./ChannelDiscovery";
|
import ChannelDiscovery from "./ChannelDiscovery";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
@ -242,7 +241,15 @@ export default function Channels({
|
||||||
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
render: (c) => <NameCell c={c} onView={() => onView(c)} />,
|
render: (c) => (
|
||||||
|
<ChannelLink
|
||||||
|
id={c.id}
|
||||||
|
title={c.title}
|
||||||
|
handle={c.handle}
|
||||||
|
thumbnailUrl={c.thumbnail_url}
|
||||||
|
onView={() => onView(c)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "stored",
|
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 (
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Avatar src={c.thumbnail_url} fallback={c.title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
|
||||||
<button
|
|
||||||
onClick={onView}
|
|
||||||
onAuxClick={(e) => {
|
|
||||||
// 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}
|
|
||||||
</button>
|
|
||||||
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
|
||||||
<a
|
|
||||||
href={ytUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted hover:text-accent shrink-0"
|
|
||||||
aria-label={t("channels.row.openOnYouTube")}
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
|
||||||
</a>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status only — the backfill *action* lives in the Actions column. When both recent and
|
// 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
|
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
type VideoHiddenMeta,
|
type VideoHiddenMeta,
|
||||||
type VideoWatchedMeta,
|
type VideoWatchedMeta,
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
|
import { channelYouTubeUrl } from "../lib/format";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { LEVEL_STYLE } from "./Toaster";
|
import { LEVEL_STYLE } from "./Toaster";
|
||||||
|
|
||||||
|
|
@ -380,7 +381,7 @@ function ClientActivityRow({
|
||||||
{t("notifications.openInManager")}
|
{t("notifications.openInManager")}
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`https://www.youtube.com/channel/${subscribed.channelId}`}
|
href={channelYouTubeUrl(subscribed.channelId)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, Sk
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../lib/api";
|
||||||
import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
||||||
// Turn a description into clickable nodes:
|
// Turn a description into clickable nodes:
|
||||||
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player
|
||||||
|
|
@ -572,7 +572,7 @@ export default function PlayerModal({
|
||||||
{navigated ? (
|
{navigated ? (
|
||||||
detail.data?.channel_id ? (
|
detail.data?.channel_id ? (
|
||||||
<a
|
<a
|
||||||
href={`https://www.youtube.com/channel/${detail.data.channel_id}`}
|
href={channelYouTubeUrl(detail.data.channel_id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-medium hover:text-accent shrink-0"
|
className="font-medium hover:text-accent shrink-0"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ export function relativeTime(iso: string | null): string {
|
||||||
return i18n.t("time.monthsAgo", { count: months });
|
return i18n.t("time.monthsAgo", { count: months });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The canonical YouTube URL for a channel: its @handle when known (nicer), else the
|
||||||
|
// /channel/<id> 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 {
|
export function formatDuration(sec: number | null): string {
|
||||||
if (sec == null) return "";
|
if (sec == null) return "";
|
||||||
const h = Math.floor(sec / 3600);
|
const h = Math.floor(sec / 3600);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue