Merge chore/code-hygiene: Phase 3 — jscpd clones to zero

Extract the last 3 duplicated blocks: shared CollapsedFilterRail (both sidebars),
VideoCard textBlock (list+card layouts), subsColumn factory (Channels +
ChannelDiscovery). jscpd 8→0 clones. Behavior-neutral; E2E-verified (feed cards,
collapsed rail); tsc/knip green.
This commit is contained in:
npeter83 2026-07-12 00:50:09 +02:00
commit 0404f176ed
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 { accountKey, LS } from "../lib/storage";
import { formatCountOrDash } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
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} />
),
},
{
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>
),
},
subsColumn<DiscoveredChannel>(t),
{
key: "videos",
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 { accountKey, LS } from "../lib/storage";
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 Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
@ -274,17 +275,7 @@ export default function Channels({
sortValue: (c) => c.stored_videos,
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
},
{
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>
),
},
subsColumn<ManagedChannel>(t),
{
key: "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 { useTranslation } from "react-i18next";
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 { useDebounced } from "../lib/useDebounced";
@ -115,30 +116,7 @@ export default function PlexSidebar({
)?.key;
if (collapsed) {
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>
);
return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
}
return (

View file

@ -5,7 +5,6 @@ import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Eye,
EyeOff,
GripVertical,
@ -13,7 +12,6 @@ import {
Pencil,
RotateCcw,
Share2,
SlidersHorizontal,
User,
X,
} from "lucide-react";
@ -44,6 +42,7 @@ import { useDebounced } from "../lib/useDebounced";
import { Switch } from "./ui/form";
import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
import CollapsedFilterRail from "./CollapsedFilterRail";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
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
// carries the active-filter count so you can tell filters are on without opening it.
if (collapsed) {
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>
);
return <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
}
return (

View file

@ -285,6 +285,23 @@ function VideoCard({
{video.title}
</a>
);
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
// differs). Safe to reuse the same element: exactly one of the two branches below renders.
const textBlock = (
<>
{title}
<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>
</>
);
if (view === "list") {
return (
@ -295,19 +312,7 @@ function VideoCard({
)}
>
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1">
{title}
<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>
</div>
<div className="min-w-0 flex-1">{textBlock}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</div>
);
@ -328,17 +333,7 @@ function VideoCard({
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
<div className="min-w-0 flex-1">
{title}
<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>
{textBlock}
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
</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>,
};
}