mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
add links and additional data to metadata section
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
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';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
|
||||||
export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
||||||
@@ -21,8 +24,8 @@ export const AlbumArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProp
|
|||||||
<JoinedArtists
|
<JoinedArtists
|
||||||
artistName={song.albumArtistName ?? ''}
|
artistName={song.albumArtistName ?? ''}
|
||||||
artists={song.albumArtists ?? []}
|
artists={song.albumArtists ?? []}
|
||||||
linkProps={{ fw: 400, isMuted: true }}
|
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
|
||||||
rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}
|
rootTextProps={JOINED_ARTISTS_MUTED_PROPS.rootTextProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ItemDetailListCellProps } from './types';
|
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';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
|
|
||||||
export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) => {
|
||||||
@@ -21,8 +24,8 @@ export const ArtistColumn = ({ isRowHovered, song }: ItemDetailListCellProps) =>
|
|||||||
<JoinedArtists
|
<JoinedArtists
|
||||||
artistName={song.artistName ?? ''}
|
artistName={song.artistName ?? ''}
|
||||||
artists={song.artists ?? []}
|
artists={song.artists ?? []}
|
||||||
linkProps={{ fw: 400, isMuted: true }}
|
linkProps={JOINED_ARTISTS_MUTED_PROPS.linkProps}
|
||||||
rootTextProps={{ fw: 400, isMuted: true, size: 'sm' }}
|
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 {
|
.row .image {
|
||||||
@@ -76,11 +117,58 @@
|
|||||||
|
|
||||||
.row .title {
|
.row .title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row .artist {
|
.row .artist {
|
||||||
font-size: var(--theme-font-size-sm);
|
font-size: var(--theme-font-size-sm);
|
||||||
color: var(--theme-colors-foreground-muted);
|
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 {
|
.row .right {
|
||||||
|
|||||||
@@ -3,7 +3,17 @@ import clsx from 'clsx';
|
|||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import { AnimatePresence } from 'motion/react';
|
import { AnimatePresence } from 'motion/react';
|
||||||
import { useOverlayScrollbars } from 'overlayscrollbars-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 { generatePath, Link } from 'react-router';
|
||||||
import { List, RowComponentProps, useDynamicRowHeight } from 'react-window-v2';
|
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 { 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 { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
|
||||||
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
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 { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||||
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
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 { Skeleton } from '/@/shared/components/skeleton/skeleton';
|
||||||
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
|
import { Album, LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||||
import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
import { ItemListKey, TableColumn } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -277,6 +293,151 @@ const TrackRow = memo(
|
|||||||
|
|
||||||
TrackRow.displayName = 'TrackRow';
|
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 ? (
|
||||||
|
<> </>
|
||||||
|
) : !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'>;
|
type RowContentProps = Omit<RowComponentProps<RowData>, 'style'>;
|
||||||
|
|
||||||
const RowContent = memo(
|
const RowContent = memo(
|
||||||
@@ -296,7 +457,6 @@ const RowContent = memo(
|
|||||||
trackColumns,
|
trackColumns,
|
||||||
trackTableSize,
|
trackTableSize,
|
||||||
}: RowContentProps) => {
|
}: RowContentProps) => {
|
||||||
const [showControls, setShowControls] = useState(false);
|
|
||||||
const item = useMemo(() => {
|
const item = useMemo(() => {
|
||||||
if (getItem) {
|
if (getItem) {
|
||||||
return getItem(index) as Album | undefined;
|
return getItem(index) as Album | undefined;
|
||||||
@@ -372,39 +532,11 @@ const RowContent = memo(
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.left}>
|
<div className={styles.left}>
|
||||||
<div className={styles.metadata}>
|
<MetadataSection
|
||||||
<Link
|
controls={controls}
|
||||||
className={styles.imageWrapper}
|
internalState={internalState}
|
||||||
onMouseEnter={() => setShowControls(true)}
|
item={item}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Fragment } from 'react';
|
import { Fragment, memo } from 'react';
|
||||||
import { generatePath, Link } from 'react-router';
|
import { generatePath, Link } from 'react-router';
|
||||||
|
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import { Text, TextProps } from '/@/shared/components/text/text';
|
import { Text, TextProps } from '/@/shared/components/text/text';
|
||||||
import { AlbumArtist, RelatedAlbumArtist, RelatedArtist } from '/@/shared/types/domain-types';
|
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 {
|
interface JoinedArtistsProps {
|
||||||
artistName: string;
|
artistName: string;
|
||||||
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
|
artists: AlbumArtist[] | RelatedAlbumArtist[] | RelatedArtist[];
|
||||||
@@ -12,7 +17,7 @@ interface JoinedArtistsProps {
|
|||||||
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
|
rootTextProps?: Partial<Omit<TextProps, 'children' | 'component'>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JoinedArtists = ({
|
const JoinedArtistsComponent = ({
|
||||||
artistName,
|
artistName,
|
||||||
artists,
|
artists,
|
||||||
linkProps,
|
linkProps,
|
||||||
@@ -205,6 +210,8 @@ export const JoinedArtists = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const JoinedArtists = memo(JoinedArtistsComponent);
|
||||||
|
|
||||||
function escapeRegex(str: string): string {
|
function escapeRegex(str: string): string {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user