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,240 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { PlexFilters } from "../../lib/api";
import { formatMoney, formatRuntime } from "../../lib/format";
import type { AsideSection, DetailAsideState, DetailFacts } from "../../lib/plexDetail";
import { regionName, vocab } from "../../lib/vocab";
import { Filterable, PrefToggle } from "./plexDetailUi";
import { Button } from "../../components/ui/Button";
import { Pressable } from "../../components/ui/Pressable";
// The detail pages' second column (the owner's pick "A"): the TMDB facts and key crew of a movie or
// a show beside its description, inside the hero — as the person page sets its known-for rail beside
// the bio — and its keywords under the text. They used to sit under the hero (movie) or
// nowhere at all (show), while the hero's text stopped at ~55% of the panel and left the rest empty. A
// narrow hero stacks the column under the text. Each section is a per-user toggle (default ON), the same
// three prefs on both pages (lib/plexDetail useDetailAside).
type OnFilter = (patch: Partial<PlexFilters>) => void;
// The customize menu's rows: one per section that has something (no toggle for an empty one).
export function DetailAsideToggles({ aside }: { aside: DetailAsideState }) {
const { t } = useTranslation();
const label: Record<AsideSection, string> = {
facts: t("plex.info.prefFacts"),
keywords: t("plex.info.prefKeywords"),
crew: t("plex.info.prefCrew"),
};
return (["facts", "keywords", "crew"] as const)
.filter((k) => aside.has[k])
.map((k) => (
<PrefToggle key={k} label={label[k]} on={aside.on[k]} onClick={() => aside.toggle(k)} />
));
}
// The hero's second column: the facts over the key crew — two lists, read down. Null when neither is
// shown. The keywords are chips that filter, as the genres are, so they sit under the text instead
// (DetailKeywords): all three in this column ran it to twice the text's height at 20 keywords and 12
// crew, a hole under the text — the measured worst case.
export function DetailAside({
facts,
shown,
onFilter,
onOpenPerson,
}: {
facts: DetailFacts;
shown: DetailAsideState["shown"];
onFilter?: OnFilter;
onOpenPerson?: (name: string) => void;
}) {
if (!shown.facts && !shown.crew) return null;
return (
<div data-testid="detail-aside" className="flex min-w-0 flex-col gap-5">
{shown.facts && <FactsSection facts={facts} onFilter={onFilter} />}
{shown.crew && <CrewSection crew={facts.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
</div>
);
}
// The structured facts: release / runtime / cert / budget / revenue / studios / countries, from
// the title's own fields + the persisted TMDB block. What filters the library is an edged chip, a plain
// fact stays flat text (edged = pressable, flat = just says).
function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFilter }) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const tmdb = facts.tmdb;
const studios = tmdb?.studios?.length ? tmdb.studios : facts.studio ? [facts.studio] : [];
const rows: { key: string; label: string; node: ReactNode }[] = [];
// The same values filter here as in the hero's meta line (year, certificate, studios).
const year = facts.year;
if (year != null)
rows.push({
key: "year",
label: t("plex.info.facts.release"),
node: (
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
>
{year}
</Filterable>
),
});
if (facts.duration_seconds)
rows.push({
key: "runtime",
label: t("plex.info.facts.runtime"),
node: formatRuntime(facts.duration_seconds, lang),
});
const cert = facts.content_rating;
if (cert)
rows.push({
key: "cert",
label: t("plex.info.facts.cert"),
node: (
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
{vocab(t, "certificate", cert)}
</Filterable>
),
});
if (tmdb?.budget)
rows.push({
key: "budget",
label: t("plex.info.facts.budget"),
node: formatMoney(tmdb.budget, lang),
});
if (tmdb?.revenue)
rows.push({
key: "revenue",
label: t("plex.info.facts.revenue"),
node: formatMoney(tmdb.revenue, lang),
});
if (studios.length)
rows.push({
key: "studios",
label: t("plex.info.facts.studios"),
node: (
<span className="flex flex-wrap gap-1.5">
{studios.map((s) => (
<Filterable
key={s}
tag
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
>
{s}
</Filterable>
))}
</span>
),
});
if (tmdb?.production_countries?.length)
rows.push({
key: "countries",
label: t("plex.info.facts.countries"),
node: tmdb.production_countries
.map((c) => regionName(t, c.code, i18n.language, c.name))
.join(", "),
});
if (rows.length === 0) return null;
return (
<section>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt>
<dd className="min-w-0 font-medium">{r.node}</dd>
</div>
))}
</dl>
</section>
);
}
// TMDB keyword chips — tap one to filter the library by that keyword. Under the hero's text.
export function DetailKeywords({
keywords,
onFilter,
className,
}: {
keywords: string[];
onFilter?: OnFilter;
className?: string;
}) {
const { t } = useTranslation();
return (
<section data-testid="detail-keywords" className={className}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
<div className="flex flex-wrap gap-1.5">
{keywords.map((k) => (
<Filterable
key={k}
tag
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
>
{k}
</Filterable>
))}
</div>
</section>
);
}
// The crew shows its first CREW_SHOWN names and a "Show N more" that opens the rest: the column beside the
// text is the hero's tallest part, and twelve names ran it ~170px past the text (the owner's call).
// A list only a little longer than that shows whole: a toggle that hides one or two names saves no room.
export const CREW_SHOWN = 6;
const CREW_FOLD_OVER = CREW_SHOWN + 2;
// The TMDB key crew — Director / Writer / Producer / …, each opening their person page. It
// complements the hero's Director line (which comes from Plex directly).
function CrewSection({
crew,
onOpenPerson,
}: {
crew: { name: string; job: string }[];
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const [all, setAll] = useState(false);
const folds = crew.length > CREW_FOLD_OVER;
const shown = folds && !all ? crew.slice(0, CREW_SHOWN) : crew;
return (
<section>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5">
{shown.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
<Pressable
selectable
onClick={() => onOpenPerson(c.name)}
title={t("plex.info.viewPerson", { name: c.name })}
className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)"
>
{c.name}
</Pressable>
) : (
<div className="truncate text-sm font-medium">{c.name}</div>
)}
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
</div>
))}
</div>
{folds && (
<Button
variant={all ? "link-muted" : "link"}
aria-expanded={all}
onClick={() => setAll((v) => !v)}
className="mt-2 text-sm"
>
{all
? t("plex.info.crewLess")
: t("plex.info.crewMore", { count: crew.length - CREW_SHOWN })}
</Button>
)}
</section>
);
}