mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
1452 lines
48 KiB
TypeScript
1452 lines
48 KiB
TypeScript
import clsx from 'clsx';
|
|
import { AnimatePresence } from 'motion/react';
|
|
import { Fragment, memo, ReactNode, useCallback, useMemo, useState } from 'react';
|
|
import { generatePath, Link } from 'react-router';
|
|
|
|
import styles from './item-card.module.css';
|
|
|
|
import i18n from '/@/i18n/i18n';
|
|
import { ItemCardControls } from '/@/renderer/components/item-card/item-card-controls';
|
|
import { ItemImage } from '/@/renderer/components/item-image/item-image';
|
|
import { getDraggedItems } from '/@/renderer/components/item-list/helpers/get-dragged-items';
|
|
import { getTitlePath } from '/@/renderer/components/item-list/helpers/get-title-path';
|
|
import {
|
|
ItemListStateActions,
|
|
useItemDraggingState,
|
|
useItemSelectionState,
|
|
} from '/@/renderer/components/item-list/helpers/item-list-state';
|
|
import { ItemControls } from '/@/renderer/components/item-list/types';
|
|
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
|
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
|
|
import { AppRoute } from '/@/renderer/router/routes';
|
|
import { useShowRatings } from '/@/renderer/store';
|
|
import {
|
|
formatDateAbsolute,
|
|
formatDateRelative,
|
|
formatDurationString,
|
|
formatPartialIsoDateUTC,
|
|
formatRating,
|
|
} from '/@/renderer/utils/format';
|
|
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
|
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
|
|
import { Group } from '/@/shared/components/group/group';
|
|
import { Icon } from '/@/shared/components/icon/icon';
|
|
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,
|
|
Genre,
|
|
LibraryItem,
|
|
Playlist,
|
|
Song,
|
|
} from '/@/shared/types/domain-types';
|
|
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
|
|
import { stringToColor } from '/@/shared/utils/string-to-color';
|
|
|
|
export type DataRow = {
|
|
align?: 'center' | 'end' | 'start';
|
|
format: (
|
|
data: Album | AlbumArtist | Artist | Genre | Playlist | Song,
|
|
) => null | ReactNode | string;
|
|
id: string;
|
|
isMuted?: boolean;
|
|
};
|
|
|
|
export interface ItemCardProps {
|
|
controls?: ItemControls;
|
|
data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined;
|
|
enableDrag?: boolean;
|
|
enableExpansion?: boolean;
|
|
enableMultiSelect?: boolean;
|
|
enableNavigation?: boolean;
|
|
imageAsLink?: boolean;
|
|
imageFetchPriority?: 'auto' | 'high' | 'low';
|
|
internalState?: ItemListStateActions;
|
|
isRound?: boolean;
|
|
itemType: LibraryItem;
|
|
rows?: DataRow[];
|
|
type?: 'compact' | 'default' | 'poster';
|
|
withControls?: boolean;
|
|
}
|
|
|
|
export const ItemCard = ({
|
|
controls,
|
|
data,
|
|
enableDrag,
|
|
enableExpansion,
|
|
enableMultiSelect,
|
|
enableNavigation = true,
|
|
imageAsLink,
|
|
imageFetchPriority,
|
|
internalState,
|
|
isRound,
|
|
itemType,
|
|
rows: providedRows,
|
|
type = 'poster',
|
|
withControls,
|
|
}: ItemCardProps) => {
|
|
const showRatings = useShowRatings();
|
|
const imageUrl = getImageUrl(data);
|
|
const rows = providedRows || [];
|
|
|
|
switch (type) {
|
|
case 'compact':
|
|
return (
|
|
<MemoizedCompactItemCard
|
|
controls={controls}
|
|
data={data}
|
|
enableDrag={enableDrag}
|
|
enableExpansion={enableExpansion}
|
|
enableMultiSelect={enableMultiSelect}
|
|
enableNavigation={enableNavigation}
|
|
imageAsLink={imageAsLink}
|
|
imageFetchPriority={imageFetchPriority}
|
|
imageUrl={imageUrl}
|
|
internalState={internalState}
|
|
isRound={isRound}
|
|
itemType={itemType}
|
|
rows={rows}
|
|
showRating={showRatings}
|
|
withControls={withControls}
|
|
/>
|
|
);
|
|
case 'poster':
|
|
return (
|
|
<MemoizedPosterItemCard
|
|
controls={controls}
|
|
data={data}
|
|
enableDrag={enableDrag}
|
|
enableExpansion={enableExpansion}
|
|
enableMultiSelect={enableMultiSelect}
|
|
enableNavigation={enableNavigation}
|
|
imageAsLink={imageAsLink}
|
|
imageFetchPriority={imageFetchPriority}
|
|
imageUrl={imageUrl}
|
|
internalState={internalState}
|
|
isRound={isRound}
|
|
itemType={itemType}
|
|
rows={rows}
|
|
showRating={showRatings}
|
|
withControls={withControls}
|
|
/>
|
|
);
|
|
case 'default':
|
|
default:
|
|
return (
|
|
<MemoizedDefaultItemCard
|
|
controls={controls}
|
|
data={data}
|
|
enableDrag={enableDrag}
|
|
enableExpansion={enableExpansion}
|
|
enableNavigation={enableNavigation}
|
|
imageAsLink={imageAsLink}
|
|
imageFetchPriority={imageFetchPriority}
|
|
imageUrl={imageUrl}
|
|
internalState={internalState}
|
|
isRound={isRound}
|
|
itemType={itemType}
|
|
rows={rows}
|
|
showRating={showRatings}
|
|
withControls={withControls}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
|
|
controls?: ItemControls;
|
|
enableExpansion?: boolean;
|
|
enableNavigation?: boolean;
|
|
imageAsLink?: boolean;
|
|
imageFetchPriority?: 'auto' | 'high' | 'low';
|
|
imageUrl: string | undefined;
|
|
internalState?: ItemListStateActions;
|
|
rows: DataRow[];
|
|
showRating: boolean;
|
|
}
|
|
|
|
type ItemCardData = NonNullable<ItemCardProps['data']>;
|
|
|
|
const ItemCardStandardImageArea = memo(function ItemCardStandardImageArea({
|
|
controls,
|
|
data,
|
|
enableExpansion,
|
|
enableImageViewport = true,
|
|
enableNavigation,
|
|
handleContextMenu,
|
|
handleImageClick,
|
|
handleLinkDragStart,
|
|
imageAsLink,
|
|
imageFetchPriority,
|
|
internalState,
|
|
isRound,
|
|
itemType,
|
|
navigationPath,
|
|
showRating,
|
|
variant,
|
|
withControls,
|
|
}: {
|
|
controls?: ItemControls;
|
|
data: ItemCardData;
|
|
enableExpansion?: boolean;
|
|
enableImageViewport?: boolean;
|
|
enableNavigation?: boolean;
|
|
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
|
|
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
|
|
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
|
|
imageAsLink?: boolean;
|
|
imageFetchPriority?: 'auto' | 'high' | 'low';
|
|
internalState?: ItemListStateActions;
|
|
isRound?: boolean;
|
|
itemType: LibraryItem;
|
|
navigationPath: null | string;
|
|
showRating: boolean;
|
|
variant: 'default' | 'poster';
|
|
withControls?: boolean;
|
|
}) {
|
|
const [showControls, setShowControls] = useState(false);
|
|
|
|
const handleMouseEnter = () => {
|
|
if (withControls) {
|
|
setShowControls(true);
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (withControls) {
|
|
setShowControls(false);
|
|
}
|
|
};
|
|
|
|
const imageContainerClassName = clsx(styles.imageContainer, {
|
|
[styles.isRound]: isRound,
|
|
});
|
|
|
|
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
|
const userRating =
|
|
'userRating' in data &&
|
|
typeof (data as { userRating: null | number }).userRating === 'number'
|
|
? (data as { userRating: null | number }).userRating
|
|
: null;
|
|
const hasRating = showRating && userRating !== null && userRating > 0;
|
|
|
|
const imageContainerContent = (
|
|
<>
|
|
{itemType === LibraryItem.GENRE &&
|
|
data &&
|
|
'name' in data &&
|
|
typeof (data as Genre).name === 'string' ? (
|
|
<GenreImagePlaceholder
|
|
className={clsx(styles.image, styles.genrePlaceholder, {
|
|
[styles.isRound]: isRound,
|
|
})}
|
|
name={(data as Genre).name}
|
|
/>
|
|
) : (
|
|
<ItemImage
|
|
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
|
enableDebounce={false}
|
|
{...(variant === 'poster' ? { enableViewport: enableImageViewport } : {})}
|
|
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
|
|
fetchPriority={imageFetchPriority}
|
|
id={(data as { imageId?: string })?.imageId}
|
|
itemType={itemType}
|
|
src={(data as { imageUrl?: string })?.imageUrl}
|
|
type="itemCard"
|
|
/>
|
|
)}
|
|
{isFavorite && <div className={styles.favoriteBadge} />}
|
|
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
|
<AnimatePresence>
|
|
{withControls && showControls && (
|
|
<ItemCardControls
|
|
controls={controls}
|
|
enableExpansion={enableExpansion}
|
|
{...(variant === 'poster' ? { internalState } : {})}
|
|
item={data}
|
|
itemType={itemType}
|
|
showRating={showRating}
|
|
type={variant}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
);
|
|
|
|
return enableNavigation && navigationPath && (imageAsLink ?? !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>
|
|
);
|
|
});
|
|
|
|
ItemCardStandardImageArea.displayName = 'ItemCardStandardImageArea';
|
|
|
|
const CompactItemCardImageArea = memo(function CompactItemCardImageArea({
|
|
controls,
|
|
data,
|
|
enableExpansion,
|
|
enableNavigation,
|
|
handleContextMenu,
|
|
handleImageClick,
|
|
handleLinkDragStart,
|
|
imageAsLink,
|
|
imageFetchPriority,
|
|
internalState,
|
|
isRound,
|
|
itemType,
|
|
navigationPath,
|
|
rows,
|
|
showRating,
|
|
withControls,
|
|
}: {
|
|
controls?: ItemControls;
|
|
data: ItemCardData;
|
|
enableExpansion?: boolean;
|
|
enableNavigation?: boolean;
|
|
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
|
|
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
|
|
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
|
|
imageAsLink?: boolean;
|
|
imageFetchPriority?: 'auto' | 'high' | 'low';
|
|
internalState?: ItemListStateActions;
|
|
isRound?: boolean;
|
|
itemType: LibraryItem;
|
|
navigationPath: null | string;
|
|
rows: DataRow[];
|
|
showRating: boolean;
|
|
withControls?: boolean;
|
|
}) {
|
|
const [showControls, setShowControls] = useState(false);
|
|
|
|
const handleMouseEnter = () => {
|
|
if (withControls) {
|
|
setShowControls(true);
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (withControls) {
|
|
setShowControls(false);
|
|
}
|
|
};
|
|
|
|
const imageContainerClassName = clsx(styles.imageContainer, {
|
|
[styles.isRound]: isRound,
|
|
});
|
|
|
|
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
|
const userRating =
|
|
'userRating' in data &&
|
|
typeof (data as { userRating: null | number }).userRating === 'number'
|
|
? (data as { userRating: null | number }).userRating
|
|
: null;
|
|
const hasRating = showRating && userRating !== null && userRating > 0;
|
|
|
|
const imageContainerContent = (
|
|
<>
|
|
{itemType === LibraryItem.GENRE &&
|
|
data &&
|
|
'name' in data &&
|
|
typeof (data as Genre).name === 'string' ? (
|
|
<GenreImagePlaceholder
|
|
className={clsx(styles.image, styles.genrePlaceholder, {
|
|
[styles.isRound]: isRound,
|
|
})}
|
|
name={(data as Genre).name}
|
|
/>
|
|
) : (
|
|
<ItemImage
|
|
className={clsx(styles.image, {
|
|
[styles.isRound]: isRound,
|
|
})}
|
|
enableDebounce={false}
|
|
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
|
|
fetchPriority={imageFetchPriority}
|
|
id={data?.imageId}
|
|
itemType={itemType}
|
|
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
|
type="itemCard"
|
|
/>
|
|
)}
|
|
{isFavorite && <div className={styles.favoriteBadge} />}
|
|
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
|
<AnimatePresence>
|
|
{withControls && showControls && data && (
|
|
<ItemCardControls
|
|
controls={controls}
|
|
enableExpansion={enableExpansion}
|
|
internalState={internalState}
|
|
item={data}
|
|
itemType={itemType}
|
|
showRating={showRating}
|
|
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 enableNavigation && navigationPath && (imageAsLink ?? !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>
|
|
);
|
|
});
|
|
|
|
CompactItemCardImageArea.displayName = 'CompactItemCardImageArea';
|
|
|
|
const CompactItemCard = ({
|
|
controls,
|
|
data,
|
|
enableDrag,
|
|
enableExpansion,
|
|
enableMultiSelect,
|
|
enableNavigation,
|
|
imageAsLink,
|
|
imageFetchPriority,
|
|
internalState,
|
|
isRound,
|
|
itemType,
|
|
rows,
|
|
showRating,
|
|
withControls,
|
|
}: ItemCardDerivativeProps) => {
|
|
const itemRowId =
|
|
data && internalState && typeof data === 'object' && 'id' in data
|
|
? internalState.extractRowId(data)
|
|
: undefined;
|
|
const isSelected = useItemSelectionState(internalState, itemRowId || undefined);
|
|
|
|
const getId = useCallback(() => {
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
|
|
const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);
|
|
return draggedItems.map((item) => item.id);
|
|
}, [data, internalState, enableMultiSelect]);
|
|
|
|
const getItem = useCallback(() => {
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
|
|
const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);
|
|
return draggedItems;
|
|
}, [data, internalState, enableMultiSelect]);
|
|
|
|
const onDragStart = useCallback(() => {
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);
|
|
if (internalState) {
|
|
internalState.setDragging(draggedItems);
|
|
}
|
|
}, [data, internalState, enableMultiSelect]);
|
|
|
|
const onDrop = useCallback(() => {
|
|
if (internalState) {
|
|
internalState.setDragging([]);
|
|
}
|
|
}, [internalState]);
|
|
|
|
const dragOperation = useMemo(
|
|
() =>
|
|
itemType === LibraryItem.QUEUE_SONG
|
|
? [DragOperation.REORDER, DragOperation.ADD]
|
|
: [DragOperation.ADD],
|
|
[itemType],
|
|
);
|
|
|
|
const drag = useMemo(
|
|
() => ({
|
|
getId,
|
|
getItem,
|
|
itemType,
|
|
onDragStart,
|
|
onDrop,
|
|
operation: dragOperation,
|
|
target: DragTarget.ALBUM,
|
|
}),
|
|
[getId, getItem, itemType, onDragStart, onDrop, dragOperation],
|
|
);
|
|
|
|
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({
|
|
drag,
|
|
isEnabled: !!enableDrag && !!data,
|
|
});
|
|
|
|
const itemId = data && internalState ? data.id : undefined;
|
|
const isDraggingState = useItemDraggingState(internalState, itemId);
|
|
const isDragging = isDraggingState || 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 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();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={clsx(styles.container, styles.compact, {
|
|
[styles.dragging]: isDragging,
|
|
[styles.selected]: isSelected,
|
|
})}
|
|
ref={ref}
|
|
>
|
|
<CompactItemCardImageArea
|
|
controls={controls}
|
|
data={data}
|
|
enableExpansion={enableExpansion}
|
|
enableNavigation={enableNavigation}
|
|
handleContextMenu={handleContextMenu}
|
|
handleImageClick={handleImageClick}
|
|
handleLinkDragStart={handleLinkDragStart}
|
|
imageAsLink={imageAsLink}
|
|
imageFetchPriority={imageFetchPriority}
|
|
internalState={internalState}
|
|
isRound={isRound}
|
|
itemType={itemType}
|
|
navigationPath={navigationPath}
|
|
rows={rows}
|
|
showRating={showRating}
|
|
withControls={withControls}
|
|
/>
|
|
</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) => (
|
|
<Text
|
|
className={clsx(styles.row, {
|
|
[styles.muted]: index > 0,
|
|
})}
|
|
key={row.id}
|
|
size={index > 0 ? 'sm' : 'md'}
|
|
>
|
|
|
|
</Text>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const DefaultItemCard = ({
|
|
controls,
|
|
data,
|
|
enableExpansion,
|
|
enableNavigation,
|
|
imageAsLink,
|
|
imageFetchPriority,
|
|
internalState,
|
|
isRound,
|
|
itemType,
|
|
rows,
|
|
showRating,
|
|
withControls,
|
|
}: ItemCardDerivativeProps) => {
|
|
const itemRowId =
|
|
data && internalState && typeof data === 'object' && 'id' in data
|
|
? internalState.extractRowId(data)
|
|
: undefined;
|
|
const isSelected = useItemSelectionState(internalState, itemRowId || undefined);
|
|
|
|
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 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();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={clsx(styles.container, {
|
|
[styles.selected]: isSelected,
|
|
})}
|
|
>
|
|
<ItemCardStandardImageArea
|
|
controls={controls}
|
|
data={data}
|
|
enableExpansion={enableExpansion}
|
|
enableNavigation={enableNavigation}
|
|
handleContextMenu={handleContextMenu}
|
|
handleImageClick={handleImageClick}
|
|
handleLinkDragStart={handleLinkDragStart}
|
|
imageAsLink={imageAsLink}
|
|
imageFetchPriority={imageFetchPriority}
|
|
internalState={internalState}
|
|
isRound={isRound}
|
|
itemType={itemType}
|
|
navigationPath={navigationPath}
|
|
showRating={showRating}
|
|
variant="default"
|
|
withControls={withControls}
|
|
/>
|
|
<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) => (
|
|
<Text
|
|
className={clsx(styles.row, {
|
|
[styles.muted]: index > 0,
|
|
})}
|
|
key={row.id}
|
|
size={index > 0 ? 'sm' : 'md'}
|
|
>
|
|
|
|
</Text>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PosterItemCard = ({
|
|
controls,
|
|
data,
|
|
enableDrag,
|
|
enableExpansion,
|
|
enableMultiSelect,
|
|
enableNavigation,
|
|
imageAsLink,
|
|
imageFetchPriority,
|
|
internalState,
|
|
isRound,
|
|
itemType,
|
|
rows,
|
|
showRating,
|
|
withControls,
|
|
}: ItemCardDerivativeProps) => {
|
|
const itemRowId =
|
|
data && internalState && typeof data === 'object' && 'id' in data
|
|
? internalState.extractRowId(data)
|
|
: undefined;
|
|
const isSelected = useItemSelectionState(internalState, itemRowId || undefined);
|
|
|
|
const getId = useCallback(() => {
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
|
|
const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);
|
|
return draggedItems.map((item) => item.id);
|
|
}, [data, internalState, enableMultiSelect]);
|
|
|
|
const getItem = useCallback(() => {
|
|
if (!data) {
|
|
return [];
|
|
}
|
|
|
|
const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);
|
|
return draggedItems;
|
|
}, [data, internalState, enableMultiSelect]);
|
|
|
|
const onDragStart = useCallback(() => {
|
|
if (!data) {
|
|
return;
|
|
}
|
|
|
|
const draggedItems = getDraggedItems(data, internalState, enableMultiSelect !== false);
|
|
if (internalState) {
|
|
internalState.setDragging(draggedItems);
|
|
}
|
|
}, [data, internalState, enableMultiSelect]);
|
|
|
|
const onDrop = useCallback(() => {
|
|
if (internalState) {
|
|
internalState.setDragging([]);
|
|
}
|
|
}, [internalState]);
|
|
|
|
const dragOperation = useMemo(
|
|
() =>
|
|
itemType === LibraryItem.QUEUE_SONG
|
|
? [DragOperation.REORDER, DragOperation.ADD]
|
|
: [DragOperation.ADD],
|
|
[itemType],
|
|
);
|
|
|
|
const drag = useMemo(
|
|
() => ({
|
|
getId,
|
|
getItem,
|
|
itemType,
|
|
onDragStart,
|
|
onDrop,
|
|
operation: dragOperation,
|
|
target: DragTarget.ALBUM,
|
|
}),
|
|
[getId, getItem, itemType, onDragStart, onDrop, dragOperation],
|
|
);
|
|
|
|
const { isDragging: isDraggingLocal, ref } = useDragDrop<HTMLDivElement>({
|
|
drag,
|
|
isEnabled: !!enableDrag && !!data,
|
|
});
|
|
|
|
const itemId = data && internalState ? data.id : undefined;
|
|
const isDraggingState = useItemDraggingState(internalState, itemId);
|
|
const isDragging = isDraggingState || 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 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();
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={clsx(styles.container, styles.poster, {
|
|
[styles.dragging]: isDragging,
|
|
[styles.selected]: isSelected,
|
|
})}
|
|
ref={ref}
|
|
>
|
|
<ItemCardStandardImageArea
|
|
controls={controls}
|
|
data={data}
|
|
enableExpansion={enableExpansion}
|
|
enableNavigation={enableNavigation}
|
|
handleContextMenu={handleContextMenu}
|
|
handleImageClick={handleImageClick}
|
|
handleLinkDragStart={handleLinkDragStart}
|
|
imageAsLink={imageAsLink}
|
|
imageFetchPriority={imageFetchPriority}
|
|
internalState={internalState}
|
|
isRound={isRound}
|
|
itemType={itemType}
|
|
navigationPath={navigationPath}
|
|
showRating={showRating}
|
|
variant="poster"
|
|
withControls={withControls}
|
|
/>
|
|
{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) => (
|
|
<Text
|
|
className={clsx(styles.row, {
|
|
[styles.muted]: index > 0,
|
|
})}
|
|
key={row.id}
|
|
size={index > 0 ? 'sm' : 'md'}
|
|
>
|
|
|
|
</Text>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MemoizedPosterItemCard = memo(PosterItemCard);
|
|
MemoizedPosterItemCard.displayName = 'MemoizedPosterItemCard';
|
|
|
|
const MemoizedCompactItemCard = memo(CompactItemCard);
|
|
MemoizedCompactItemCard.displayName = 'MemoizedCompactItemCard';
|
|
|
|
const MemoizedDefaultItemCard = memo(DefaultItemCard);
|
|
MemoizedDefaultItemCard.displayName = 'MemoizedDefaultItemCard';
|
|
|
|
export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[] => {
|
|
return [
|
|
{
|
|
format: (data) => {
|
|
const explicitStatus = 'explicitStatus' in data ? data.explicitStatus : null;
|
|
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,
|
|
})}
|
|
>
|
|
<ExplicitIndicator explicitStatus={explicitStatus} />
|
|
{data.name}
|
|
</Link>
|
|
);
|
|
case LibraryItem.ALBUM_ARTIST:
|
|
return (
|
|
<Link
|
|
state={{ item: data }}
|
|
to={generatePath(
|
|
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
|
{
|
|
albumArtistId: data.id,
|
|
},
|
|
)}
|
|
>
|
|
<ExplicitIndicator explicitStatus={explicitStatus} />
|
|
{data.name}
|
|
</Link>
|
|
);
|
|
case LibraryItem.GENRE:
|
|
return (
|
|
<Link
|
|
state={{ item: data }}
|
|
to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {
|
|
genreId: 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 (
|
|
<>
|
|
<ExplicitIndicator explicitStatus={explicitStatus} />
|
|
{data.name}
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return (
|
|
<>
|
|
<ExplicitIndicator explicitStatus={explicitStatus} />
|
|
{data.name}
|
|
</>
|
|
);
|
|
}
|
|
return '';
|
|
},
|
|
id: 'name',
|
|
},
|
|
{
|
|
format: (data) => {
|
|
if ('albumArtists' in data && Array.isArray(data.albumArtists)) {
|
|
return (
|
|
<JoinedArtists
|
|
artistName={data.albumArtistName}
|
|
artists={data.albumArtists}
|
|
linkProps={{ fw: 400, isMuted: true }}
|
|
rootTextProps={{
|
|
fw: 400,
|
|
isMuted: type === 'compact' ? false : true,
|
|
size: 'sm',
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
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 formatDurationString(data.duration);
|
|
}
|
|
return '';
|
|
},
|
|
id: 'duration',
|
|
},
|
|
{
|
|
format: (data) => {
|
|
if ('releaseYear' in data && data.releaseYear != null) {
|
|
const releaseYear = data.releaseYear;
|
|
const originalYear =
|
|
'originalYear' in data && data.originalYear > 0 ? data.originalYear : null;
|
|
|
|
if (originalYear !== null && originalYear !== releaseYear) {
|
|
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
|
|
}
|
|
|
|
return String(releaseYear);
|
|
}
|
|
return '';
|
|
},
|
|
id: 'releaseYear',
|
|
},
|
|
{
|
|
format: (data) => {
|
|
if ('releaseDate' in data && data.releaseDate) {
|
|
if (
|
|
'originalDate' in data &&
|
|
data.originalDate &&
|
|
data.originalDate !== data.releaseDate
|
|
) {
|
|
return `${formatPartialIsoDateUTC(data.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(data.releaseDate)}`;
|
|
}
|
|
|
|
return `${formatPartialIsoDateUTC(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 (
|
|
<Group align="center" gap="xs">
|
|
<Icon icon="lastPlayed" size="sm" />
|
|
{formatDateRelative(data.lastPlayedAt)}
|
|
</Group>
|
|
);
|
|
}
|
|
return '';
|
|
},
|
|
id: 'lastPlayedAt',
|
|
},
|
|
{
|
|
format: (data) => {
|
|
if ('playCount' in data && data.playCount !== null) {
|
|
return i18n.t('entity.play', { count: 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 i18n.t('entity.trackWithCount', { count: data.songCount });
|
|
}
|
|
return '';
|
|
},
|
|
id: 'songCount',
|
|
},
|
|
{
|
|
format: (data) => {
|
|
if ('albumCount' in data && data.albumCount !== null) {
|
|
return i18n.t('entity.albumWithCount', { count: 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 | Genre | Playlist | Song | undefined) => {
|
|
if (data && 'imageUrl' in data) {
|
|
return data.imageUrl || undefined;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const GenreImagePlaceholder = ({ className, name }: { className?: string; name: string }) => {
|
|
const { color, isLight } = useMemo(() => stringToColor(name), [name]);
|
|
return (
|
|
<div
|
|
className={className}
|
|
style={{
|
|
backgroundColor: color,
|
|
color: isLight ? '#000' : '#fff',
|
|
}}
|
|
>
|
|
<span className={styles.genrePlaceholderText}>{name}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getItemNavigationPath = (
|
|
data: Album | AlbumArtist | Artist | Genre | 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 = memo(
|
|
({
|
|
data,
|
|
index,
|
|
row,
|
|
type,
|
|
}: {
|
|
data: Album | AlbumArtist | Artist | Genre | 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;
|
|
|
|
const formattedContent = useMemo(() => {
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
return row.format(data);
|
|
}, [data, row]);
|
|
|
|
if (!data) {
|
|
return (
|
|
<div
|
|
className={clsx(styles.row, alignmentClass, {
|
|
[styles.compact]: type === 'compact',
|
|
[styles.default]: type === 'default',
|
|
[styles.muted]: isMuted,
|
|
[styles.poster]: type === 'poster',
|
|
})}
|
|
>
|
|
|
|
</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'}
|
|
>
|
|
{formattedContent}
|
|
</Text>
|
|
);
|
|
},
|
|
);
|
|
|
|
ItemCardRow.displayName = 'ItemCardRow';
|
|
|
|
export const MemoizedItemCard = memo(ItemCard);
|