refactor(channels): move cells + column defs into channelColumns.tsx

R12 S4c. Channels.tsx shed its ~150-line column block and 3 cell components
into the co-located channelColumns.tsx:

- SyncBadge / SyncCell / ActionsCell move verbatim (prop-driven, self-contained).
- the column array becomes a buildChannelColumns(deps) factory next to
  subsColumn — the manager owns the state/mutations and passes them in; the
  inline patch.mutate wrappers become onHide/onDeep callbacks so the factory
  doesn't couple to the mutation type.

Channels.tsx 905 -> 628 lines; no behaviour change. Gate green (tsc / eslint 0
errors / prettier / knip / vitest 79) + self-E2E: the manager renders every
column and cell (priority stepper, actions, sync badges, tags) with no console
errors.
This commit is contained in:
2026-08-11 23:27:16 +02:00
parent 51463b226e
commit 69d34f7fa3
2 changed files with 366 additions and 315 deletions
+21 -314
View File
@@ -2,31 +2,17 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
Ban,
Check,
Eye,
EyeOff,
History,
LayoutGrid,
Pencil,
RefreshCw,
Table,
UserMinus,
X,
} from "lucide-react";
import { Ban, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
import { api, type ManagedChannel } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { formatEta } from "../lib/format";
import { buildChannelColumns } from "./channelColumns";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { PriorityStepper } from "./PriorityStepper";
import DataTable, { type Column } from "./DataTable";
import DataTable from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useChannelPriority } from "../lib/useChannelPriority";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
@@ -36,8 +22,6 @@ import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./PageShell";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
@@ -284,159 +268,23 @@ export default function Channels() {
.sort((a, b) => b - a)
.map((p) => ({ value: String(p), label: String(p) }));
const columns: Column<ManagedChannel>[] = [
{
key: "priority",
header: t("channels.cols.priority"),
align: "center",
width: "56px",
sortable: true,
hideInCard: true,
sortValue: (c) => c.priority,
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
render: (c) => (
<PriorityStepper
priority={c.priority}
onPriority={(d) => bumpPriority(c.id, d)}
onSet={(v) => setPriority(c.id, v)}
/>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "left",
nowrap: true,
width: "104px",
cardLabel: false,
render: (c) => (
<ActionsCell
c={c}
isAdmin={isAdmin}
canWrite={canWrite}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
onReset={() => onReset(c)}
onUnsubscribe={() => onUnsub(c)}
/>
),
},
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
// Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
// Without this a single long name blows the column ~289→429px and overflows the centered table.
render: (c) => (
<div className="max-w-[18rem]">
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={() => onView(c)}
/>
</div>
),
},
{
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
},
{
key: "stored",
header: t("channels.cols.stored"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => (
<span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
),
},
subsColumn<ManagedChannel>(t),
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
},
{
key: "length",
header: t("channels.cols.length"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => (
<span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds)}
</span>
),
},
{
key: "types",
header: t("channels.cols.types"),
nowrap: true,
render: (c) => (
<Tooltip hint={t("channels.cols.typesHint")}>
<span className="text-muted tabular-nums cursor-help">
{c.count_normal} / {c.count_short} / {c.count_live}
</span>
</Tooltip>
),
},
{
key: "sync",
header: t("channels.cols.sync"),
nowrap: true,
cardLabel: false,
filter: {
kind: "select",
options: [
{ value: "fully", label: t("channels.filters.fullySynced") },
{ value: "needsFull", label: t("channels.filters.needsFull") },
],
test: (c, v) =>
v === "fully"
? c.recent_synced && c.backfill_done
: v === "needsFull"
? !c.backfill_done
: true,
},
render: (c) => <SyncCell c={c} />,
},
{
key: "tags",
header: t("channels.cols.tags"),
cardLabel: false,
// Bounded width so the cell can't widen the column and reflow the whole table; the control
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
width: "12rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<ChannelTagsControl
userTags={userTags}
attachedIds={c.tag_ids}
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag}
/>
),
},
];
const columns = buildChannelColumns({
t,
isAdmin,
canWrite,
prioOptions,
userTags,
bumpPriority,
setPriority,
onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }),
onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }),
onReset,
onUnsub,
onView,
toggleTag,
createAndAttachTag,
onFilterByTag,
});
// Status chips sit in the table's controls row (next to paging) — compact and close to
// where they act.
@@ -762,144 +610,3 @@ function Stat({ label, value, hint }: { label: string; value: string | number; h
</Tooltip>
);
}
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
return (
<Tooltip hint={hint ?? ""}>
<span
className={`inline-flex items-center gap-1 text-3xs px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{ok && <Check className="w-3 h-3" />}
{label}
</span>
</Tooltip>
);
}
// 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
// present plus the missing/queued state.
function SyncCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
if (c.recent_synced && c.backfill_done) {
return (
<SyncBadge
ok
label={t("channels.row.fullySynced")}
hint={t("channels.row.fullySyncedHint")}
/>
);
}
return (
<div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
hint={
c.recent_synced
? t("channels.row.recentSyncedHint")
: t("channels.row.recentNotSyncedHint")
}
/>
{c.backfill_done ? (
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryQueued")}
hint={t("channels.row.queuedRequestedHint")}
/>
) : c.deep_in_queue ? (
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryComing")}
hint={t("channels.row.queuedByOtherHint")}
/>
) : (
<SyncBadge
ok={false}
label={t("channels.row.full")}
hint={t("channels.row.fullNotFetchedHint")}
/>
)}
</div>
);
}
function ActionsCell({
c,
isAdmin,
canWrite,
onHide,
onDeep,
onReset,
onUnsubscribe,
}: {
c: ManagedChannel;
isAdmin: boolean;
canWrite: boolean;
onHide: () => void;
onDeep: () => void;
onReset: () => void;
onUnsubscribe: () => void;
}) {
const { t } = useTranslation();
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
// state — always actionable. Regular users get the per-user "request full history" opt-in,
// which only applies until the back-catalog is in (or already coming for everyone).
const done = c.backfill_done;
const requested = !done && c.deep_requested;
const optInActionable = !done && !c.deep_in_queue;
const backfillHint = isAdmin
? t("channels.row.resetHint")
: c.deep_in_queue
? t("channels.row.queuedByOtherHint")
: requested
? t("channels.row.queuedRequestedHint")
: done
? t("channels.row.fullHint")
: t("channels.row.backfillThisHint");
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button
onClick={onHide}
className="text-muted hover:text-fg shrink-0"
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
onClick={onUnsubscribe}
className="text-muted hover:text-danger shrink-0"
aria-label={t("channels.row.unsubscribeOnYoutube")}
>
<UserMinus className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}
+345 -1
View File
@@ -1,6 +1,15 @@
import type { ComponentProps } from "react";
import type { TFunction } from "i18next";
import { formatCountOrDash } from "../lib/format";
import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import type { ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./DataTable";
import Tooltip from "./Tooltip";
import { PriorityStepper } from "./PriorityStepper";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import { ChannelTagsControl } from "./ChannelTags";
/** 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
@@ -18,3 +27,338 @@ export function subsColumn<T extends { subscriber_count: number | null }>(t: TFu
),
};
}
function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
return (
<Tooltip hint={hint ?? ""}>
<span
className={`inline-flex items-center gap-1 text-3xs px-1.5 py-0.5 rounded-full border ${
ok ? "border-accent/40 text-accent" : "border-border text-muted"
}`}
>
{ok && <Check className="w-3 h-3" />}
{label}
</span>
</Tooltip>
);
}
// 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
// present plus the missing/queued state.
function SyncCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
if (c.recent_synced && c.backfill_done) {
return (
<SyncBadge
ok
label={t("channels.row.fullySynced")}
hint={t("channels.row.fullySyncedHint")}
/>
);
}
return (
<div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
hint={
c.recent_synced
? t("channels.row.recentSyncedHint")
: t("channels.row.recentNotSyncedHint")
}
/>
{c.backfill_done ? (
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryQueued")}
hint={t("channels.row.queuedRequestedHint")}
/>
) : c.deep_in_queue ? (
<SyncBadge
ok={false}
label={t("channels.row.fullHistoryComing")}
hint={t("channels.row.queuedByOtherHint")}
/>
) : (
<SyncBadge
ok={false}
label={t("channels.row.full")}
hint={t("channels.row.fullNotFetchedHint")}
/>
)}
</div>
);
}
function ActionsCell({
c,
isAdmin,
canWrite,
onHide,
onDeep,
onReset,
onUnsubscribe,
}: {
c: ManagedChannel;
isAdmin: boolean;
canWrite: boolean;
onHide: () => void;
onDeep: () => void;
onReset: () => void;
onUnsubscribe: () => void;
}) {
const { t } = useTranslation();
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
// state — always actionable. Regular users get the per-user "request full history" opt-in,
// which only applies until the back-catalog is in (or already coming for everyone).
const done = c.backfill_done;
const requested = !done && c.deep_requested;
const optInActionable = !done && !c.deep_in_queue;
const backfillHint = isAdmin
? t("channels.row.resetHint")
: c.deep_in_queue
? t("channels.row.queuedByOtherHint")
: requested
? t("channels.row.queuedRequestedHint")
: done
? t("channels.row.fullHint")
: t("channels.row.backfillThisHint");
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button
onClick={onHide}
className="text-muted hover:text-fg shrink-0"
aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
>
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</Tooltip>
{canWrite && (
<Tooltip hint={t("channels.row.unsubscribeHint")}>
<button
onClick={onUnsubscribe}
className="text-muted hover:text-danger shrink-0"
aria-label={t("channels.row.unsubscribeOnYoutube")}
>
<UserMinus className="w-4 h-4" />
</button>
</Tooltip>
)}
</div>
);
}
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
* this factory just wires them into `Column<ManagedChannel>` render/filter/sort defs so the ~150-line
* column block lives next to its cells instead of inside the page component. */
export interface ChannelColumnDeps {
t: TFunction;
isAdmin: boolean;
canWrite: boolean;
prioOptions: { value: string; label: string }[];
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
bumpPriority: (id: string, delta: number) => void;
setPriority: (id: string, value: number) => void;
onHide: (c: ManagedChannel) => void;
onDeep: (c: ManagedChannel) => void;
onReset: (c: ManagedChannel) => void;
onUnsub: (c: ManagedChannel) => void;
onView: (c: ManagedChannel) => void;
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
createAndAttachTag: (channelId: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
}
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
const {
t,
isAdmin,
canWrite,
prioOptions,
userTags,
bumpPriority,
setPriority,
onHide,
onDeep,
onReset,
onUnsub,
onView,
toggleTag,
createAndAttachTag,
onFilterByTag,
} = deps;
return [
{
key: "priority",
header: t("channels.cols.priority"),
align: "center",
width: "56px",
sortable: true,
hideInCard: true,
sortValue: (c) => c.priority,
filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
render: (c) => (
<PriorityStepper
priority={c.priority}
onPriority={(d) => bumpPriority(c.id, d)}
onSet={(v) => setPriority(c.id, v)}
/>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "left",
nowrap: true,
width: "104px",
cardLabel: false,
render: (c) => (
<ActionsCell
c={c}
isAdmin={isAdmin}
canWrite={canWrite}
onHide={() => onHide(c)}
onDeep={() => onDeep(c)}
onReset={() => onReset(c)}
onUnsubscribe={() => onUnsub(c)}
/>
),
},
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
// Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
// cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
// Without this a single long name blows the column ~289→429px and overflows the centered table.
render: (c) => (
<div className="max-w-[18rem]">
<ChannelLink
id={c.id}
title={c.title}
handle={c.handle}
thumbnailUrl={c.thumbnail_url}
onView={() => onView(c)}
/>
</div>
),
},
{
key: "about",
header: t("channels.cols.about"),
hideInCard: true,
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
},
{
key: "stored",
header: t("channels.cols.stored"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => (
<span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
),
},
subsColumn<ManagedChannel>(t),
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
sortable: true,
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
},
{
key: "length",
header: t("channels.cols.length"),
align: "right",
nowrap: true,
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => (
<span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds)}
</span>
),
},
{
key: "types",
header: t("channels.cols.types"),
nowrap: true,
render: (c) => (
<Tooltip hint={t("channels.cols.typesHint")}>
<span className="text-muted tabular-nums cursor-help">
{c.count_normal} / {c.count_short} / {c.count_live}
</span>
</Tooltip>
),
},
{
key: "sync",
header: t("channels.cols.sync"),
nowrap: true,
cardLabel: false,
filter: {
kind: "select",
options: [
{ value: "fully", label: t("channels.filters.fullySynced") },
{ value: "needsFull", label: t("channels.filters.needsFull") },
],
test: (c, v) =>
v === "fully"
? c.recent_synced && c.backfill_done
: v === "needsFull"
? !c.backfill_done
: true,
},
render: (c) => <SyncCell c={c} />,
},
{
key: "tags",
header: t("channels.cols.tags"),
cardLabel: false,
// Bounded width so the cell can't widen the column and reflow the whole table; the control
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
width: "12rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<ChannelTagsControl
userTags={userTags}
attachedIds={c.tag_ids}
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag}
/>
),
},
];
}