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:
parent
1efd089749
commit
b59ad00fac
7 changed files with 86 additions and 98 deletions
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
39
frontend/src/components/CollapsedFilterRail.tsx
Normal file
39
frontend/src/components/CollapsedFilterRail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
18
frontend/src/components/channelColumns.tsx
Normal file
18
frontend/src/components/channelColumns.tsx
Normal 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>,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue