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:
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Info } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { formatDate, formatDuration, formatViews } from "../lib/format";
|
||||
@@ -9,17 +8,14 @@ import { linkify } from "../lib/linkify";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { Badge } from "./ui/Badge";
|
||||
|
||||
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
|
||||
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
|
||||
// small ⓘ button — a thumbnail-corner overlay in the four thumbnailed views, an inline button beside
|
||||
// the actions in the one-line row (see the caller). It reveals only with the other hover controls on a
|
||||
// mouse and is dropped entirely on touch (`.reveal-hover`), where a tap already plays/resumes and a
|
||||
// hover card would only be an invisible trap. Hover mode also opens it on keyboard focus, so it stays
|
||||
// reachable by Tab.
|
||||
// A video's details (E8·S3, decision D3): full (un-clamped) title, channel, views·duration·date, a
|
||||
// description snippet, and the channel's tags. E28 C1 moved it from a hover ⓘ on the thumbnail into
|
||||
// the video's action list ("Details"): the hover card was invisible on touch and one more button in
|
||||
// the card's hover row, and the menu is the one door every device has. It opens anchored to the card
|
||||
// the menu came from, as a small dialog.
|
||||
//
|
||||
// The card face already carries title/channel/date/duration/views, so the box's real added value is
|
||||
// the FULL title (the card clamps to 2 lines) plus the description + tags, which aren't in the feed
|
||||
@@ -27,54 +23,44 @@ import { Badge } from "./ui/Badge";
|
||||
// `videoDetail`, the tags by resolving the channel's `tag_ids` (from `channelDetail`, shared with the
|
||||
// channel page's cache) against the tag list. Until they land the header still shows from the Video
|
||||
// we already have, so the box is never empty.
|
||||
//
|
||||
// The caller owns the trigger's placement + reveal via `className`: a thumbnail-corner overlay for the
|
||||
// four thumbnailed views, an inline button beside the actions for the one-line row. The action ROW
|
||||
// itself is budgeted to exactly six buttons (a seventh wraps in every view — see VideoCard's ROW_COL),
|
||||
// so the trigger deliberately lives OUTSIDE it.
|
||||
export default function VideoInfoCard({
|
||||
export default function VideoDetailsAt({
|
||||
video,
|
||||
round = false,
|
||||
className,
|
||||
anchor,
|
||||
onClose,
|
||||
}: {
|
||||
video: Video;
|
||||
/** The trigger sits on the thumbnail: the round, dark translucent IconButton (A2). */
|
||||
round?: boolean;
|
||||
/** Trigger placement + hover-reveal. The reveal/touch-suppression rules live with the host, which
|
||||
* knows its surface (overlay vs inline); the skin is the IconButton's. */
|
||||
className?: string;
|
||||
anchor: Element;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom-end",
|
||||
open: true,
|
||||
onOpenChange: (next) => {
|
||||
if (!next) onClose();
|
||||
},
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "bottom-start",
|
||||
offset: 6,
|
||||
// A touch of open intent (so brushing past the button while reaching another control doesn't
|
||||
// pop it), a longer close grace so the cursor can travel into the interactive card.
|
||||
hoverDelay: { open: 140, close: 160 },
|
||||
fitHeight: true,
|
||||
});
|
||||
const { setPositionReference } = pop.refs;
|
||||
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
|
||||
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: qk.videoDetail(video.id),
|
||||
queryFn: () => api.videoDetail(video.id),
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: channel } = useQuery({
|
||||
queryKey: qk.channel(video.channel_id),
|
||||
queryFn: () => api.channelDetail(video.channel_id),
|
||||
enabled: open && !!video.channel_id,
|
||||
enabled: !!video.channel_id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: qk.tags(),
|
||||
queryFn: () => api.tags(),
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
@@ -95,53 +81,36 @@ export default function VideoInfoCard({
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
variant={round ? "media" : "ghost"}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
|
||||
// both preventDefault (no navigation) and stopPropagation (no card open); harmless inline too.
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
label={t("card.info")}
|
||||
className={className}
|
||||
>
|
||||
<Info />
|
||||
</IconButton>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("card.info")}
|
||||
className={`${SURFACE.popover} w-85 max-w-[92vw] overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
|
||||
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
|
||||
>
|
||||
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Avatar
|
||||
src={video.channel_thumbnail}
|
||||
fallback={video.channel_title ?? ""}
|
||||
className="w-6 h-6 rounded-full shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted truncate">{video.channel_title}</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("card.info")}
|
||||
className={`${SURFACE.popover} w-85 max-w-[92vw] overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
|
||||
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
|
||||
>
|
||||
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Avatar
|
||||
src={video.channel_thumbnail}
|
||||
fallback={video.channel_title ?? ""}
|
||||
className="w-6 h-6 rounded-full shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted truncate">{video.channel_title}</span>
|
||||
</div>
|
||||
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
|
||||
{description ? (
|
||||
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
|
||||
{linkify(description)}
|
||||
</p>
|
||||
) : detail ? (
|
||||
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
|
||||
) : null}
|
||||
{tagNames.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{tagNames.map((tg) => (
|
||||
<Badge key={tg.id}>{tg.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
|
||||
{description ? (
|
||||
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
|
||||
{linkify(description)}
|
||||
</p>
|
||||
) : detail ? (
|
||||
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
|
||||
) : null}
|
||||
{tagNames.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{tagNames.map((tg) => (
|
||||
<Badge key={tg.id}>{tg.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user