`npm run knip` now runs a second, --production pass, which asks whether the code that ships uses an export, not just whether anything does. Of the 47 exports only tests reached: - 34 are exported for their test and say so with @internal, naming the test. - 11 were module-private names with a stray `export`. Two of them were not used at all and are deleted. - The unused alreadyReported re-export is gone. Test kits, the e2e suite and the two build-tooling modules are excluded from the production project, each named in knip.jsonc. noUncheckedIndexedAccess is now on for the node and e2e tsconfigs too (0 errors), matching the app project. tsconfig.node.json listed a tailwind.config.js that has not existed since Tailwind 4. That entry is now playwright.config.ts, which the typed lint finds through this project.
220 lines
8.5 KiB
TypeScript
220 lines
8.5 KiB
TypeScript
import type { ComponentProps, ReactNode } from "react";
|
|
import type { TFunction } from "i18next";
|
|
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
|
import { formatCountOrDash, formatTotalHours } 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 { StoredCell } from "./channelCells";
|
|
import { isFullySynced } from "./channelSync";
|
|
import { RelativeTime } from "./ui/RelativeTime";
|
|
|
|
/** 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,
|
|
lang: string,
|
|
): 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, lang)}</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. */
|
|
/** @internal exported for channelColumns.dom.test.tsx */
|
|
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;
|
|
/** The UI language, for the numbers — the compiler re-builds the columns when it changes. */
|
|
lang: string;
|
|
prioOptions: { value: string; label: string }[];
|
|
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
|
|
bumpPriority: (id: string, delta: number) => void;
|
|
setPriority: (id: string, value: number) => void;
|
|
/** The row's "…" — the door to the channel's one action list (`useChannelActions`). */
|
|
more: (c: ManagedChannel) => ReactNode;
|
|
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,
|
|
lang,
|
|
prioOptions,
|
|
userTags,
|
|
bumpPriority,
|
|
setPriority,
|
|
more,
|
|
onView,
|
|
toggleTag,
|
|
createAndAttachTag,
|
|
onFilterByTag,
|
|
suggestionsFor,
|
|
onAcceptSuggestion,
|
|
onDismissSuggestion,
|
|
} = deps;
|
|
return [
|
|
{
|
|
key: "priority",
|
|
header: t("channels.cols.priority"),
|
|
align: "center",
|
|
// Sized to the longer language's label + the funnel + a sort arrow ("Prioritás ▼ ⧩"): fixed layout
|
|
// clips a header that outgrows its column instead of widening it.
|
|
width: "7.5rem",
|
|
// Off on a tablet-wide table, with About and Length: the row's "…" menu sets the priority too.
|
|
tabletHidden: true,
|
|
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)}
|
|
hint={t("channels.row.priorityHint")}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
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,
|
|
tabletHidden: 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, lang), width: "6rem" },
|
|
{
|
|
key: "lastUpload",
|
|
header: t("channels.cols.lastUpload"),
|
|
nowrap: true,
|
|
width: "8rem", // "Utolsó feltöltés" + a sort arrow, as the priority column above
|
|
sortable: true,
|
|
sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
|
|
render: (c) => <RelativeTime at={c.last_video_at} empty="—" className="text-muted" />,
|
|
},
|
|
{
|
|
key: "length",
|
|
header: t("channels.cols.length"),
|
|
align: "right",
|
|
nowrap: true,
|
|
width: "4.5rem",
|
|
tabletHidden: true,
|
|
sortable: true,
|
|
sortValue: (c) => c.total_duration_seconds,
|
|
render: (c) => (
|
|
<span className="text-muted tabular-nums">
|
|
{formatTotalHours(c.total_duration_seconds, lang)}
|
|
</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)}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: "actions",
|
|
header: "",
|
|
align: "right",
|
|
// Room for the "…" at 44px on touch (`.touch-target`), not just its 28px under a mouse.
|
|
width: "3.25rem",
|
|
nowrap: true,
|
|
cardLabel: false,
|
|
// The row's full-history, hide and unsubscribe buttons went into the menu (E28 C1): the row
|
|
// keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…".
|
|
render: more,
|
|
},
|
|
];
|
|
}
|