implement item list grid card row customization

This commit is contained in:
jeffvli
2025-11-14 15:18:25 -08:00
parent 56d0669510
commit b6c3200419
17 changed files with 559 additions and 149 deletions
@@ -96,6 +96,18 @@
color: var(--theme-colors-foreground-muted);
}
.row.align-start {
text-align: left;
}
.row.align-center {
text-align: center;
}
.row.align-end {
text-align: right;
}
.container.poster {
padding: 0;
background-color: inherit;
+333 -93
View File
@@ -1,4 +1,5 @@
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';
@@ -11,6 +12,7 @@ import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/i
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';
@@ -26,6 +28,13 @@ import {
} 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;
@@ -33,16 +42,11 @@ export interface ItemCardProps {
internalState?: ItemListStateActions;
isRound?: boolean;
itemType: LibraryItem;
rows?: DataRow[];
type?: 'compact' | 'default' | 'poster';
withControls?: boolean;
}
type DataRow = {
format: (data: Album | AlbumArtist | Artist | Playlist | Song) => ReactNode | string;
id: string;
isMuted?: boolean;
};
export const ItemCard = ({
controls,
data,
@@ -50,11 +54,13 @@ export const ItemCard = ({
internalState,
isRound,
itemType,
rows: providedRows,
type = 'poster',
withControls,
}: ItemCardProps) => {
const imageUrl = getImageUrl(data);
const rows = getDataRows(itemType);
const defaultRows = getDataRows();
const rows = providedRows && providedRows.length > 0 ? providedRows : defaultRows;
switch (type) {
case 'compact':
@@ -218,9 +224,20 @@ const CompactItemCard = ({
)}
</AnimatePresence>
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows.map((row) => (
<ItemCardRow data={data!} key={row.id} row={row} type="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>
@@ -232,11 +249,21 @@ const CompactItemCard = ({
<div className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}>
<Skeleton className={styles.image} />
<div className={clsx(styles.detailContainer, styles.compact)}>
{rows.map((row) => (
<div className={styles.row} key={row.id}>
&nbsp;
</div>
))}
{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>
@@ -352,9 +379,20 @@ const DefaultItemCard = ({
</AnimatePresence>
</div>
<div className={styles.detailContainer}>
{rows.map((row) => (
<ItemCardRow data={data!} key={row.id} row={row} type="default" />
))}
{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>
);
@@ -366,11 +404,20 @@ const DefaultItemCard = ({
<Skeleton className={styles.image} />
</div>
<div className={styles.detailContainer}>
{rows.map((row) => (
<div className={styles.row} key={row.id}>
&nbsp;
</div>
))}
{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>
);
@@ -531,9 +578,20 @@ const PosterItemCard = ({
</div>
{data && (
<div className={styles.detailContainer}>
{rows.map((row) => (
<ItemCardRow data={data} key={row.id} row={row} type="poster" />
))}
{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>
@@ -546,83 +604,253 @@ const PosterItemCard = ({
<Skeleton className={clsx(styles.image, { [styles.isRound]: isRound })} />
</div>
<div className={styles.detailContainer}>
{rows.map((row) => (
<div className={styles.row} key={row.id}>
&nbsp;
</div>
))}
{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 getDataRows = (itemType: LibraryItem): DataRow[] => {
switch (itemType) {
case LibraryItem.ALBUM:
return [
{
format: (data) => {
const album = data as Album;
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: album.id,
albumId: song.albumId,
})}
>
{album.name}
{song.album}
</Link>
);
},
id: 'name',
},
{
format: (data) => {
const album = data as Album;
return album.albumArtists.map((artist, index) => (
<Fragment key={artist.id}>
<Link
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
albumArtistId: artist.id,
})}
>
{artist.name}
</Link>
{index < album.albumArtists.length - 1 && <Separator />}
</Fragment>
));
},
id: 'albumArtists',
isMuted: true,
},
];
case LibraryItem.ALBUM_ARTIST:
return [{ format: (data) => (data as AlbumArtist).name, id: 'name' }];
case LibraryItem.ARTIST:
return [{ format: (data) => (data as Artist).name, id: 'name' }];
case LibraryItem.PLAYLIST:
return [{ format: (data) => (data as Playlist).name, id: 'name' }];
case LibraryItem.SONG:
return [{ format: (data) => (data as Song).name, id: 'name' }];
default:
return [];
}
}
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 = (itemType: LibraryItem) => {
switch (itemType) {
case LibraryItem.ALBUM:
return 2;
case LibraryItem.ALBUM_ARTIST:
return 1;
case LibraryItem.ARTIST:
return 1;
case LibraryItem.PLAYLIST:
return 2;
case LibraryItem.SONG:
return 2;
default:
return 1;
}
export const getDataRowsCount = () => {
return getDataRows().length;
};
const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => {
@@ -635,20 +863,32 @@ const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | unde
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, {
className={clsx(styles.row, alignmentClass, {
[styles.compact]: type === 'compact',
[styles.default]: type === 'default',
[styles.muted]: row.isMuted,
[styles.muted]: isMuted,
[styles.poster]: type === 'poster',
})}
>
@@ -659,10 +899,10 @@ const ItemCardRow = ({
return (
<Text
className={clsx(styles.row, {
className={clsx(styles.row, alignmentClass, {
[styles.compact]: type === 'compact',
[styles.default]: type === 'default',
[styles.muted]: row.isMuted,
[styles.muted]: isMuted,
[styles.poster]: type === 'poster',
})}
>