chore(ui): dedup last 3 jscpd clones → 0 (collapsed rail, video-card text block, subs column)

- CollapsedFilterRail: the byte-identical 31-line collapsed sidebar rail was in
  both Sidebar (feed) and PlexSidebar → one shared component.
- VideoCard: the title + channel-button + meta block was duplicated across the
  list-row and card layouts → a local `textBlock` element (only one layout branch
  renders, so reusing the element is safe).
- subsColumn<T>(t): the identical 'subscribers' DataTable column in Channels +
  ChannelDiscovery → a generic factory in channelColumns.tsx.

All behavior-neutral; jscpd now reports 0 clones (was 8 at Phase 0 baseline).
tsc + knip green.
This commit is contained in:
npeter83 2026-07-12 00:46:52 +02:00
parent 1efd089749
commit b59ad00fac
7 changed files with 86 additions and 98 deletions

View file

@ -4,6 +4,7 @@ import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api"; import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS } from "../lib/storage"; import { accountKey, LS } from "../lib/storage";
import { formatCountOrDash } from "../lib/format"; import { formatCountOrDash } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -78,17 +79,7 @@ export default function ChannelDiscovery({
<ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} /> <ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} />
), ),
}, },
{ subsColumn<DiscoveredChannel>(t),
key: "subs",
header: t("channels.cols.subs"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
),
},
{ {
key: "videos", key: "videos",
header: t("channels.discovery.cols.totalVideos"), header: t("channels.discovery.cols.totalVideos"),

View file

@ -20,7 +20,8 @@ import { api, type ManagedChannel, type Tag } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage"; import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
import { formatCountOrDash, formatEta, formatTotalHours, relativeTime } from "../lib/format"; import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable"; import DataTable, { type Column } from "./DataTable";
@ -274,17 +275,7 @@ export default function Channels({
sortValue: (c) => c.stored_videos, sortValue: (c) => c.stored_videos,
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>, render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
}, },
{ subsColumn<ManagedChannel>(t),
key: "subs",
header: t("channels.cols.subs"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
),
},
{ {
key: "lastUpload", key: "lastUpload",
header: t("channels.cols.lastUpload"), header: t("channels.cols.lastUpload"),

View file

@ -0,0 +1,39 @@
import { ChevronRight, SlidersHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
// The thin 46px rail shown when a filter sidebar is collapsed (both the feed Sidebar and the Plex
// PlexSidebar rendered byte-identical copies): an expand control + a filter icon carrying the
// active-filter count so you can tell filters are on without opening the panel.
export default function CollapsedFilterRail({
activeCount,
onToggleCollapse,
}: {
activeCount: number;
onToggleCollapse: () => void;
}) {
const { t } = useTranslation();
return (
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>
</aside>
);
}

View file

@ -1,7 +1,8 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react"; import { Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react";
import CollapsedFilterRail from "./CollapsedFilterRail";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
@ -115,30 +116,7 @@ export default function PlexSidebar({
)?.key; )?.key;
if (collapsed) { if (collapsed) {
return ( return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>
</aside>
);
} }
return ( return (

View file

@ -5,7 +5,6 @@ import {
Check, Check,
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight,
Eye, Eye,
EyeOff, EyeOff,
GripVertical, GripVertical,
@ -13,7 +12,6 @@ import {
Pencil, Pencil,
RotateCcw, RotateCcw,
Share2, Share2,
SlidersHorizontal,
User, User,
X, X,
} from "lucide-react"; } from "lucide-react";
@ -44,6 +42,7 @@ import { useDebounced } from "../lib/useDebounced";
import { Switch } from "./ui/form"; import { Switch } from "./ui/form";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget"; import SavedViewsWidget from "./SavedViewsWidget";
import CollapsedFilterRail from "./CollapsedFilterRail";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const SORT_IDS = [ const SORT_IDS = [
@ -477,30 +476,7 @@ export default function Sidebar({
// Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that // Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that
// carries the active-filter count so you can tell filters are on without opening it. // carries the active-filter count so you can tell filters are on without opening it.
if (collapsed) { if (collapsed) {
return ( return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 border-r border-border bg-surface/40 py-3">
<button
onClick={onToggleCollapse}
title={t("sidebar.expandPanel")}
aria-label={t("sidebar.expandPanel")}
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={onToggleCollapse}
title={t("sidebar.filters")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<SlidersHorizontal className="w-5 h-5" />
{activeCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
{activeCount > 9 ? "9+" : activeCount}
</span>
)}
</button>
</aside>
);
} }
return ( return (

View file

@ -285,17 +285,10 @@ function VideoCard({
{video.title} {video.title}
</a> </a>
); );
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
if (view === "list") { // differs). Safe to reuse the same element: exactly one of the two branches below renders.
return ( const textBlock = (
<div <>
className={clsx(
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
watched && "opacity-55"
)}
>
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1">
{title} {title}
<button <button
onClick={(e) => { onClick={(e) => {
@ -307,7 +300,19 @@ function VideoCard({
{video.channel_title} {video.channel_title}
</button> </button>
<div className="text-xs text-muted mt-0.5">{meta}</div> <div className="text-xs text-muted mt-0.5">{meta}</div>
</div> </>
);
if (view === "list") {
return (
<div
className={clsx(
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
watched && "opacity-55"
)}
>
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1">{textBlock}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
); );
@ -328,17 +333,7 @@ function VideoCard({
className="w-9 h-9 rounded-full shrink-0 mt-0.5" className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title} {textBlock}
<button
onClick={(e) => {
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
</button>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} /> <Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div> </div>
</div> </div>

View file

@ -0,0 +1,18 @@
import type { TFunction } from "i18next";
import { formatCountOrDash } from "../lib/format";
import type { Column } from "./DataTable";
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) identical
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
* channel row shapes. */
export function subsColumn<T extends { subscriber_count: number | null }>(t: TFunction): Column<T> {
return {
key: "subs",
header: t("channels.cols.subs"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => <span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>,
};
}