refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
@@ -0,0 +1,173 @@
import type { ReactNode } from "react";
import { Play } from "lucide-react";
import type { TFunction } from "i18next";
import type { Video } from "../../lib/api";
import { formatDuration } from "../../lib/format";
import type { Column } from "../../lib/tableColumns";
import { Pressable } from "../../components/ui/Pressable";
import RemoteImage from "../../components/ui/RemoteImage";
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
//
// The `grip` column carries no renderer: the drag handle needs the `useSortable` attributes that
// only exist inside the row component, which a `render(row)` closure can't reach. It stays in the
// list anyway so ONE column array drives both the header and the body — the row renderer fills
// that cell itself. Everything else is an ordinary cell.
export const GRIP_KEY = "grip";
export function playlistColumns({
t,
indexOf,
onPlay,
more,
}: {
t: TFunction;
/** Position in the STORED order (1-based on screen) — not the row's position in the current view,
* so a view-sort never renumbers the playlist under the reader. */
indexOf: (v: Video) => number;
onPlay: (v: Video) => void;
/** The row's "…" — the visible door to its menu, where "Remove" now lives. */
more: (v: Video) => ReactNode;
}): Column<Video>[] {
const cols: Column<Video>[] = [
{ key: GRIP_KEY, header: "", width: "2.25rem", render: () => null, hideInCard: true },
{
key: "pos",
header: "#",
align: "right",
width: "4.5rem",
nowrap: true,
hideInCard: true,
// The position number is the first thing a phone row can spare; the order still shows.
narrowHidden: true,
// Sorting by "#" ascending is the stored order itself — that is what makes it a truthful
// default for the header arrow, and a one-click way back from any other sort.
sortable: true,
sortValue: indexOf,
render: (v) => <span className="text-xs text-muted tabular-nums">{indexOf(v) + 1}</span>,
},
{
key: "title",
header: t("playlists.colTitle"),
// The flexible column: under `table-fixed` (see Playlists.tsx) width:100% hands it every
// pixel the fixed columns don't claim. Auto layout could not do this — a `truncate` cell is
// `white-space: nowrap`, so its MIN-content width is the whole title, and the column grew
// until Length and the remove button hung off the page scroller.
width: "100%",
sortable: true,
sortValue: (v) => v.title ?? "",
filter: { kind: "text", get: (v) => v.title ?? "" },
cardPrimary: true,
render: (v) => (
<div className="flex items-center gap-2.5 min-w-0">
<Pressable
onClick={() => onPlay(v)}
aria-label={`${t("card.play")} — ${v.title}`}
// A NAMED group: the row is a `group` too (its "…" reveals on the row's hover), and a
// plain group-hover here would light the play overlay whenever the row is hovered.
className="relative w-15.5 h-8.75 rounded-sm overflow-hidden bg-surface shrink-0 group/thumb touch-target"
>
{v.thumbnail_url && (
<RemoteImage src={v.thumbnail_url} alt="" className="w-full h-full object-cover" />
)}
{/* Narrow, the Length column is gone and its value rides on the picture, as on the
media card (the owner's pick A). */}
<span className="pointer-events-none absolute right-0.5 bottom-0.5 @2xl:hidden">
{lengthCell(v, t, true)}
</span>
<span className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:opacity-100 transition">
<Play className="w-4 h-4 text-white fill-current" />
</span>
</Pressable>
<Pressable
selectable
onClick={() => onPlay(v)}
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
data-nav-open=""
className="min-h-6 min-w-0 flex-1 touch-target"
>
{/* Two lines and the channel under them while the Channel column is hidden (the owner,
2026-09-26: at 375 the one-line title kept ~40px); one line beside the columns. */}
<span className="break-words text-[0.8125rem] leading-snug text-fg line-clamp-2 @2xl:line-clamp-1">
{v.title}
</span>
{v.channel_title && (
<span className="block truncate text-2xs text-muted @2xl:hidden">
{v.channel_title}
</span>
)}
</Pressable>
</div>
),
},
{
key: "channel",
header: t("playlists.colChannel"),
width: "12rem",
narrowHidden: true,
nowrap: true,
sortable: true,
sortValue: (v) => v.channel_title ?? "",
filter: { kind: "text", get: (v) => v.channel_title ?? "" },
render: (v) => (
<span className="text-[0.8125rem] text-muted truncate block">{v.channel_title}</span>
),
},
{
key: "duration",
header: t("playlists.colDuration"),
align: "right",
width: "5rem",
// Narrow, the length rides on the picture instead (the title cell's badge).
narrowHidden: true,
nowrap: true,
sortable: true,
// Nulls (live/upcoming, or not yet enriched) sort last in BOTH directions in the old
// comparator; sortRows is a plain numeric compare, so park them at the end of an ascending
// sort with a large sentinel rather than letting them read as zero-length.
sortValue: (v) => v.duration_seconds ?? Number.MAX_SAFE_INTEGER,
render: (v) => lengthCell(v, t),
},
];
cols.push({
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,
// The X that stood here went into the menu with the Delete key and an undo toast: a
// one-click destructive button on every row, for an action the toast now makes reversible.
render: more,
});
return cols;
}
// A video's length, or its live / upcoming state when it has none. `onPicture`: the dark badge on the
// thumbnail (the narrow row), readable on any frame, like the media card's length badge.
function lengthCell(v: Video, t: TFunction, onPicture = false): ReactNode {
if (v.duration_seconds != null)
return onPicture ? (
<span className="block rounded-sm bg-black/80 px-1 text-3xs leading-4 font-medium text-white tabular-nums">
{formatDuration(v.duration_seconds)}
</span>
) : (
<span className="text-2xs text-muted tabular-nums">{formatDuration(v.duration_seconds)}</span>
);
if (v.live_status !== "live" && v.live_status !== "upcoming") return null;
return (
<span
className={`text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
v.live_status === "live"
? "bg-live text-live-fg" /* the broadcast red, not --danger */
: onPicture
? "bg-black/80 text-white"
: "bg-card border border-border text-muted"
}`}
>
{t(`card.${v.live_status}`)}
</span>
);
}