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,91 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// jsdom only because the module graph reaches `lib/api` -> `i18n`, which reads localStorage at
|
||||
// import time; nothing here touches the DOM.
|
||||
// The two pure rules behind the comfort shelf. Both are one-liners that eight call sites
|
||||
// depend on, and both are exactly the kind of mapping that rots silently: a wrong pin kind is a 404
|
||||
// nobody sees until they try that one surface, and a move rule that disagrees with the menu row's
|
||||
// disabled state shows an enabled row that no-ops.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pinTargetForCard, shelfMoveTarget } from "./comfortPin";
|
||||
import type { DiscoverCard } from "../../lib/api";
|
||||
|
||||
const card = (over: Partial<DiscoverCard>): DiscoverCard =>
|
||||
({
|
||||
source: "youtube",
|
||||
kind: "video",
|
||||
id: "x",
|
||||
title: "t",
|
||||
subtitle: null,
|
||||
thumb: null,
|
||||
aspect: "wide",
|
||||
duration_seconds: null,
|
||||
position_seconds: 0,
|
||||
remaining_seconds: null,
|
||||
status: "new",
|
||||
video: null,
|
||||
plex: null,
|
||||
...over,
|
||||
}) as DiscoverCard;
|
||||
|
||||
describe("pinTargetForCard", () => {
|
||||
it("pins a Plex SHOW as a show and every other Plex tile as a leaf item", () => {
|
||||
// The distinction the whole shelf turns on: "put that series on" has to resolve to an episode,
|
||||
// so a show may not be stored as the item it is browsed as.
|
||||
expect(pinTargetForCard(card({ source: "plex", kind: "show", id: "1234" }))).toEqual({
|
||||
kind: "plex_show",
|
||||
ref: "1234",
|
||||
});
|
||||
expect(pinTargetForCard(card({ source: "plex", kind: "movie", id: "99" }))).toEqual({
|
||||
kind: "plex_item",
|
||||
ref: "99",
|
||||
});
|
||||
expect(pinTargetForCard(card({ source: "plex", kind: "episode", id: "77" }))).toEqual({
|
||||
kind: "plex_item",
|
||||
ref: "77",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps the three YouTube kinds to their own columns", () => {
|
||||
expect(pinTargetForCard(card({ kind: "video", id: "vid" }))).toEqual({
|
||||
kind: "video",
|
||||
ref: "vid",
|
||||
});
|
||||
expect(pinTargetForCard(card({ kind: "channel", id: "UC1" }))).toEqual({
|
||||
kind: "channel",
|
||||
ref: "UC1",
|
||||
});
|
||||
expect(pinTargetForCard(card({ kind: "playlist", id: "12" }))).toEqual({
|
||||
kind: "playlist",
|
||||
ref: "12",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shelfMoveTarget", () => {
|
||||
it("moves within the shelf", () => {
|
||||
expect(shelfMoveTarget(2, "first", 4)).toBe(0);
|
||||
expect(shelfMoveTarget(2, "left", 4)).toBe(1);
|
||||
expect(shelfMoveTarget(2, "right", 4)).toBe(3);
|
||||
expect(shelfMoveTarget(2, "last", 4)).toBe(3);
|
||||
});
|
||||
|
||||
it("reports -1 for every move that would go nowhere", () => {
|
||||
// This is what the menu reads to disable a row, so each of these is a row the user must not be
|
||||
// able to press onto a no-op.
|
||||
expect(shelfMoveTarget(0, "first", 4)).toBe(-1);
|
||||
expect(shelfMoveTarget(0, "left", 4)).toBe(-1);
|
||||
expect(shelfMoveTarget(3, "right", 4)).toBe(-1);
|
||||
expect(shelfMoveTarget(3, "last", 4)).toBe(-1);
|
||||
// A single-item shelf can do none of the four.
|
||||
expect(shelfMoveTarget(0, "right", 1)).toBe(-1);
|
||||
expect(shelfMoveTarget(0, "last", 1)).toBe(-1);
|
||||
});
|
||||
|
||||
it("reports -1 for an index that is not on the shelf", () => {
|
||||
// `findIndex` returns -1 when the shelf has already been refetched without that pin — the menu
|
||||
// may still be open over the tile that was there a moment ago.
|
||||
expect(shelfMoveTarget(-1, "right", 4)).toBe(-1);
|
||||
expect(shelfMoveTarget(9, "left", 4)).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A Plex query follows a language switch. `plexLang()` read the i18n singleton, and under the
|
||||
// React Compiler a value with no reactive input is computed once: after switching to Hungarian the
|
||||
// comfort shelf kept asking for its English cards. The language now comes off useTranslation. Real
|
||||
// i18n (the key-echo mock does not re-render on a switch, which is the very thing under test).
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { setLanguage } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { useComfortPin } from "./comfortPin";
|
||||
|
||||
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
|
||||
// run that render landed after the test environment was gone ("window is not defined").
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
setLanguage("en");
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("the comfort shelf after a language switch", () => {
|
||||
it("asks for its cards in the new language", async () => {
|
||||
const get = vi.spyOn(api, "comfortPins").mockImplementation(() => new Promise(() => {}));
|
||||
setLanguage("en");
|
||||
renderHook(() => useComfortPin(true), { wrapper });
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith("en"));
|
||||
act(() => setLanguage("hu"));
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith("hu"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pin, PinOff } from "lucide-react";
|
||||
|
||||
import { plexLang } from "../../i18n";
|
||||
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
|
||||
// "put this on my comfort shelf", from wherever you happen to be looking at the thing.
|
||||
//
|
||||
// The shelf is the Home surface's object, but the GESTURE belongs wherever a pinnable thing is
|
||||
// shown — a video, a channel, a playlist, a Plex show or movie: you decide something is comfort media
|
||||
// while you are watching it. (Locked decision 3: an episode, a season and a person are not pin kinds,
|
||||
// so their cards and strips carry no pin.) This file is the one place the pin lives; every surface
|
||||
// composes the same `ActionMenuItem` into its object's ONE action list: the video, Plex and
|
||||
// channel actions (`useVideoActions`, `usePlexCardActions`, `Channels`), the playlist rail and page,
|
||||
// and Home's shelves. There is no pin-only menu any more.
|
||||
//
|
||||
// Feature layer, not `components/ui/`: it speaks a domain API slice, which the platform boundary
|
||||
// (eslint `no-restricted-imports`) forbids a `ui/` primitive from doing.
|
||||
|
||||
/** What the API needs to pin something: the kind, and the id the rest of the API already speaks for
|
||||
* that kind (a video id, a channel id, a Plex `rating_key`, a playlist id). Mirrors
|
||||
* `app/discover/pins.py::PIN_COLUMNS`. */
|
||||
export interface PinTarget {
|
||||
kind: PinKind;
|
||||
ref: string;
|
||||
}
|
||||
|
||||
// Constructors rather than five call sites writing the kind strings by hand. The mapping from a
|
||||
// domain object to its pin kind is an invariant, so it lives in a function — a `"plex_show"` typo'd
|
||||
// as `"plex-show"` at one of eight call sites would be a 404 nobody notices until they try it.
|
||||
export const pinVideo = (videoId: string): PinTarget => ({ kind: "video", ref: videoId });
|
||||
export const pinChannel = (channelId: string): PinTarget => ({ kind: "channel", ref: channelId });
|
||||
export const pinPlaylist = (playlistId: number): PinTarget => ({
|
||||
kind: "playlist",
|
||||
ref: String(playlistId),
|
||||
});
|
||||
/** A Plex tile. A SHOW is pinned as a show ("put that series on" resolves to whichever episode is
|
||||
* next); a movie or a single episode is pinned as the leaf item it is. */
|
||||
export const pinPlex = (card: { id: string; type: string }): PinTarget => ({
|
||||
kind: card.type === "show" ? "plex_show" : "plex_item",
|
||||
ref: card.id,
|
||||
});
|
||||
|
||||
/** A unified discover card back to the thing it was pinned as — the inverse of the mapping in
|
||||
* `app/discover/cards.py`. Null for a kind that cannot be pinned. */
|
||||
export function pinTargetForCard(
|
||||
card: Pick<DiscoverCard, "source" | "kind" | "id">,
|
||||
): PinTarget | null {
|
||||
if (card.source === "plex") return pinPlex({ id: card.id, type: card.kind });
|
||||
if (card.kind === "video") return pinVideo(card.id);
|
||||
if (card.kind === "channel") return pinChannel(card.id);
|
||||
if (card.kind === "playlist") return { kind: "playlist", ref: card.id };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Where a shelf move lands. The shelf is a HORIZONTAL rail, so the words are left/right rather than
|
||||
* the playlist table's up/down — the same four moves, named after what the user actually sees. */
|
||||
export type ShelfMove = "first" | "left" | "right" | "last";
|
||||
|
||||
/** `from` -> the index the move targets, or -1 when there is nowhere to go. Pure, so the disabled
|
||||
* state of a menu row and the move it would perform are decided by ONE rule instead of two that can
|
||||
* disagree (a row that looks available and then no-ops is the worst of the three states). */
|
||||
export function shelfMoveTarget(from: number, to: ShelfMove, length: number): number {
|
||||
if (from < 0 || from >= length) return -1;
|
||||
const target =
|
||||
to === "first" ? 0 : to === "last" ? length - 1 : to === "left" ? from - 1 : from + 1;
|
||||
return target === from || target < 0 || target >= length ? -1 : target;
|
||||
}
|
||||
|
||||
/** The identity a target is looked up by. One function, so "is this pinned?" and "what did the
|
||||
* server give back?" can never key the shelf two different ways. */
|
||||
const targetKey = (target: PinTarget): string => `${target.kind}:${target.ref}`;
|
||||
|
||||
export interface ComfortPin {
|
||||
/** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them
|
||||
* invites the next surface to re-derive the three-state loading dance (pinned / not pinned /
|
||||
* not known yet) that `item` already encapsulates, which is what this file exists to prevent. */
|
||||
remove: (pinId: number, title: string) => void;
|
||||
/** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */
|
||||
item: (target: PinTarget | null, title: string) => ActionMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The shelf's client half.
|
||||
*
|
||||
* `open` is the host menu's own open flag. The shelf is only fetched once a menu has actually been
|
||||
* opened (and stays cached afterwards), because the pinned-ness of a row is needed to LABEL one menu
|
||||
* item and nothing else — firing `/api/discover/pins`, which resolves a `next` per non-leaf pin, on
|
||||
* every feed load would be a real request for a string almost nobody reads that session.
|
||||
*/
|
||||
export function useComfortPin(open: boolean): ComfortPin {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides
|
||||
// WHEN the first fetch happens, not whether the answer survives the menu closing.
|
||||
const [everOpen, setEverOpen] = useState(false);
|
||||
// Latched during render: the query below is gated on it, so an effect delayed the first fetch by
|
||||
// a commit for no reason. Convergent — the write falsifies its own condition.
|
||||
if (open && !everOpen) setEverOpen(true);
|
||||
|
||||
const shelf = useQuery({
|
||||
queryKey: qk.comfortPins(plexLang(i18n.language)),
|
||||
queryFn: () => api.comfortPins(plexLang(i18n.language)),
|
||||
enabled: everOpen,
|
||||
// The shelf is hand-curated and short; re-asking on every menu open would be noise.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const pins = shelf.data?.items;
|
||||
const pinIdOf = useCallback(
|
||||
(target: PinTarget): number | null | undefined => {
|
||||
if (!pins) return undefined;
|
||||
const key = targetKey(target);
|
||||
for (const card of pins) {
|
||||
const other = pinTargetForCard(card);
|
||||
if (other && targetKey(other) === key) return card.pin_id ?? null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[pins],
|
||||
);
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
void qc.invalidateQueries({ queryKey: qk.comfortPins() });
|
||||
// The prefix, not an exact key: the Home payload is per-budget, and pinning something can change
|
||||
// the hero as well as the shelf.
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
}, [qc]);
|
||||
|
||||
const add = useCallback(
|
||||
async (target: PinTarget, title: string) => {
|
||||
try {
|
||||
await api.addComfortPin(target.kind, target.ref);
|
||||
// The shelf lives on another page, so without this the click has no visible consequence at
|
||||
// all from the feed — the one place a toast genuinely carries the whole feedback.
|
||||
notify({ level: "success", message: t("home.pin.added", { title }) });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("home.pin.addFailed"));
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
[invalidate, t],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(pinId: number, title: string) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await api.removeComfortPin(pinId);
|
||||
notify({ level: "success", message: t("home.pin.removed", { title }) });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("home.pin.removeFailed"));
|
||||
}
|
||||
invalidate();
|
||||
})();
|
||||
},
|
||||
[invalidate, t],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(target: PinTarget, title: string) => {
|
||||
const pinId = pinIdOf(target);
|
||||
if (pinId == null) void add(target, title);
|
||||
else remove(pinId, title);
|
||||
},
|
||||
[add, pinIdOf, remove],
|
||||
);
|
||||
|
||||
const item = useCallback(
|
||||
(target: PinTarget | null, title: string): ActionMenuItem[] => {
|
||||
if (!target) return [];
|
||||
const pinId = pinIdOf(target);
|
||||
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
|
||||
// something that is already on the shelf. Only ever seen on the first menu open of a session.
|
||||
if (pinId === undefined)
|
||||
return [
|
||||
{
|
||||
id: "comfort-pin",
|
||||
label: t("home.pin.add"),
|
||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
separatorBefore: true,
|
||||
disabled: true,
|
||||
onSelect: () => {},
|
||||
},
|
||||
];
|
||||
return [
|
||||
{
|
||||
id: "comfort-pin",
|
||||
label: pinId === null ? t("home.pin.add") : t("home.pin.remove"),
|
||||
icon: pinId === null ? <Pin className="w-4 h-4" /> : <PinOff className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
// One row, two states, and only ONE of them is destructive — so `danger` flips with the
|
||||
// label and the icon rather than being decided at a call site. The shelf's own hand-rolled
|
||||
// unpin row on Home got the flag first; leaving this one neutral painted the identical
|
||||
// command (same label, same icon, same `removeComfortPin`) two different colours on one
|
||||
// screen — the hero's menu and the Continue-watching rail come through here.
|
||||
danger: pinId !== null,
|
||||
onSelect: () => toggle(target, title),
|
||||
},
|
||||
];
|
||||
},
|
||||
[pinIdOf, t, toggle],
|
||||
);
|
||||
|
||||
return { remove, item };
|
||||
}
|
||||
Reference in New Issue
Block a user