diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0c0e22f5e..b611caa87 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1145,6 +1145,7 @@ "year": "$t(common.year)" }, "view": { + "detail": "detail", "grid": "grid", "list": "list", "table": "table" diff --git a/src/renderer/components/item-detail/item-detail.module.css b/src/renderer/components/item-detail/item-detail.module.css index 1e359b6a1..134edaa51 100644 --- a/src/renderer/components/item-detail/item-detail.module.css +++ b/src/renderer/components/item-detail/item-detail.module.css @@ -1,84 +1,178 @@ .container { - display: grid; - grid-template-rows: 1fr; - grid-template-columns: auto minmax(0, 1fr); - gap: var(--theme-spacing-sm); + position: relative; 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 { +.placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.row { + display: grid; + grid-template-columns: 240px 1fr; + gap: var(--theme-spacing-md); + padding: 0 var(--theme-spacing-md) var(--theme-spacing-xl) var(--theme-spacing-md); +} + +.image-wrapper { position: relative; - display: none; - height: 100%; - min-height: 0; - aspect-ratio: 1/1; + display: block; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + color: inherit; + text-decoration: none; + border-radius: var(--theme-radius-md); &::before { position: absolute; top: 0; left: 0; + z-index: 5; width: 100%; height: 100%; + pointer-events: none; content: ''; background-color: rgb(0 0 0); opacity: 0; - transition: all 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; } &:hover { - &::before { - opacity: 0.6; + @mixin dark { + &::before { + opacity: 0.7; + } + } + + @mixin light { + &::before { + opacity: 0.5; + } } } - - @container (min-width: 500px) { - display: block; - } } -.image { - aspect-ratio: 1/1; +.row .image { + object-fit: var(--theme-image-fit); + border-radius: var(--theme-radius-md); } -.metadata-container { +.row .metadata { 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; + gap: var(--theme-spacing-xs); align-items: center; - justify-content: space-between; - font-weight: 600; - line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--theme-font-size-md); + text-align: center; } -.metadata-container .header .title { - max-width: 70%; +.row .title { + font-weight: 500; +} + +.row .artist { + font-size: var(--theme-font-size-sm); + color: var(--theme-colors-foreground-muted); +} + +.row .tracks-table { + width: 100%; + font-size: var(--theme-font-size-sm); + table-layout: fixed; +} + +.row .track-col-number { + width: 3.5rem; + min-width: 3.5rem; + max-width: 3.5rem; + overflow: hidden; + text-overflow: ellipsis; + color: var(--theme-colors-foreground-muted); + text-align: left; + white-space: nowrap; +} + +.row .track-col-title { + width: auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.metadata-container .content { +.row .track-col-duration { + width: 8rem; + min-width: 8rem; + max-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + color: var(--theme-colors-foreground-muted); + text-align: right; + white-space: nowrap; +} + +.row .track-col-favorite { + width: 2.5rem; + min-width: 2.5rem; + max-width: 2.5rem; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + white-space: nowrap; +} + +.row .track-col-rating { + width: 5.5rem; + min-width: 5.5rem; + max-width: 5.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skeleton-image { + width: 100%; + aspect-ratio: 1; + border-radius: var(--theme-radius-md); +} + +.skeleton-title { + width: 75%; + height: 1.25rem; +} + +.skeleton-artist { + width: 50%; + height: 1rem; +} + +.skeleton-tracks { display: flex; flex-direction: column; gap: var(--theme-spacing-xs); } -.metadata-container .content .tags { +.skeleton-track-row { + display: grid; + grid-template-columns: 40px 1fr 8rem; + gap: var(--theme-spacing-sm); + align-items: center; +} + +.skeleton-track-cell { + width: 100%; + height: 1rem; +} + +.skeleton-track-cell-title { + width: 100%; + min-width: 0; + height: 1rem; } diff --git a/src/renderer/components/item-detail/item-detail.tsx b/src/renderer/components/item-detail/item-detail.tsx index c7b12ee32..8e33ed30d 100644 --- a/src/renderer/components/item-detail/item-detail.tsx +++ b/src/renderer/components/item-detail/item-detail.tsx @@ -1,146 +1,385 @@ -// import { AnimatePresence } from 'motion/react'; -// import { MouseEvent, useMemo, useState } from 'react'; -// import { Link } from 'react-router'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import formatDuration from 'format-duration'; +import throttle from 'lodash/throttle'; +import { AnimatePresence } from 'motion/react'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import { memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { generatePath, Link } from 'react-router'; +import { List, RowComponentProps, useDynamicRowHeight } from 'react-window-v2'; -// import styles from './item-detail.module.css'; +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'; +import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls'; +import { ItemImage } from '/@/renderer/components/item-image/item-image'; +import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id'; +import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; +import { + ItemListStateActions, + useItemListState, +} from '/@/renderer/components/item-list/helpers/item-list-state'; +import { ItemControls } from '/@/renderer/components/item-list/types'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; +import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; +import { AppRoute } from '/@/renderer/router/routes'; +import { Icon } from '/@/shared/components/icon/icon'; +import { ReadOnlyRating } from '/@/shared/components/read-only-rating/read-only-rating'; +import { Skeleton } from '/@/shared/components/skeleton/skeleton'; +import { Album, Song } from '/@/shared/types/domain-types'; -// interface ItemDetailProps { -// data: Album | AlbumArtist | Artist | Playlist | Song | undefined; -// itemHeight: number; -// itemType: LibraryItem; -// onClick?: (e: MouseEvent, item: unknown, itemType: LibraryItem) => void; -// withControls?: boolean; -// } +interface ItemDetailListProps { + currentPage?: number; + data?: unknown[]; + dataVersion?: number; + getItem?: (index: number) => unknown; + internalState?: ItemListStateActions; + itemCount?: number; + items?: unknown[]; + onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise | void; + rowHeight?: number; +} -// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => { -// const imageUrl = getImageUrl(data); +interface RowData { + controls?: ItemControls; + data: unknown[]; + getItem?: (index: number) => unknown; + internalState: ItemListStateActions; + isMutatingFavorite: boolean; + queryClient: ReturnType; +} -// const [showControls, setShowControls] = useState(false); +interface TrackRowProps { + isMutatingFavorite: boolean; + onFavoriteClick: (song: Song) => void; + song: Song; +} -// const { background } = useFastAverageColor({ -// algorithm: 'simple', -// src: imageUrl, -// srcLoaded: false, -// }); +const TrackRow = memo(({ isMutatingFavorite, onFavoriteClick, song }: TrackRowProps) => { + const discAndCol = + `${song.discNumber ?? 1}` + ' - ' + song.trackNumber.toString().padStart(2, '0'); -// // const tags = [...(data?.genres ?? [])]; + return ( + + + {discAndCol} + + {song.name} + + {formatDuration(song.duration)} + + +
{ + event.stopPropagation(); + event.preventDefault(); + onFavoriteClick(song); + }} + onDoubleClick={(event) => { + event.stopPropagation(); + event.preventDefault(); + }} + role="button" + > + +
+ + + + + + ); +}); -// const tags = useMemo(() => { -// if (!data) { -// return []; -// } +TrackRow.displayName = 'TrackRow'; -// const items: { -// color?: string; -// id: string; -// isLight?: boolean; -// itemType: LibraryItem; -// name: string; -// }[] = []; +type RowContentProps = Omit, 'style'>; -// 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 }); -// }); -// } +/** + * Inner row content – memoized with custom comparator so it does NOT re-render when only + * `style` or `ariaAttributes` change (e.g. on scroll). Only re-renders when data/index/mutation state change. + */ +const RowContent = memo( + ({ + controls, + data, + getItem, + index, + internalState, + isMutatingFavorite, + queryClient, + }: RowContentProps) => { + const [showControls, setShowControls] = useState(false); + const item = useMemo(() => { + if (getItem) { + return getItem(index) as Album | undefined; + } -// 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 }); -// }); -// } + return (data?.[index] as Album | undefined) || undefined; + }, [data, getItem, index]); -// // 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 }); -// // }); -// // } + const { data: songData } = useQuery({ + enabled: !!item && !!item.id, + ...albumQueries.detail({ + query: { + id: item?.id || '', + }, + serverId: item?._serverId || '', + }), + }); -// return items; -// }, [data]); + const songs = useMemo(() => { + return ( + songData?.songs || + Array.from({ length: item?.songCount || 0 }, (_, i) => ({ + duration: 0, + id: `${item?.id}-${i}`, + name: '', + trackNumber: i + 1, + })) + ); + }, [songData, item?.id, item?.songCount]); -// 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 onFavoriteClick = useCallback((song: Song) => { + // TODO: toggle favorite for song + void song; + }, []); -// const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => { -// if (data && 'imageUrl' in data) { -// return data.imageUrl || undefined; -// } + if (!item) { + return ( + <> +
+
+ + + +
+
+
+
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ + ); + } -// return undefined; -// }; + return ( + <> +
+
+ setShowControls(true)} + onMouseLeave={() => setShowControls(false)} + state={{ item }} + to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { + albumId: item.id, + })} + > + + + {controls && showControls && ( + + )} + + +
{item.name}
+
{item.albumArtistName}
+
+
+ +
+ + + {songs.map((song) => ( + + ))} + +
+
+ + ); + }, + (prev, next) => + prev.index === next.index && + prev.data === next.data && + prev.getItem === next.getItem && + prev.internalState === next.internalState && + prev.queryClient === next.queryClient && + prev.isMutatingFavorite === next.isMutatingFavorite && + prev.controls === next.controls, +); + +RowContent.displayName = 'RowContent'; + +const RowComponent = memo((props: RowComponentProps): ReactElement => { + const { style, ...rowContentProps } = props; + return ( +
+ +
+ ); +}); + +RowComponent.displayName = 'ItemDetailRow'; + +export const ItemDetailList = ({ + currentPage, + data, + dataVersion, + getItem, + itemCount: externalItemCount, + items, + onRangeChanged, +}: ItemDetailListProps) => { + const containerRef = useRef(null); + const queryClient = useQueryClient(); + const controls = useDefaultItemListControls(); + const isMutatingCreateFavorite = useIsMutatingCreateFavorite(); + const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite(); + const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite; + + const rowHeight = useDynamicRowHeight({ + defaultRowHeight: 300, + }); + + const isInfinite = data !== undefined || getItem !== undefined; + const isPaginated = items !== undefined || currentPage !== undefined; + + const dataSource = useMemo(() => { + if (isInfinite && data) { + return data; + } + if (isPaginated && items) { + return items; + } + return []; + }, [data, isInfinite, isPaginated, items]); + + const itemCount = useMemo(() => { + if (externalItemCount !== undefined) { + return externalItemCount; + } + return dataSource.length; + }, [dataSource.length, externalItemCount]); + + // Create extract row ID function + const extractRowId = useMemo(() => createExtractRowId(), []); + + // Create getData function + const getDataFn = useCallback(() => dataSource, [dataSource]); + + // Create internal state + const internalState = useItemListState(getDataFn, extractRowId); + + const handleRowsRendered = useCallback( + (range: { startIndex: number; stopIndex: number }) => { + if (onRangeChanged) { + onRangeChanged(range); + } + }, + [onRangeChanged], + ); + + const throttledHandleRowsRendered = useMemo( + () => + throttle(handleRowsRendered, 150, { + leading: true, + trailing: true, + }), + [handleRowsRendered], + ); + + useEffect(() => { + return () => { + throttledHandleRowsRendered.cancel(); + }; + }, [throttledHandleRowsRendered]); + + const rowProps = useMemo( + () => ({ + controls, + data: dataSource, + getItem, + internalState, + isMutatingFavorite, + queryClient, + }), + [controls, dataSource, getItem, internalState, isMutatingFavorite, queryClient], + ); + + const [initialize, osInstance] = useOverlayScrollbars({ + defer: false, + events: { + initialized(osInstance) { + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + }, + }, + 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: container } = containerRef; + + if (!container || !container.firstElementChild) { + return; + } + + const viewport = container.firstElementChild as HTMLElement; + + initialize({ + elements: { viewport }, + target: container, + }); + + return () => osInstance()?.destroy(); + }, [initialize, osInstance]); + + return ( +
+ ) => ReactElement} + rowCount={itemCount} + rowHeight={rowHeight} + rowProps={rowProps} + /> +
+ ); +}; diff --git a/src/renderer/features/albums/components/album-list-content.tsx b/src/renderer/features/albums/components/album-list-content.tsx index e530772d8..aa5b1e86d 100644 --- a/src/renderer/features/albums/components/album-list-content.tsx +++ b/src/renderer/features/albums/components/album-list-content.tsx @@ -36,6 +36,18 @@ const AlbumListPaginatedTable = lazy(() => })), ); +const AlbumListInfiniteDetail = lazy(() => + import('/@/renderer/features/albums/components/album-list-infinite-detail').then((module) => ({ + default: module.AlbumListInfiniteDetail, + })), +); + +const AlbumListPaginatedDetail = lazy(() => + import('/@/renderer/features/albums/components/album-list-paginated-detail').then((module) => ({ + default: module.AlbumListPaginatedDetail, + })), +); + const AlbumListFilters = () => { return ( @@ -179,6 +191,30 @@ export const AlbumListView = ({ return null; } } + case ListDisplayType.DETAIL: { + switch (pagination) { + case ListPaginationType.INFINITE: { + return ( + + ); + } + case ListPaginationType.PAGINATED: { + return ( + + ); + } + default: + return null; + } + } } return null; diff --git a/src/renderer/features/albums/components/album-list-infinite-detail.tsx b/src/renderer/features/albums/components/album-list-infinite-detail.tsx new file mode 100644 index 000000000..ec02aff3b --- /dev/null +++ b/src/renderer/features/albums/components/album-list-infinite-detail.tsx @@ -0,0 +1,59 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; + +import { api } from '/@/renderer/api'; +import { ItemDetailList } from '/@/renderer/components/item-detail/item-detail'; +import { useItemListInfiniteLoader } from '/@/renderer/components/item-list/helpers/item-list-infinite-loader'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemListComponentProps } from '/@/renderer/components/item-list/types'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { + AlbumListQuery, + AlbumListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumListInfiniteDetailProps extends ItemListComponentProps {} + +export const AlbumListInfiniteDetail = ({ + itemsPerPage = 100, + query = { + sortBy: AlbumListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, +}: AlbumListInfiniteDetailProps) => { + const listCountQuery = albumQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getAlbumList; + + const { dataVersion, getItem, itemCount, loadedItems, onRangeChanged } = + useItemListInfiniteLoader({ + eventKey: ItemListKey.ALBUM, + itemsPerPage, + itemType: LibraryItem.ALBUM, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + return ( + + ); +}; diff --git a/src/renderer/features/albums/components/album-list-paginated-detail.tsx b/src/renderer/features/albums/components/album-list-paginated-detail.tsx new file mode 100644 index 000000000..fc3b97c9f --- /dev/null +++ b/src/renderer/features/albums/components/album-list-paginated-detail.tsx @@ -0,0 +1,65 @@ +import { UseSuspenseQueryOptions } from '@tanstack/react-query'; + +import { api } from '/@/renderer/api'; +import { ItemDetailList } from '/@/renderer/components/item-detail/item-detail'; +import { useItemListPaginatedLoader } from '/@/renderer/components/item-list/helpers/item-list-paginated-loader'; +import { useItemListScrollPersist } from '/@/renderer/components/item-list/helpers/use-item-list-scroll-persist'; +import { ItemListWithPagination } from '/@/renderer/components/item-list/item-list-pagination/item-list-pagination'; +import { useItemListPagination } from '/@/renderer/components/item-list/item-list-pagination/use-item-list-pagination'; +import { ItemListComponentProps } from '/@/renderer/components/item-list/types'; +import { albumQueries } from '/@/renderer/features/albums/api/album-api'; +import { + AlbumListQuery, + AlbumListSort, + LibraryItem, + SortOrder, +} from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +interface AlbumListPaginatedDetailProps extends ItemListComponentProps {} + +export const AlbumListPaginatedDetail = ({ + itemsPerPage = 100, + query = { + sortBy: AlbumListSort.NAME, + sortOrder: SortOrder.ASC, + }, + saveScrollOffset = true, + serverId, +}: AlbumListPaginatedDetailProps) => { + const listCountQuery = albumQueries.listCount({ + query: { ...query }, + serverId: serverId, + }) as UseSuspenseQueryOptions; + + const listQueryFn = api.controller.getAlbumList; + + const { currentPage, onChange } = useItemListPagination(); + + const { data, pageCount, totalItemCount } = useItemListPaginatedLoader({ + currentPage, + eventKey: ItemListKey.ALBUM, + itemsPerPage, + itemType: LibraryItem.ALBUM, + listCountQuery, + listQueryFn, + query, + serverId, + }); + + const { handleOnScrollEnd, scrollOffset } = useItemListScrollPersist({ + enabled: saveScrollOffset, + }); + + return ( + + + + ); +}; diff --git a/src/renderer/features/shared/components/list-config-menu.tsx b/src/renderer/features/shared/components/list-config-menu.tsx index 137868b6b..07b5c7e86 100644 --- a/src/renderer/features/shared/components/list-config-menu.tsx +++ b/src/renderer/features/shared/components/list-config-menu.tsx @@ -37,6 +37,15 @@ const DISPLAY_TYPES = [ ), value: ListDisplayType.GRID, }, + { + label: ( + + + {i18n.t('table.config.view.detail', { postProcess: 'sentenceCase' }) as string} + + ), + value: ListDisplayType.DETAIL, + }, // { // disabled: true, // label: ( @@ -190,6 +199,10 @@ const Config = ({ /> ); + case ListDisplayType.DETAIL: + // Detail view doesn't have specific configuration options + return null; + default: return null; } diff --git a/src/renderer/utils/format.tsx b/src/renderer/utils/format.tsx index cff6a160e..a455e1041 100644 --- a/src/renderer/utils/format.tsx +++ b/src/renderer/utils/format.tsx @@ -104,24 +104,23 @@ export const formatDurationString = (duration: number) => { return part.replace(/^0/, ''); }); - let string: string = ''; + const parts: string[] = []; + const len = rawDuration.length; - switch (rawDuration.length) { - case 1: - string = `${formattedDuration[0]}${i18n.t('datetime.secondShort')}`; - break; - case 2: - string = `${formattedDuration[0]}${i18n.t('datetime.minuteShort')} ${formattedDuration[1]}${i18n.t('datetime.secondShort')}`; - break; - case 3: - string = `${formattedDuration[0]}${i18n.t('datetime.hourShort')} ${formattedDuration[1]}${i18n.t('datetime.minuteShort')} ${formattedDuration[2]}${i18n.t('datetime.secondShort')}`; - break; - case 4: - string = `${formattedDuration[0]}${i18n.t('datetime.dayShort')} ${formattedDuration[1]}${i18n.t('datetime.hourShort')} ${formattedDuration[2]}${i18n.t('datetime.minuteShort')} ${formattedDuration[3]}${i18n.t('datetime.secondShort')}`; - break; + if (len >= 1 && formattedDuration[len - 1] !== undefined) { + parts.push(`${formattedDuration[len - 1]}${i18n.t('datetime.secondShort')}`); + } + if (len >= 2 && formattedDuration[len - 2]) { + parts.unshift(`${formattedDuration[len - 2]}${i18n.t('datetime.minuteShort')}`); + } + if (len >= 3 && formattedDuration[len - 3]) { + parts.unshift(`${formattedDuration[len - 3]}${i18n.t('datetime.hourShort')}`); + } + if (len >= 4 && formattedDuration[len - 4]) { + parts.unshift(`${formattedDuration[len - 4]}${i18n.t('datetime.dayShort')}`); } - return string; + return parts.join(' '); }; export const formatDurationStringShort = (duration: number) => { diff --git a/src/shared/components/read-only-rating/read-only-rating.module.css b/src/shared/components/read-only-rating/read-only-rating.module.css new file mode 100644 index 000000000..6b8141710 --- /dev/null +++ b/src/shared/components/read-only-rating/read-only-rating.module.css @@ -0,0 +1,31 @@ +.root { + display: inline-flex; + gap: 0.125rem; + align-items: center; + line-height: 1; +} + +.root.interactive { + cursor: pointer; +} + +.root.xs { + font-size: var(--theme-font-size-xs); +} + +.root.sm { + font-size: var(--theme-font-size-sm); +} + +.root.md { + font-size: var(--theme-font-size-md); +} + +.filled { + color: var(--theme-colors-primary); +} + +.empty { + color: var(--theme-colors-foreground-muted); + opacity: 0.6; +} diff --git a/src/shared/components/read-only-rating/read-only-rating.tsx b/src/shared/components/read-only-rating/read-only-rating.tsx new file mode 100644 index 000000000..1c1319f79 --- /dev/null +++ b/src/shared/components/read-only-rating/read-only-rating.tsx @@ -0,0 +1,81 @@ +import clsx from 'clsx'; +import { memo, useCallback, useState } from 'react'; + +import styles from './read-only-rating.module.css'; + +const MAX_STARS = 5; + +interface ReadOnlyRatingProps { + className?: string; + onChange?: (value: number) => void; + size?: 'md' | 'sm' | 'xs'; + value?: null | number; +} + +function ReadOnlyRatingComponent({ className, onChange, size = 'sm', value }: ReadOnlyRatingProps) { + const [hoverIndex, setHoverIndex] = useState(null); + const rating = Math.min(MAX_STARS, Math.max(0, value ?? 0)); + const displayCount = hoverIndex !== null ? hoverIndex : Math.floor(rating); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!onChange) return; + const el = e.currentTarget; + const width = (el as HTMLElement).offsetWidth; + if (width <= 0) return; + const x = e.clientX - el.getBoundingClientRect().left; + const segment = Math.floor((x / width) * MAX_STARS); + const filled = segment < 0 ? 0 : Math.min(MAX_STARS, segment + 1); + setHoverIndex(filled); + }, + [onChange], + ); + + const handlePointerLeave = useCallback(() => { + setHoverIndex(null); + }, []); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!onChange) return; + e.preventDefault(); + e.stopPropagation(); + const el = e.currentTarget; + const width = (el as HTMLElement).offsetWidth; + if (width <= 0) return; + const x = e.clientX - el.getBoundingClientRect().left; + const segment = Math.floor((x / width) * MAX_STARS); + const clicked = segment < 0 ? 0 : Math.min(MAX_STARS, segment + 1); + onChange(clicked === rating ? 0 : clicked); + }, + [onChange, rating], + ); + + const isInteractive = typeof onChange === 'function'; + + return ( + + {Array.from({ length: MAX_STARS }, (_, i) => ( + + ★ + + ))} + + ); +} + +export const ReadOnlyRating = memo(ReadOnlyRatingComponent); + +ReadOnlyRating.displayName = 'ReadOnlyRating'; diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index 11d58b146..cb687f08f 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -34,6 +34,7 @@ export enum ItemListKey { } export enum ListDisplayType { + DETAIL = 'detail', GRID = 'poster', LIST = 'list', TABLE = 'table',