Files
siftlode/frontend/src/lib/tableColumns.ts
T
peter 078194731b feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place
- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
2026-10-11 19:37:13 +02:00

111 lines
4.8 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;
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;
}
// 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",
},
} as const;
export function columnHideClass(
col: { narrowHidden?: boolean; tabletHidden?: boolean },
part: "cell" | "col" | "cue" = "cell",
): string {
if (col.narrowHidden) return HIDE_BELOW.narrow[part];
if (col.tabletHidden) return HIDE_BELOW.tablet[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);
}