import clsx from 'clsx'; import formatDuration from 'format-duration'; import { AnimatePresence } from 'motion/react'; import { Fragment, memo, ReactNode, useState } from 'react'; import { generatePath, Link } from 'react-router'; import styles from './item-card.module.css'; import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls'; import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items'; import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path'; import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state'; import { ItemControls } from '/@/renderer/components/item-list/types'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { AppRoute } from '/@/renderer/router/routes'; import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format'; import { Image } from '/@/shared/components/image/image'; import { Separator } from '/@/shared/components/separator/separator'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Text } from '/@/shared/components/text/text'; import { useDoubleClick } from '/@/shared/hooks/use-double-click'; import { Album, AlbumArtist, Artist, LibraryItem, Playlist, Song, } from '/@/shared/types/domain-types'; import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; export type DataRow = { align?: 'center' | 'end' | 'start'; format: (data: Album | AlbumArtist | Artist | Playlist | Song) => null | ReactNode | string; id: string; isMuted?: boolean; }; export interface ItemCardProps { controls?: ItemControls; data: Album | AlbumArtist | Artist | Playlist | Song | undefined; enableDrag?: boolean; enableExpansion?: boolean; internalState?: ItemListStateActions; isRound?: boolean; itemType: LibraryItem; rows?: DataRow[]; type?: 'compact' | 'default' | 'poster'; withControls?: boolean; } export const ItemCard = ({ controls, data, enableDrag, enableExpansion, internalState, isRound, itemType, rows: providedRows, type = 'poster', withControls, }: ItemCardProps) => { const imageUrl = getImageUrl(data); const defaultRows = getDataRows(); const rows = providedRows && providedRows.length > 0 ? providedRows : defaultRows; switch (type) { case 'compact': return ( ); case 'poster': return ( ); case 'default': default: return ( ); } }; export interface ItemCardDerivativeProps extends Omit { controls?: ItemControls; enableExpansion?: boolean; imageUrl: string | undefined; internalState?: ItemListStateActions; rows: DataRow[]; } const CompactItemCard = ({ controls, data, enableExpansion, imageUrl, internalState, isRound, itemType, rows, withControls, }: ItemCardDerivativeProps) => { const [showControls, setShowControls] = useState(false); const isSelected = data && internalState && typeof data === 'object' && 'id' in data ? internalState.isSelected(internalState.extractRowId(data) || '') : false; const handleClick = useDoubleClick({ onDoubleClick: (e: React.MouseEvent) => { if (!data || !controls || !internalState) { return; } controls.onDoubleClick?.({ event: e, internalState, item: data as any, itemType, }); }, onSingleClick: (e: React.MouseEvent) => { if (!data || !controls || !internalState) { return; } // Don't trigger selection if clicking on interactive elements const target = e.target as HTMLElement; const isInteractiveElement = target.closest( 'button, a, input, select, textarea, [role="button"]', ); if (isInteractiveElement) { return; } controls.onClick?.({ event: e, internalState, item: data as any, itemType, }); }, }); if (data) { const navigationPath = getItemNavigationPath(data, itemType); const handleMouseEnter = () => { if (withControls) { setShowControls(true); } }; const handleMouseLeave = () => { if (withControls) { setShowControls(false); } }; const handleContextMenu = (e: React.MouseEvent) => { if (!data || !controls) { return; } e.preventDefault(); controls.onMore?.({ event: e, internalState, item: data as any, itemType, }); }; const handleImageClick = (e: React.MouseEvent) => { // Prevent navigation on double-click, let the double-click handler work if (e.detail === 2 && navigationPath) { e.preventDefault(); } handleClick(e as any); }; const handleLinkDragStart = (e: React.DragEvent) => { // Prevent default browser link drag behavior to allow custom drag and drop e.preventDefault(); e.stopPropagation(); }; const imageContainerClassName = clsx(styles.imageContainer, { [styles.isRound]: isRound, }); const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite; const imageContainerContent = ( <> {isFavorite && } {withControls && showControls && ( )} {rows .filter( (row): row is NonNullable => row !== null && row !== undefined, ) .map((row, index) => ( ))} > ); return ( {navigationPath && !internalState ? ( {imageContainerContent} ) : ( {imageContainerContent} )} ); } return ( {rows .filter( (row): row is NonNullable => row !== null && row !== undefined, ) .map((row, index) => ( 0, })} key={row.id} > ))} ); }; const DefaultItemCard = ({ controls, data, enableExpansion, imageUrl, internalState, isRound, itemType, rows, withControls, }: ItemCardDerivativeProps) => { const [showControls, setShowControls] = useState(false); const isSelected = data && internalState && typeof data === 'object' && 'id' in data ? internalState.isSelected(internalState.extractRowId(data) || '') : false; const handleClick = useDoubleClick({ onDoubleClick: (e: React.MouseEvent) => { if (!data || !controls || !internalState) { return; } controls.onDoubleClick?.({ event: e, internalState, item: data as any, itemType, }); }, onSingleClick: (e: React.MouseEvent) => { if (!data || !controls || !internalState) { return; } // Don't trigger selection if clicking on interactive elements const target = e.target as HTMLElement; const isInteractiveElement = target.closest( 'button, a, input, select, textarea, [role="button"]', ); if (isInteractiveElement) { return; } controls.onClick?.({ event: e, internalState, item: data as any, itemType, }); }, }); if (data) { const navigationPath = getItemNavigationPath(data, itemType); const handleMouseEnter = () => { if (withControls) { setShowControls(true); } }; const handleMouseLeave = () => { if (withControls) { setShowControls(false); } }; const handleContextMenu = (e: React.MouseEvent) => { if (!data || !controls) { return; } e.preventDefault(); controls.onMore?.({ event: e, internalState, item: data as any, itemType, }); }; const handleImageClick = (e: React.MouseEvent) => { // Prevent navigation on double-click, let the double-click handler work if (e.detail === 2 && navigationPath) { e.preventDefault(); } handleClick(e as any); }; const handleLinkDragStart = (e: React.DragEvent) => { // Prevent default browser link drag behavior to allow custom drag and drop e.preventDefault(); e.stopPropagation(); }; const imageContainerClassName = clsx(styles.imageContainer, { [styles.isRound]: isRound, }); const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite; const imageContainerContent = ( <> {isFavorite && } {withControls && showControls && ( )} > ); return ( {navigationPath && !internalState ? ( {imageContainerContent} ) : ( {imageContainerContent} )} {rows .filter( (row): row is NonNullable => row !== null && row !== undefined, ) .map((row, index) => ( ))} ); } return ( {rows .filter( (row): row is NonNullable => row !== null && row !== undefined, ) .map((row, index) => ( 0, })} key={row.id} > ))} ); }; const PosterItemCard = ({ controls, data, enableDrag, enableExpansion, imageUrl, internalState, isRound, itemType, rows, withControls, }: ItemCardDerivativeProps) => { const [showControls, setShowControls] = useState(false); const isSelected = data && internalState && typeof data === 'object' && 'id' in data ? internalState.isSelected(internalState.extractRowId(data) || '') : false; const { isDragging: isDraggingLocal, ref } = useDragDrop({ drag: { getId: () => { if (!data) { return []; } const draggedItems = getDraggedItems(data, internalState); return draggedItems.map((item) => item.id); }, getItem: () => { if (!data) { return []; } const draggedItems = getDraggedItems(data, internalState); return draggedItems; }, itemType, onDragStart: () => { if (!data) { return; } const draggedItems = getDraggedItems(data, internalState); if (internalState) { internalState.setDragging(draggedItems); } }, onDrop: () => { if (internalState) { internalState.setDragging([]); } }, operation: itemType === LibraryItem.QUEUE_SONG ? [DragOperation.REORDER, DragOperation.ADD] : [DragOperation.ADD], target: DragTarget.ALBUM, }, isEnabled: !!enableDrag && !!data, }); const isDragging = data && internalState ? internalState.isDragging(data.id) : isDraggingLocal; const handleClick = useDoubleClick({ onDoubleClick: (e: React.MouseEvent) => { if (!data || !controls || !internalState) { return; } controls.onDoubleClick?.({ event: e, internalState, item: data as any, itemType, }); }, onSingleClick: (e: React.MouseEvent) => { if (!data || !controls || !internalState) { return; } // Don't trigger selection if clicking on interactive elements const target = e.target as HTMLElement; const isInteractiveElement = target.closest( 'button, a, input, select, textarea, [role="button"]', ); if (isInteractiveElement) { return; } controls.onClick?.({ event: e, internalState, item: data as any, itemType, }); }, }); if (data) { const navigationPath = getItemNavigationPath(data, itemType); const handleMouseEnter = () => { if (withControls) { setShowControls(true); } }; const handleMouseLeave = () => { if (withControls) { setShowControls(false); } }; const handleContextMenu = (e: React.MouseEvent) => { if (!data || !controls) { return; } e.preventDefault(); controls.onMore?.({ event: e, internalState, item: data as any, itemType, }); }; const handleImageClick = (e: React.MouseEvent) => { // Prevent navigation on double-click, let the double-click handler work if (e.detail === 2 && navigationPath) { e.preventDefault(); } handleClick(e as any); }; const handleLinkDragStart = (e: React.DragEvent) => { // Prevent default browser link drag behavior to allow custom drag and drop e.preventDefault(); e.stopPropagation(); }; const imageContainerClassName = clsx(styles.imageContainer, { [styles.isRound]: isRound, }); const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite; const imageContainerContent = ( <> {isFavorite && } {withControls && showControls && data && ( )} > ); return ( {navigationPath && !internalState ? ( {imageContainerContent} ) : ( {imageContainerContent} )} {data && ( {rows .filter( (row): row is NonNullable => row !== null && row !== undefined, ) .map((row, index) => ( ))} )} ); } return ( {rows .filter( (row): row is NonNullable => row !== null && row !== undefined, ) .map((row, index) => ( 0, })} key={row.id} > ))} ); }; export const getDataRows = (): DataRow[] => { return [ { format: (data) => { if ('name' in data && data.name) { if ('id' in data && data.id) { if ('_itemType' in data) { switch (data._itemType) { case LibraryItem.ALBUM: return ( {data.name} ); case LibraryItem.ALBUM_ARTIST: return ( {data.name} ); case LibraryItem.PLAYLIST: return ( {data.name} ); default: return data.name; } } } return data.name; } return ''; }, id: 'name', }, { format: (data) => { if ('albumArtists' in data && Array.isArray(data.albumArtists)) { return (data as Album | Song).albumArtists.map((artist, index) => ( {artist.name} {index < (data as Album | Song).albumArtists.length - 1 && ( )} )); } return ''; }, id: 'albumArtists', isMuted: true, }, { format: (data) => { if ('artists' in data && Array.isArray(data.artists)) { return (data as Album | Song).artists.map((artist, index) => ( {artist.name} {index < (data as Album | Song).artists.length - 1 && } )); } return ''; }, id: 'artists', isMuted: true, }, { format: (data) => { if ('duration' in data && data.duration !== null) { return formatDuration(data.duration * 1000); } return ''; }, id: 'duration', }, { format: (data) => { if ('releaseYear' in data && data.releaseYear !== null) { return String(data.releaseYear); } return ''; }, id: 'releaseYear', }, { format: (data) => { if ('releaseDate' in data && data.releaseDate) { return data.releaseDate; } return ''; }, id: 'releaseDate', }, { format: (data) => { if ('createdAt' in data && data.createdAt) { return formatDateAbsolute(data.createdAt); } return ''; }, id: 'createdAt', }, { format: (data) => { if ('lastPlayedAt' in data && data.lastPlayedAt) { return formatDateRelative(data.lastPlayedAt); } return ''; }, id: 'lastPlayedAt', }, { format: (data) => { if ('playCount' in data && data.playCount !== null) { return String(data.playCount); } return ''; }, id: 'playCount', }, { format: (data) => { if ('genres' in data && Array.isArray(data.genres)) { return (data as Album | AlbumArtist | Song).genres .map((genre) => genre.name) .join(', '); } return ''; }, id: 'genres', isMuted: true, }, { format: (data) => { if ('album' in data && data.album) { const song = data as Song; if ('albumId' in song && song.albumId) { const albumData = { id: song.albumId, imageUrl: song.imageUrl, name: song.album, }; return ( {song.album} ); } return song.album; } return ''; }, id: 'album', isMuted: true, }, { format: (data) => { if ('songCount' in data && data.songCount !== null) { return String(data.songCount); } return ''; }, id: 'songCount', }, { format: (data) => { if ('albumCount' in data && data.albumCount !== null) { return String(data.albumCount); } return ''; }, id: 'albumCount', }, { format: (data) => { if ( 'userRating' in data && (data as Album | AlbumArtist | Song).userRating !== null ) { return formatRating(data as Album | AlbumArtist | Song); } return null; }, id: 'rating', }, { format: (data) => { if ('userFavorite' in data) { return (data as Album | AlbumArtist | Song).userFavorite ? '★' : ''; } return ''; }, id: 'userFavorite', }, ]; }; export const getDataRowsCount = () => { return getDataRows().length; }; const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => { if (data && 'imageUrl' in data) { return data.imageUrl || undefined; } return undefined; }; const getItemNavigationPath = ( data: Album | AlbumArtist | Artist | Playlist | Song | undefined, itemType: LibraryItem, ): null | string => { if (!data || !('id' in data) || !data.id) { return null; } const effectiveItemType = '_itemType' in data && data._itemType ? data._itemType : itemType; return getTitlePath(effectiveItemType, data.id); }; const ItemCardRow = ({ data, index, row, type, }: { data: Album | AlbumArtist | Artist | Playlist | Song | undefined; index: number; row: DataRow; type?: 'compact' | 'default' | 'poster'; }) => { const alignmentClass = row.align === 'center' ? styles['align-center'] : row.align === 'end' ? styles['align-end'] : styles['align-start']; // All rows except the first one (index 0) should be muted const isMuted = index > 0 || row.isMuted; if (!data) { return ( ); } return ( 0 ? 'sm' : 'md'} > {row.format(data)} ); }; export const MemoizedItemCard = memo(ItemCard);