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,99 @@
|
||||
import type { Video, VideoStatus } from "../../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../../lib/feedView";
|
||||
import type { NEXT_PAGE } from "../../lib/nextPage";
|
||||
import VideoCard from "../../features/video/VideoCard";
|
||||
import { VideoMenu } from "./VideoMenu";
|
||||
import type { VideoActionHandlers } from "../../features/video/useVideoActions";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import VirtualGrid from "../../components/ui/VirtualGrid";
|
||||
|
||||
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
||||
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
|
||||
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
|
||||
// RE-MEASURE these when a mode's layout changes: they have gone stale twice already, most recently
|
||||
// when the action row moved onto the card's picture (cards 340 -> 262, small 257 -> 226).
|
||||
// The card figures are the roughest of the set by nature — a card's height follows its column width
|
||||
// (262 measured at 4 columns in a 1268px window), so no single constant is right for every window.
|
||||
const ROW_EST: Record<FeedView, number> = {
|
||||
cards: 262,
|
||||
cardsSmall: 226,
|
||||
rows: 117,
|
||||
rowsCompact: 46,
|
||||
tiles: 138,
|
||||
};
|
||||
|
||||
export interface VirtualFeedProps {
|
||||
items: Video[];
|
||||
view: FeedView;
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onResetState: (id: string) => void;
|
||||
onOpen: (v: Video, startAt?: number | null) => void;
|
||||
/** The card menu's channel-filter row (see `useVideoActions`); omitted where it has no place. */
|
||||
channelFilter?: VideoActionHandlers["channelFilter"];
|
||||
hasNextPage: boolean;
|
||||
isFetching: boolean;
|
||||
fetchNextPage: (options: typeof NEXT_PAGE) => unknown;
|
||||
}
|
||||
|
||||
// The feed's videos on the shared VirtualGrid engine — this wrapper only maps a FeedView to the
|
||||
// grid's layout spec and renders a VideoCard per item. All the virtualization mechanics (measure,
|
||||
// chunk, place, infinite-scroll) live in VirtualGrid.
|
||||
export default function VirtualFeed({
|
||||
items,
|
||||
view,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onResetState,
|
||||
onOpen,
|
||||
channelFilter,
|
||||
hasNextPage,
|
||||
isFetching,
|
||||
fetchNextPage,
|
||||
}: VirtualFeedProps) {
|
||||
const { minCol, colMaxWidth, maxCols, thumb } = FEED_VIEW_SPEC[view];
|
||||
// The card menu (the video's whole action list) lives HERE rather than in each of the
|
||||
// feed's callers: this is the list, and `useContextMenu`'s contract is one menu per list. Mounting
|
||||
// it at this level is also what gives the channel page the menu for free — it renders
|
||||
// `<Feed channelScoped>`, so it is the same component all the way down.
|
||||
const menu = useContextMenu<Video>();
|
||||
const openId = menu.open ? menu.target?.id : undefined;
|
||||
return (
|
||||
<>
|
||||
<VirtualGrid
|
||||
items={items}
|
||||
getKey={(v) => v.id}
|
||||
minCol={minCol}
|
||||
colMaxWidth={colMaxWidth}
|
||||
maxCols={maxCols}
|
||||
rowEst={ROW_EST[view]}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetching={isFetching}
|
||||
fetchNextPage={fetchNextPage}
|
||||
keyboardNav
|
||||
renderItem={(v, itemWidth, index) => (
|
||||
<VideoCard
|
||||
video={v}
|
||||
view={view}
|
||||
navIndex={index}
|
||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||
width={thumb ? 0 : itemWidth}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpen={onOpen}
|
||||
menuProps={menu.triggerProps}
|
||||
menuButtonProps={menu.buttonProps}
|
||||
// A boolean per card, not the open id: only the two cards whose answer changes re-render.
|
||||
menuOpen={openId === v.id}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<VideoMenu
|
||||
menu={menu}
|
||||
handlers={{ onState, onToggleSave, onResetState, onOpen, channelFilter }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user