import { useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; import React, { CSSProperties, ReactNode, useEffect, useRef } from 'react'; import { CellComponentProps } from 'react-window-v2'; import styles from './item-table-list-column.module.css'; import i18n from '/@/i18n/i18n'; import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items'; import { ActionsColumn } from '/@/renderer/components/item-list/item-table-list/columns/actions-column'; import { AlbumArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/album-artists-column'; import { ArtistsColumn } from '/@/renderer/components/item-list/item-table-list/columns/artists-column'; import { CountColumn } from '/@/renderer/components/item-list/item-table-list/columns/count-column'; import { DateColumn, RelativeDateColumn, } from '/@/renderer/components/item-list/item-table-list/columns/date-column'; import { DefaultColumn } from '/@/renderer/components/item-list/item-table-list/columns/default-column'; import { DurationColumn } from '/@/renderer/components/item-list/item-table-list/columns/duration-column'; import { FavoriteColumn } from '/@/renderer/components/item-list/item-table-list/columns/favorite-column'; import { GenreBadgeColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-badge-column'; import { GenreColumn } from '/@/renderer/components/item-list/item-table-list/columns/genre-column'; import { ImageColumn } from '/@/renderer/components/item-list/item-table-list/columns/image-column'; import { NumericColumn } from '/@/renderer/components/item-list/item-table-list/columns/numeric-column'; import { PathColumn } from '/@/renderer/components/item-list/item-table-list/columns/path-column'; import { RatingColumn } from '/@/renderer/components/item-list/item-table-list/columns/rating-column'; import { RowIndexColumn } from '/@/renderer/components/item-list/item-table-list/columns/row-index-column'; import { SizeColumn } from '/@/renderer/components/item-list/item-table-list/columns/size-column'; import { TextColumn } from '/@/renderer/components/item-list/item-table-list/columns/text-column'; import { TitleColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-column'; import { TitleCombinedColumn } from '/@/renderer/components/item-list/item-table-list/columns/title-combined-column'; import { TableItemProps } from '/@/renderer/components/item-list/item-table-list/item-table-list'; import { ItemControls, ItemListItem } from '/@/renderer/components/item-list/types'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { Flex } from '/@/shared/components/flex/flex'; import { Icon } from '/@/shared/components/icon/icon'; import { Skeleton } from '/@/shared/components/skeleton/skeleton'; import { Text } from '/@/shared/components/text/text'; import { LibraryItem, QueueSong, Song } from '/@/shared/types/domain-types'; import { DragOperation, DragTarget, DragTargetMap } from '/@/shared/types/drag-and-drop'; import { TableColumn } from '/@/shared/types/types'; export interface ItemTableListColumn extends CellComponentProps {} export interface ItemTableListInnerColumn extends ItemTableListColumn { controls: ItemControls; dragRef?: null | React.Ref; isDraggedOver?: 'bottom' | 'top' | null; isDragging?: boolean; type: TableColumn; } export const ItemTableListColumn = (props: ItemTableListColumn) => { const type = props.columns[props.columnIndex].id as TableColumn; const isHeaderEnabled = !!props.enableHeader; const isDataRow = isHeaderEnabled ? props.rowIndex > 0 : true; const item = isDataRow ? props.data[props.rowIndex] : null; const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item; const { isDraggedOver, isDragging: isDraggingLocal, ref: dragRef, } = useDragDrop({ drag: { getId: () => { if (!item || !isDataRow) { return []; } const draggedItems = getDraggedItems(item as any, props.internalState); return draggedItems.map((draggedItem) => draggedItem.id); }, getItem: () => { if (!item || !isDataRow) { return []; } const draggedItems = getDraggedItems(item as any, props.internalState); return draggedItems; }, itemType: props.itemType, onDragStart: () => { if (!item || !isDataRow || !props.internalState) { return; } const draggedItems = getDraggedItems(item as any, props.internalState); props.internalState.setDragging(draggedItems); }, onDrop: () => { if (props.internalState) { props.internalState.setDragging([]); } }, operation: props.itemType === LibraryItem.QUEUE_SONG ? [DragOperation.REORDER, DragOperation.ADD] : [DragOperation.ADD], target: DragTargetMap[props.itemType] || DragTarget.GENERIC, }, drop: { canDrop: () => { if (props.itemType === LibraryItem.QUEUE_SONG) { return true; } return false; }, getData: () => { return { id: [(item as unknown as { id: string }).id], item: [item as unknown as unknown[]], itemType: props.itemType, type: DragTargetMap[props.itemType] || DragTarget.GENERIC, }; }, onDrag: () => { return; }, onDragLeave: () => { return; }, onDrop: (args) => { if (args.self.type === DragTarget.QUEUE_SONG) { const sourceServerId = ( args.source.item?.[0] as unknown as { _serverId: string } )._serverId; const sourceItemType = args.source.itemType as LibraryItem; const droppedOnUniqueId = ( args.self.item?.[0] as unknown as { _uniqueId: string } )._uniqueId; switch (args.source.type) { case DragTarget.ALBUM: { props.playerContext.addToQueueByFetch( sourceServerId, args.source.id, sourceItemType, { edge: args.edge, uniqueId: droppedOnUniqueId }, ); break; } case DragTarget.ALBUM_ARTIST: { props.playerContext.addToQueueByFetch( sourceServerId, args.source.id, sourceItemType, { edge: args.edge, uniqueId: droppedOnUniqueId }, ); break; } case DragTarget.ARTIST: { props.playerContext.addToQueueByFetch( sourceServerId, args.source.id, sourceItemType, { edge: args.edge, uniqueId: droppedOnUniqueId }, ); break; } case DragTarget.GENRE: { props.playerContext.addToQueueByFetch( sourceServerId, args.source.id, sourceItemType, { edge: args.edge, uniqueId: droppedOnUniqueId }, ); break; } case DragTarget.PLAYLIST: { props.playerContext.addToQueueByFetch( sourceServerId, args.source.id, sourceItemType, { edge: args.edge, uniqueId: droppedOnUniqueId }, ); break; } case DragTarget.QUEUE_SONG: { const sourceItems = (args.source.item || []) as QueueSong[]; if ( sourceItems.length > 0 && args.edge && (args.edge === 'top' || args.edge === 'bottom') ) { props.playerContext.moveSelectedTo( sourceItems, args.edge, droppedOnUniqueId, ); } break; } case DragTarget.SONG: { const sourceItems = (args.source.item || []) as Song[]; if (sourceItems.length > 0) { props.playerContext.addToQueueByData(sourceItems, { edge: args.edge, uniqueId: droppedOnUniqueId, }); } break; } default: { break; } } } if (props.internalState) { props.internalState.setDragging([]); } return; }, }, isEnabled: shouldEnableDrag, }); const isDragging = item && typeof item === 'object' && 'id' in item && props.internalState ? props.internalState.isDragging((item as any).id) : isDraggingLocal; const controls = props.controls; const dragProps = { dragRef: shouldEnableDrag ? dragRef : null, isDraggedOver: isDraggedOver === 'top' || isDraggedOver === 'bottom' ? isDraggedOver : null, isDragging, }; if (isHeaderEnabled && props.rowIndex === 0) { return ; } switch (type) { case TableColumn.ACTIONS: case TableColumn.SKIP: return ; case TableColumn.ALBUM_ARTIST: return ; case TableColumn.ALBUM_COUNT: case TableColumn.PLAY_COUNT: case TableColumn.SONG_COUNT: return ; case TableColumn.ARTIST: return ; case TableColumn.BIOGRAPHY: case TableColumn.COMMENT: return ; case TableColumn.BIT_RATE: case TableColumn.BPM: case TableColumn.CHANNELS: case TableColumn.DISC_NUMBER: case TableColumn.TRACK_NUMBER: case TableColumn.YEAR: return ; case TableColumn.DATE_ADDED: case TableColumn.RELEASE_DATE: return ; case TableColumn.DURATION: return ; case TableColumn.GENRE: return ; case TableColumn.GENRE_BADGE: return ; case TableColumn.IMAGE: return ; case TableColumn.LAST_PLAYED: return ; case TableColumn.PATH: return ; case TableColumn.ROW_INDEX: return ; case TableColumn.SIZE: return ; case TableColumn.TITLE: return ; case TableColumn.TITLE_COMBINED: return ( ); case TableColumn.USER_FAVORITE: return ; case TableColumn.USER_RATING: return ; default: return ; } }; const NonMutedColumns = [TableColumn.TITLE, TableColumn.TITLE_COMBINED]; export const TableColumnTextContainer = ( props: ItemTableListColumn & { children: React.ReactNode; className?: string; containerClassName?: string; controls: ItemControls; dragRef?: null | React.Ref; isDraggedOver?: 'bottom' | 'top' | null; isDragging?: boolean; type: TableColumn; }, ) => { const containerRef = useRef(null); const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex; const item = isDataRow ? props.data[props.rowIndex] : null; const isSelected = item && typeof item === 'object' && 'id' in item ? props.internalState.isSelected(props.internalState.extractRowId(item) || '') : false; const isDragging = props.isDragging ?? false; const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); useEffect(() => { if (!isDataRow || !containerRef.current) return; const container = containerRef.current; const rowIndex = props.rowIndex; const handleMouseEnter = () => { // Find all cells in the same row and add hover class const allCells = document.querySelectorAll( `[data-row-index="${props.tableId}-${rowIndex}"]`, ); allCells.forEach((cell) => cell.classList.add(styles.rowHovered)); }; const handleMouseLeave = () => { // Remove hover class from all cells in the same row const allCells = document.querySelectorAll( `[data-row-index="${props.tableId}-${rowIndex}"]`, ); allCells.forEach((cell) => cell.classList.remove(styles.rowHovered)); }; container.addEventListener('mouseenter', handleMouseEnter); container.addEventListener('mouseleave', handleMouseLeave); return () => { container.removeEventListener('mouseenter', handleMouseEnter); container.removeEventListener('mouseleave', handleMouseLeave); }; }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight, props.tableId]); // Apply dragged over state to all cells in the row so border can span entire row useEffect(() => { if (!isDataRow || !containerRef.current) return; const rowIndex = props.rowIndex; const draggedOverState = props.isDraggedOver; if (draggedOverState) { // Find all cells in the same row and add dragged over class const allCells = document.querySelectorAll( `[data-row-index="${props.tableId}-${rowIndex}"]`, ); allCells.forEach((cell, index) => { if (draggedOverState === 'top') { cell.classList.add(styles.draggedOverTop); cell.classList.remove(styles.draggedOverBottom); // Mark first cell so border can span full width if (index === 0) { cell.classList.add(styles.draggedOverFirstCell); } else { cell.classList.remove(styles.draggedOverFirstCell); } } else if (draggedOverState === 'bottom') { cell.classList.add(styles.draggedOverBottom); cell.classList.remove(styles.draggedOverTop); // Mark first cell so border can span full width if (index === 0) { cell.classList.add(styles.draggedOverFirstCell); } else { cell.classList.remove(styles.draggedOverFirstCell); } } }); } else { // Remove dragged over classes from all cells in the same row const allCells = document.querySelectorAll( `[data-row-index="${props.tableId}-${rowIndex}"]`, ); allCells.forEach((cell) => { cell.classList.remove(styles.draggedOverTop); cell.classList.remove(styles.draggedOverBottom); cell.classList.remove(styles.draggedOverFirstCell); }); } }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); const handleCellClick = (event: React.MouseEvent) => { // Don't trigger row selection if clicking on interactive elements const target = event.target as HTMLElement; const isInteractiveElement = target.closest( 'button, a, input, select, textarea, [role="button"]', ); if (isInteractiveElement) { return; } if (isDataRow && item && props.enableSelection) { props.controls.onClick?.({ event, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, }); } }; return (
0, [styles.withVerticalBorder]: props.enableVerticalBorders, })} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} onClick={handleCellClick} ref={mergedRef} style={props.style} > {props.children}
); }; export const TableColumnContainer = ( props: ItemTableListColumn & { children: React.ReactNode; className?: string; containerStyle?: CSSProperties; controls: ItemControls; dragRef?: null | React.Ref; isDraggedOver?: 'bottom' | 'top' | null; isDragging?: boolean; type: TableColumn; }, ) => { const containerRef = useRef(null); const isDataRow = props.enableHeader ? props.rowIndex > 0 : true; const dataIndex = props.enableHeader ? props.rowIndex - 1 : props.rowIndex; const item = isDataRow ? props.data[props.rowIndex] : null; const isSelected = item && typeof item === 'object' && 'id' in item ? props.internalState.isSelected(props.internalState.extractRowId(item) || '') : false; const isDragging = props.isDragging ?? false; const mergedRef = useMergedRef(containerRef, props.dragRef ?? null); useEffect(() => { if (!isDataRow || !containerRef.current) return; const container = containerRef.current; const rowIndex = props.rowIndex; const handleMouseEnter = () => { // Find all cells in the same row and add hover class const allCells = document.querySelectorAll( `[data-row-index="${props.tableId}-${rowIndex}"]`, ); allCells.forEach((cell) => cell.classList.add(styles.rowHovered)); }; const handleMouseLeave = () => { // Remove hover class from all cells in the same row const allCells = document.querySelectorAll( `[data-row-index="${props.tableId}-${rowIndex}"]`, ); allCells.forEach((cell) => cell.classList.remove(styles.rowHovered)); }; container.addEventListener('mouseenter', handleMouseEnter); container.addEventListener('mouseleave', handleMouseLeave); return () => { container.removeEventListener('mouseenter', handleMouseEnter); container.removeEventListener('mouseleave', handleMouseLeave); }; }, [isDataRow, props.rowIndex, props.enableRowHoverHighlight, props.tableId]); // Apply dragged over state to all cells in the row so border can span entire row useEffect(() => { if (!isDataRow || !containerRef.current) return; const rowIndex = props.rowIndex; const draggedOverState = props.isDraggedOver; if (draggedOverState) { // Find all cells in the same row and add dragged over class const allCells = document.querySelectorAll( `[data-row-index="${props.tableId}-${rowIndex}"]`, ); allCells.forEach((cell, index) => { if (draggedOverState === 'top') { cell.classList.add(styles.draggedOverTop); cell.classList.remove(styles.draggedOverBottom); // Mark first cell so border can span full width if (index === 0) { cell.classList.add(styles.draggedOverFirstCell); } else { cell.classList.remove(styles.draggedOverFirstCell); } } else if (draggedOverState === 'bottom') { cell.classList.add(styles.draggedOverBottom); cell.classList.remove(styles.draggedOverTop); // Mark first cell so border can span full width if (index === 0) { cell.classList.add(styles.draggedOverFirstCell); } else { cell.classList.remove(styles.draggedOverFirstCell); } } }); } else { // Remove dragged over classes from all cells in the same row const allCells = document.querySelectorAll( `[data-row-index="${props.tableId}-${rowIndex}"]`, ); allCells.forEach((cell) => { cell.classList.remove(styles.draggedOverTop); cell.classList.remove(styles.draggedOverBottom); cell.classList.remove(styles.draggedOverFirstCell); }); } }, [isDataRow, props.rowIndex, props.isDraggedOver, props.tableId]); const handleCellClick = (event: React.MouseEvent) => { // Don't trigger row selection if clicking on interactive elements const target = event.target as HTMLElement; const isInteractiveElement = target.closest( 'button, a, input, select, textarea, [role="button"]', ); if (isInteractiveElement) { return; } if (isDataRow && item && props.enableSelection) { props.controls.onClick?.({ event, internalState: props.internalState, item: item as ItemListItem, itemType: props.itemType, }); } }; return (
0, [styles.withVerticalBorder]: props.enableVerticalBorders, })} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} onClick={handleCellClick} ref={mergedRef} style={{ ...props.containerStyle, ...props.style }} > {props.children}
); }; export const TableColumnHeaderContainer = ( props: ItemTableListColumn & { className?: string; containerClassName?: string; controls: ItemControls; type: TableColumn; }, ) => { return ( {columnLabelMap[props.type]} ); }; const columnLabelMap: Record = { [TableColumn.ACTIONS]: ( ), [TableColumn.ALBUM]: i18n.t('table.column.album', { postProcess: 'upperCase' }) as string, [TableColumn.ALBUM_ARTIST]: i18n.t('table.column.albumArtist', { postProcess: 'upperCase', }) as string, [TableColumn.ALBUM_COUNT]: i18n.t('table.column.albumCount', { postProcess: 'upperCase', }) as string, [TableColumn.ARTIST]: i18n.t('table.column.artist', { postProcess: 'upperCase' }) as string, [TableColumn.BIOGRAPHY]: i18n.t('table.column.biography', { postProcess: 'upperCase', }) as string, [TableColumn.BIT_RATE]: i18n.t('table.column.bitrate', { postProcess: 'upperCase' }) as string, [TableColumn.BPM]: i18n.t('table.column.bpm', { postProcess: 'upperCase' }) as string, [TableColumn.CHANNELS]: i18n.t('table.column.channels', { postProcess: 'upperCase' }) as string, [TableColumn.CODEC]: i18n.t('table.column.codec', { postProcess: 'upperCase' }) as string, [TableColumn.COMMENT]: i18n.t('table.column.comment', { postProcess: 'upperCase' }) as string, [TableColumn.DATE_ADDED]: i18n.t('table.column.dateAdded', { postProcess: 'upperCase', }) as string, [TableColumn.DISC_NUMBER]: ( ), [TableColumn.DURATION]: ( ), [TableColumn.GENRE]: i18n.t('table.column.genre', { postProcess: 'upperCase' }) as string, [TableColumn.GENRE_BADGE]: i18n.t('table.column.genre', { postProcess: 'upperCase', }) as string, [TableColumn.ID]: 'ID', [TableColumn.IMAGE]: '', [TableColumn.LAST_PLAYED]: i18n.t('table.column.lastPlayed', { postProcess: 'upperCase', }) as string, [TableColumn.OWNER]: i18n.t('table.column.owner', { postProcess: 'upperCase' }) as string, [TableColumn.PATH]: i18n.t('table.column.path', { postProcess: 'upperCase' }) as string, [TableColumn.PLAY_COUNT]: i18n.t('table.column.playCount', { postProcess: 'upperCase', }) as string, [TableColumn.RELEASE_DATE]: i18n.t('table.column.releaseDate', { postProcess: 'upperCase', }) as string, [TableColumn.ROW_INDEX]: ( ), [TableColumn.SIZE]: i18n.t('table.column.size', { postProcess: 'upperCase' }) as string, [TableColumn.SKIP]: '', [TableColumn.SONG_COUNT]: i18n.t('table.column.songCount', { postProcess: 'upperCase', }) as string, [TableColumn.TITLE]: i18n.t('table.column.title', { postProcess: 'upperCase' }) as string, [TableColumn.TITLE_COMBINED]: i18n.t('table.column.title', { postProcess: 'upperCase', }) as string, [TableColumn.TRACK_NUMBER]: ( ), [TableColumn.USER_FAVORITE]: ( ), [TableColumn.USER_RATING]: i18n.t('table.column.rating', { postProcess: 'upperCase', }) as string, [TableColumn.YEAR]: i18n.t('table.column.releaseYear', { postProcess: 'upperCase' }) as string, }; export const ColumnNullFallback = (props: ItemTableListInnerColumn) => { return  ; }; export const ColumnSkeletonVariable = (props: ItemTableListInnerColumn) => { return ( ); }; export const ColumnSkeletonFixed = (props: ItemTableListInnerColumn) => { return ( ); };