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,161 @@
|
||||
import type { CSSProperties, Ref } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||
import type { VideoDetail } from "../../lib/api";
|
||||
import type { AutoMode, LoopMode } from "../../lib/playerModes";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { ClampedText } from "../../components/ui/ClampedText";
|
||||
import { renderDescription } from "./descriptionLinks";
|
||||
|
||||
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
||||
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
||||
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
|
||||
|
||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
||||
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
||||
* theme, labels always shown) and the big-view docked control bar (`variant:"docked"`, white-on-dark,
|
||||
* labels hidden until a breakpoint). Only the theme + label-visibility differ; the behaviour is one,
|
||||
* so a mode/icon change lands once. The wrapper (fragment vs. bordered row) stays at the call site. */
|
||||
export function PlayerTransportControls({
|
||||
variant,
|
||||
index,
|
||||
queueLength,
|
||||
autoMode,
|
||||
loopMode,
|
||||
onPrev,
|
||||
onNext,
|
||||
onCycleAuto,
|
||||
onCycleLoop,
|
||||
}: {
|
||||
variant: "docked" | "modal";
|
||||
index: number;
|
||||
queueLength: number;
|
||||
autoMode: AutoMode;
|
||||
loopMode: LoopMode;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onCycleAuto: () => void;
|
||||
onCycleLoop: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const docked = variant === "docked";
|
||||
// The docked bar sits on the video (always dark): the overlay variant; the modal's is a page surface.
|
||||
const chrome = docked ? "overlay" : "ghost";
|
||||
const navLabel = docked ? "hidden sm:inline" : "";
|
||||
const counter = docked ? "tabular-nums text-white/70" : "text-muted tabular-nums";
|
||||
const divider = docked ? "h-4 w-px bg-white/20" : "w-px h-4 bg-border";
|
||||
const toggleLabel = docked ? "hidden md:inline" : "";
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={chrome}
|
||||
size="sm"
|
||||
icon={<SkipBack />}
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
label={t("player.previous")}
|
||||
title={`${t("player.previous")} · Shift+←`}
|
||||
>
|
||||
<span className={navLabel}>{t("player.previous")}</span>
|
||||
</Button>
|
||||
<span className={counter}>
|
||||
{index + 1} / {queueLength}
|
||||
</span>
|
||||
<Button
|
||||
variant={chrome}
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={index === queueLength - 1}
|
||||
label={t("player.next")}
|
||||
title={`${t("player.next")} · Shift+→`}
|
||||
>
|
||||
<span className={navLabel}>{t("player.next")}</span>
|
||||
<SkipForward />
|
||||
</Button>
|
||||
<span className={divider} />
|
||||
{/* Persistent playback settings — cycle on click, saved to your account. */}
|
||||
<Button
|
||||
variant={chrome}
|
||||
size="sm"
|
||||
pressed={autoMode !== "off"}
|
||||
icon={
|
||||
autoMode === "prev" ? <SkipBack /> : autoMode === "random" ? <Shuffle /> : <SkipForward />
|
||||
}
|
||||
onClick={onCycleAuto}
|
||||
label={`${t("player.autoAdvance.label")}: ${t(`player.autoAdvance.${autoMode}`)}`}
|
||||
title={t("player.autoAdvance.hint")}
|
||||
>
|
||||
<span className={toggleLabel}>
|
||||
{t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={chrome}
|
||||
size="sm"
|
||||
pressed={loopMode !== "off"}
|
||||
icon={loopMode === "one" ? <Repeat1 /> : <Repeat />}
|
||||
onClick={onCycleLoop}
|
||||
label={`${t("player.loop.label")}: ${t(`player.loop.${loopMode}`)}`}
|
||||
title={t("player.loop.hint")}
|
||||
>
|
||||
<span className={toggleLabel}>
|
||||
{t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The description, said the same way in its two places (the popover under 1280 px, the panel beside
|
||||
* the video above it): loading, failed with a retry, the text in the caller's box, or none. One
|
||||
* ladder, so the two cannot drift (the failed state was once tested in the panel only).
|
||||
* `ref` / `style` / `className` dress the text's box only. `clamp` cuts it to three lines with a
|
||||
* More / Less (the panel, where the description sits under the actions and is rarely read whole). */
|
||||
export function PlayerDescription({
|
||||
detail,
|
||||
currentId,
|
||||
onSeek,
|
||||
onLoadVideo,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
clamp = false,
|
||||
}: {
|
||||
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
|
||||
currentId: string;
|
||||
onSeek: (seconds: number) => void;
|
||||
onLoadVideo: (id: string, start: number | null) => void;
|
||||
className: string;
|
||||
style?: CSSProperties;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
clamp?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>;
|
||||
if (detail.isError && !detail.data)
|
||||
return (
|
||||
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
|
||||
{t("player.descriptionFailed")}
|
||||
</StateMessage>
|
||||
);
|
||||
const text = detail.data?.description;
|
||||
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>;
|
||||
if (clamp)
|
||||
return (
|
||||
<ClampedText
|
||||
text={text}
|
||||
lines={3}
|
||||
moreLabel={t("player.descriptionMore")}
|
||||
lessLabel={t("player.descriptionLess")}
|
||||
textClassName={className}
|
||||
>
|
||||
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
||||
</ClampedText>
|
||||
);
|
||||
return (
|
||||
<div ref={ref} style={style} className={className}>
|
||||
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user