Files
siftlode/frontend/src/components/channelColumns.tsx
T
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00

221 lines
7.9 KiB
TypeScript

import type { ComponentProps } from "react";
import type { TFunction } from "i18next";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./ui/DataTable";
import { PriorityStepper } from "./ui/PriorityStepper";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import { ActionsCell, StoredCell } from "./channelCells";
import { isFullySynced } from "./channelSync";
/** 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>
),
};
}
/** Sync-status pre-filter for the subscribed table. The Sync COLUMN is gone (R14) — its status folded
* into a charm on Stored — so the top chips' `filters.sync` value is applied here instead of via a
* column predicate. Same vocab as the old column filter: "fully" | "needsFull" | "" (all). */
export function applySyncFilter(rows: ManagedChannel[], value: unknown): ManagedChannel[] {
if (value === "fully") return rows.filter(isFullySynced);
if (value === "needsFull") return rows.filter((c) => !c.backfill_done);
return rows;
}
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
return values.some((v) => c.tag_ids.includes(Number(v)));
}
/** 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 outside the page component (its cell components are in channelCells). */
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;
// E8·S4 — suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss.
suggestionsFor: (channelId: string) => SuggestedTag[];
onAcceptSuggestion: (channelId: string, tagId: number) => void;
onDismissSuggestion: (channelId: string, tagId: number) => void;
}
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
const {
t,
isAdmin,
canWrite,
prioOptions,
userTags,
bumpPriority,
setPriority,
onHide,
onDeep,
onReset,
onUnsub,
onView,
toggleTag,
createAndAttachTag,
onFilterByTag,
suggestionsFor,
onAcceptSuggestion,
onDismissSuggestion,
} = deps;
return [
{
key: "priority",
header: t("channels.cols.priority"),
align: "center",
width: "5.5rem",
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: "5.5rem",
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,
// A FLUID column (R14): no width cap. Under the manager table's fixed layout this cell takes a
// share of the leftover space and ChannelLink truncates the name to fit (full name on hover via
// title), so a long name can't push the table wider than its container at any zoom.
render: (c) => (
<div className="min-w-0">
<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,
// The other FLUID column — AboutCell already truncates to its cell.
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
},
{
key: "stored",
header: t("channels.cols.stored"),
align: "right",
nowrap: true,
width: "5rem",
sortable: true,
sortValue: (c) => c.stored_videos,
// The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in).
render: (c) => <StoredCell c={c} />,
},
{ ...subsColumn<ManagedChannel>(t), width: "6rem" },
{
key: "lastUpload",
header: t("channels.cols.lastUpload"),
nowrap: true,
width: "6rem",
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,
width: "4.5rem",
sortable: true,
sortValue: (c) => c.total_duration_seconds,
render: (c) => (
<span className="text-muted tabular-nums">
{formatTotalHours(c.total_duration_seconds)}
</span>
),
},
{
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: "9rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: tagsColumnTest,
},
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}
suggestions={suggestionsFor(c.id)}
onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)}
onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)}
/>
),
},
];
}