import { useEffect, useMemo, useState } from "react"; import { MoreVertical, RefreshCw } from "lucide-react"; import { useLocation } from "react-router"; import { useViewTransitionNavigate } from "@/hooks/useViewTransition"; import type { ItemDetail, MediaItemUserState } from "@/api/types"; import { useOptionalAuth } from "@/hooks/useAuth"; import { useRefreshItemMetadata, useWatchedStateMutation } from "@/hooks/queries/items"; import { type DismissHomeItemVariables, useDismissHomeItem } from "@/hooks/queries/homeDismissals"; import { useToggleFavorite } from "@/hooks/queries/favorites"; import { useToggleWatchlist } from "@/hooks/queries/watchlist"; import RefreshMetadataDialog from "@/components/RefreshMetadataDialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { useWatchPlaybackController } from "@/playback/watchPlaybackContext"; type MediaItemType = ItemDetail["type"]; type MediaItemMenuEntry = | { kind: "action"; key: | "playFromBeginning" | "toggleWatched" | "toggleFavorite" | "toggleWatchlist" | "dismissFromHome" | "viewPlayHistory" | "refreshMetadata"; label: string; } | { kind: "separator" }; interface BuildMediaItemMenuModelOptions { mediaType: MediaItemType; userState?: MediaItemUserState; hasPartialProgress?: boolean; isAdmin: boolean; showCollectionActions?: boolean; dismissLabel?: string; } interface MediaItemMenuProps { contentId: string; mediaType: MediaItemType; userState?: MediaItemUserState; variant?: "poster" | "wide"; /** When false, hides favorites and watchlist actions (e.g. for episodes). Defaults to true. */ showCollectionActions?: boolean; dismissAction?: DismissHomeItemVariables; hasPartialProgress?: boolean; } export function buildMediaItemMenuModel({ mediaType, userState, hasPartialProgress = false, isAdmin, showCollectionActions = true, dismissLabel, }: BuildMediaItemMenuModelOptions): MediaItemMenuEntry[] { const entries: MediaItemMenuEntry[] = []; const isLeaf = mediaType === "movie" || mediaType === "episode"; if (isLeaf && (hasPartialProgress || userState?.played === true)) { entries.push({ kind: "action", key: "playFromBeginning", label: "Play from Beginning", }); } if (userState) { entries.push({ kind: "action", key: "toggleWatched", label: userState.played ? "Mark Unwatched" : "Mark Watched", }); if (showCollectionActions) { entries.push( { kind: "action", key: "toggleFavorite", label: userState.is_favorite ? "Remove from Favorites" : "Add to Favorites", }, { kind: "action", key: "toggleWatchlist", label: userState.in_watchlist ? "Remove from Watchlist" : "Add to Watchlist", }, ); } } if (isAdmin) { if (entries.length > 0) { entries.push({ kind: "separator" }); } entries.push( { kind: "action", key: "viewPlayHistory", label: "View Play History", }, { kind: "action", key: "refreshMetadata", label: "Refresh Metadata", }, ); } if (dismissLabel) { if (entries.length > 0) { entries.push({ kind: "separator" }); } entries.push({ kind: "action", key: "dismissFromHome", label: dismissLabel, }); } return entries; } function stopMenuEvent(event: Pick) { event.preventDefault(); event.stopPropagation(); } export default function MediaItemMenu({ contentId, mediaType, userState, variant = "poster", showCollectionActions = true, dismissAction, hasPartialProgress = false, }: MediaItemMenuProps) { const navigate = useViewTransitionNavigate(); const location = useLocation(); const playbackController = useWatchPlaybackController(); const auth = useOptionalAuth(); const isAdmin = auth?.user?.role === "admin"; const [currentUserState, setCurrentUserState] = useState(userState); const [refreshDialogOpen, setRefreshDialogOpen] = useState(false); useEffect(() => { setCurrentUserState(userState); }, [userState?.played, userState?.is_favorite, userState?.in_watchlist]); const watchedMutation = useWatchedStateMutation({ content_id: contentId, type: mediaType, user_data: currentUserState ? { played: currentUserState.played } : undefined, }); const favoriteMutation = useToggleFavorite(contentId); const watchlistMutation = useToggleWatchlist(contentId); const refreshMetadataMutation = useRefreshItemMetadata(); const dismissHomeItemMutation = useDismissHomeItem(); const dismissLabel = dismissAction?.surface === "continue_watching" ? "Remove from Continue Watching" : dismissAction?.surface === "next_up" ? "Remove from Next Up" : undefined; const currentHref = useMemo( () => `${location.pathname}${location.search}`, [location.pathname, location.search], ); const model = buildMediaItemMenuModel({ mediaType, userState: currentUserState, hasPartialProgress, isAdmin, showCollectionActions, dismissLabel, }); const isPending = watchedMutation.isPending || favoriteMutation.isPending || watchlistMutation.isPending || refreshMetadataMutation.isPending || dismissHomeItemMutation.isPending; const triggerClassName = cn( "inline-flex items-center justify-center rounded-md border border-border/20 bg-background/60 text-foreground shadow-sm backdrop-blur-sm transition-[opacity,background-color] duration-150 hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70", variant === "wide" ? "size-9" : "size-8", "opacity-100 md:opacity-0 md:group-hover/card:opacity-100 md:group-focus-within/card:opacity-100", ); async function handleAction(actionKey: Extract["key"]) { switch (actionKey) { case "playFromBeginning": { playbackController.startPlayback({ contentId, restart: true, returnHref: currentHref, }); return; } case "toggleWatched": { if (!currentUserState) return; const nextPlayed = !currentUserState.played; await watchedMutation.mutateAsync(nextPlayed); setCurrentUserState((prev) => (prev ? { ...prev, played: nextPlayed } : prev)); return; } case "toggleFavorite": { if (!currentUserState) return; await favoriteMutation.mutateAsync(currentUserState.is_favorite); setCurrentUserState((prev) => (prev ? { ...prev, is_favorite: !prev.is_favorite } : prev)); return; } case "toggleWatchlist": { if (!currentUserState) return; await watchlistMutation.mutateAsync(currentUserState.in_watchlist); setCurrentUserState((prev) => prev ? { ...prev, in_watchlist: !prev.in_watchlist } : prev, ); return; } case "viewPlayHistory": { navigate(`/admin/history?media_item_id=${encodeURIComponent(contentId)}`); return; } case "dismissFromHome": { if (!dismissAction) return; await dismissHomeItemMutation.mutateAsync(dismissAction); return; } case "refreshMetadata": { setRefreshDialogOpen(true); return; } } } function handleRefreshConfirm(mode: "quick" | "complete") { setRefreshDialogOpen(false); refreshMetadataMutation.mutate({ item: { content_id: contentId, type: mediaType }, mode }); } return ( <>
{model.length === 0 ? ( ) : ( {model.map((entry, index) => { if (entry.kind === "separator") { return ; } return ( { void handleAction(entry.key); }} > {entry.key === "refreshMetadata" && refreshMetadataMutation.isPending ? ( ) : null} {entry.label} ); })} )}
); }