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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user