Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell, the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate register, with its three imports. Placement: Plex's chip builder moves into lib beside the feed's, the folder names into features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed" itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic. The floating header re-measures when one page's pills replace another's in the same commit (the claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a page with a rail adds in lib.
262 lines
10 KiB
TypeScript
262 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 "../../components/ui/DataTable";
|
|
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
|
import ChannelLink from "./ChannelLink";
|
|
import AboutCell from "./AboutCell";
|
|
import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/ChannelTags";
|
|
import { StoredCell } from "./channelCells";
|
|
import { isFullySynced } from "./channelSync";
|
|
import { RelativeTime } from "../../components/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,
|
|
cardRow: true,
|
|
// 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,
|
|
},
|
|
];
|
|
}
|