The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import type { ComponentProps, ReactNode } from "react";
|
|
import type { TFunction } from "i18next";
|
|
import { NO_TAG_SENTINEL, type ManagedChannel, type Tag } from "../lib/api";
|
|
import { tagPickLabel, type TagList } from "../lib/tagPicks";
|
|
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";
|
|
import type { FilterChip } from "./TagFilterChips";
|
|
|
|
/** 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 — 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)));
|
|
}
|
|
|
|
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
|
|
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
|
|
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
|
|
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away —
|
|
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule
|
|
* (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
|
|
export function tagFilterChips(o: {
|
|
userTags: Tag[];
|
|
tags: TagList;
|
|
picked: string[];
|
|
noTagOn: boolean;
|
|
toggle: (tagId: number) => void;
|
|
toggleNoTag: () => void;
|
|
t: TFunction;
|
|
}): FilterChip[] {
|
|
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
|
|
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
|
|
return [
|
|
...others.map((id) => ({
|
|
key: `t${id}`,
|
|
label: tagPickLabel(Number(id), o.tags, o.t),
|
|
pending: o.tags.isPending,
|
|
active: true,
|
|
onToggle: () => o.toggle(Number(id)),
|
|
})),
|
|
...o.userTags.map((tg) => ({
|
|
key: `t${tg.id}`,
|
|
label: tg.name,
|
|
active: o.picked.includes(String(tg.id)),
|
|
onToggle: () => o.toggle(tg.id),
|
|
})),
|
|
{
|
|
key: NO_TAG_SENTINEL,
|
|
label: o.t("channels.tags.noTag"),
|
|
active: o.noTagOn,
|
|
onToggle: o.toggleNoTag,
|
|
},
|
|
];
|
|
}
|
|
|
|
/** 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;
|
|
// 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: 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 (the retired Sync column folded 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: the row
|
|
// keeps what is read or tuned all the time — priority, tags, the ✓ — and ends in its "…".
|
|
render: more,
|
|
},
|
|
];
|
|
}
|