mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-16 21:50:35 +02:00
initial progress on item detail list
This commit is contained in:
@@ -1145,6 +1145,7 @@
|
|||||||
"year": "$t(common.year)"
|
"year": "$t(common.year)"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
|
"detail": "detail",
|
||||||
"grid": "grid",
|
"grid": "grid",
|
||||||
"list": "list",
|
"list": "list",
|
||||||
"table": "table"
|
"table": "table"
|
||||||
|
|||||||
@@ -1,84 +1,178 @@
|
|||||||
.container {
|
.container {
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-rows: 1fr;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
gap: var(--theme-spacing-sm);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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;
|
position: relative;
|
||||||
display: none;
|
display: block;
|
||||||
height: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
aspect-ratio: 1;
|
||||||
aspect-ratio: 1/1;
|
overflow: hidden;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
z-index: 5;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
content: '';
|
content: '';
|
||||||
background-color: rgb(0 0 0);
|
background-color: rgb(0 0 0);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
&::before {
|
@mixin dark {
|
||||||
opacity: 0.6;
|
&::before {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
&::before {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (min-width: 500px) {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.row .image {
|
||||||
aspect-ratio: 1/1;
|
object-fit: var(--theme-image-fit);
|
||||||
|
border-radius: var(--theme-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-container {
|
.row .metadata {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--theme-spacing-sm);
|
gap: var(--theme-spacing-xs);
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: var(--theme-spacing-xs) 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-container .header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
overflow: hidden;
|
||||||
font-weight: 600;
|
text-overflow: ellipsis;
|
||||||
line-height: 1.2;
|
font-size: var(--theme-font-size-md);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-container .header .title {
|
.row .title {
|
||||||
max-width: 70%;
|
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;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--theme-spacing-xs);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,146 +1,385 @@
|
|||||||
// import { AnimatePresence } from 'motion/react';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
// import { MouseEvent, useMemo, useState } from 'react';
|
import formatDuration from 'format-duration';
|
||||||
// import { Link } from 'react-router';
|
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 { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
||||||
// import { useFastAverageColor } from '/@/renderer/hooks';
|
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
||||||
// import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
import { createExtractRowId } from '/@/renderer/components/item-list/helpers/extract-row-id';
|
||||||
// import { Badge } from '/@/shared/components/badge/badge';
|
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
|
||||||
// import { Divider } from '/@/shared/components/divider/divider';
|
import {
|
||||||
// import { Group } from '/@/shared/components/group/group';
|
ItemListStateActions,
|
||||||
// import { Image } from '/@/shared/components/image/image';
|
useItemListState,
|
||||||
// import { Rating } from '/@/shared/components/rating/rating';
|
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||||
// import { Text } from '/@/shared/components/text/text';
|
import { ItemControls } from '/@/renderer/components/item-list/types';
|
||||||
// import {
|
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
// Album,
|
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||||
// AlbumArtist,
|
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||||
// Artist,
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
// LibraryItem,
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
// Playlist,
|
import { ReadOnlyRating } from '/@/shared/components/read-only-rating/read-only-rating';
|
||||||
// Song,
|
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||||
// } from '/@/shared/types/domain-types';
|
import { Album, Song } from '/@/shared/types/domain-types';
|
||||||
// import { stringToColor } from '/@/shared/utils/string-to-color';
|
|
||||||
|
|
||||||
// interface ItemDetailProps {
|
interface ItemDetailListProps {
|
||||||
// data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
|
currentPage?: number;
|
||||||
// itemHeight: number;
|
data?: unknown[];
|
||||||
// itemType: LibraryItem;
|
dataVersion?: number;
|
||||||
// onClick?: (e: MouseEvent<HTMLDivElement>, item: unknown, itemType: LibraryItem) => void;
|
getItem?: (index: number) => unknown;
|
||||||
// withControls?: boolean;
|
internalState?: ItemListStateActions;
|
||||||
// }
|
itemCount?: number;
|
||||||
|
items?: unknown[];
|
||||||
|
onRangeChanged?: (range: { startIndex: number; stopIndex: number }) => Promise<void> | void;
|
||||||
|
rowHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// export const ItemDetail = ({ data, itemType, onClick, withControls }: ItemDetailProps) => {
|
interface RowData {
|
||||||
// const imageUrl = getImageUrl(data);
|
controls?: ItemControls;
|
||||||
|
data: unknown[];
|
||||||
|
getItem?: (index: number) => unknown;
|
||||||
|
internalState: ItemListStateActions;
|
||||||
|
isMutatingFavorite: boolean;
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>;
|
||||||
|
}
|
||||||
|
|
||||||
// const [showControls, setShowControls] = useState(false);
|
interface TrackRowProps {
|
||||||
|
isMutatingFavorite: boolean;
|
||||||
|
onFavoriteClick: (song: Song) => void;
|
||||||
|
song: Song;
|
||||||
|
}
|
||||||
|
|
||||||
// const { background } = useFastAverageColor({
|
const TrackRow = memo(({ isMutatingFavorite, onFavoriteClick, song }: TrackRowProps) => {
|
||||||
// algorithm: 'simple',
|
const discAndCol =
|
||||||
// src: imageUrl,
|
`${song.discNumber ?? 1}` + ' - ' + song.trackNumber.toString().padStart(2, '0');
|
||||||
// srcLoaded: false,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // const tags = [...(data?.genres ?? [])];
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className={styles.trackColNumber} style={{ fontFamily: 'monospace' }}>
|
||||||
|
{discAndCol}
|
||||||
|
</td>
|
||||||
|
<td className={styles.trackColTitle}>{song.name}</td>
|
||||||
|
<td className={styles.trackColDuration} style={{ fontFamily: 'monospace' }}>
|
||||||
|
{formatDuration(song.duration)}
|
||||||
|
</td>
|
||||||
|
<td className={styles.trackColFavorite}>
|
||||||
|
<div
|
||||||
|
aria-disabled={isMutatingFavorite}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
onFavoriteClick(song);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<Icon icon="favorite" size="xs" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={styles.trackColRating}>
|
||||||
|
<ReadOnlyRating size="md" value={song.userRating} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// const tags = useMemo(() => {
|
TrackRow.displayName = 'TrackRow';
|
||||||
// if (!data) {
|
|
||||||
// return [];
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const items: {
|
type RowContentProps = Omit<RowComponentProps<RowData>, 'style'>;
|
||||||
// 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 }) => {
|
* Inner row content – memoized with custom comparator so it does NOT re-render when only
|
||||||
// items.push({ id: tag.id, itemType: LibraryItem.ALBUM_ARTIST, name: tag.name });
|
* `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)) {
|
return (data?.[index] as Album | undefined) || undefined;
|
||||||
// data.genres?.forEach((tag: { id: string; itemType: LibraryItem; name: string }) => {
|
}, [data, getItem, index]);
|
||||||
// const { color, isLight } = stringToColor(tag.name);
|
|
||||||
// items.push({ ...tag, color, isLight });
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // if ('tags' in data && typeof data.tags === 'object') {
|
const { data: songData } = useQuery({
|
||||||
// // console.log('data.tags :>> ', data.tags);
|
enabled: !!item && !!item.id,
|
||||||
// // Object.entries(data.tags).forEach(([key, value]) => {
|
...albumQueries.detail({
|
||||||
// // items.push({ id: key, itemType: LibraryItem.TAG, name: value });
|
query: {
|
||||||
// // });
|
id: item?.id || '',
|
||||||
// // }
|
},
|
||||||
|
serverId: item?._serverId || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
// return items;
|
const songs = useMemo(() => {
|
||||||
// }, [data]);
|
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 (
|
const onFavoriteClick = useCallback((song: Song) => {
|
||||||
// <div
|
// TODO: toggle favorite for song
|
||||||
// className={styles.container}
|
void song;
|
||||||
// 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 (!item) {
|
||||||
// if (data && 'imageUrl' in data) {
|
return (
|
||||||
// return data.imageUrl || undefined;
|
<>
|
||||||
// }
|
<div className={styles.left}>
|
||||||
|
<div className={styles.metadata}>
|
||||||
|
<Skeleton className={styles.skeletonImage} />
|
||||||
|
<Skeleton className={styles.skeletonTitle} />
|
||||||
|
<Skeleton className={styles.skeletonArtist} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.right}>
|
||||||
|
<div className={styles.skeletonTracks}>
|
||||||
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
|
<div className={styles.skeletonTrackRow} key={i}>
|
||||||
|
<Skeleton className={styles.skeletonTrackCell} />
|
||||||
|
<Skeleton className={styles.skeletonTrackCellTitle} />
|
||||||
|
<Skeleton className={styles.skeletonTrackCell} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// return undefined;
|
return (
|
||||||
// };
|
<>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<div className={styles.metadata}>
|
||||||
|
<Link
|
||||||
|
className={styles.imageWrapper}
|
||||||
|
onMouseEnter={() => setShowControls(true)}
|
||||||
|
onMouseLeave={() => setShowControls(false)}
|
||||||
|
state={{ item }}
|
||||||
|
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
|
||||||
|
albumId: item.id,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ItemImage
|
||||||
|
className={styles.image}
|
||||||
|
id={item.imageId}
|
||||||
|
itemType={item._itemType}
|
||||||
|
type="itemCard"
|
||||||
|
/>
|
||||||
|
<AnimatePresence>
|
||||||
|
{controls && showControls && (
|
||||||
|
<ItemCardControls
|
||||||
|
controls={controls}
|
||||||
|
enableExpansion={false}
|
||||||
|
internalState={internalState}
|
||||||
|
item={item}
|
||||||
|
itemType={item._itemType}
|
||||||
|
showRating={true}
|
||||||
|
type="compact"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Link>
|
||||||
|
<div className={styles.title}>{item.name}</div>
|
||||||
|
<div className={styles.artist}>{item.albumArtistName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.right}>
|
||||||
|
<table className={styles.tracksTable}>
|
||||||
|
<tbody>
|
||||||
|
{songs.map((song) => (
|
||||||
|
<TrackRow
|
||||||
|
isMutatingFavorite={isMutatingFavorite}
|
||||||
|
key={song.id}
|
||||||
|
onFavoriteClick={onFavoriteClick}
|
||||||
|
song={song as Song}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(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<RowData>): ReactElement => {
|
||||||
|
const { style, ...rowContentProps } = props;
|
||||||
|
return (
|
||||||
|
<div className={styles.row} style={style}>
|
||||||
|
<RowContent {...rowContentProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
RowComponent.displayName = 'ItemDetailRow';
|
||||||
|
|
||||||
|
export const ItemDetailList = ({
|
||||||
|
currentPage,
|
||||||
|
data,
|
||||||
|
dataVersion,
|
||||||
|
getItem,
|
||||||
|
itemCount: externalItemCount,
|
||||||
|
items,
|
||||||
|
onRangeChanged,
|
||||||
|
}: ItemDetailListProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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<RowData>(
|
||||||
|
() => ({
|
||||||
|
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 (
|
||||||
|
<div className={styles.container} ref={containerRef}>
|
||||||
|
<List
|
||||||
|
onRowsRendered={throttledHandleRowsRendered}
|
||||||
|
rowComponent={RowComponent as (props: RowComponentProps<RowData>) => ReactElement}
|
||||||
|
rowCount={itemCount}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
rowProps={rowProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 = () => {
|
const AlbumListFilters = () => {
|
||||||
return (
|
return (
|
||||||
<ListWithSidebarContainer.SidebarPortal>
|
<ListWithSidebarContainer.SidebarPortal>
|
||||||
@@ -179,6 +191,30 @@ export const AlbumListView = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case ListDisplayType.DETAIL: {
|
||||||
|
switch (pagination) {
|
||||||
|
case ListPaginationType.INFINITE: {
|
||||||
|
return (
|
||||||
|
<AlbumListInfiniteDetail
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
query={mergedQuery}
|
||||||
|
serverId={server.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case ListPaginationType.PAGINATED: {
|
||||||
|
return (
|
||||||
|
<AlbumListPaginatedDetail
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
query={mergedQuery}
|
||||||
|
serverId={server.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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<AlbumListQuery> {}
|
||||||
|
|
||||||
|
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<number, Error, number, readonly unknown[]>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ItemDetailList
|
||||||
|
data={loadedItems}
|
||||||
|
dataVersion={dataVersion}
|
||||||
|
getItem={getItem}
|
||||||
|
itemCount={itemCount}
|
||||||
|
onRangeChanged={onRangeChanged}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<AlbumListQuery> {}
|
||||||
|
|
||||||
|
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<number, Error, number, readonly unknown[]>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ItemListWithPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
onChange={onChange}
|
||||||
|
pageCount={pageCount}
|
||||||
|
totalItemCount={totalItemCount}
|
||||||
|
>
|
||||||
|
<ItemDetailList currentPage={currentPage} items={data || []} />
|
||||||
|
</ItemListWithPagination>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -37,6 +37,15 @@ const DISPLAY_TYPES = [
|
|||||||
),
|
),
|
||||||
value: ListDisplayType.GRID,
|
value: ListDisplayType.GRID,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Group align="center" justify="center" p="sm">
|
||||||
|
<Icon icon="layoutList" size="lg" />
|
||||||
|
{i18n.t('table.config.view.detail', { postProcess: 'sentenceCase' }) as string}
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
value: ListDisplayType.DETAIL,
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// disabled: true,
|
// disabled: true,
|
||||||
// label: (
|
// label: (
|
||||||
@@ -190,6 +199,10 @@ const Config = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case ListDisplayType.DETAIL:
|
||||||
|
// Detail view doesn't have specific configuration options
|
||||||
|
return null;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,24 +104,23 @@ export const formatDurationString = (duration: number) => {
|
|||||||
return part.replace(/^0/, '');
|
return part.replace(/^0/, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
let string: string = '';
|
const parts: string[] = [];
|
||||||
|
const len = rawDuration.length;
|
||||||
|
|
||||||
switch (rawDuration.length) {
|
if (len >= 1 && formattedDuration[len - 1] !== undefined) {
|
||||||
case 1:
|
parts.push(`${formattedDuration[len - 1]}${i18n.t('datetime.secondShort')}`);
|
||||||
string = `${formattedDuration[0]}${i18n.t('datetime.secondShort')}`;
|
}
|
||||||
break;
|
if (len >= 2 && formattedDuration[len - 2]) {
|
||||||
case 2:
|
parts.unshift(`${formattedDuration[len - 2]}${i18n.t('datetime.minuteShort')}`);
|
||||||
string = `${formattedDuration[0]}${i18n.t('datetime.minuteShort')} ${formattedDuration[1]}${i18n.t('datetime.secondShort')}`;
|
}
|
||||||
break;
|
if (len >= 3 && formattedDuration[len - 3]) {
|
||||||
case 3:
|
parts.unshift(`${formattedDuration[len - 3]}${i18n.t('datetime.hourShort')}`);
|
||||||
string = `${formattedDuration[0]}${i18n.t('datetime.hourShort')} ${formattedDuration[1]}${i18n.t('datetime.minuteShort')} ${formattedDuration[2]}${i18n.t('datetime.secondShort')}`;
|
}
|
||||||
break;
|
if (len >= 4 && formattedDuration[len - 4]) {
|
||||||
case 4:
|
parts.unshift(`${formattedDuration[len - 4]}${i18n.t('datetime.dayShort')}`);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return string;
|
return parts.join(' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDurationStringShort = (duration: number) => {
|
export const formatDurationStringShort = (duration: number) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 | number>(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 (
|
||||||
|
<span
|
||||||
|
aria-label={isInteractive ? undefined : `${rating} out of ${MAX_STARS} stars`}
|
||||||
|
className={clsx(
|
||||||
|
styles.root,
|
||||||
|
size && styles[size],
|
||||||
|
isInteractive && styles.interactive,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={isInteractive ? handleClick : undefined}
|
||||||
|
onPointerLeave={isInteractive ? handlePointerLeave : undefined}
|
||||||
|
onPointerMove={isInteractive ? handlePointerMove : undefined}
|
||||||
|
role={isInteractive ? undefined : 'img'}
|
||||||
|
>
|
||||||
|
{Array.from({ length: MAX_STARS }, (_, i) => (
|
||||||
|
<span className={i < displayCount ? styles.filled : styles.empty} key={i}>
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReadOnlyRating = memo(ReadOnlyRatingComponent);
|
||||||
|
|
||||||
|
ReadOnlyRating.displayName = 'ReadOnlyRating';
|
||||||
@@ -34,6 +34,7 @@ export enum ItemListKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum ListDisplayType {
|
export enum ListDisplayType {
|
||||||
|
DETAIL = 'detail',
|
||||||
GRID = 'poster',
|
GRID = 'poster',
|
||||||
LIST = 'list',
|
LIST = 'list',
|
||||||
TABLE = 'table',
|
TABLE = 'table',
|
||||||
|
|||||||
Reference in New Issue
Block a user