Files
siftlode/frontend/src/lib/tableColumns.ts
T
peter efbbb23525 feat(playlists): act on the selected rows from the toolbar, and undo either
While rows are selected the toolbar's second row is the band: the count,
the last target as one click, Move to, Remove, Clear selection. Both write
in one request and undo back to the places; a video the target held before
stays there. Delete takes the selection when there is one. A failure puts
the rows back ticked.

A row's own move goes through the same write, so it undoes too, and the
single-row move route and service function are gone. A playlist row drops
its grip below a 24rem table: the row menu keeps the moves.
2026-11-01 22:07:09 +01:00

122 lines
5.4 KiB
TypeScript

import type { ReactNode } from "react";
// The column contract every table-shaped view shares — `DataTable`, the playlist manager's hybrid
// table, the channel card grid — plus the filter predicate that reads it.
//
// It lives in `lib/`, not beside the header component in `components/ui/tableHeader`, because `lib/`
// helpers need it too (`sortOptions`, `useFilterState`, `channelsUrlState`) and the dependency
// direction is one-way: `lib/` may not import from `components/` (lint-enforced, see
// eslint.config.js). The contract is data + closures; nothing in it renders.
type ColumnFilter<T> =
| { kind: "text"; get: (row: T) => string }
| {
kind: "select";
options: { value: string; label: string }[];
test: (row: T, value: string) => boolean;
}
| {
kind: "multi";
options: { value: string; label: string }[];
test: (row: T, values: string[]) => boolean;
};
export interface Column<T> {
key: string;
header: string;
// A header that is a control of its own (the box that selects every row), in place of `header`.
headerCell?: ReactNode;
render: (row: T) => ReactNode;
align?: "left" | "right" | "center";
width?: string;
sortable?: boolean;
sortValue?: (row: T) => string | number;
filter?: ColumnFilter<T>;
// Keep the cell on one line so the column auto-sizes to its content (table-layout: auto).
nowrap?: boolean;
// Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard`
// omits the column; `cardLabel:false` shows the value without its header label.
cardPrimary?: boolean;
hideInCard?: boolean;
cardLabel?: boolean;
// …and `cardRow` gives the value a line of its own: a row's lone "…" flowed in after the facts,
// at the end of one card's line and on the next line of another — never in the same place twice.
cardRow?: boolean;
// A column a table WITHOUT the card fallback (the playlist's drag-reorderable hybrid) leaves out
// while the TABLE is narrower than 42rem: its fixed widths alone overran the box and left the title
// no width. The table's own width, not the viewport's (an `@container` round it): beside the
// playlist rail a desktop window gave the table 432 px, and the title column was 0 px wide.
narrowHidden?: boolean;
// …and one a table leaves out while it is narrower than 64rem — a 1024 tablet window, or a desktop
// one beside a panel: a secondary column that gives its room back to the name (the owner,
// 2026-09-27: at 1024 the channel name had 60 px while About took as much). Same `@container` rule.
tabletHidden?: boolean;
// …and one it leaves out while it is narrower than 24rem, a phone beside the nav rail: there the
// title kept 44 px. Only for a column whose job has another way (the grip: the row menu's moves).
phoneHidden?: boolean;
}
// The classes that hide a column below its width (`narrowHidden` / `tabletHidden`), for its cells and,
// in a fixed layout, for its <col> too: a hidden cell under a <col> that keeps its width would leave an
// empty stripe. The table's OWN width decides, so the caller wraps the table in an `@container`.
// `cue` is the inverse: shown ONLY while the column is hidden — the hidden-filter cue names a
// filter whose funnel went off screen, and by the same container query, so it cannot disagree with
// the table about which columns are gone (not at any text size: both are rem).
// Whole literals, because Tailwind only generates a class it can read in one piece.
const HIDE_BELOW = {
narrow: {
cell: " hidden @2xl:table-cell",
col: " hidden @2xl:table-column",
cue: " @2xl:hidden",
},
tablet: {
cell: " hidden @5xl:table-cell",
col: " hidden @5xl:table-column",
cue: " @5xl:hidden",
},
phone: {
cell: " hidden @sm:table-cell",
col: " hidden @sm:table-column",
cue: " @sm:hidden",
},
} as const;
export function columnHideClass(
col: { narrowHidden?: boolean; tabletHidden?: boolean; phoneHidden?: boolean },
part: "cell" | "col" | "cue" = "cell",
): string {
if (col.narrowHidden) return HIDE_BELOW.narrow[part];
if (col.tabletHidden) return HIDE_BELOW.tablet[part];
if (col.phoneHidden) return HIDE_BELOW.phone[part];
return "";
}
export type FilterMap = Record<string, string | string[]>;
export function isActive(value: string | string[] | undefined): boolean {
return Array.isArray(value) ? value.length > 0 : !!value;
}
/** Apply every column's own filter predicate. A column with no filter, or an empty value, passes. */
export function filterRows<T>(rows: T[], columns: Column<T>[], filters: FilterMap): T[] {
return rows.filter((row) =>
columns.every((col) => {
const f = col.filter;
if (!f) return true;
const val = filters[col.key];
if (!isActive(val)) return true;
if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase());
if (f.kind === "select") return f.test(row, String(val));
return f.test(row, val as string[]);
}),
);
}
/** What a set filter says, in the words its funnel uses: an option's label, not its value. */
export function filterValueLabel<T>(col: Column<T>, value: string | string[]): string {
const f = col.filter;
if (!f || f.kind === "text") return String(value);
const label = (v: string) => f.options.find((o) => o.value === v)?.label ?? v;
return Array.isArray(value) ? value.map(label).join(", ") : label(value);
}