mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
add new table to album detail
This commit is contained in:
@@ -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<HTMLDivElement | null>;
|
||||||
|
enabled: boolean;
|
||||||
|
headerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
}) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
|
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
|
||||||
import { useMergedRef } from '@mantine/hooks';
|
import { useMergedRef } from '@mantine/hooks';
|
||||||
import clsx from 'clsx';
|
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 { CellComponentProps } from 'react-window-v2';
|
||||||
|
|
||||||
import styles from './item-table-list-column.module.css';
|
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 item = isDataRow ? props.data[props.rowIndex] : null;
|
||||||
const shouldEnableDrag = !!props.enableDrag && isDataRow && !!item;
|
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 {
|
const {
|
||||||
isDraggedOver,
|
isDraggedOver,
|
||||||
isDragging: isDraggingLocal,
|
isDragging: isDraggingLocal,
|
||||||
@@ -264,6 +306,49 @@ export const ItemTableListColumn = (props: ItemTableListColumn) => {
|
|||||||
return <TableColumnHeaderContainer {...props} controls={controls} type={type} />;
|
return <TableColumnHeaderContainer {...props} controls={controls} type={type} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...props.style, // Apply virtualization styles (position, top, left, width, height)
|
||||||
|
backgroundColor: 'var(--theme-bg-secondary)',
|
||||||
|
borderBottom: '1px solid var(--theme-border-color)',
|
||||||
|
marginLeft: pinnedLeftWidth > 0 ? `-${pinnedLeftWidth}px` : 0,
|
||||||
|
width: `${totalTableWidth}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{groupHeader}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case TableColumn.ACTIONS:
|
case TableColumn.ACTIONS:
|
||||||
case TableColumn.SKIP:
|
case TableColumn.SKIP:
|
||||||
|
|||||||
@@ -45,6 +45,49 @@
|
|||||||
position: relative;
|
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 {
|
.item-table-pinned-rows-grid-container.with-header::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AnimatePresence } from 'motion/react';
|
|||||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||||
import React, {
|
import React, {
|
||||||
type JSXElementConstructor,
|
type JSXElementConstructor,
|
||||||
|
ReactElement,
|
||||||
Ref,
|
Ref,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -18,6 +19,7 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { type CellComponentProps, Grid } from 'react-window-v2';
|
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 styles from './item-table-list.module.css';
|
||||||
|
|
||||||
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
|
import { ExpandedListContainer } from '/@/renderer/components/item-list/expanded-list-container';
|
||||||
@@ -97,6 +99,7 @@ interface VirtualizedTableGridProps {
|
|||||||
enableSelection: boolean;
|
enableSelection: boolean;
|
||||||
enableVerticalBorders: boolean;
|
enableVerticalBorders: boolean;
|
||||||
getRowHeight: (index: number, cellProps: TableItemProps) => number;
|
getRowHeight: (index: number, cellProps: TableItemProps) => number;
|
||||||
|
groups?: TableGroupHeader[];
|
||||||
headerHeight: number;
|
headerHeight: number;
|
||||||
internalState: ItemListStateActions;
|
internalState: ItemListStateActions;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
@@ -104,11 +107,11 @@ interface VirtualizedTableGridProps {
|
|||||||
onRangeChanged?: ItemTableListProps['onRangeChanged'];
|
onRangeChanged?: ItemTableListProps['onRangeChanged'];
|
||||||
parsedColumns: ReturnType<typeof parseTableColumns>;
|
parsedColumns: ReturnType<typeof parseTableColumns>;
|
||||||
pinnedLeftColumnCount: number;
|
pinnedLeftColumnCount: number;
|
||||||
pinnedLeftColumnRef: React.RefObject<HTMLDivElement>;
|
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
|
||||||
pinnedRightColumnCount: number;
|
pinnedRightColumnCount: number;
|
||||||
pinnedRightColumnRef: React.RefObject<HTMLDivElement>;
|
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
|
||||||
pinnedRowCount: number;
|
pinnedRowCount: number;
|
||||||
pinnedRowRef: React.RefObject<HTMLDivElement>;
|
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
|
||||||
playerContext: PlayerContext;
|
playerContext: PlayerContext;
|
||||||
showLeftShadow: boolean;
|
showLeftShadow: boolean;
|
||||||
showRightShadow: boolean;
|
showRightShadow: boolean;
|
||||||
@@ -137,6 +140,7 @@ const VirtualizedTableGrid = React.memo(
|
|||||||
enableSelection,
|
enableSelection,
|
||||||
enableVerticalBorders,
|
enableVerticalBorders,
|
||||||
getRowHeight,
|
getRowHeight,
|
||||||
|
groups,
|
||||||
headerHeight,
|
headerHeight,
|
||||||
internalState,
|
internalState,
|
||||||
itemType,
|
itemType,
|
||||||
@@ -163,12 +167,71 @@ const VirtualizedTableGrid = React.memo(
|
|||||||
[calculatedColumnWidths],
|
[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(
|
const itemProps: TableItemProps = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
calculatedColumnWidths,
|
||||||
cellPadding,
|
cellPadding,
|
||||||
columns: parsedColumns,
|
columns: parsedColumns,
|
||||||
controls,
|
controls,
|
||||||
data: enableHeader ? [null, ...data] : data,
|
data: dataWithGroups,
|
||||||
enableAlternateRowColors,
|
enableAlternateRowColors,
|
||||||
enableColumnReorder,
|
enableColumnReorder,
|
||||||
enableColumnResize,
|
enableColumnResize,
|
||||||
@@ -180,31 +243,42 @@ const VirtualizedTableGrid = React.memo(
|
|||||||
enableSelection,
|
enableSelection,
|
||||||
enableVerticalBorders,
|
enableVerticalBorders,
|
||||||
getRowHeight,
|
getRowHeight,
|
||||||
|
groups,
|
||||||
internalState,
|
internalState,
|
||||||
itemType,
|
itemType,
|
||||||
|
pinnedLeftColumnCount,
|
||||||
|
pinnedLeftColumnWidths,
|
||||||
|
pinnedRightColumnCount,
|
||||||
|
pinnedRightColumnWidths,
|
||||||
playerContext,
|
playerContext,
|
||||||
size,
|
size,
|
||||||
tableId,
|
tableId,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
calculatedColumnWidths,
|
||||||
cellPadding,
|
cellPadding,
|
||||||
controls,
|
controls,
|
||||||
parsedColumns,
|
parsedColumns,
|
||||||
enableHeader,
|
dataWithGroups,
|
||||||
data,
|
|
||||||
enableAlternateRowColors,
|
enableAlternateRowColors,
|
||||||
enableColumnReorder,
|
enableColumnReorder,
|
||||||
enableColumnResize,
|
enableColumnResize,
|
||||||
enableDrag,
|
enableDrag,
|
||||||
enableExpansion,
|
enableExpansion,
|
||||||
|
enableHeader,
|
||||||
enableHorizontalBorders,
|
enableHorizontalBorders,
|
||||||
enableRowHoverHighlight,
|
enableRowHoverHighlight,
|
||||||
enableSelection,
|
enableSelection,
|
||||||
enableVerticalBorders,
|
enableVerticalBorders,
|
||||||
getRowHeight,
|
getRowHeight,
|
||||||
|
groups,
|
||||||
internalState,
|
internalState,
|
||||||
playerContext,
|
|
||||||
itemType,
|
itemType,
|
||||||
|
pinnedLeftColumnCount,
|
||||||
|
pinnedLeftColumnWidths,
|
||||||
|
pinnedRightColumnCount,
|
||||||
|
pinnedRightColumnWidths,
|
||||||
|
playerContext,
|
||||||
size,
|
size,
|
||||||
tableId,
|
tableId,
|
||||||
],
|
],
|
||||||
@@ -490,7 +564,20 @@ const VirtualizedTableGrid = React.memo(
|
|||||||
|
|
||||||
VirtualizedTableGrid.displayName = 'VirtualizedTableGrid';
|
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 {
|
export interface TableItemProps {
|
||||||
|
calculatedColumnWidths?: number[];
|
||||||
cellPadding?: ItemTableListProps['cellPadding'];
|
cellPadding?: ItemTableListProps['cellPadding'];
|
||||||
columns: ItemTableListColumnConfig[];
|
columns: ItemTableListColumnConfig[];
|
||||||
controls: ItemControls;
|
controls: ItemControls;
|
||||||
@@ -506,9 +593,14 @@ export interface TableItemProps {
|
|||||||
enableSelection?: ItemTableListProps['enableSelection'];
|
enableSelection?: ItemTableListProps['enableSelection'];
|
||||||
enableVerticalBorders?: ItemTableListProps['enableVerticalBorders'];
|
enableVerticalBorders?: ItemTableListProps['enableVerticalBorders'];
|
||||||
getRowHeight: (index: number, cellProps: TableItemProps) => number;
|
getRowHeight: (index: number, cellProps: TableItemProps) => number;
|
||||||
|
groups?: TableGroupHeader[];
|
||||||
internalState: ItemListStateActions;
|
internalState: ItemListStateActions;
|
||||||
itemType: ItemTableListProps['itemType'];
|
itemType: ItemTableListProps['itemType'];
|
||||||
onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => void;
|
onRowClick?: (item: any, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
pinnedLeftColumnCount?: number;
|
||||||
|
pinnedLeftColumnWidths?: number[];
|
||||||
|
pinnedRightColumnCount?: number;
|
||||||
|
pinnedRightColumnWidths?: number[];
|
||||||
playerContext: PlayerContext;
|
playerContext: PlayerContext;
|
||||||
size?: ItemTableListProps['size'];
|
size?: ItemTableListProps['size'];
|
||||||
tableId: string;
|
tableId: string;
|
||||||
@@ -528,8 +620,10 @@ interface ItemTableListProps {
|
|||||||
enableHorizontalBorders?: boolean;
|
enableHorizontalBorders?: boolean;
|
||||||
enableRowHoverHighlight?: boolean;
|
enableRowHoverHighlight?: boolean;
|
||||||
enableSelection?: boolean;
|
enableSelection?: boolean;
|
||||||
|
enableStickyHeader?: boolean;
|
||||||
enableVerticalBorders?: boolean;
|
enableVerticalBorders?: boolean;
|
||||||
getRowId?: ((item: unknown) => string) | string;
|
getRowId?: ((item: unknown) => string) | string;
|
||||||
|
groups?: TableGroupHeader[];
|
||||||
headerHeight?: number;
|
headerHeight?: number;
|
||||||
initialTop?: {
|
initialTop?: {
|
||||||
behavior?: 'auto' | 'smooth';
|
behavior?: 'auto' | 'smooth';
|
||||||
@@ -564,8 +658,10 @@ export const ItemTableList = ({
|
|||||||
enableHorizontalBorders = false,
|
enableHorizontalBorders = false,
|
||||||
enableRowHoverHighlight = true,
|
enableRowHoverHighlight = true,
|
||||||
enableSelection = true,
|
enableSelection = true,
|
||||||
|
enableStickyHeader = false,
|
||||||
enableVerticalBorders = false,
|
enableVerticalBorders = false,
|
||||||
getRowId,
|
getRowId,
|
||||||
|
groups,
|
||||||
headerHeight = 40,
|
headerHeight = 40,
|
||||||
initialTop,
|
initialTop,
|
||||||
itemType,
|
itemType,
|
||||||
@@ -666,7 +762,15 @@ export const ItemTableList = ({
|
|||||||
const pinnedRightColumnCount = parsedColumns.filter((col) => col.pinned === 'right').length;
|
const pinnedRightColumnCount = parsedColumns.filter((col) => col.pinned === 'right').length;
|
||||||
|
|
||||||
const pinnedRowCount = enableHeader ? 1 : 0;
|
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 totalColumnCount = columnCount - pinnedLeftColumnCount - pinnedRightColumnCount;
|
||||||
const pinnedRowRef = useRef<HTMLDivElement>(null);
|
const pinnedRowRef = useRef<HTMLDivElement>(null);
|
||||||
const rowRef = useRef<HTMLDivElement>(null);
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -680,6 +784,59 @@ export const ItemTableList = ({
|
|||||||
const handleRef = useRef<ItemListHandle | null>(null);
|
const handleRef = useRef<ItemListHandle | null>(null);
|
||||||
const containerFocusRef = useRef<HTMLDivElement | null>(null);
|
const containerFocusRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const { shouldShowStickyHeader, stickyTop } = useFixedTableHeader({
|
||||||
|
containerRef: containerFocusRef,
|
||||||
|
enabled: enableHeader && enableStickyHeader,
|
||||||
|
headerRef: pinnedRowRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stickyHeaderRef = useRef<HTMLDivElement | null>(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(() => {
|
useEffect(() => {
|
||||||
const el = rowRef.current;
|
const el = rowRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -1177,9 +1334,12 @@ export const ItemTableList = ({
|
|||||||
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
|
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
setShowLeftShadow(false);
|
setShowLeftShadow(false);
|
||||||
setShowRightShadow(false);
|
setShowRightShadow(false);
|
||||||
return;
|
}, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkScrollPosition = () => {
|
const checkScrollPosition = () => {
|
||||||
@@ -1204,13 +1364,16 @@ export const ItemTableList = ({
|
|||||||
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
|
const row = rowRef.current?.childNodes[0] as HTMLDivElement;
|
||||||
|
|
||||||
if (!row || !enableHeader) {
|
if (!row || !enableHeader) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
setShowTopShadow(false);
|
setShowTopShadow(false);
|
||||||
return;
|
}, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkScrollPosition = () => {
|
const checkScrollPosition = () => {
|
||||||
const scrollTop = row.scrollTop;
|
const currentScrollTop = row.scrollTop;
|
||||||
setShowTopShadow(scrollTop > 0);
|
setShowTopShadow(currentScrollTop > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
checkScrollPosition();
|
checkScrollPosition();
|
||||||
@@ -1226,6 +1389,33 @@ export const ItemTableList = ({
|
|||||||
(index: number, cellProps: TableItemProps) => {
|
(index: number, cellProps: TableItemProps) => {
|
||||||
const height = size === 'compact' ? 40 : size === 'large' ? 88 : 64;
|
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 =
|
const baseHeight =
|
||||||
typeof rowHeight === 'number' ? rowHeight : rowHeight?.(index, cellProps) || height;
|
typeof rowHeight === 'number' ? rowHeight : rowHeight?.(index, cellProps) || height;
|
||||||
|
|
||||||
@@ -1236,12 +1426,55 @@ export const ItemTableList = ({
|
|||||||
|
|
||||||
return baseHeight;
|
return baseHeight;
|
||||||
},
|
},
|
||||||
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size],
|
[enableHeader, headerHeight, rowHeight, pinnedRowCount, size, groups],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getDataFn = useCallback(() => {
|
const getDataFn = useCallback(() => {
|
||||||
return enableHeader ? [null, ...data] : data;
|
// Reconstruct data array with group headers inserted
|
||||||
}, [data, enableHeader]);
|
// 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]);
|
const extractRowId = useMemo(() => createExtractRowId(getRowId), [getRowId]);
|
||||||
|
|
||||||
@@ -1428,14 +1661,201 @@ export const ItemTableList = ({
|
|||||||
onColumnResized,
|
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 (
|
||||||
|
<div
|
||||||
|
className={styles.stickyHeader}
|
||||||
|
ref={stickyHeaderRef}
|
||||||
|
style={{
|
||||||
|
top: `${stickyTop}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.stickyHeaderRow}>
|
||||||
|
{pinnedLeftColumnCount > 0 && (
|
||||||
|
<div
|
||||||
|
className={styles.stickyHeaderSection}
|
||||||
|
style={{ width: `${pinnedLeftWidth}px` }}
|
||||||
|
>
|
||||||
|
{parsedColumns
|
||||||
|
.filter((col) => col.pinned === 'left')
|
||||||
|
.map((col) => {
|
||||||
|
const columnIndex = parsedColumns.findIndex((c) => c === col);
|
||||||
|
return (
|
||||||
|
<CellComponent
|
||||||
|
ariaAttributes={{
|
||||||
|
'aria-colindex': columnIndex + 1,
|
||||||
|
role: 'gridcell',
|
||||||
|
}}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
key={col.id}
|
||||||
|
rowIndex={0}
|
||||||
|
style={{
|
||||||
|
height: headerHeight,
|
||||||
|
width: calculatedColumnWidths[columnIndex],
|
||||||
|
}}
|
||||||
|
{...stickyHeaderItemProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.stickyHeaderSection} style={{ width: `${mainWidth}px` }}>
|
||||||
|
{parsedColumns
|
||||||
|
.filter((col) => col.pinned === null)
|
||||||
|
.map((col) => {
|
||||||
|
const columnIndex = parsedColumns.findIndex((c) => c === col);
|
||||||
|
return (
|
||||||
|
<CellComponent
|
||||||
|
ariaAttributes={{
|
||||||
|
'aria-colindex': columnIndex + 1,
|
||||||
|
role: 'gridcell',
|
||||||
|
}}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
key={col.id}
|
||||||
|
rowIndex={0}
|
||||||
|
style={{
|
||||||
|
height: headerHeight,
|
||||||
|
width: calculatedColumnWidths[columnIndex],
|
||||||
|
}}
|
||||||
|
{...stickyHeaderItemProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{pinnedRightColumnCount > 0 && (
|
||||||
|
<div
|
||||||
|
className={styles.stickyHeaderSection}
|
||||||
|
style={{ width: `${pinnedRightWidth}px` }}
|
||||||
|
>
|
||||||
|
{parsedColumns
|
||||||
|
.filter((col) => col.pinned === 'right')
|
||||||
|
.map((col) => {
|
||||||
|
const columnIndex = parsedColumns.findIndex((c) => c === col);
|
||||||
|
return (
|
||||||
|
<CellComponent
|
||||||
|
ariaAttributes={{
|
||||||
|
'aria-colindex': columnIndex + 1,
|
||||||
|
role: 'gridcell',
|
||||||
|
}}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
key={col.id}
|
||||||
|
rowIndex={0}
|
||||||
|
style={{
|
||||||
|
height: headerHeight,
|
||||||
|
width: calculatedColumnWidths[columnIndex],
|
||||||
|
}}
|
||||||
|
{...stickyHeaderItemProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
shouldShowStickyHeader,
|
||||||
|
enableHeader,
|
||||||
|
stickyTop,
|
||||||
|
calculatedColumnWidths,
|
||||||
|
pinnedLeftColumnCount,
|
||||||
|
pinnedRightColumnCount,
|
||||||
|
totalColumnCount,
|
||||||
|
parsedColumns,
|
||||||
|
headerHeight,
|
||||||
|
CellComponent,
|
||||||
|
stickyHeaderItemProps,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.itemTableListContainer}
|
className={styles.itemTableListContainer}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onMouseDown={(e) => (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}
|
ref={containerFocusRef}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
|
{StickyHeader}
|
||||||
<VirtualizedTableGrid
|
<VirtualizedTableGrid
|
||||||
calculatedColumnWidths={calculatedColumnWidths}
|
calculatedColumnWidths={calculatedColumnWidths}
|
||||||
CellComponent={CellComponent}
|
CellComponent={CellComponent}
|
||||||
@@ -1453,6 +1873,7 @@ export const ItemTableList = ({
|
|||||||
enableSelection={enableSelection}
|
enableSelection={enableSelection}
|
||||||
enableVerticalBorders={enableVerticalBorders}
|
enableVerticalBorders={enableVerticalBorders}
|
||||||
getRowHeight={getRowHeight}
|
getRowHeight={getRowHeight}
|
||||||
|
groups={groups}
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
internalState={internalState}
|
internalState={internalState}
|
||||||
itemType={itemType}
|
itemType={itemType}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.simple-item-table-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .alternate-row-even {
|
||||||
|
background-color: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alternate-row-odd {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: darken(var(--theme-colors-background), 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: darken(var(--theme-colors-background), 2%);
|
||||||
|
}
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* .row-hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-hover:hover {
|
||||||
|
background-color: var(--theme-colors-surface);
|
||||||
|
opacity: 0.7;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.row-selected {
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .with-horizontal-border {
|
||||||
|
border-bottom: 1px solid var(--theme-colors-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.with-vertical-border {
|
||||||
|
border-right: 1px solid var(--theme-colors-border);
|
||||||
|
} */
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { useId, useMemo } from 'react';
|
||||||
|
|
||||||
|
import styles from './simple-item-table.module.css';
|
||||||
|
|
||||||
|
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
|
||||||
|
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||||
|
import { useItemListState } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||||
|
import { parseTableColumns } from '/@/renderer/components/item-list/helpers/parse-table-columns';
|
||||||
|
import { TableItemProps } 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 { TableColumnHeaderContainer } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||||
|
import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
|
||||||
|
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
|
import { Table } from '/@/shared/components/table/table';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
enum TableItemSize {
|
||||||
|
COMPACT = 40,
|
||||||
|
DEFAULT = 64,
|
||||||
|
LARGE = 88,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimpleItemTableProps {
|
||||||
|
cellPadding?: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
|
||||||
|
columns: ItemTableListColumnConfig[];
|
||||||
|
data: unknown[];
|
||||||
|
enableAlternateRowColors?: boolean;
|
||||||
|
enableHeader?: boolean;
|
||||||
|
enableHorizontalBorders?: boolean;
|
||||||
|
enableRowHoverHighlight?: boolean;
|
||||||
|
enableSelection?: boolean;
|
||||||
|
enableVerticalBorders?: boolean;
|
||||||
|
getRowId?: ((item: unknown) => 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 (
|
||||||
|
<div className={styles.simpleItemTableContainer}>
|
||||||
|
<Table
|
||||||
|
highlightOnHover={enableRowHoverHighlight}
|
||||||
|
striped={enableAlternateRowColors}
|
||||||
|
withColumnBorders={enableVerticalBorders}
|
||||||
|
withRowBorders={enableHorizontalBorders}
|
||||||
|
>
|
||||||
|
{enableHeader && (
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
{parsedColumns.map((column, columnIndex) => (
|
||||||
|
<Table.Th
|
||||||
|
key={column.id}
|
||||||
|
style={{
|
||||||
|
textAlign:
|
||||||
|
column.align === 'start'
|
||||||
|
? 'left'
|
||||||
|
: column.align === 'end'
|
||||||
|
? 'right'
|
||||||
|
: 'center',
|
||||||
|
width: column.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableColumnHeaderContainer
|
||||||
|
{...tableItemProps}
|
||||||
|
ariaAttributes={{
|
||||||
|
'aria-colindex': columnIndex + 1,
|
||||||
|
role: 'gridcell',
|
||||||
|
}}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
controls={controls}
|
||||||
|
rowIndex={0}
|
||||||
|
style={{ width: column.width }}
|
||||||
|
type={column.id}
|
||||||
|
/>
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
)}
|
||||||
|
<Table.Tbody>
|
||||||
|
{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 (
|
||||||
|
<Table.Tr
|
||||||
|
className={clsx({
|
||||||
|
[styles.alternateRowEven]:
|
||||||
|
enableAlternateRowColors && rowIndex % 2 === 0,
|
||||||
|
[styles.alternateRowOdd]:
|
||||||
|
enableAlternateRowColors && rowIndex % 2 === 1,
|
||||||
|
[styles.rowHover]: enableRowHoverHighlight,
|
||||||
|
[styles.rowSelected]: isSelected,
|
||||||
|
[styles.withHorizontalBorder]:
|
||||||
|
enableHorizontalBorders && enableHeader && !isLastRow,
|
||||||
|
})}
|
||||||
|
data-row-index={`${tableId}-${adjustedRowIndex}`}
|
||||||
|
key={internalState.extractRowId(item) || rowIndex}
|
||||||
|
>
|
||||||
|
{parsedColumns.map((column, columnIndex) => {
|
||||||
|
const isLastColumn = columnIndex === parsedColumns.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Td
|
||||||
|
className={clsx({
|
||||||
|
[styles.withVerticalBorder]:
|
||||||
|
enableVerticalBorders && !isLastColumn,
|
||||||
|
})}
|
||||||
|
key={column.id}
|
||||||
|
style={{
|
||||||
|
textAlign:
|
||||||
|
column.align === 'start'
|
||||||
|
? 'left'
|
||||||
|
: column.align === 'end'
|
||||||
|
? 'right'
|
||||||
|
: 'center',
|
||||||
|
width: column.width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ItemTableListColumn
|
||||||
|
{...tableItemProps}
|
||||||
|
ariaAttributes={{
|
||||||
|
'aria-colindex': columnIndex + 1,
|
||||||
|
role: 'gridcell',
|
||||||
|
}}
|
||||||
|
columnIndex={columnIndex}
|
||||||
|
rowIndex={adjustedRowIndex}
|
||||||
|
style={{ width: column.width }}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,23 +5,35 @@ import { generatePath, Link, useParams } from 'react-router';
|
|||||||
|
|
||||||
import styles from './album-detail-content.module.css';
|
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 { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
import { AlbumInfiniteCarousel } from '/@/renderer/features/albums/components/album-infinite-carousel';
|
||||||
import { LibraryBackgroundOverlay } from '/@/renderer/features/shared/components/library-background-overlay';
|
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 { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||||
import { useContainerQuery } from '/@/renderer/hooks';
|
import { useContainerQuery } from '/@/renderer/hooks';
|
||||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
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 { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
|
||||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
|
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { AlbumListSort, SortOrder } from '/@/shared/types/domain-types';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { Play } from '/@/shared/types/types';
|
import { AlbumListSort, LibraryItem, Song, SortOrder } from '/@/shared/types/domain-types';
|
||||||
|
import { ItemListKey, ListDisplayType, Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface AlbumDetailContentProps {
|
interface AlbumDetailContentProps {
|
||||||
background?: string;
|
background?: string;
|
||||||
@@ -35,27 +47,22 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
|||||||
albumQueries.detail({ query: { id: albumId }, serverId: server.id }),
|
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 { ref, ...cq } = useContainerQuery();
|
||||||
const { externalLinks, lastFM, musicBrainz } = useGeneralSettings();
|
const { externalLinks, lastFM, musicBrainz } = useGeneralSettings();
|
||||||
const genreRoute = useGenreRoute();
|
const genreRoute = useGenreRoute();
|
||||||
|
|
||||||
const carousels = useMemo(
|
const carousels = [
|
||||||
() => [
|
|
||||||
{
|
{
|
||||||
excludeIds: detail?.id ? [detail.id] : undefined,
|
excludeIds: detailQuery?.data?.id ? [detailQuery.data.id] : undefined,
|
||||||
isHidden: !detail?.albumArtists?.[0]?.id,
|
isHidden: !detailQuery?.data?.albumArtists?.[0]?.id,
|
||||||
query: {
|
query: {
|
||||||
_custom: {
|
_custom: {
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
ExcludeItemIds: detail?.id,
|
ExcludeItemIds: detailQuery?.data?.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
artistIds: detail?.albumArtists.length
|
artistIds: detailQuery?.data?.albumArtists.length
|
||||||
? [detail.albumArtists[0].id]
|
? [detailQuery?.data?.albumArtists[0].id]
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
sortBy: AlbumListSort.YEAR,
|
sortBy: AlbumListSort.YEAR,
|
||||||
@@ -64,11 +71,11 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
|||||||
uniqueId: 'moreFromArtist',
|
uniqueId: 'moreFromArtist',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
excludeIds: detail?.id ? [detail.id] : undefined,
|
excludeIds: detailQuery?.data?.id ? [detailQuery.data.id] : undefined,
|
||||||
isHidden: !detailQuery?.data?.genres?.[0],
|
isHidden: !detailQuery?.data?.genres?.[0],
|
||||||
query: {
|
query: {
|
||||||
genres: detailQuery.data?.genres.length
|
genres: detailQuery?.data?.genres.length
|
||||||
? [detailQuery.data.genres[0].id]
|
? [detailQuery?.data?.genres[0].id]
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
sortBy: AlbumListSort.RANDOM,
|
sortBy: AlbumListSort.RANDOM,
|
||||||
@@ -79,9 +86,7 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
|||||||
})} ${detailQuery?.data?.genres?.[0]?.name}`,
|
})} ${detailQuery?.data?.genres?.[0]?.name}`,
|
||||||
uniqueId: 'relatedGenres',
|
uniqueId: 'relatedGenres',
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
[detail?.id, detail?.albumArtists, detailQuery?.data?.genres, t],
|
|
||||||
);
|
|
||||||
const playButtonBehavior = usePlayButtonBehavior();
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
|
|
||||||
const handlePlay = async (playType?: Play) => {};
|
const handlePlay = async (playType?: Play) => {};
|
||||||
@@ -125,6 +130,17 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
<ListConfigMenu
|
||||||
|
displayTypes={[{ hidden: true, value: ListDisplayType.GRID }]}
|
||||||
|
listKey={ItemListKey.ALBUM_DETAIL}
|
||||||
|
optionsConfig={{
|
||||||
|
table: {
|
||||||
|
itemsPerPage: { hidden: true },
|
||||||
|
pagination: { hidden: true },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
tableColumnsData={SONG_TABLE_COLUMNS}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</section>
|
</section>
|
||||||
{showGenres && (
|
{showGenres && (
|
||||||
@@ -198,6 +214,12 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{detailQuery?.data?.songs && detailQuery.data.songs.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<AlbumDetailSongsTable songs={detailQuery.data.songs} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<Stack gap="lg" mt="3rem">
|
<Stack gap="lg" mt="3rem">
|
||||||
{cq.height || cq.width ? (
|
{cq.height || cq.width ? (
|
||||||
<>
|
<>
|
||||||
@@ -225,3 +247,203 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Group
|
||||||
|
align="center"
|
||||||
|
h="100%"
|
||||||
|
px="md"
|
||||||
|
style={{ background: 'var(--theme-colors-background)' }}
|
||||||
|
w="100%"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
indeterminate={isSomeSelected}
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<Text size="sm">
|
||||||
|
{t('common.disc', { postProcess: 'sentenceCase' })}{' '}
|
||||||
|
{discGroup.discNumber}
|
||||||
|
{discGroup.discSubtitle && ` - ${discGroup.discSubtitle}`}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
rowHeight: 40,
|
||||||
|
}));
|
||||||
|
}, [discGroups, t]);
|
||||||
|
|
||||||
|
if (!tableConfig || columns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemTableList
|
||||||
|
autoFitColumns={tableConfig.autoFitColumns}
|
||||||
|
CellComponent={ItemTableListColumn}
|
||||||
|
columns={columns}
|
||||||
|
data={songs}
|
||||||
|
enableAlternateRowColors={tableConfig.enableAlternateRowColors}
|
||||||
|
enableDrag
|
||||||
|
enableExpansion={false}
|
||||||
|
enableHeader
|
||||||
|
enableHorizontalBorders={tableConfig.enableHorizontalBorders}
|
||||||
|
enableRowHoverHighlight={tableConfig.enableRowHoverHighlight}
|
||||||
|
enableSelection
|
||||||
|
enableStickyHeader
|
||||||
|
enableVerticalBorders={tableConfig.enableVerticalBorders}
|
||||||
|
groups={groups}
|
||||||
|
itemType={LibraryItem.SONG}
|
||||||
|
onColumnReordered={handleColumnReordered}
|
||||||
|
onColumnResized={handleColumnResized}
|
||||||
|
size={tableConfig.size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useMemo } from 'react';
|
import { forwardRef, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { generatePath, Link, useParams } from 'react-router';
|
import { generatePath, Link, useParams } from 'react-router';
|
||||||
|
|
||||||
@@ -25,7 +25,8 @@ interface AlbumDetailHeaderProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AlbumDetailHeader = ({ background }: AlbumDetailHeaderProps) => {
|
export const AlbumDetailHeader = forwardRef<HTMLDivElement, AlbumDetailHeaderProps>(
|
||||||
|
({ background }, ref) => {
|
||||||
const { albumId } = useParams() as { albumId: string };
|
const { albumId } = useParams() as { albumId: string };
|
||||||
const server = useCurrentServer();
|
const server = useCurrentServer();
|
||||||
const detailQuery = useQuery(
|
const detailQuery = useQuery(
|
||||||
@@ -69,7 +70,8 @@ export const AlbumDetailHeader = ({ background }: AlbumDetailHeaderProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'duration',
|
id: 'duration',
|
||||||
value: detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
value:
|
||||||
|
detailQuery?.data?.duration && formatDurationString(detailQuery.data.duration),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'playCount',
|
id: 'playCount',
|
||||||
@@ -109,7 +111,7 @@ export const AlbumDetailHeader = ({ background }: AlbumDetailHeaderProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack ref={ref}>
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
imageUrl={detailQuery?.data?.imageUrl}
|
imageUrl={detailQuery?.data?.imageUrl}
|
||||||
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
|
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
|
||||||
@@ -160,4 +162,5 @@ export const AlbumDetailHeader = ({ background }: AlbumDetailHeaderProps) => {
|
|||||||
</LibraryHeader>
|
</LibraryHeader>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ const GeneralSettingsSchema = z.object({
|
|||||||
artistBackgroundBlur: z.number(),
|
artistBackgroundBlur: z.number(),
|
||||||
artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
|
artistItems: z.array(SortableItemSchema(ArtistItemSchema)),
|
||||||
buttonSize: z.number(),
|
buttonSize: z.number(),
|
||||||
disabledContextMenu: z.record(z.boolean()),
|
disabledContextMenu: z.record(z.string(), z.boolean()),
|
||||||
doubleClickQueueAll: z.boolean(),
|
doubleClickQueueAll: z.boolean(),
|
||||||
externalLinks: z.boolean(),
|
externalLinks: z.boolean(),
|
||||||
followSystemTheme: z.boolean(),
|
followSystemTheme: z.boolean(),
|
||||||
@@ -655,6 +655,35 @@ const initialState: SettingsState = {
|
|||||||
globalMediaHotkeys: false,
|
globalMediaHotkeys: false,
|
||||||
},
|
},
|
||||||
lists: {
|
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: {
|
fullScreen: {
|
||||||
display: ListDisplayType.TABLE,
|
display: ListDisplayType.TABLE,
|
||||||
grid: {
|
grid: {
|
||||||
|
|||||||
@@ -20,11 +20,9 @@ export enum ItemListKey {
|
|||||||
ARTIST = LibraryItem.ARTIST,
|
ARTIST = LibraryItem.ARTIST,
|
||||||
FULL_SCREEN = 'fullScreen',
|
FULL_SCREEN = 'fullScreen',
|
||||||
GENRE = LibraryItem.GENRE,
|
GENRE = LibraryItem.GENRE,
|
||||||
NOW_PLAYING = 'nowPlaying',
|
|
||||||
PLAYLIST = LibraryItem.PLAYLIST,
|
PLAYLIST = LibraryItem.PLAYLIST,
|
||||||
PLAYLIST_SONG = LibraryItem.PLAYLIST_SONG,
|
PLAYLIST_SONG = LibraryItem.PLAYLIST_SONG,
|
||||||
QUEUE_SONG = LibraryItem.QUEUE_SONG,
|
QUEUE_SONG = LibraryItem.QUEUE_SONG,
|
||||||
SIDE_DRAWER_QUEUE = 'sideDrawerQueue',
|
|
||||||
SIDE_QUEUE = 'sideQueue',
|
SIDE_QUEUE = 'sideQueue',
|
||||||
SONG = LibraryItem.SONG,
|
SONG = LibraryItem.SONG,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user