add links and additional data to metadata section

This commit is contained in:
jeffvli
2026-02-09 03:46:29 -08:00
parent 2d01b8e3f7
commit cb6c2092e5
5 changed files with 277 additions and 44 deletions
@@ -1,6 +1,9 @@
import { ItemDetailListCellProps } from './types';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import {
JOINED_ARTISTS_MUTED_PROPS,
JoinedArtists,
} from '/@/renderer/features/albums/components/joined-artists';
import { Text } from '/@/shared/components/text/text';
export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
@@ -21,8 +24,8 @@ export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProp
<JoinedArtists
artistName={song.albumArtistName ?? ''}
artists={song.albumArtists ?? []}
linkProps={{ fw: 400, isMuted: true }}
rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
/>
);
};
@@ -1,6 +1,9 @@
import { ItemDetailListCellProps } from './types';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import {
JOINED_ARTISTS_MUTED_PROPS,
JoinedArtists,
} from '/@/renderer/features/albums/components/joined-artists';
import { Text } from '/@/shared/components/text/text';
export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
@@ -21,8 +24,8 @@ export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) =>
<JoinedArtists
artistName={song.artistName ?? ''}
artists={song.artists ?? []}
linkProps={{ fw: 400, isMuted: true }}
rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
/>
);
};
@@ -55,6 +55,47 @@
}
}
}
&:hover .favorite-badge,
&:hover .rating-badge {
opacity: 0;
}
}
.favorite-badge {
position: absolute;
top: -50px;
left: -50px;
z-index: 1;
width: 80px;
height: 80px;
pointer-events: none;
background-color: var(--theme-colors-primary);
box-shadow: 0 0 10px 8px rgb(0 0 0 / 80%);
opacity: 1;
transform: rotate(-45deg);
transition: opacity 0.2s ease-in-out;
}
.rating-badge {
position: absolute;
top: var(--theme-spacing-sm);
right: var(--theme-spacing-sm);
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--theme-spacing-xs) var(--theme-spacing-sm);
font-size: var(--theme-font-size-md);
font-weight: 600;
color: var(--theme-colors-foreground);
text-shadow: 0 1px 2px rgb(0 0 0 / 80%);
pointer-events: none;
background-color: var(--theme-colors-primary);
border-radius: var(--theme-radius-md);
box-shadow: 0 2px 8px rgb(0 0 0 / 50%);
opacity: 1;
transition: opacity 0.2s ease-in-out;
}
.row .image {
@@ -76,11 +117,58 @@
.row .title {
font-weight: 500;
color: inherit;
text-decoration: none;
}
.row .title:hover {
text-decoration: underline;
}
.row .artist {
font-size: var(--theme-font-size-sm);
color: var(--theme-colors-foreground-muted);
text-decoration: none;
}
.row .artist-plain-text:hover {
text-decoration: underline;
}
.row .metadata-link {
color: inherit;
text-decoration: none;
}
.row .metadata-link:hover {
text-decoration: underline;
}
.row .metadata-extra {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-3xs);
align-items: center;
width: 100%;
margin-top: var(--theme-spacing-3xs);
font-size: var(--theme-font-size-sm);
color: var(--theme-colors-foreground-muted);
text-align: center;
}
.row .metadata-line {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-wrap-style: balance;
white-space: nowrap;
}
.row .metadata-line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
}
.row .right {
@@ -3,7 +3,17 @@ import clsx from 'clsx';
import throttle from 'lodash/throttle';
import { AnimatePresence } from 'motion/react';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import { memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Fragment,
memo,
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { generatePath, Link } from 'react-router';
import { List, RowComponentProps, useDynamicRowHeight } from 'react-window-v2';
@@ -31,12 +41,18 @@ import {
import { useItemDragDropState } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-drag-drop-state';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import {
JOINED_ARTISTS_MUTED_PROPS,
JoinedArtists,
} from '/@/renderer/features/albums/components/joined-artists';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { AppRoute } from '/@/renderer/router/routes';
import { useSettingsStore } from '/@/renderer/store';
import { useSettingsStore, useShowRatings } from '/@/renderer/store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Text } from '/@/shared/components/text/text';
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
import { ItemListKey, TableColumn } from '/@/shared/types/types';
@@ -277,6 +293,151 @@ const TrackRow = memo(
TrackRow.displayName = 'TrackRow';
interface MetadataSectionProps {
controls?: ItemControls;
internalState: ItemListStateActions;
item: Album;
}
const MetadataSection = memo(
({ controls, internalState, item }: MetadataSectionProps) => {
const { t } = useTranslation();
const showRatings = useShowRatings();
const [isImageHovered, setIsImageHovered] = useState(false);
const [isMetadataHovered, setIsMetadataHovered] = useState(false);
const isFavorite = item.userFavorite ?? false;
const userRating = item.userRating ?? null;
const hasRating = showRatings && userRating !== null && userRating > 0;
const metadataExtra = useMemo(() => {
const parts: Array<{ content: React.ReactNode; key: string }> = [];
const releaseStr =
(item.releaseDate && formatDateAbsoluteUTC(item.releaseDate)) ||
(item.releaseYear != null ? String(item.releaseYear) : '');
if (releaseStr) parts.push({ content: releaseStr, key: 'release' });
const genres = item.genres?.filter((g) => g.name) ?? [];
if (genres.length > 0) {
parts.push({
content: genres.map((genre, i) => (
<Fragment key={genre.id}>
{i > 0 && ', '}
<Link
className={styles.metadataLink}
to={generatePath(AppRoute.LIBRARY_GENRES_DETAIL, {
genreId: genre.id,
})}
>
{genre.name}
</Link>
</Fragment>
)),
key: 'genres',
});
}
const songCount = item.songCount ?? 0;
const duration = item.duration ?? 0;
const tracksAndDurationParts: string[] = [];
if (songCount > 0) {
tracksAndDurationParts.push(t('entity.trackWithCount', { count: songCount }));
}
if (duration > 0) {
tracksAndDurationParts.push(formatDurationString(duration));
}
const tracksAndDuration = tracksAndDurationParts.join(' · ');
if (tracksAndDuration) {
parts.push({ content: tracksAndDuration, key: 'tracks' });
}
return parts.length > 0 ? parts : null;
}, [item, t]);
const hasArtist =
(item.albumArtistName?.trim()?.length ?? 0) > 0 || (item.albumArtists?.length ?? 0) > 0;
return (
<div
className={styles.metadata}
onMouseEnter={() => setIsMetadataHovered(true)}
onMouseLeave={() => setIsMetadataHovered(false)}
>
<Link
className={styles.imageWrapper}
onMouseEnter={() => setIsImageHovered(true)}
onMouseLeave={() => setIsImageHovered(false)}
state={{ item }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: item.id,
})}
>
<ItemImage
className={styles.image}
id={item.imageId}
itemType={item._itemType}
type="itemCard"
/>
{isFavorite && <div className={styles.favoriteBadge} />}
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
<AnimatePresence>
{controls && isImageHovered && (
<ItemCardControls
controls={controls}
enableExpansion={false}
internalState={internalState}
item={item}
itemType={item._itemType}
showRating={true}
type="compact"
/>
)}
</AnimatePresence>
</Link>
<Link
className={styles.title}
state={{ item }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: item.id,
})}
>
{item.name}
</Link>
<div className={styles.artist}>
{!hasArtist ? (
<>&nbsp;</>
) : !isMetadataHovered ? (
<Text className={styles.artistPlainText} component="span" isMuted size="sm">
{item.albumArtistName ?? ''}
</Text>
) : (
<JoinedArtists
artistName={item.albumArtistName ?? ''}
artists={item.albumArtists ?? []}
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
/>
)}
</div>
{metadataExtra && metadataExtra.length > 0 && (
<div className={styles.metadataExtra}>
{metadataExtra.map((part) => (
<div
className={clsx(styles.metadataLine, {
[styles.metadataLineClamp2]: part.key === 'genres',
})}
key={part.key}
>
{part.content}
</div>
))}
</div>
)}
</div>
);
},
(prev, next) => prev.item === next.item,
);
MetadataSection.displayName = 'MetadataSection';
type RowContentProps = Omit<RowComponentProps<RowData>, 'style'>;
const RowContent = memo(
@@ -296,7 +457,6 @@ const RowContent = memo(
trackColumns,
trackTableSize,
}: RowContentProps) => {
const [showControls, setShowControls] = useState(false);
const item = useMemo(() => {
if (getItem) {
return getItem(index) as Album | undefined;
@@ -372,39 +532,11 @@ const RowContent = memo(
return (
<>
<div className={styles.left}>
<div className={styles.metadata}>
<Link
className={styles.imageWrapper}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
state={{ item }}
to={generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, {
albumId: item.id,
})}
>
<ItemImage
className={styles.image}
id={item.imageId}
itemType={item._itemType}
type="itemCard"
/>
<AnimatePresence>
{controls && showControls && (
<ItemCardControls
controls={controls}
enableExpansion={false}
internalState={internalState}
item={item}
itemType={item._itemType}
showRating={true}
type="compact"
/>
)}
</AnimatePresence>
</Link>
<div className={styles.title}>{item.name}</div>
<div className={styles.artist}>{item.albumArtistName}</div>
</div>
<MetadataSection
controls={controls}
internalState={internalState}
item={item}
/>
</div>
<div className={styles.right}>
@@ -1,10 +1,15 @@
import { Fragment } from 'react';
import { Fragment, memo } from 'react';
import { generatePath, Link } from 'react-router';
import { AppRoute } from '/@/renderer/router/routes';
import { Text, TextProps } from '/@/shared/components/text/text';
import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
export const JOINED_ARTISTS_MUTED_PROPS = {
linkProps: { fw: 400, isMuted: true },
rootTextProps: { fw: 400, isMuted: true, size: 'sm' as const },
} as const;
interface JoinedArtistsProps {
artistName: string;
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
@@ -12,7 +17,7 @@ interface JoinedArtistsProps {
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
}
export const JoinedArtists = ({
const JoinedArtistsComponent = ({
artistName,
artists,
linkProps,
@@ -205,6 +210,8 @@ export const JoinedArtists = ({
);
};
export const JoinedArtists = memo(JoinedArtistsComponent);
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}