add new table to album detail

This commit is contained in:
jeffvli
2025-11-16 13:53:04 -08:00
parent 31d9ab048d
commit f366b50550
10 changed files with 1318 additions and 194 deletions
@@ -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 { 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 <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) {
case TableColumn.ACTIONS:
case TableColumn.SKIP:
@@ -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;
@@ -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<typeof parseTableColumns>;
pinnedLeftColumnCount: number;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement>;
pinnedLeftColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRightColumnCount: number;
pinnedRightColumnRef: React.RefObject<HTMLDivElement>;
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowCount: number;
pinnedRowRef: React.RefObject<HTMLDivElement>;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
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<HTMLDivElement>) => 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<HTMLDivElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
@@ -680,6 +784,59 @@ export const ItemTableList = ({
const handleRef = useRef<ItemListHandle | 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(() => {
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 (
<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 (
<div
className={styles.itemTableListContainer}
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}
tabIndex={0}
>
{StickyHeader}
<VirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={CellComponent}
@@ -1453,6 +1873,7 @@ export const ItemTableList = ({
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
getRowHeight={getRowHeight}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
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 { 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) => {
/>
</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>
</section>
{showGenres && (
@@ -198,6 +214,12 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
</section>
)}
{detailQuery?.data?.songs && detailQuery.data.songs.length > 0 && (
<section>
<AlbumDetailSongsTable songs={detailQuery.data.songs} />
</section>
)}
<Stack gap="lg" mt="3rem">
{cq.height || cq.width ? (
<>
@@ -225,3 +247,203 @@ export const AlbumDetailContent = ({ background }: AlbumDetailContentProps) => {
</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 { 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<HTMLDivElement, AlbumDetailHeaderProps>(
({ 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 (
<Stack>
<LibraryHeader
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''}
{...background}
>
<Stack gap="lg">
<Pill.Group>
{metadataItems.map(
(item, index) =>
item.value && (
<Pill key={`item-${item.id}-${index}`}>{item.value}</Pill>
),
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 (
<Stack ref={ref}>
<LibraryHeader
imageUrl={detailQuery?.data?.imageUrl}
item={{ route: AppRoute.LIBRARY_ALBUMS, type: LibraryItem.ALBUM }}
title={detailQuery?.data?.name || ''}
{...background}
>
<Stack gap="lg">
<Pill.Group>
{metadataItems.map(
(item, index) =>
item.value && (
<Pill key={`item-${item.id}-${index}`}>{item.value}</Pill>
),
)}
</Pill.Group>
{showRating && (
<Rating
onChange={handleUpdateRating}
readOnly={detailQuery?.isFetching || updateRatingMutation.isPending}
value={detailQuery?.data?.userRating || 0}
/>
)}
</Pill.Group>
{showRating && (
<Rating
onChange={handleUpdateRating}
readOnly={detailQuery?.isFetching || updateRatingMutation.isPending}
value={detailQuery?.data?.userRating || 0}
/>
)}
<Group
gap="md"
mah="4rem"
style={{
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
}}
>
{detailQuery?.data?.albumArtists.map((artist) => (
<Text
component={Link}
fw={600}
isLink
key={`artist-${artist.id}`}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
variant="subtle"
>
{artist.name}
</Text>
))}
</Group>
</Stack>
</LibraryHeader>
</Stack>
);
};
<Group
gap="md"
mah="4rem"
style={{
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
}}
>
{detailQuery?.data?.albumArtists.map((artist) => (
<Text
component={Link}
fw={600}
isLink
key={`artist-${artist.id}`}
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
variant="subtle"
>
{artist.name}
</Text>
))}
</Group>
</Stack>
</LibraryHeader>
</Stack>
);
},
);
+30 -1
View File
@@ -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: {
-2
View File
@@ -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,
}