From f366b50550131b148b3e38d4c112e9061b976b27 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sun, 16 Nov 2025 13:53:04 -0800 Subject: [PATCH] add new table to album detail --- .../hooks/use-fixed-table-header.tsx | 43 ++ .../item-table-list-column.tsx | 87 +++- .../item-table-list.module.css | 43 ++ .../item-table-list/item-table-list.tsx | 459 +++++++++++++++++- .../simple-item-table.module.css | 37 ++ .../simple-item-table/simple-item-table.tsx | 243 ++++++++++ .../components/album-detail-content.tsx | 306 ++++++++++-- .../albums/components/album-detail-header.tsx | 261 +++++----- src/renderer/store/settings.store.ts | 31 +- src/shared/types/types.ts | 2 - 10 files changed, 1318 insertions(+), 194 deletions(-) create mode 100644 src/renderer/components/item-list/item-table-list/hooks/use-fixed-table-header.tsx create mode 100644 src/renderer/components/simple-item-table/simple-item-table.module.css create mode 100644 src/renderer/components/simple-item-table/simple-item-table.tsx diff --git a/src/renderer/components/item-list/item-table-list/hooks/use-fixed-table-header.tsx b/src/renderer/components/item-list/item-table-list/hooks/use-fixed-table-header.tsx new file mode 100644 index 000000000..37404ea4d --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/hooks/use-fixed-table-header.tsx @@ -0,0 +1,43 @@ +import { useInView } from 'motion/react'; +import { RefObject, useMemo } from 'react'; + +import { useWindowSettings } from '/@/renderer/store/settings.store'; +import { Platform } from '/@/shared/types/types'; + +export const useFixedTableHeader = ({ + containerRef, + enabled, + headerRef, +}: { + containerRef: RefObject; + enabled: boolean; + headerRef: RefObject; +}) => { + const { windowBarStyle } = useWindowSettings(); + + const topMargin = + windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS + ? '-130px' + : '-100px'; + + const isTableHeaderInView = useInView(headerRef, { + margin: `${topMargin} 0px 0px 0px`, + }); + + const isTableInView = useInView(containerRef, { + margin: `${topMargin} 0px 0px 0px`, + }); + + const shouldShowStickyHeader = useMemo(() => { + return enabled && !isTableHeaderInView && isTableInView; + }, [enabled, isTableHeaderInView, isTableInView]); + + const stickyTop = useMemo(() => { + return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65; + }, [windowBarStyle]); + + return { + shouldShowStickyHeader, + stickyTop, + }; +}; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 7e855dc05..ffe4023be 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -11,7 +11,7 @@ import { import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; import { useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; -import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react'; +import React, { CSSProperties, ReactElement, ReactNode, useEffect, useRef, useState } from 'react'; import { CellComponentProps } from 'react-window-v2'; import styles from './item-table-list-column.module.css'; @@ -76,6 +76,48 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { const item = isDataRow ? props.data[props.rowIndex] : null; const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item; + // Check if this row should render a group header (must be before conditional returns) + // Group headers are rendered in the main grid at columnIndex 0 (first unpinned column) + // We detect this by checking if columnIndex equals pinnedLeftColumnCount (first column of main grid) + // or if columnIndex is 0 and there are no pinned columns + // Groups are defined by itemCount, so we calculate which group this row belongs to + let groupHeader: 'GROUP_HEADER' | null | ReactElement = null; + if (props.groups && isDataRow && props.groups.length > 0) { + // Calculate which group this row index belongs to + let cumulativeDataIndex = 0; + const headerOffset = props.enableHeader ? 1 : 0; + + const originalData = props.data.filter((item) => item !== null); + + for (let groupIndex = 0; groupIndex < props.groups.length; groupIndex++) { + const group = props.groups[groupIndex]; + const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; + + if (props.rowIndex === groupHeaderIndex) { + const isMainGridFirstColumn = + props.columnIndex === (props.pinnedLeftColumnCount || 0) || + (props.columnIndex === 0 && (props.pinnedLeftColumnCount || 0) === 0); + + // Only render group header in the first column of the main grid + if (isMainGridFirstColumn) { + groupHeader = group.render({ + data: originalData, + groupIndex, + index: props.rowIndex, + internalState: props.internalState, + startDataIndex: cumulativeDataIndex, + }); + } else { + // For other columns in this row, return marker to skip rendering + groupHeader = 'GROUP_HEADER'; + } + break; + } + + cumulativeDataIndex += group.itemCount; + } + } + const { isDraggedOver, isDragging: isDraggingLocal, @@ -264,6 +306,49 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => { return ; } + // Render group header if this row should have one + if (groupHeader) { + if (groupHeader === 'GROUP_HEADER') { + // For non-first columns, render empty cell (group header spans all columns) + return null; + } + // For first column of main grid, render the group header spanning full table width + // Calculate widths to span across all grids using calculated column widths + const pinnedLeftWidth = + props.pinnedLeftColumnWidths?.reduce((sum, width) => sum + width, 0) || 0; + const pinnedRightWidth = + props.pinnedRightColumnWidths?.reduce((sum, width) => sum + width, 0) || 0; + + // Use calculated column widths if available (they include all columns in order) + // Otherwise fall back to summing column config widths + const totalTableWidth = props.calculatedColumnWidths + ? props.calculatedColumnWidths.reduce((sum, width) => sum + width, 0) + : pinnedLeftWidth + + props.columns + .slice( + props.pinnedLeftColumnCount || 0, + props.columns.length - (props.pinnedRightColumnCount || 0), + ) + .reduce((sum, col) => sum + col.width, 0) + + pinnedRightWidth; + + // Use negative margins to extend beyond cell boundaries and span full width + // Apply props.style for virtualization positioning (top, left, position, etc.) + return ( +
0 ? `-${pinnedLeftWidth}px` : 0, + width: `${totalTableWidth}px`, + }} + > + {groupHeader} +
+ ); + } + switch (type) { case TableColumn.ACTIONS: case TableColumn.SKIP: diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.module.css b/src/renderer/components/item-list/item-table-list/item-table-list.module.css index 6341f49a6..6fe350cf2 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list.module.css @@ -45,6 +45,49 @@ position: relative; } +.item-table-pinned-rows-grid-container.header-fixed { + position: fixed !important; + top: 65px; + z-index: 15; + background-color: var(--theme-bg-primary); + box-shadow: 0 -1px 0 0 var(--theme-colors-border); + transition: position 0.2s ease-in-out; +} + +.item-table-pinned-rows-grid-container.header-window-bar { + top: 95px; +} + +.item-table-list-container.header-fixed-margin { + margin-top: 36px !important; +} + +.sticky-header { + position: fixed; + z-index: 15; + display: flex; + flex-direction: row; + padding: 0 2rem; + margin: 0 -2rem; + pointer-events: none; + background-color: var(--theme-colors-background); + border-bottom: 1px solid var(--theme-colors-border); + box-shadow: 0 -1px 0 0 var(--theme-colors-border); +} + +.sticky-header-row { + display: flex; + flex-direction: row; + width: 100%; +} + +.sticky-header-section { + display: flex; + flex-direction: row; + overflow: hidden; + pointer-events: auto; +} + .item-table-pinned-rows-grid-container.with-header::after { position: absolute; right: 0; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list.tsx b/src/renderer/components/item-list/item-table-list/item-table-list.tsx index 27ba72b67..61bc1014d 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list.tsx @@ -7,6 +7,7 @@ import { AnimatePresence } from 'motion/react'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import React, { type JSXElementConstructor, + ReactElement, Ref, useCallback, useEffect, @@ -18,6 +19,7 @@ import React, { } from 'react'; import { type CellComponentProps, Grid } from 'react-window-v2'; +import { useFixedTableHeader } from './hooks/use-fixed-table-header'; import styles from './item-table-list.module.css'; import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container'; @@ -97,6 +99,7 @@ interface VirtualizedTableGridProps { enableSelection: boolean; enableVerticalBorders: boolean; getRowHeight: (index: number, cellProps: TableItemProps) => number; + groups?: TableGroupHeader[]; headerHeight: number; internalState: ItemListStateActions; itemType: LibraryItem; @@ -104,11 +107,11 @@ interface VirtualizedTableGridProps { onRangeChanged?: ItemTableListProps['onRangeChanged']; parsedColumns: ReturnType; pinnedLeftColumnCount: number; - pinnedLeftColumnRef: React.RefObject; + pinnedLeftColumnRef: React.RefObject; pinnedRightColumnCount: number; - pinnedRightColumnRef: React.RefObject; + pinnedRightColumnRef: React.RefObject; pinnedRowCount: number; - pinnedRowRef: React.RefObject; + pinnedRowRef: React.RefObject; playerContext: PlayerContext; showLeftShadow: boolean; showRightShadow: boolean; @@ -137,6 +140,7 @@ const VirtualizedTableGrid = React.memo( enableSelection, enableVerticalBorders, getRowHeight, + groups, headerHeight, internalState, itemType, @@ -163,12 +167,71 @@ const VirtualizedTableGrid = React.memo( [calculatedColumnWidths], ); + // Calculate pinned column widths for group header positioning + const pinnedLeftColumnWidths = useMemo(() => { + return Array.from({ length: pinnedLeftColumnCount }, (_, i) => columnWidth(i)); + }, [pinnedLeftColumnCount, columnWidth]); + + const pinnedRightColumnWidths = useMemo(() => { + return Array.from({ length: pinnedRightColumnCount }, (_, i) => + columnWidth(i + pinnedLeftColumnCount + totalColumnCount), + ); + }, [pinnedRightColumnCount, pinnedLeftColumnCount, totalColumnCount, columnWidth]); + + // Create data array with group headers inserted as null values + // Groups are defined by itemCount, so we calculate indexes based on cumulative item counts + const dataWithGroups = useMemo(() => { + const result: (null | unknown)[] = enableHeader ? [null] : []; + + if (!groups || groups.length === 0) { + // No groups, just add all data + result.push(...data); + return result; + } + + // Calculate group header indexes based on itemCounts + const groupIndexes: number[] = []; + let cumulativeDataIndex = 0; + const headerOffset = enableHeader ? 1 : 0; + + groups.forEach((group, groupIndex) => { + // Group header appears before its items + // Index = header offset + cumulative data index + number of previous group headers + const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; + groupIndexes.push(groupHeaderIndex); + cumulativeDataIndex += group.itemCount; + }); + + let dataIndex = 0; + const startIndex = enableHeader ? 1 : 0; + let groupHeaderCount = 0; + + // Iterate through the expanded row space (data + group headers) + for ( + let rowIndex = startIndex; + rowIndex < startIndex + data.length + groupIndexes.length; + rowIndex++ + ) { + // Check if this row should have a group header + const expectedGroupIndex = groupIndexes[groupHeaderCount]; + if (expectedGroupIndex !== undefined && rowIndex === expectedGroupIndex) { + result.push(null); // Group header row + groupHeaderCount++; + } else if (dataIndex < data.length) { + result.push(data[dataIndex]); + dataIndex++; + } + } + return result; + }, [data, enableHeader, groups]); + const itemProps: TableItemProps = useMemo( () => ({ + calculatedColumnWidths, cellPadding, columns: parsedColumns, controls, - data: enableHeader ? [null, ...data] : data, + data: dataWithGroups, enableAlternateRowColors, enableColumnReorder, enableColumnResize, @@ -180,31 +243,42 @@ const VirtualizedTableGrid = React.memo( enableSelection, enableVerticalBorders, getRowHeight, + groups, internalState, itemType, + pinnedLeftColumnCount, + pinnedLeftColumnWidths, + pinnedRightColumnCount, + pinnedRightColumnWidths, playerContext, size, tableId, }), [ + calculatedColumnWidths, cellPadding, controls, parsedColumns, - enableHeader, - data, + dataWithGroups, enableAlternateRowColors, enableColumnReorder, enableColumnResize, enableDrag, enableExpansion, + enableHeader, enableHorizontalBorders, enableRowHoverHighlight, enableSelection, enableVerticalBorders, getRowHeight, + groups, internalState, - playerContext, itemType, + pinnedLeftColumnCount, + pinnedLeftColumnWidths, + pinnedRightColumnCount, + pinnedRightColumnWidths, + playerContext, size, tableId, ], @@ -490,7 +564,20 @@ const VirtualizedTableGrid = React.memo( VirtualizedTableGrid.displayName = 'VirtualizedTableGrid'; +export interface TableGroupHeader { + itemCount: number; + render: (props: { + data: unknown[]; + groupIndex: number; + index: number; + internalState: ItemListStateActions; + startDataIndex: number; + }) => ReactElement; + rowHeight?: ((index: number) => number) | number; +} + export interface TableItemProps { + calculatedColumnWidths?: number[]; cellPadding?: ItemTableListProps['cellPadding']; columns: ItemTableListColumnConfig[]; controls: ItemControls; @@ -506,9 +593,14 @@ export interface TableItemProps { enableSelection?: ItemTableListProps['enableSelection']; enableVerticalBorders?: ItemTableListProps['enableVerticalBorders']; getRowHeight: (index: number, cellProps: TableItemProps) => number; + groups?: TableGroupHeader[]; internalState: ItemListStateActions; itemType: ItemTableListProps['itemType']; onRowClick?: (item: any, event: React.MouseEvent) => void; + pinnedLeftColumnCount?: number; + pinnedLeftColumnWidths?: number[]; + pinnedRightColumnCount?: number; + pinnedRightColumnWidths?: number[]; playerContext: PlayerContext; size?: ItemTableListProps['size']; tableId: string; @@ -528,8 +620,10 @@ interface ItemTableListProps { enableHorizontalBorders?: boolean; enableRowHoverHighlight?: boolean; enableSelection?: boolean; + enableStickyHeader?: boolean; enableVerticalBorders?: boolean; getRowId?: ((item: unknown) => string) | string; + groups?: TableGroupHeader[]; headerHeight?: number; initialTop?: { behavior?: 'auto' | 'smooth'; @@ -564,8 +658,10 @@ export const ItemTableList = ({ enableHorizontalBorders = false, enableRowHoverHighlight = true, enableSelection = true, + enableStickyHeader = false, enableVerticalBorders = false, getRowId, + groups, headerHeight = 40, initialTop, itemType, @@ -666,7 +762,15 @@ export const ItemTableList = ({ const pinnedRightColumnCount = parsedColumns.filter((col) => col.pinned === 'right').length; const pinnedRowCount = enableHeader ? 1 : 0; - const totalRowCount = totalItemCount - pinnedRowCount; + + // Calculate group header row count - each group has one header row + const groupHeaderRowCount = useMemo(() => { + if (!groups || groups.length === 0) return 0; + return groups.length; + }, [groups]); + + // Group headers are inserted at specific indexes, so they add to the total row count + const totalRowCount = totalItemCount - pinnedRowCount + groupHeaderRowCount; const totalColumnCount = columnCount - pinnedLeftColumnCount - pinnedRightColumnCount; const pinnedRowRef = useRef(null); const rowRef = useRef(null); @@ -680,6 +784,59 @@ export const ItemTableList = ({ const handleRef = useRef(null); const containerFocusRef = useRef(null); + const { shouldShowStickyHeader, stickyTop } = useFixedTableHeader({ + containerRef: containerFocusRef, + enabled: enableHeader && enableStickyHeader, + headerRef: pinnedRowRef, + }); + + const stickyHeaderRef = useRef(null); + + // Sync scroll position and update position of sticky header + useEffect(() => { + if ( + !shouldShowStickyHeader || + !stickyHeaderRef.current || + !rowRef.current || + !containerFocusRef.current + ) { + return; + } + + const stickyHeader = stickyHeaderRef.current; + const stickyMainSection = stickyHeader.querySelector( + `.${styles.stickyHeaderSection}`, + ) as HTMLDivElement; + const mainGrid = rowRef.current.childNodes[0] as HTMLDivElement; + const container = containerFocusRef.current; + + if (!stickyMainSection || !mainGrid || !container) { + return; + } + + const updatePosition = () => { + const containerRect = container.getBoundingClientRect(); + stickyHeader.style.left = `${containerRect.left}px`; + }; + + const syncScroll = () => { + stickyMainSection.scrollLeft = mainGrid.scrollLeft; + }; + + updatePosition(); + syncScroll(); + + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + mainGrid.addEventListener('scroll', syncScroll); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + mainGrid.removeEventListener('scroll', syncScroll); + }; + }, [shouldShowStickyHeader]); + useEffect(() => { const el = rowRef.current; if (!el) return; @@ -1177,9 +1334,12 @@ export const ItemTableList = ({ const row = rowRef.current?.childNodes[0] as HTMLDivElement; if (!row) { - setShowLeftShadow(false); - setShowRightShadow(false); - return; + const timeout = setTimeout(() => { + setShowLeftShadow(false); + setShowRightShadow(false); + }, 0); + + return () => clearTimeout(timeout); } const checkScrollPosition = () => { @@ -1204,13 +1364,16 @@ export const ItemTableList = ({ const row = rowRef.current?.childNodes[0] as HTMLDivElement; if (!row || !enableHeader) { - setShowTopShadow(false); - return; + const timeout = setTimeout(() => { + setShowTopShadow(false); + }, 0); + + return () => clearTimeout(timeout); } const checkScrollPosition = () => { - const scrollTop = row.scrollTop; - setShowTopShadow(scrollTop > 0); + const currentScrollTop = row.scrollTop; + setShowTopShadow(currentScrollTop > 0); }; checkScrollPosition(); @@ -1226,6 +1389,33 @@ export const ItemTableList = ({ (index: number, cellProps: TableItemProps) => { const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64; + // Check if this row is a group header row and has a custom row height + if (groups && groups.length > 0) { + // Calculate which group this index belongs to + let cumulativeDataIndex = 0; + const headerOffset = enableHeader ? 1 : 0; + + for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) { + const group = groups[groupIndex]; + const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; + + if (index === groupHeaderIndex) { + if (group.rowHeight !== undefined) { + const groupRowHeight = + typeof group.rowHeight === 'number' + ? group.rowHeight + : group.rowHeight(index); + if (groupRowHeight !== undefined) { + return groupRowHeight; + } + } + break; + } + + cumulativeDataIndex += group.itemCount; + } + } + const baseHeight = typeof rowHeight === 'number' ? rowHeight : rowHeight?.(index, cellProps) || height; @@ -1236,12 +1426,55 @@ export const ItemTableList = ({ return baseHeight; }, - [enableHeader, headerHeight, rowHeight, pinnedRowCount, size], + [enableHeader, headerHeight, rowHeight, pinnedRowCount, size, groups], ); const getDataFn = useCallback(() => { - return enableHeader ? [null, ...data] : data; - }, [data, enableHeader]); + // Reconstruct data array with group headers inserted + // Groups are defined by itemCount, so we calculate indexes based on cumulative item counts + const result: (null | unknown)[] = enableHeader ? [null] : []; + + if (!groups || groups.length === 0) { + // No groups, just add all data + result.push(...data); + return result; + } + + // Calculate group header indexes based on itemCounts + const groupIndexes: number[] = []; + let cumulativeDataIndex = 0; + const headerOffset = enableHeader ? 1 : 0; + + groups.forEach((group, groupIndex) => { + // Group header appears before its items + // Index = header offset + cumulative data index + number of previous group headers + const groupHeaderIndex = headerOffset + cumulativeDataIndex + groupIndex; + groupIndexes.push(groupHeaderIndex); + cumulativeDataIndex += group.itemCount; + }); + + let dataIndex = 0; + const startIndex = enableHeader ? 1 : 0; + let groupHeaderCount = 0; + + // Iterate through the expanded row space (data + group headers) + for ( + let rowIndex = startIndex; + rowIndex < startIndex + data.length + groupIndexes.length; + rowIndex++ + ) { + // Check if this row should have a group header + const expectedGroupIndex = groupIndexes[groupHeaderCount]; + if (expectedGroupIndex !== undefined && rowIndex === expectedGroupIndex) { + result.push(null); // Group header row + groupHeaderCount++; + } else if (dataIndex < data.length) { + result.push(data[dataIndex]); + dataIndex++; + } + } + return result; + }, [data, enableHeader, groups]); const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]); @@ -1428,14 +1661,201 @@ export const ItemTableList = ({ onColumnResized, }); + // Create itemProps for sticky header + const stickyHeaderItemProps: TableItemProps = useMemo( + () => ({ + calculatedColumnWidths, + cellPadding, + columns: parsedColumns, + controls, + data: [null], // Header row + enableAlternateRowColors, + enableColumnReorder: !!onColumnReordered, + enableColumnResize: !!onColumnResized, + enableDrag, + enableExpansion, + enableHeader, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + getRowHeight, + groups, + internalState, + itemType, + pinnedLeftColumnCount, + pinnedLeftColumnWidths: calculatedColumnWidths.slice(0, pinnedLeftColumnCount), + pinnedRightColumnCount, + pinnedRightColumnWidths: calculatedColumnWidths.slice( + pinnedLeftColumnCount + totalColumnCount, + ), + playerContext, + size, + tableId, + }), + [ + calculatedColumnWidths, + cellPadding, + controls, + parsedColumns, + enableAlternateRowColors, + enableDrag, + enableExpansion, + enableHeader, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + getRowHeight, + groups, + internalState, + itemType, + onColumnReordered, + onColumnResized, + pinnedLeftColumnCount, + pinnedRightColumnCount, + playerContext, + size, + tableId, + totalColumnCount, + ], + ); + + const StickyHeader = useMemo(() => { + if (!shouldShowStickyHeader || !enableHeader) { + return null; + } + + const pinnedLeftWidth = calculatedColumnWidths + .slice(0, pinnedLeftColumnCount) + .reduce((sum, width) => sum + width, 0); + const mainWidth = calculatedColumnWidths + .slice(pinnedLeftColumnCount, pinnedLeftColumnCount + totalColumnCount) + .reduce((sum, width) => sum + width, 0); + const pinnedRightWidth = calculatedColumnWidths + .slice(pinnedLeftColumnCount + totalColumnCount) + .reduce((sum, width) => sum + width, 0); + + return ( +
+
+ {pinnedLeftColumnCount > 0 && ( +
+ {parsedColumns + .filter((col) => col.pinned === 'left') + .map((col) => { + const columnIndex = parsedColumns.findIndex((c) => c === col); + return ( + + ); + })} +
+ )} +
+ {parsedColumns + .filter((col) => col.pinned === null) + .map((col) => { + const columnIndex = parsedColumns.findIndex((c) => c === col); + return ( + + ); + })} +
+ {pinnedRightColumnCount > 0 && ( +
+ {parsedColumns + .filter((col) => col.pinned === 'right') + .map((col) => { + const columnIndex = parsedColumns.findIndex((c) => c === col); + return ( + + ); + })} +
+ )} +
+
+ ); + }, [ + shouldShowStickyHeader, + enableHeader, + stickyTop, + calculatedColumnWidths, + pinnedLeftColumnCount, + pinnedRightColumnCount, + totalColumnCount, + parsedColumns, + headerHeight, + CellComponent, + stickyHeaderItemProps, + ]); + return (
(e.currentTarget as HTMLDivElement).focus()} + onMouseDown={(e) => { + const element = e.currentTarget as HTMLDivElement; + // Focus without scrolling into view + if (element.focus) { + element.focus({ preventScroll: true }); + } + }} ref={containerFocusRef} tabIndex={0} > + {StickyHeader} string) | string; + itemType: LibraryItem; + size?: 'compact' | 'default' | 'large'; +} + +export const SimpleItemTable = ({ + cellPadding = 'sm', + columns, + data, + enableAlternateRowColors = false, + enableHeader = true, + enableHorizontalBorders = false, + enableRowHoverHighlight = true, + enableSelection = true, + enableVerticalBorders = false, + getRowId, + itemType, + size = 'default', +}: SimpleItemTableProps) => { + const tableId = useId(); + const playerContext = usePlayer(); + + // Filter out pinned columns by setting pinned to null + const columnsWithoutPinning = useMemo( + () => + columns.map((col) => ({ + ...col, + pinned: null, + })), + [columns], + ); + + // Parse columns (filters disabled and sorts by pinned position, but we've removed pinning) + const parsedColumns = useMemo( + () => parseTableColumns(columnsWithoutPinning), + [columnsWithoutPinning], + ); + + // Create extractRowId function + const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]); + + // Use item list state for selection + const internalState = useItemListState(() => data, extractRowId); + + // Get default item controls + const controls = useDefaultItemListControls(); + + // Calculate row height based on size + const DEFAULT_ROW_HEIGHT = useMemo(() => { + switch (size) { + case 'compact': + return TableItemSize.COMPACT; + case 'large': + return TableItemSize.LARGE; + case 'default': + default: + return TableItemSize.DEFAULT; + } + }, [size]); + + const tableItemProps: TableItemProps = useMemo( + () => ({ + cellPadding, + columns: parsedColumns, + controls, + data: enableHeader ? [null, ...data] : data, + enableAlternateRowColors, + enableColumnReorder: false, + enableColumnResize: false, + enableDrag: false, + enableExpansion: false, + enableHeader, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + getRowHeight: () => DEFAULT_ROW_HEIGHT, + internalState, + itemType, + playerContext, + size, + tableId, + }), + [ + cellPadding, + parsedColumns, + controls, + enableHeader, + data, + enableAlternateRowColors, + enableHorizontalBorders, + enableRowHoverHighlight, + enableSelection, + enableVerticalBorders, + DEFAULT_ROW_HEIGHT, + internalState, + itemType, + playerContext, + size, + tableId, + ], + ); + + return ( +
+ + {enableHeader && ( + + + {parsedColumns.map((column, columnIndex) => ( + + + + ))} + + + )} + + {data.map((item, rowIndex) => { + const adjustedRowIndex = enableHeader ? rowIndex + 1 : rowIndex; + const isSelected = + item && typeof item === 'object' && 'id' in item + ? internalState.isSelected(internalState.extractRowId(item) || '') + : false; + + const isLastRow = rowIndex === data.length - 1; + + return ( + + {parsedColumns.map((column, columnIndex) => { + const isLastColumn = columnIndex === parsedColumns.length - 1; + + return ( + + + + ); + })} + + ); + })} + +
+
+ ); +}; diff --git a/src/renderer/features/albums/components/album-detail-content.tsx b/src/renderer/features/albums/components/album-detail-content.tsx index 50ef03c13..4a797511e 100644 --- a/src/renderer/features/albums/components/album-detail-content.tsx +++ b/src/renderer/features/albums/components/album-detail-content.tsx @@ -5,23 +5,35 @@ import { generatePath, Link, useParams } from 'react-router'; import styles from './album-detail-content.module.css'; +import { useItemListColumnReorder } from '/@/renderer/components/item-list/helpers/use-item-list-column-reorder'; +import { useItemListColumnResize } from '/@/renderer/components/item-list/helpers/use-item-list-column-resize'; +import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; +import { ItemTableList } from '/@/renderer/components/item-list/item-table-list/item-table-list'; +import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel'; import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay'; +import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { PlayButton } from '/@/renderer/features/shared/components/play-button'; import { useContainerQuery } from '/@/renderer/hooks'; import { useGenreRoute } from '/@/renderer/hooks/use-genre-route'; import { useCurrentServer } from '/@/renderer/store'; -import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store'; +import { + useGeneralSettings, + usePlayButtonBehavior, + useSettingsStore, +} from '/@/renderer/store/settings.store'; import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Button } from '/@/shared/components/button/button'; +import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Group } from '/@/shared/components/group/group'; import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Stack } from '/@/shared/components/stack/stack'; -import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types'; -import { Play } from '/@/shared/types/types'; +import { Text } from '/@/shared/components/text/text'; +import { AlbumListSort, LibraryItem, Song, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types'; interface AlbumDetailContentProps { background?: string; @@ -35,53 +47,46 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => { albumQueries.detail({ query: { id: albumId }, serverId: server.id }), ); - const { data: detail } = useQuery( - albumQueries.detail({ query: { id: albumId }, serverId: server.id }), - ); - const { ref, ...cq } = useContainerQuery(); const { externalLinks, lastFM, musicBrainz } = useGeneralSettings(); const genreRoute = useGenreRoute(); - const carousels = useMemo( - () => [ - { - excludeIds: detail?.id ? [detail.id] : undefined, - isHidden: !detail?.albumArtists?.[0]?.id, - query: { - _custom: { - jellyfin: { - ExcludeItemIds: detail?.id, - }, + const carousels = [ + { + excludeIds: detailQuery?.data?.id ? [detailQuery.data.id] : undefined, + isHidden: !detailQuery?.data?.albumArtists?.[0]?.id, + query: { + _custom: { + jellyfin: { + ExcludeItemIds: detailQuery?.data?.id, }, - artistIds: detail?.albumArtists.length - ? [detail.albumArtists[0].id] - : undefined, }, - sortBy: AlbumListSort.YEAR, - sortOrder: SortOrder.DESC, - title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }), - uniqueId: 'moreFromArtist', + artistIds: detailQuery?.data?.albumArtists.length + ? [detailQuery?.data?.albumArtists[0].id] + : undefined, }, - { - excludeIds: detail?.id ? [detail.id] : undefined, - isHidden: !detailQuery?.data?.genres?.[0], - query: { - genres: detailQuery.data?.genres.length - ? [detailQuery.data.genres[0].id] - : undefined, - }, - sortBy: AlbumListSort.RANDOM, - sortOrder: SortOrder.ASC, - title: `${t('page.albumDetail.moreFromGeneric', { - item: '', - postProcess: 'sentenceCase', - })} ${detailQuery?.data?.genres?.[0]?.name}`, - uniqueId: 'relatedGenres', + sortBy: AlbumListSort.YEAR, + sortOrder: SortOrder.DESC, + title: t('page.albumDetail.moreFromArtist', { postProcess: 'sentenceCase' }), + uniqueId: 'moreFromArtist', + }, + { + excludeIds: detailQuery?.data?.id ? [detailQuery.data.id] : undefined, + isHidden: !detailQuery?.data?.genres?.[0], + query: { + genres: detailQuery?.data?.genres.length + ? [detailQuery?.data?.genres[0].id] + : undefined, }, - ], - [detail?.id, detail?.albumArtists, detailQuery?.data?.genres, t], - ); + sortBy: AlbumListSort.RANDOM, + sortOrder: SortOrder.ASC, + title: `${t('page.albumDetail.moreFromGeneric', { + item: '', + postProcess: 'sentenceCase', + })} ${detailQuery?.data?.genres?.[0]?.name}`, + uniqueId: 'relatedGenres', + }, + ]; const playButtonBehavior = usePlayButtonBehavior(); const handlePlay = async (playType?: Play) => {}; @@ -125,6 +130,17 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => { /> + {showGenres && ( @@ -198,6 +214,12 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => { )} + {detailQuery?.data?.songs && detailQuery.data.songs.length > 0 && ( +
+ +
+ )} + {cq.height || cq.width ? ( <> @@ -225,3 +247,203 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
); }; + +interface AlbumDetailSongsTableProps { + songs: Song[]; +} + +const AlbumDetailSongsTable = ({ songs }: AlbumDetailSongsTableProps) => { + const { t } = useTranslation(); + const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.ALBUM_DETAIL]?.table); + + const columns = useMemo(() => { + return tableConfig?.columns || []; + }, [tableConfig?.columns]); + + const { handleColumnReordered } = useItemListColumnReorder({ + itemListKey: ItemListKey.ALBUM_DETAIL, + }); + + const { handleColumnResized } = useItemListColumnResize({ + itemListKey: ItemListKey.ALBUM_DETAIL, + }); + + const discGroups = useMemo(() => { + if (songs.length === 0) return []; + + const groups: Array<{ + discNumber: number; + discSubtitle: null | string; + itemCount: number; + }> = []; + let lastDiscNumber = -1; + let currentGroupStartIndex = 0; + + songs.forEach((song, index) => { + if (song.discNumber !== lastDiscNumber) { + // If we have a previous group, calculate its item count + if (groups.length > 0) { + groups[groups.length - 1].itemCount = index - currentGroupStartIndex; + } + // Start a new group + groups.push({ + discNumber: song.discNumber, + discSubtitle: song.discSubtitle, + itemCount: 0, // Will be calculated when we encounter the next group or end + }); + currentGroupStartIndex = index; + lastDiscNumber = song.discNumber; + } + }); + + // Set item count for the last group + if (groups.length > 0) { + groups[groups.length - 1].itemCount = songs.length - currentGroupStartIndex; + } + + return groups; + }, [songs]); + + // const maxHeight = useMemo(() => { + // if (!tableConfig) return undefined; + + // const headerHeight = 40; + // const rowHeights = { + // compact: 40, + // default: 64, + // large: 88, + // }; + // const rowHeight = rowHeights[tableConfig.size || 'default']; + // const maxRows = 20; + + // return headerHeight + maxRows * rowHeight; + // }, [tableConfig]); + + // Uncomment to enable static table height + // const containerHeight = useMemo(() => { + // if (!tableConfig || !maxHeight) return undefined; + + // const headerHeight = 40; + // const rowHeights = { + // compact: 40, + // default: 64, + // large: 88, + // }; + // const rowHeight = rowHeights[tableConfig.size || 'default']; + // const actualRows = Math.min(songs.length, 20); + + // return Math.min(headerHeight + actualRows * rowHeight, maxHeight); + // }, [tableConfig, maxHeight, songs.length]); + + const groups = useMemo(() => { + if (discGroups.length <= 1) { + return undefined; + } + + return discGroups.map((discGroup) => ({ + itemCount: discGroup.itemCount, + render: ({ + data, + internalState, + startDataIndex, + }: { + data: unknown[]; + groupIndex: number; + index: number; + internalState: any; + startDataIndex: number; + }) => { + const groupItems = data.slice(startDataIndex, startDataIndex + discGroup.itemCount); + + const selectedCount = groupItems.filter((item) => { + if (!item || typeof item !== 'object' || !('id' in item)) return false; + const rowId = internalState.extractRowId(item); + return rowId ? internalState.isSelected(rowId) : false; + }).length; + + const isAllSelected = selectedCount === groupItems.length; + const isSomeSelected = selectedCount > 0 && selectedCount < groupItems.length; + + const handleCheckboxChange = () => { + const selectableItems = groupItems; + + if (isAllSelected) { + // Deselect all items in the group + const currentlySelected = internalState.getSelected(); + const groupItemIds = new Set( + selectableItems + .map((item) => internalState.extractRowId(item)) + .filter(Boolean), + ); + const itemsToKeep = currentlySelected.filter( + (item) => !groupItemIds.has(internalState.extractRowId(item) || ''), + ); + internalState.setSelected(itemsToKeep); + } else { + // Select all items in the group (add to existing selection) + const currentlySelected = internalState.getSelected(); + const selectedIds = new Set( + currentlySelected + .map((item) => internalState.extractRowId(item)) + .filter(Boolean), + ); + const itemsToAdd = selectableItems.filter( + (item) => !selectedIds.has(internalState.extractRowId(item) || ''), + ); + internalState.setSelected([...currentlySelected, ...itemsToAdd]); + } + }; + + return ( + + + + {t('common.disc', { postProcess: 'sentenceCase' })}{' '} + {discGroup.discNumber} + {discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`} + + + ); + }, + rowHeight: 40, + })); + }, [discGroups, t]); + + if (!tableConfig || columns.length === 0) { + return null; + } + + return ( + + ); +}; diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index fb541a5ef..3a929d3f1 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { forwardRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { generatePath, Link, useParams } from 'react-router'; @@ -25,139 +25,142 @@ interface AlbumDetailHeaderProps { }; } -export const AlbumDetailHeader = ({ background }: AlbumDetailHeaderProps) => { - const { albumId } = useParams() as { albumId: string }; - const server = useCurrentServer(); - const detailQuery = useQuery( - albumQueries.detail({ query: { id: albumId }, serverId: server?.id }), - ); - const { t } = useTranslation(); +export const AlbumDetailHeader = forwardRef( + ({ background }, ref) => { + const { albumId } = useParams() as { albumId: string }; + const server = useCurrentServer(); + const detailQuery = useQuery( + albumQueries.detail({ query: { id: albumId }, serverId: server?.id }), + ); + const { t } = useTranslation(); - const showRating = - detailQuery?.data?._serverType === ServerType.NAVIDROME || - detailQuery?.data?._serverType === ServerType.SUBSONIC; + const showRating = + detailQuery?.data?._serverType === ServerType.NAVIDROME || + detailQuery?.data?._serverType === ServerType.SUBSONIC; - const originalDifferentFromRelease = - detailQuery.data?.originalDate && - detailQuery.data.originalDate !== detailQuery.data.releaseDate; + const originalDifferentFromRelease = + detailQuery.data?.originalDate && + detailQuery.data.originalDate !== detailQuery.data.releaseDate; - const releasePrefix = originalDifferentFromRelease - ? t('page.albumDetail.released', { postProcess: 'sentenceCase' }) - : '♫'; + const releasePrefix = originalDifferentFromRelease + ? t('page.albumDetail.released', { postProcess: 'sentenceCase' }) + : '♫'; - const releaseTypes = useMemo( - () => - normalizeReleaseTypes(detailQuery.data?.releaseTypes ?? [], t).map((type) => ({ - id: type, - value: titleCase(type), - })) || [], - [detailQuery.data?.releaseTypes, t], - ); + const releaseTypes = useMemo( + () => + normalizeReleaseTypes(detailQuery.data?.releaseTypes ?? [], t).map((type) => ({ + id: type, + value: titleCase(type), + })) || [], + [detailQuery.data?.releaseTypes, t], + ); - const metadataItems = releaseTypes.concat([ - { - id: 'releaseDate', - value: - detailQuery?.data?.releaseDate && - `${releasePrefix} ${formatDateAbsoluteUTC(detailQuery?.data?.releaseDate)}`, - }, - { - id: 'songCount', - value: t('entity.trackWithCount', { - count: detailQuery?.data?.songCount as number, - }), - }, - { - id: 'duration', - value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration), - }, - { - id: 'playCount', - value: - typeof detailQuery?.data?.playCount === 'number' && - t('entity.play', { - count: detailQuery?.data?.playCount, - }), - }, - { - id: 'version', - value: detailQuery.data?.version, - }, - ]); - - if (originalDifferentFromRelease) { - const formatted = `♫ ${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`; - metadataItems.splice(0, 0, { - id: 'originalDate', - value: formatted, - }); - } - - const updateRatingMutation = useSetRating({}); - - const handleUpdateRating = (rating: number) => { - if (!detailQuery?.data) return; - - updateRatingMutation.mutate({ - apiClientProps: { serverId: detailQuery.data._serverId }, - query: { - id: [detailQuery.data.id], - rating, - type: LibraryItem.ALBUM, + const metadataItems = releaseTypes.concat([ + { + id: 'releaseDate', + value: + detailQuery?.data?.releaseDate && + `${releasePrefix} ${formatDateAbsoluteUTC(detailQuery?.data?.releaseDate)}`, }, - }); - }; + { + id: 'songCount', + value: t('entity.trackWithCount', { + count: detailQuery?.data?.songCount as number, + }), + }, + { + id: 'duration', + value: + detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration), + }, + { + id: 'playCount', + value: + typeof detailQuery?.data?.playCount === 'number' && + t('entity.play', { + count: detailQuery?.data?.playCount, + }), + }, + { + id: 'version', + value: detailQuery.data?.version, + }, + ]); - return ( - - - - - {metadataItems.map( - (item, index) => - item.value && ( - {item.value} - ), + if (originalDifferentFromRelease) { + const formatted = `♫ ${formatDateAbsoluteUTC(detailQuery!.data!.originalDate)}`; + metadataItems.splice(0, 0, { + id: 'originalDate', + value: formatted, + }); + } + + const updateRatingMutation = useSetRating({}); + + const handleUpdateRating = (rating: number) => { + if (!detailQuery?.data) return; + + updateRatingMutation.mutate({ + apiClientProps: { serverId: detailQuery.data._serverId }, + query: { + id: [detailQuery.data.id], + rating, + type: LibraryItem.ALBUM, + }, + }); + }; + + return ( + + + + + {metadataItems.map( + (item, index) => + item.value && ( + {item.value} + ), + )} + + {showRating && ( + )} - - {showRating && ( - - )} - - {detailQuery?.data?.albumArtists.map((artist) => ( - - {artist.name} - - ))} - - - - - ); -}; + + {detailQuery?.data?.albumArtists.map((artist) => ( + + {artist.name} + + ))} + + + + + ); + }, +); diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index cdf6c1f59..8d33a9390 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -218,7 +218,7 @@ const GeneralSettingsSchema = z.object({ artistBackgroundBlur: z.number(), artistItems: z.array(SortableItemSchema(ArtistItemSchema)), buttonSize: z.number(), - disabledContextMenu: z.record(z.boolean()), + disabledContextMenu: z.record(z.string(), z.boolean()), doubleClickQueueAll: z.boolean(), externalLinks: z.boolean(), followSystemTheme: z.boolean(), @@ -655,6 +655,35 @@ const initialState: SettingsState = { globalMediaHotkeys: false, }, lists: { + ['albumDetail']: { + display: ListDisplayType.TABLE, + grid: { + itemGap: 'md', + itemsPerRow: 6, + itemsPerRowEnabled: false, + rows: [], + }, + itemsPerPage: 100, + pagination: ListPaginationType.INFINITE, + table: { + autoFitColumns: true, + columns: pickTableColumns({ + autoSizeColumns: [], + columns: SONG_TABLE_COLUMNS, + enabledColumns: [ + TableColumn.TRACK_NUMBER, + TableColumn.TITLE, + TableColumn.DURATION, + TableColumn.USER_FAVORITE, + ], + }), + enableAlternateRowColors: false, + enableHorizontalBorders: false, + enableRowHoverHighlight: false, + enableVerticalBorders: false, + size: 'compact', + }, + }, fullScreen: { display: ListDisplayType.TABLE, grid: { diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index 8510e1755..d2c707047 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -20,11 +20,9 @@ export enum ItemListKey { ARTIST = LibraryItem.ARTIST, FULL_SCREEN = 'fullScreen', GENRE = LibraryItem.GENRE, - NOW_PLAYING = 'nowPlaying', PLAYLIST = LibraryItem.PLAYLIST, PLAYLIST_SONG = LibraryItem.PLAYLIST_SONG, QUEUE_SONG = LibraryItem.QUEUE_SONG, - SIDE_DRAWER_QUEUE = 'sideDrawerQueue', SIDE_QUEUE = 'sideQueue', SONG = LibraryItem.SONG, }