feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)

- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
This commit is contained in:
2026-09-26 01:23:44 +02:00
parent 9d7526ee57
commit 75ae810866
26 changed files with 1189 additions and 557 deletions
+62 -25
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -23,24 +23,78 @@ export default function AddToPlaylist({
size = "sm",
}: {
videoId: string;
variant?: "ghost" | "outline" | "overlay";
variant?: "ghost" | "outline" | "overlay" | "media";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const pop = useMembershipPopover(open, setOpen);
return (
<>
<IconButton
ref={pop.refs.setReference}
variant={variant}
size={size}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
label={t("playlists.addToPlaylist")}
>
<ListPlus />
</IconButton>
<MembershipPanel videoId={videoId} pop={pop} />
</>
);
}
const pop = useAnchoredPopover({
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
* button: anchored to the element the menu came from, and gone when it closes. */
export function AddToPlaylistAt({
videoId,
anchor,
onClose,
}: {
videoId: string;
anchor: Element;
onClose: () => void;
}) {
const pop = useMembershipPopover(true, (next) => {
if (!next) onClose();
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
return <MembershipPanel videoId={videoId} pop={pop} />;
}
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
return useAnchoredPopover({
open,
onOpenChange: setOpen,
onOpenChange,
offset: 6,
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
// roving list so they don't steal the caret.
hostsInput: true,
});
}
function MembershipPanel({
videoId,
pop,
}: {
videoId: string;
pop: ReturnType<typeof useAnchoredPopover>;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const open = pop.open;
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const membership = useQuery({
queryKey: qk.playlistMembership(videoId),
@@ -95,23 +149,6 @@ export default function AddToPlaylist({
return (
<>
<IconButton
ref={pop.refs.setReference}
variant={variant}
size={size}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
label={t("playlists.addToPlaylist")}
>
<ListPlus />
</IconButton>
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}