- 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.
111 lines
4.8 KiB
TypeScript
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);
|
|
}
|