add genre image card placeholder

This commit is contained in:
jeffvli
2026-01-30 21:39:05 -08:00
parent 6962a05c96
commit 4d60f5b8d9
6 changed files with 148 additions and 40 deletions
@@ -19,6 +19,7 @@ import {
Album, Album,
AlbumArtist, AlbumArtist,
Artist, Artist,
Genre,
LibraryItem, LibraryItem,
Playlist, Playlist,
ServerType, ServerType,
@@ -30,7 +31,7 @@ interface ItemCardControlsProps {
controls?: ItemControls; controls?: ItemControls;
enableExpansion?: boolean; enableExpansion?: boolean;
internalState?: ItemListStateActions; internalState?: ItemListStateActions;
item: Album | AlbumArtist | Artist | Playlist | Song | undefined; item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined;
itemType: LibraryItem; itemType: LibraryItem;
showRating: boolean; showRating: boolean;
type?: 'compact' | 'default' | 'poster'; type?: 'compact' | 'default' | 'poster';
@@ -60,7 +61,7 @@ const containerProps = {
const createPlayHandler = const createPlayHandler =
( (
controls: ItemControls | undefined, controls: ItemControls | undefined,
item: Album | AlbumArtist | Artist | Playlist | Song | undefined, item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,
internalState: ItemListStateActions | undefined, internalState: ItemListStateActions | undefined,
itemType: LibraryItem, itemType: LibraryItem,
playType: Play, playType: Play,
@@ -108,7 +109,7 @@ const createPlayHandler =
const createFavoriteHandler = const createFavoriteHandler =
( (
controls: ItemControls | undefined, controls: ItemControls | undefined,
item: Album | AlbumArtist | Artist | Playlist | Song | undefined, item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,
internalState: ItemListStateActions | undefined, internalState: ItemListStateActions | undefined,
itemType: LibraryItem, itemType: LibraryItem,
) => ) =>
@@ -133,7 +134,7 @@ const createFavoriteHandler =
const createRatingChangeHandler = const createRatingChangeHandler =
( (
controls: ItemControls | undefined, controls: ItemControls | undefined,
item: Album | AlbumArtist | Artist | Playlist | Song | undefined, item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,
internalState: ItemListStateActions | undefined, internalState: ItemListStateActions | undefined,
itemType: LibraryItem, itemType: LibraryItem,
) => ) =>
@@ -165,7 +166,7 @@ const moreDoubleClickHandler = (e: MouseEvent<HTMLButtonElement>) => {
const createMoreHandler = const createMoreHandler =
( (
controls: ItemControls | undefined, controls: ItemControls | undefined,
item: Album | AlbumArtist | Artist | Playlist | Song | undefined, item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,
internalState: ItemListStateActions | undefined, internalState: ItemListStateActions | undefined,
itemType: LibraryItem, itemType: LibraryItem,
) => ) =>
@@ -183,7 +184,7 @@ const createMoreHandler =
const createExpandHandler = const createExpandHandler =
( (
controls: ItemControls | undefined, controls: ItemControls | undefined,
item: Album | AlbumArtist | Artist | Playlist | Song | undefined, item: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,
internalState: ItemListStateActions | undefined, internalState: ItemListStateActions | undefined,
itemType: LibraryItem, itemType: LibraryItem,
) => ) =>
@@ -113,6 +113,18 @@
border-radius: 50%; border-radius: 50%;
} }
.genre-placeholder {
box-sizing: border-box;
}
.genre-placeholder-text {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.detail-container { .detail-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -38,22 +38,26 @@ import {
Album, Album,
AlbumArtist, AlbumArtist,
Artist, Artist,
Genre,
LibraryItem, LibraryItem,
Playlist, Playlist,
Song, Song,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { DragOperation, DragTarget } from '/@/shared/types/drag-and-drop';
import { stringToColor } from '/@/shared/utils/string-to-color';
export type DataRow = { export type DataRow = {
align?: 'center' | 'end' | 'start'; align?: 'center' | 'end' | 'start';
format: (data: Album | AlbumArtist | Artist | Playlist | Song) => null | ReactNode | string; format: (
data: Album | AlbumArtist | Artist | Genre | Playlist | Song,
) => null | ReactNode | string;
id: string; id: string;
isMuted?: boolean; isMuted?: boolean;
}; };
export interface ItemCardProps { export interface ItemCardProps {
controls?: ItemControls; controls?: ItemControls;
data: Album | AlbumArtist | Artist | Playlist | Song | undefined; data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined;
enableDrag?: boolean; enableDrag?: boolean;
enableExpansion?: boolean; enableExpansion?: boolean;
enableMultiSelect?: boolean; enableMultiSelect?: boolean;
@@ -341,6 +345,17 @@ const CompactItemCard = ({
const imageContainerContent = ( 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 <ItemImage
className={clsx(styles.image, { className={clsx(styles.image, {
[styles.isRound]: isRound, [styles.isRound]: isRound,
@@ -351,6 +366,7 @@ const CompactItemCard = ({
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl} src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard" type="itemCard"
/> />
)}
{isFavorite && <div className={styles.favoriteBadge} />} {isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>} {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence> <AnimatePresence>
@@ -565,6 +581,17 @@ const DefaultItemCard = ({
const imageContainerContent = ( 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 <ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })} className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false} enableDebounce={false}
@@ -573,6 +600,7 @@ const DefaultItemCard = ({
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl} src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
type="itemCard" type="itemCard"
/> />
)}
{isFavorite && <div className={styles.favoriteBadge} />} {isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>} {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence> <AnimatePresence>
@@ -850,6 +878,17 @@ const PosterItemCard = ({
const imageContainerContent = ( 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 <ItemImage
className={clsx(styles.image, { [styles.isRound]: isRound })} className={clsx(styles.image, { [styles.isRound]: isRound })}
enableDebounce={false} enableDebounce={false}
@@ -858,6 +897,7 @@ const PosterItemCard = ({
src={(data as { imageUrl: string })?.imageUrl} src={(data as { imageUrl: string })?.imageUrl}
type="itemCard" type="itemCard"
/> />
)}
{isFavorite && <div className={styles.favoriteBadge} />} {isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>} {hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence> <AnimatePresence>
@@ -999,6 +1039,17 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
{data.name} {data.name}
</Link> </Link>
); );
case LibraryItem.GENRE:
return (
<Link
state={{ item: data }}
to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {
genreId: data.id,
})}
>
{data.name}
</Link>
);
case LibraryItem.PLAYLIST: case LibraryItem.PLAYLIST:
return ( return (
<Link <Link
@@ -1227,7 +1278,7 @@ export const getDataRowsCount = () => {
return getDataRows().length; return getDataRows().length;
}; };
const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | undefined) => { const getImageUrl = (data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined) => {
if (data && 'imageUrl' in data) { if (data && 'imageUrl' in data) {
return data.imageUrl || undefined; return data.imageUrl || undefined;
} }
@@ -1235,8 +1286,30 @@ const getImageUrl = (data: Album | AlbumArtist | Artist | Playlist | Song | unde
return undefined; return undefined;
}; };
const GenreImagePlaceholder = ({ className, name }: { className?: string; name: string }) => {
const { color, isLight } = useMemo(() => stringToColor(name), [name]);
return (
<div
className={className}
style={{
alignItems: 'center',
backgroundColor: color,
color: isLight ? '#000' : '#fff',
display: 'flex',
height: '100%',
justifyContent: 'center',
padding: 'var(--theme-spacing-sm)',
textAlign: 'center',
width: '100%',
}}
>
<span className={styles.genrePlaceholderText}>{name}</span>
</div>
);
};
const getItemNavigationPath = ( const getItemNavigationPath = (
data: Album | AlbumArtist | Artist | Playlist | Song | undefined, data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined,
itemType: LibraryItem, itemType: LibraryItem,
): null | string => { ): null | string => {
if (!data || !('id' in data) || !data.id) { if (!data || !('id' in data) || !data.id) {
@@ -1255,7 +1328,7 @@ const ItemCardRow = memo(
row, row,
type, type,
}: { }: {
data: Album | AlbumArtist | Artist | Playlist | Song | undefined; data: Album | AlbumArtist | Artist | Genre | Playlist | Song | undefined;
index: number; index: number;
row: DataRow; row: DataRow;
type?: 'compact' | 'default' | 'poster'; type?: 'compact' | 'default' | 'poster';
@@ -2,7 +2,15 @@ import {
ItemListStateActions, ItemListStateActions,
ItemListStateItemWithRequiredProperties, ItemListStateItemWithRequiredProperties,
} from '/@/renderer/components/item-list/helpers/item-list-state'; } from '/@/renderer/components/item-list/helpers/item-list-state';
import { Album, AlbumArtist, Artist, Folder, Playlist, Song } from '/@/shared/types/domain-types'; import {
Album,
AlbumArtist,
Artist,
Folder,
Genre,
Playlist,
Song,
} from '/@/shared/types/domain-types';
/** /**
* Type guard to assert that an item has the required properties for dragging * Type guard to assert that an item has the required properties for dragging
@@ -34,7 +42,7 @@ const hasRequiredDragProperties = (
* @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId) * @returns Array of items that should be dragged (with original values, asserting id, itemType, and _serverId)
*/ */
export const getDraggedItems = ( export const getDraggedItems = (
data: Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined, data: Album | AlbumArtist | Artist | Folder | Genre | Playlist | Song | undefined,
internalState?: ItemListStateActions, internalState?: ItemListStateActions,
updateSelection: boolean = true, updateSelection: boolean = true,
): ItemListStateItemWithRequiredProperties[] => { ): ItemListStateItemWithRequiredProperties[] => {
@@ -26,6 +26,10 @@ const getDefaultRowsForItemType = (
return [rowMap.get('name')].filter( return [rowMap.get('name')].filter(
(row): row is NonNullable<typeof row> => row !== undefined, (row): row is NonNullable<typeof row> => row !== undefined,
); );
case LibraryItem.GENRE:
return [rowMap.get('name')].filter(
(row): row is NonNullable<typeof row> => row !== undefined,
);
case LibraryItem.PLAYLIST: case LibraryItem.PLAYLIST:
return [rowMap.get('name')].filter( return [rowMap.get('name')].filter(
(row): row is NonNullable<typeof row> => row !== undefined, (row): row is NonNullable<typeof row> => row !== undefined,
@@ -53,6 +57,7 @@ const getRowIdFromTableColumn = (tableColumn: TableColumn): null | string => {
[TableColumn.CHANNELS]: null, [TableColumn.CHANNELS]: null,
[TableColumn.CODEC]: null, [TableColumn.CODEC]: null,
[TableColumn.COMMENT]: null, [TableColumn.COMMENT]: null,
[TableColumn.COMPOSER]: null,
[TableColumn.DATE_ADDED]: 'createdAt', [TableColumn.DATE_ADDED]: 'createdAt',
[TableColumn.DISC_NUMBER]: null, [TableColumn.DISC_NUMBER]: null,
[TableColumn.DURATION]: 'duration', [TableColumn.DURATION]: 'duration',
+10 -1
View File
@@ -4,6 +4,7 @@ import {
AlbumArtist, AlbumArtist,
Artist, Artist,
Folder, Folder,
Genre,
LibraryItem, LibraryItem,
Playlist, Playlist,
Song, Song,
@@ -78,7 +79,15 @@ export interface ItemListHandle {
scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void; scrollToOffset: (offset: number, options?: { behavior?: 'auto' | 'smooth' }) => void;
} }
export type ItemListItem = Album | AlbumArtist | Artist | Folder | Playlist | Song | undefined; export type ItemListItem =
| Album
| AlbumArtist
| Artist
| Folder
| Genre
| Playlist
| Song
| undefined;
export interface ItemListTableComponentProps<TQuery> extends ItemListComponentProps<TQuery> { export interface ItemListTableComponentProps<TQuery> extends ItemListComponentProps<TQuery> {
autoFitColumns?: boolean; autoFitColumns?: boolean;