add initial item detail list design

This commit is contained in:
jeffvli
2025-09-29 17:35:03 -07:00
parent d4b8b12687
commit 3f3a02ba8c
4 changed files with 533 additions and 0 deletions
@@ -0,0 +1,84 @@
.container {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--theme-spacing-sm);
width: 100%;
height: 100%;
padding: var(--theme-spacing-sm);
container-type: inline-size;
background: var(--theme-colors-surface);
border-radius: var(--theme-radius-md);
@container (min-width: 500px) {
grid-template-columns: minmax(0, 1fr);
}
}
.image-container {
position: relative;
display: none;
height: 100%;
min-height: 0;
aspect-ratio: 1/1;
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
content: '';
background-color: rgb(0 0 0);
opacity: 0;
transition: all 0.2s ease-in-out;
}
&:hover {
&::before {
opacity: 0.6;
}
}
@container (min-width: 500px) {
display: block;
}
}
.image {
aspect-ratio: 1/1;
}
.metadata-container {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-sm);
width: 100%;
height: 100%;
padding: var(--theme-spacing-xs) 0;
overflow: hidden;
}
.metadata-container .header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
line-height: 1.2;
}
.metadata-container .header .title {
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metadata-container .content {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-xs);
}
.metadata-container .content .tags {
}
@@ -0,0 +1,146 @@
import { AnimatePresence } from 'motion/react';
import { MouseEvent, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import styles from './item-detail.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { useFastAverageColor } from '/@/renderer/hooks';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Badge } from '/@/shared/components/badge/badge';
import { Divider } from '/@/shared/components/divider/divider';
import { Group } from '/@/shared/components/group/group';
import { Image } from '/@/shared/components/image/image';
import { Rating } from '/@/shared/components/rating/rating';
import { Text } from '/@/shared/components/text/text';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
Playlist,
Song,
} from '/@/shared/types/domain-types';
import { stringToColor } from '/@/shared/utils/string-to-color';
interface ItemDetailProps {
data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
itemHeight: number;
itemType: LibraryItem;
onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
withControls?: boolean;
}
export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
const imageUrl = getImageUrl(data);
const [showControls, setShowControls] = useState(false);
const { background } = useFastAverageColor({
algorithm: 'simple',
src: imageUrl,
srcLoaded: false,
});
// const tags = [...(data?.genres ?? [])];
const tags = useMemo(() => {
if (!data) {
return [];
}
const items: {
color?: string;
id: string;
isLight?: boolean;
itemType: LibraryItem;
name: string;
}[] = [];
if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
data.albumArtists?.forEach((tag: { id: string; name: string }) => {
items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
});
}
if ('genres' in data && Array.isArray(data.genres)) {
data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
const { color, isLight } = stringToColor(tag.name);
items.push({ ...tag, color, isLight });
});
}
// if ('tags' in data && typeof data.tags === 'object') {
// console.log('data.tags :>> ', data.tags);
// Object.entries(data.tags).forEach(([key, value]) => {
// items.push({ id: key, itemType: LibraryItem.TAG, name: value });
// });
// }
return items;
}, [data]);
return (
<div
className={styles.container}
onClick={(e) => onClick?.(e, data, itemType)}
style={{ backgroundColor: background }}
>
<div
className={styles.imageContainer}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
<Image alt={data?.name} src={imageUrl} />
<AnimatePresence>
{withControls && showControls && <ItemCardControls type="compact" />}
</AnimatePresence>
</div>
<div className={styles.metadataContainer}>
<div className={styles.header}>
<Text className={styles.title} component={Link} isLink size="lg" weight={500}>
{data?.name}
</Text>
<Group>
{data && 'userRating' in data && (
<Rating size="xs" value={data?.userRating ?? 0} />
)}
{data && 'userFavorite' in data && (
<ActionIcon
icon="favorite"
iconProps={{
fill: data?.userFavorite ? 'primary' : 'default',
}}
size="xs"
/>
)}
</Group>
</div>
<Divider />
<div className={styles.content}>
<Group className={styles.tags} gap="xs">
{tags.map((tag) => (
<Badge
key={tag.id}
style={{
backgroundColor: tag.color,
color: tag.isLight ? 'black' : 'white',
}}
>
{tag.name}
</Badge>
))}
</Group>
</div>
</div>
</div>
);
};
const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
if (data && 'imageUrl' in data) {
return data.imageUrl || undefined;
}
return undefined;
};
@@ -0,0 +1,18 @@
.item-detail-container {
display: flex;
flex-direction: column !important;
width: 100%;
height: 100%;
padding: 0 var(--theme-spacing-md);
}
.list-expanded-container {
width: 100%;
height: 100%;
}
.item-list {
width: 100%;
height: 100%;
padding-bottom: var(--theme-spacing-sm);
}
@@ -0,0 +1,285 @@
import { useElementSize, useMergedRef } from '@mantine/hooks';
import { throttle } from 'lodash';
import { AnimatePresence, motion, Variants } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import {
MouseEvent,
Ref,
UIEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { List, ListImperativeAPI, RowComponentProps, useListRef } from 'react-window-v2';
import styles from './item-list-detail.module.css';
import { ItemDetail } from '/@/renderer/components/item-detail/item-detail';
import { ExpandedListItem } from '/@/renderer/components/item-list/expanded-list-item';
import {
ItemListStateActions,
useItemListState,
} from '/@/renderer/components/item-list/helpers/item-list-state';
import { LibraryItem } from '/@/shared/types/domain-types';
export interface ItemGridProps {
data: unknown[];
enableExpansion?: boolean;
enableSelection?: boolean;
initialTopMostItemIndex?:
| number
| {
align: 'center' | 'end' | 'start';
behavior: 'auto' | 'smooth';
index: number;
offset?: number;
};
itemType: LibraryItem;
onEndReached?: (index: number) => void;
onItemClick?: (item: unknown, index: number) => void;
onItemContextMenu?: (item: unknown, index: number) => void;
onItemDoubleClick?: (item: unknown, index: number) => void;
onRangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
onScroll?: (e: UIEvent<HTMLDivElement>) => void;
onScrollEnd?: () => void;
onStartReached?: (index: number) => void;
ref: Ref<ListImperativeAPI>;
totalItemCount?: number;
}
const expandedAnimationVariants: Variants = {
hidden: {
height: 0,
minHeight: 0,
},
show: {
minHeight: '300px',
transition: {
duration: 0.3,
ease: 'easeInOut',
},
},
};
export const ItemListDetail = ({
data,
enableExpansion = false,
enableSelection = false,
initialTopMostItemIndex = 0,
itemType,
onEndReached,
onItemClick,
onItemContextMenu,
onItemDoubleClick,
onRangeChanged,
onScroll,
onScrollEnd,
onStartReached,
totalItemCount = 0,
}: ItemGridProps) => {
const itemDetailRef = useListRef(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const { ref: containerRef, width: containerWidth } = useElementSize();
const mergedContainerRef = useMergedRef(containerRef, scrollContainerRef);
const internalState = useItemListState();
const [initialize] = useOverlayScrollbars({
defer: true,
events: {
initialized(osInstance) {
const { viewport } = osInstance.elements();
viewport.style.overflowX = `var(--os-viewport-overflow-x)`;
viewport.style.overflowY = `var(--os-viewport-overflow-y)`;
},
},
options: {
overflow: { x: 'hidden', y: 'scroll' },
paddingAbsolute: true,
scrollbars: {
autoHide: 'leave',
autoHideDelay: 500,
pointers: ['mouse', 'pen', 'touch'],
theme: 'feishin-os-scrollbar',
visibility: 'visible',
},
},
});
useEffect(() => {
const { current: root } = scrollContainerRef;
if (root) {
initialize({
elements: { viewport: root.firstElementChild as HTMLElement },
target: root,
});
}
}, [itemDetailRef, initialize]);
const hasExpanded = internalState.hasExpanded();
const handleExpand = useCallback(
(_e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => {
if (item && typeof item === 'object' && 'id' in item && 'serverId' in item) {
internalState.toggleExpanded({
id: item.id as string,
itemType: itemType,
serverId: item.serverId as string,
});
}
},
[internalState],
);
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
onScroll?.(e);
},
[onScroll],
);
const [tableMeta, setTableMeta] = useState<null | {
itemHeight: number;
}>(null);
// Throttled function to update table meta
const throttledSetTableMeta = useMemo(() => {
return throttle((width: number) => {
const isSm = width >= 600;
const isMd = width >= 768;
const isLg = width >= 1200;
const isXl = width >= 1500;
const is2xl = width >= 1920;
const is3xl = width >= 2560;
let itemHeight = 160;
if (is3xl) {
itemHeight = 160;
} else if (is2xl) {
itemHeight = 160;
} else if (isXl) {
itemHeight = 160;
} else if (isLg) {
itemHeight = 160;
} else if (isMd) {
itemHeight = 160;
} else if (isSm) {
itemHeight = 160;
} else {
itemHeight = 160;
}
if (itemHeight === 0) {
return;
}
setTableMeta({
itemHeight,
});
}, 200);
}, []);
useLayoutEffect(() => {
throttledSetTableMeta(containerWidth);
}, [containerWidth, data.length, itemType, throttledSetTableMeta]);
const handleOnRowsRendered = useCallback(
(visibleRows: { startIndex: number; stopIndex: number }) => {
onRangeChanged?.({
endIndex: visibleRows.stopIndex * (tableMeta?.columnCount || 0),
startIndex: visibleRows.startIndex * (tableMeta?.columnCount || 0),
});
if (onStartReached || onEndReached) {
const totalRows = Math.ceil(totalItemCount / (tableMeta?.columnCount || 0));
const startRow = visibleRows.startIndex;
const endRow = visibleRows.stopIndex;
if (startRow === 0) {
onStartReached?.(startRow);
}
if (endRow >= totalRows) {
onEndReached?.(endRow);
}
}
},
[onEndReached, onRangeChanged, onStartReached, totalItemCount, tableMeta?.columnCount],
);
return (
<motion.div
animate={{
height: '100%',
opacity: 1,
transition: {
duration: 1,
ease: 'backInOut',
},
}}
className={styles.itemDetailContainer}
data-overlayscrollbars-initialize=""
initial={{ opacity: 0 }}
ref={mergedContainerRef}
>
<List
listRef={itemDetailRef}
onRowsRendered={handleOnRowsRendered}
onScroll={handleScroll}
rowComponent={RowComponent}
rowCount={data.length}
rowHeight={tableMeta?.itemHeight || 0}
rowProps={{
data,
handleExpand,
itemHeight: tableMeta?.itemHeight || 0,
itemType,
}}
/>
<AnimatePresence>
{hasExpanded && (
<motion.div
animate="show"
className={styles.listExpandedContainer}
exit="hidden"
initial="hidden"
style={{ height: '500px' }}
variants={expandedAnimationVariants}
>
<ExpandedListItem internalState={internalState} itemType={itemType} />
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
function RowComponent({
data,
handleExpand,
index,
itemHeight,
itemType,
style,
}: RowComponentProps<{
data: any[];
handleExpand: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
itemHeight: number;
itemType: LibraryItem;
}>) {
return (
<div className={styles.itemList} style={style}>
<ItemDetail
data={data[index]}
itemHeight={itemHeight}
itemType={itemType}
onClick={(e, item, itemType) => handleExpand(e, item, itemType)}
withControls
/>
</div>
);
}