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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user