mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-17 00:44:23 +02:00
implement item list grid card row customization
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
</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}
|
||||
>
|
||||
|
||||
</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}>
|
||||
|
||||
</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}
|
||||
>
|
||||
|
||||
</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}>
|
||||
|
||||
</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}
|
||||
>
|
||||
|
||||
</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',
|
||||
})}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user