Files
feishin/src/renderer/components/item-card/item-card.tsx
T

1099 lines
36 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 { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
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;
enableExpansion?: boolean;
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
rows?: DataRow[];
type?: 'compact' | 'default' | 'poster';
withControls?: boolean;
}
export const ItemCard = ({
controls,
data,
enableDrag,
enableExpansion,
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}
enableExpansion={enableExpansion}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
itemType={itemType}
rows={rows}
withControls={withControls}
/>
);
case 'poster':
return (
<PosterItemCard
controls={controls}
data={data}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
itemType={itemType}
rows={rows}
withControls={withControls}
/>
);
case 'default':
default:
return (
<DefaultItemCard
controls={controls}
data={data}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
imageUrl={imageUrl}
internalState={internalState}
isRound={isRound}
itemType={itemType}
rows={rows}
withControls={withControls}
/>
);
}
};
export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
controls?: ItemControls;
enableExpansion?: boolean;
imageUrl: string | undefined;
internalState?: ItemListStateActions;
rows: DataRow[];
}
const CompactItemCard = ({
controls,
data,
enableExpansion,
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 navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
}
e.preventDefault();
controls.onMore?.({
event: e,
internalState,
item: data as any,
itemType,
});
};
const handleImageClick = (e: React.MouseEvent<HTMLElement>) => {
// Prevent navigation on double-click, let the double-click handler work
if (e.detail === 2 && navigationPath) {
e.preventDefault();
}
handleClick(e as any);
};
const handleLinkDragStart = (e: React.DragEvent<HTMLAnchorElement>) => {
// Prevent default browser link drag behavior to allow custom drag and drop
e.preventDefault();
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const imageContainerContent = (
<>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
{isFavorite && <div className={styles.favoriteBadge} />}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
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>
</>
);
return (
<div
className={clsx(styles.container, styles.compact, {
[styles.selected]: isSelected,
})}
>
{navigationPath && !internalState ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</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,
enableExpansion,
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 navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
}
e.preventDefault();
controls.onMore?.({
event: e,
internalState,
item: data as any,
itemType,
});
};
const handleImageClick = (e: React.MouseEvent<HTMLElement>) => {
// Prevent navigation on double-click, let the double-click handler work
if (e.detail === 2 && navigationPath) {
e.preventDefault();
}
handleClick(e as any);
};
const handleLinkDragStart = (e: React.DragEvent<HTMLAnchorElement>) => {
// Prevent default browser link drag behavior to allow custom drag and drop
e.preventDefault();
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const imageContainerContent = (
<>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
{isFavorite && <div className={styles.favoriteBadge} />}
<AnimatePresence>
{withControls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
item={data}
itemType={itemType}
type="default"
/>
)}
</AnimatePresence>
</>
);
return (
<div
className={clsx(styles.container, {
[styles.selected]: isSelected,
})}
>
{navigationPath && !internalState ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</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,
enableExpansion,
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 navigationPath = getItemNavigationPath(data, itemType);
const handleMouseEnter = () => {
if (withControls) {
setShowControls(true);
}
};
const handleMouseLeave = () => {
if (withControls) {
setShowControls(false);
}
};
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
if (!data || !controls) {
return;
}
e.preventDefault();
controls.onMore?.({
event: e,
internalState,
item: data as any,
itemType,
});
};
const handleImageClick = (e: React.MouseEvent<HTMLElement>) => {
// Prevent navigation on double-click, let the double-click handler work
if (e.detail === 2 && navigationPath) {
e.preventDefault();
}
handleClick(e as any);
};
const handleLinkDragStart = (e: React.DragEvent<HTMLAnchorElement>) => {
// Prevent default browser link drag behavior to allow custom drag and drop
e.preventDefault();
e.stopPropagation();
};
const imageContainerClassName = clsx(styles.imageContainer, {
[styles.isRound]: isRound,
});
const isFavorite =
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
const imageContainerContent = (
<>
<Image
className={clsx(styles.image, { [styles.isRound]: isRound })}
src={imageUrl}
/>
{isFavorite && <div className={styles.favoriteBadge} />}
<AnimatePresence>
{withControls && showControls && data && (
<ItemCardControls
controls={controls}
enableExpansion={enableExpansion}
internalState={internalState}
item={data}
itemType={itemType}
type="poster"
/>
)}
</AnimatePresence>
</>
);
return (
<div
className={clsx(styles.container, styles.poster, {
[styles.dragging]: isDragging,
[styles.selected]: isSelected,
})}
ref={ref}
>
{navigationPath && !internalState ? (
<Link
className={imageContainerClassName}
draggable={false}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onDragStart={handleLinkDragStart}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
state={{ item: data }}
to={navigationPath}
>
{imageContainerContent}
</Link>
) : (
<div
className={imageContainerClassName}
onClick={handleImageClick}
onContextMenu={handleContextMenu}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{imageContainerContent}
</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
state={{ item: data }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: data.id,
})}
>
{data.name}
</Link>
);
case LibraryItem.ALBUM_ARTIST:
return (
<Link
state={{ item: data }}
to={generatePath(
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
{
albumArtistId: data.id,
},
)}
>
{data.name}
</Link>
);
case LibraryItem.PLAYLIST:
return (
<Link
state={{ item: data }}
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
state={{ item: artist }}
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
state={{ item: artist }}
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) {
const albumData = {
id: song.albumId,
imageUrl: song.imageUrl,
name: song.album,
};
return (
<Link
state={{ item: albumData }}
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 getItemNavigationPath = (
data: Album | AlbumArtist | Artist | Playlist | Song | undefined,
itemType: LibraryItem,
): null | string => {
if (!data || !('id' in data) || !data.id) {
return null;
}
const effectiveItemType = '_itemType' in data && data._itemType ? data._itemType : itemType;
return getTitlePath(effectiveItemType, data.id);
};
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.bold]: index === 0,
[styles.compact]: type === 'compact',
[styles.default]: type === 'default',
[styles.muted]: isMuted,
[styles.poster]: type === 'poster',
})}
size={index > 0 ? 'sm' : 'md'}
>
{row.format(data)}
</Text>
);
};
export const MemoizedItemCard = memo(ItemCard);