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,207 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Bookmark,
|
||||
BookmarkX,
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Filter,
|
||||
FilterX,
|
||||
Info,
|
||||
ListPlus,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SkipBack,
|
||||
Tv,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api, type Video, type VideoStatus } from "../../lib/api";
|
||||
import { isResettable } from "../../lib/api/feed";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { pinVideo, useComfortPin } from "../comfort/comfortPin";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { useNavigationActions } from "../navigation/navigationContext";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
|
||||
|
||||
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
|
||||
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
|
||||
// and the Home shelves list the same actions in the same order. The card itself keeps only its
|
||||
// state and the everyday toggles (watched, save, add to playlist) on hover; everything else —
|
||||
// download, details, copy, hide, reset, pin — lives here, and only here.
|
||||
|
||||
export interface VideoActionHandlers {
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
/** Omitted where a surface cannot reset a video's state (Home's shelves). */
|
||||
onResetState?: (id: string) => void;
|
||||
/** Omitted where the host menu leads with its own Play row (Home's shelf card resolves `next`). */
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
/** The feed's channel filter, where the menu can narrow it: the library feed. Omitted on a channel
|
||||
* page (the view is that channel already), in a live YouTube search and on Home. */
|
||||
channelFilter?: { ids: string[]; set: (ids: string[]) => void };
|
||||
}
|
||||
|
||||
const icon = "w-4 h-4";
|
||||
|
||||
/** `open` is the host menu's own flag: the download state and the shelf are fetched only once a
|
||||
* menu has been opened, since they only label a row. Returns the item builder and the follow-up
|
||||
* panel to render beside the menu (one per list). */
|
||||
export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const { openChannel } = useNavigationActions();
|
||||
const pin = useComfortPin(open);
|
||||
const copy = useCopyItems();
|
||||
const index = useQuery({
|
||||
queryKey: qk.downloadIndex(),
|
||||
queryFn: api.downloadIndex,
|
||||
enabled: open,
|
||||
staleTime: 10000,
|
||||
});
|
||||
const [followUp, setFollowUp] = useState<VideoFollowUpState | null>(null);
|
||||
|
||||
/** `pin: false` where the host menu carries its own shelf rows (Home's rails). */
|
||||
const items = (
|
||||
v: Video,
|
||||
anchor: Element | null,
|
||||
{ pin: withPin = true }: { pin?: boolean } = {},
|
||||
): ActionMenuItem[] => {
|
||||
const { onState, onToggleSave, onResetState, onOpen, channelFilter } = handlers;
|
||||
const watched = v.status === "watched";
|
||||
const hidden = v.status === "hidden";
|
||||
const inProgress = v.position_seconds > 0 && !watched;
|
||||
const dl = index.data?.[v.id];
|
||||
// The row opens the download dialog, so it says so; the download's state is the line under its
|
||||
// label — "Downloaded" read as a status, not as a row that opens something — and
|
||||
// not a `title`, which a key or a finger never shows.
|
||||
const dlState =
|
||||
dl === "done"
|
||||
? t("downloads.button.downloaded")
|
||||
: dl === "queued" || dl === "running" || dl === "paused"
|
||||
? t("downloads.button.queued")
|
||||
: undefined;
|
||||
// The two panels open beside the object; a menu opened with no element to anchor to (none of
|
||||
// the triggers does that today) leaves them out rather than opening them at the corner.
|
||||
const at = (kind: "playlist" | "details") =>
|
||||
anchor ? () => setFollowUp({ kind, video: v, anchor }) : null;
|
||||
const addTo = at("playlist");
|
||||
const details = at("details");
|
||||
return [
|
||||
...(onOpen
|
||||
? [
|
||||
{
|
||||
id: "play",
|
||||
label: inProgress ? t("card.continue") : t("card.play"),
|
||||
icon: <Play className={icon} />,
|
||||
onSelect: () => onOpen(v, null),
|
||||
},
|
||||
...(inProgress
|
||||
? [
|
||||
{
|
||||
id: "restart",
|
||||
label: t("card.restartTitle"),
|
||||
icon: <SkipBack className={icon} />,
|
||||
onSelect: () => onOpen(v, 0),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "channel",
|
||||
label: t("card.menu.openChannel"),
|
||||
icon: <Tv className={icon} />,
|
||||
onSelect: () => openChannel(v.channel_id, v.channel_title ?? t("card.thisChannel")),
|
||||
},
|
||||
// The channel's videos in this feed, or the channel out of the filter again: channels OR
|
||||
// together, so adding one widens a filter that already names others (as "Show in feed" does).
|
||||
...(channelFilter
|
||||
? [
|
||||
channelFilter.ids.includes(v.channel_id)
|
||||
? {
|
||||
id: "channel-filter",
|
||||
label: t("card.menu.channelFilterRemove"),
|
||||
icon: <FilterX className={icon} />,
|
||||
onSelect: () =>
|
||||
channelFilter.set(channelFilter.ids.filter((id) => id !== v.channel_id)),
|
||||
}
|
||||
: {
|
||||
id: "channel-filter",
|
||||
label: t("card.menu.channelFilterAdd"),
|
||||
icon: <Filter className={icon} />,
|
||||
onSelect: () => channelFilter.set([...channelFilter.ids, v.channel_id]),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "watched",
|
||||
label: watched ? t("card.menu.unmarkWatched") : t("card.markWatched"),
|
||||
icon: watched ? <Undo2 className={icon} /> : <Check className={icon} />,
|
||||
separatorBefore: true,
|
||||
onSelect: () => onState(v.id, watched ? "new" : "watched"),
|
||||
},
|
||||
{
|
||||
id: "save",
|
||||
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
|
||||
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
|
||||
onSelect: () => onToggleSave(v.id, !v.saved),
|
||||
},
|
||||
...(addTo
|
||||
? [
|
||||
{
|
||||
id: "playlist",
|
||||
label: t("playlists.addToPlaylist"),
|
||||
icon: <ListPlus className={icon} />,
|
||||
onSelect: addTo,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "download",
|
||||
label: t("downloads.menu.download"),
|
||||
note: dlState,
|
||||
icon: <Download className={icon} />,
|
||||
onSelect: () => setFollowUp({ kind: "download", video: v }),
|
||||
},
|
||||
...(details
|
||||
? [
|
||||
{
|
||||
id: "details",
|
||||
label: t("card.info"),
|
||||
icon: <Info className={icon} />,
|
||||
separatorBefore: true,
|
||||
onSelect: details,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...copy(v.title, v.watch_url).map((it, i) =>
|
||||
i === 0 && !details ? { ...it, separatorBefore: true } : it,
|
||||
),
|
||||
{
|
||||
id: "hide",
|
||||
label: hidden ? t("card.unhide") : t("card.hide"),
|
||||
icon: hidden ? <Eye className={icon} /> : <EyeOff className={icon} />,
|
||||
separatorBefore: true,
|
||||
onSelect: () => onState(v.id, hidden ? "new" : "hidden"),
|
||||
},
|
||||
...(onResetState && isResettable(v)
|
||||
? [
|
||||
{
|
||||
id: "reset",
|
||||
label: t("card.resetState"),
|
||||
icon: <RotateCcw className={icon} />,
|
||||
onSelect: () => onResetState(v.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []),
|
||||
];
|
||||
};
|
||||
|
||||
const panel = followUp && <VideoFollowUp state={followUp} onClose={() => setFollowUp(null)} />;
|
||||
return { items, panel };
|
||||
}
|
||||
Reference in New Issue
Block a user