From 3f3a02ba8c333f39a1dc1c7376ebaa572f4138fc Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 29 Sep 2025 17:35:03 -0700 Subject: [PATCH] add initial item detail list design --- .../item-detail/item-detail.module.css | 84 ++++++ .../components/item-detail/item-detail.tsx | 146 +++++++++ .../item-list-detail.module.css | 18 ++ .../item-list-detail/item-list-detail.tsx | 285 ++++++++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 src/renderer/components/item-detail/item-detail.module.css create mode 100644 src/renderer/components/item-detail/item-detail.tsx create mode 100644 src/renderer/components/item-list/item-list-detail/item-list-detail.module.css create mode 100644 src/renderer/components/item-list/item-list-detail/item-list-detail.tsx diff --git a/src/renderer/components/item-detail/item-detail.module.css b/src/renderer/components/item-detail/item-detail.module.css new file mode 100644 index 000000000..1e359b6a1 --- /dev/null +++ b/src/renderer/components/item-detail/item-detail.module.css @@ -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 { +} diff --git a/src/renderer/components/item-detail/item-detail.tsx b/src/renderer/components/item-detail/item-detail.tsx new file mode 100644 index 000000000..5810f669b --- /dev/null +++ b/src/renderer/components/item-detail/item-detail.tsx @@ -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, 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 ( +
onClick?.(e, data, itemType)} + style={{ backgroundColor: background }} + > +
withControls && setShowControls(true)} + onMouseLeave={() => withControls && setShowControls(false)} + > + {data?.name} + + {withControls && showControls && } + +
+
+
+ + {data?.name} + + + {data && 'userRating' in data && ( + + )} + {data && 'userFavorite' in data && ( + + )} + +
+ +
+ + {tags.map((tag) => ( + + {tag.name} + + ))} + +
+
+
+ ); +}; + +const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => { + if (data && 'imageUrl' in data) { + return data.imageUrl || undefined; + } + + return undefined; +}; diff --git a/src/renderer/components/item-list/item-list-detail/item-list-detail.module.css b/src/renderer/components/item-list/item-list-detail/item-list-detail.module.css new file mode 100644 index 000000000..62a1178da --- /dev/null +++ b/src/renderer/components/item-list/item-list-detail/item-list-detail.module.css @@ -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); +} diff --git a/src/renderer/components/item-list/item-list-detail/item-list-detail.tsx b/src/renderer/components/item-list/item-list-detail/item-list-detail.tsx new file mode 100644 index 000000000..0794bfd1c --- /dev/null +++ b/src/renderer/components/item-list/item-list-detail/item-list-detail.tsx @@ -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) => void; + onScrollEnd?: () => void; + onStartReached?: (index: number) => void; + ref: Ref; + 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(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, 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) => { + onScroll?.(e); + }, + [onScroll], + ); + + const [tableMeta, setTableMeta] = useState(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 ( + + + + {hasExpanded && ( + + + + )} + + + ); +}; + +function RowComponent({ + data, + handleExpand, + index, + itemHeight, + itemType, + style, +}: RowComponentProps<{ + data: any[]; + handleExpand: (e: MouseEvent, item: unknown, itemType: LibraryItem) => void; + itemHeight: number; + itemType: LibraryItem; +}>) { + return ( +
+ handleExpand(e, item, itemType)} + withControls + /> +
+ ); +}