mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
add initial item detail list design
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user