Files
feishin/src/renderer/components/item-card/item-card.tsx
T
2025-11-29 19:33:33 -08:00

922 lines
30 KiB
TypeScript

import clsx from 'clsx';
import formatDuration from 'format-duration';
import { AnimatePresence } from 'motion/react';
import { Fragment, memo, ReactNode, useState } from 'react';
import { generatePath, Link } from 'react-router';
import styles from './item-card.module.css';
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes';
import { formatDateAbsolute, formatDateRelative, formatRating } from '/@/renderer/utils/format';
import { Image } from '/@/shared/components/image/image';
import { Separator } from '/@/shared/components/separator/separator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
import { useDoubleClick } from '/@/shared/hooks/use-double-click';
import {
Album,
AlbumArtist,
Artist,
LibraryItem,
Playlist,
Song,
} from '/@/shared/types/domain-types';
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
export type DataRow = {
align?: 'center' | 'end' | 'start';
format: (data: Album | AlbumArtist | Artist | Playlist | Song) => null | ReactNode | string;
id: string;
isMuted?: boolean;
};
export interface ItemCardProps {
controls?: ItemControls;
data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
enableDrag?: boolean;
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
rows?: DataRow[];
type?: 'compact' | 'default' | 'poster';
withControls?: boolean;
}
export const ItemCard = ({
controls,
data,
enableDrag,
internalState,
isRound,
itemType,
rows: providedRows,
type = 'poster',
withControls,
}: ItemCardProps) => {
const imageUrl = getImageUrl(data);
const defaultRows = getDataRows();
const rows = providedRows && providedRows.length > 0 ? providedRows : defaultRows;
switch (type) {
case 'compact':
return (
<CompactItemCard
controls={controls}
data={data}
enableDrag={enableDrag}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
itemType={itemType}
rows={rows}
withControls={withControls}
/>
);
case 'poster':
return (
<PosterItemCard
controls={controls}
data={data}
enableDrag={enableDrag}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
itemType={itemType}
rows={rows}
withControls={withControls}
/>
);
case 'default':
default:
return (
<DefaultItemCard
controls={controls}
data={data}
enableDrag={enableDrag}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
itemType={itemType}
rows={rows}
withControls={withControls}
/>
);
}
};
export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
controls?: ItemControls;
imageUrl: string | undefined;
internalState?: ItemListStateActions;
rows: DataRow[];
}
const CompactItemCard = ({
controls,
data,
imageUrl,
internalState,
isRound,
itemType,
rows,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected(internalState.extractRowId(data) || '')
: false;
const handleClick = useDoubleClick({
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
controls.onDoubleClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
});
if (data) {
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls) {
return;
}
e.preventDefault();
controls.onMore?.({
event: e,
internalState,
item: data as any,
itemType,
});
};
return (
<div
className={clsx(styles.container, styles.compact, {
[styles.selected]: isSelected,
})}
>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
item={data}
itemType={itemType}
type="compact"
/>
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> =>
row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="compact"
/>
))}
</div>
</div>
</div>
);
}
return (
<div className={clsx(styles.container, styles.compact)}>
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={styles.image} />
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows
.filter(
(row): row is NonNullable<typeof row> =>
row !== null && row !== undefined,
)
.map((row, index) => (
<div
className={clsx(styles.row, {
[styles.muted]: index > 0,
})}
key={row.id}
>
&nbsp;
</div>
))}
</div>
</div>
</div>
);
};
const DefaultItemCard = ({
controls,
data,
imageUrl,
internalState,
isRound,
itemType,
rows,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected(internalState.extractRowId(data) || '')
: false;
const handleClick = useDoubleClick({
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
controls.onDoubleClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
});
if (data) {
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls) {
return;
}
e.preventDefault();
controls.onMore?.({
event: e,
internalState,
item: data as any,
itemType,
});
};
return (
<div
className={clsx(styles.container, {
[styles.selected]: isSelected,
})}
>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
item={data}
itemType={itemType}
type="default"
/>
)}
</AnimatePresence>
</div>
<div className={styles.detailContainer}>
{rows
.filter(
(row): row is NonNullable<typeof row> =>
row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data!}
index={index}
key={row.id}
row={row}
type="default"
/>
))}
</div>
</div>
);
}
return (
<div className={clsx(styles.container)}>
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={styles.image} />
</div>
<div className={styles.detailContainer}>
{rows
.filter(
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
)
.map((row, index) => (
<div
className={clsx(styles.row, {
[styles.muted]: index > 0,
})}
key={row.id}
>
&nbsp;
</div>
))}
</div>
</div>
);
};
const PosterItemCard = ({
controls,
data,
enableDrag,
imageUrl,
internalState,
isRound,
itemType,
rows,
withControls,
}: ItemCardDerivativeProps) => {
const [showControls, setShowControls] = useState(false);
const isSelected =
data && internalState && typeof data === 'object' && 'id' in data
? internalState.isSelected(internalState.extractRowId(data) || '')
: false;
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({
drag: {
getId: () => {
if (!data) {
return [];
}
const draggedItems = getDraggedItems(data, internalState);
return draggedItems.map((item) => item.id);
},
getItem: () => {
if (!data) {
return [];
}
const draggedItems = getDraggedItems(data, internalState);
return draggedItems;
},
itemType,
onDragStart: () => {
if (!data) {
return;
}
const draggedItems = getDraggedItems(data, internalState);
if (internalState) {
internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (internalState) {
internalState.setDragging([]);
}
},
operation:
itemType === LibraryItem.QUEUE_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: [DragOperation.ADD],
target: DragTarget.ALBUM,
},
isEnabled: !!enableDrag && !!data,
});
const isDragging = data && internalState ? internalState.isDragging(data.id) : isDraggingLocal;
const handleClick = useDoubleClick({
onDoubleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
controls.onDoubleClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
onSingleClick: (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls || !internalState) {
return;
}
// Don't trigger selection if clicking on interactive elements
const target = e.target as HTMLElement;
const isInteractiveElement = target.closest(
'button, a, input, select, textarea, [role="button"]',
);
if (isInteractiveElement) {
return;
}
controls.onClick?.({
event: e,
internalState,
item: data as any,
itemType,
});
},
});
if (data) {
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!data || !controls) {
return;
}
e.preventDefault();
controls.onMore?.({
event: e,
internalState,
item: data as any,
itemType,
});
};
return (
<div
className={clsx(styles.container, styles.poster, {
[styles.dragging]: isDragging,
[styles.selected]: isSelected,
})}
ref={ref}
>
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={handleClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
internalState={internalState}
item={data}
itemType={itemType}
type="poster"
/>
)}
</AnimatePresence>
</div>
{data && (
<div className={styles.detailContainer}>
{rows
.filter(
(row): row is NonNullable<typeof row> =>
row !== null && row !== undefined,
)
.map((row, index) => (
<ItemCardRow
data={data}
index={index}
key={row.id}
row={row}
type="poster"
/>
))}
</div>
)}
</div>
);
}
return (
<div className={clsx(styles.container, styles.poster)}>
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={clsx(styles.image, { [styles.isRound]: isRound })} />
</div>
<div className={styles.detailContainer}>
{rows
.filter(
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
)
.map((row, index) => (
<div
className={clsx(styles.row, {
[styles.muted]: index > 0,
})}
key={row.id}
>
&nbsp;
</div>
))}
</div>
</div>
);
};
export const getDataRows = (): DataRow[] => {
return [
{
format: (data) => {
if ('name' in data && data.name) {
if ('id' in data && data.id) {
if ('_itemType' in data) {
switch (data._itemType) {
case LibraryItem.ALBUM:
return (
<Link
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: data.id,
})}
>
{data.name}
</Link>
);
case LibraryItem.ALBUM_ARTIST:
return (
<Link
to={generatePath(
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
{
albumArtistId: data.id,
},
)}
>
{data.name}
</Link>
);
case LibraryItem.PLAYLIST:
return (
<Link
to={generatePath(AppRoute.PLAYLISTS_DETAIL_SONGS, {
playlistId: data.id,
})}
>
{data.name}
</Link>
);
default:
return data.name;
}
}
}
return data.name;
}
return '';
},
id: 'name',
},
{
format: (data) => {
if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
return (data as Album | Song).albumArtists.map((artist, index) => (
<Fragment key={artist.id}>
<Link
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Link>
{index < (data as Album | Song).albumArtists.length - 1 && (
<Separator />
)}
</Fragment>
));
}
return '';
},
id: 'albumArtists',
isMuted: true,
},
{
format: (data) => {
if ('artists' in data && Array.isArray(data.artists)) {
return (data as Album | Song).artists.map((artist, index) => (
<Fragment key={artist.id}>
<Link
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Link>
{index < (data as Album | Song).artists.length - 1 && <Separator />}
</Fragment>
));
}
return '';
},
id: 'artists',
isMuted: true,
},
{
format: (data) => {
if ('duration' in data && data.duration !== null) {
return formatDuration(data.duration * 1000);
}
return '';
},
id: 'duration',
},
{
format: (data) => {
if ('releaseYear' in data && data.releaseYear !== null) {
return String(data.releaseYear);
}
return '';
},
id: 'releaseYear',
},
{
format: (data) => {
if ('releaseDate' in data && data.releaseDate) {
return data.releaseDate;
}
return '';
},
id: 'releaseDate',
},
{
format: (data) => {
if ('createdAt' in data && data.createdAt) {
return formatDateAbsolute(data.createdAt);
}
return '';
},
id: 'createdAt',
},
{
format: (data) => {
if ('lastPlayedAt' in data && data.lastPlayedAt) {
return formatDateRelative(data.lastPlayedAt);
}
return '';
},
id: 'lastPlayedAt',
},
{
format: (data) => {
if ('playCount' in data && data.playCount !== null) {
return String(data.playCount);
}
return '';
},
id: 'playCount',
},
{
format: (data) => {
if ('genres' in data && Array.isArray(data.genres)) {
return (data as Album | AlbumArtist | Song).genres
.map((genre) => genre.name)
.join(', ');
}
return '';
},
id: 'genres',
isMuted: true,
},
{
format: (data) => {
if ('album' in data && data.album) {
const song = data as Song;
if ('albumId' in song && song.albumId) {
return (
<Link
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: song.albumId,
})}
>
{song.album}
</Link>
);
}
return song.album;
}
return '';
},
id: 'album',
isMuted: true,
},
{
format: (data) => {
if ('songCount' in data && data.songCount !== null) {
return String(data.songCount);
}
return '';
},
id: 'songCount',
},
{
format: (data) => {
if ('albumCount' in data && data.albumCount !== null) {
return String(data.albumCount);
}
return '';
},
id: 'albumCount',
},
{
format: (data) => {
if (
'userRating' in data &&
(data as Album | AlbumArtist | Song).userRating !== null
) {
return formatRating(data as Album | AlbumArtist | Song);
}
return null;
},
id: 'rating',
},
{
format: (data) => {
if ('userFavorite' in data) {
return (data as Album | AlbumArtist | Song).userFavorite ? '★' : '';
}
return '';
},
id: 'userFavorite',
},
];
};
export const getDataRowsCount = () => {
return getDataRows().length;
};
const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
if (data && 'imageUrl' in data) {
return data.imageUrl || undefined;
}
return undefined;
};
const ItemCardRow = ({
data,
index,
row,
type,
}: {
data: Album | AlbumArtist | Artist | Playlist | Song | undefined;
index: number;
row: DataRow;
type?: 'compact' | 'default' | 'poster';
}) => {
const alignmentClass =
row.align === 'center'
? styles['align-center']
: row.align === 'end'
? styles['align-end']
: styles['align-start'];
// All rows except the first one (index 0) should be muted
const isMuted = index > 0 || row.isMuted;
if (!data) {
return (
<div
className={clsx(styles.row, alignmentClass, {
[styles.compact]: type === 'compact',
[styles.default]: type === 'default',
[styles.muted]: isMuted,
[styles.poster]: type === 'poster',
})}
>
&nbsp;
</div>
);
}
return (
<Text
className={clsx(styles.row, alignmentClass, {
[styles.compact]: type === 'compact',
[styles.default]: type === 'default',
[styles.muted]: isMuted,
[styles.poster]: type === 'poster',
})}
>
{row.format(data)}
</Text>
);
};
export const MemoizedItemCard = memo(ItemCard);