add links, favorite, rating to album group column (#2139)

- additionally fix drop and drop interactions
This commit is contained in:
jeffvli
2026-07-10 00:59:07 -07:00
parent 57155e15ed
commit 8fc30e6936
10 changed files with 241 additions and 14 deletions
@@ -0,0 +1,10 @@
.controls {
display: flex;
gap: var(--theme-spacing-xs);
align-items: center;
margin-top: var(--theme-spacing-xxs);
}
.hidden {
display: none;
}
@@ -0,0 +1,114 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import styles from './album-group-controls.module.css';
import { albumQueries } from '/@/renderer/features/albums/api/album-api';
import { useSetFavorite } from '/@/renderer/features/shared/hooks/use-set-favorite';
import { useSetRating } from '/@/renderer/features/shared/hooks/use-set-rating';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { useShowRatings } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Rating } from '/@/shared/components/rating/rating';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
const isRatingSupported = (serverType: ServerType | undefined) =>
serverType === ServerType.NAVIDROME || serverType === ServerType.SUBSONIC;
const useAlbumGroupAlbum = (albumId: string | undefined, serverId: string | undefined) => {
const enabled = !!albumId && !!serverId && !albumId.startsWith('dummy/');
return useQuery({
...albumQueries.detail({ query: { id: albumId! }, serverId: serverId! }),
enabled,
staleTime: 5 * 60 * 1000,
});
};
interface AlbumGroupControlsProps {
albumId: string | undefined;
isGroupHovered: boolean;
serverId: string | undefined;
serverType: ServerType | undefined;
}
export const AlbumGroupControls = ({
albumId,
isGroupHovered,
serverId,
serverType,
}: AlbumGroupControlsProps) => {
const showRatingsSetting = useShowRatings();
const detailQuery = useAlbumGroupAlbum(albumId, serverId);
const setFavorite = useSetFavorite();
const setRating = useSetRating();
const isMutatingCreateFavorite = useIsMutatingCreateFavorite();
const isMutatingDeleteFavorite = useIsMutatingDeleteFavorite();
const isMutatingFavorite = isMutatingCreateFavorite || isMutatingDeleteFavorite;
const isMutatingRating = useIsMutatingRating();
const album = detailQuery.data;
const showRating = showRatingsSetting && isRatingSupported(serverType ?? album?._serverType);
const handleFavorite = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
if (!album) return;
setFavorite(album._serverId, [album.id], LibraryItem.ALBUM, !album.userFavorite);
},
[album, setFavorite],
);
const handleRating = useCallback(
(rating: number) => {
if (!album) return;
if (album.userRating === rating) {
return setRating(album._serverId, [album.id], LibraryItem.ALBUM, 0);
}
return setRating(album._serverId, [album.id], LibraryItem.ALBUM, rating);
},
[album, setRating],
);
if (!album) {
return null;
}
return (
<div className={styles.controls}>
<ActionIcon
className={album.userFavorite || isGroupHovered ? undefined : styles.hidden}
disabled={isMutatingFavorite}
icon="favorite"
iconProps={{
color: album.userFavorite ? 'primary' : 'muted',
fill: album.userFavorite ? 'primary' : undefined,
size: 'md',
}}
onClick={handleFavorite}
onDoubleClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
size="xs"
variant="subtle"
/>
{showRating && (
<Rating
className={album.userRating || isGroupHovered ? undefined : styles.hidden}
onChange={handleRating}
readOnly={isMutatingRating}
size="xs"
value={album.userRating || 0}
/>
)}
</div>
);
};
@@ -13,6 +13,32 @@
height: 100%; height: 100%;
aspect-ratio: 1; aspect-ratio: 1;
padding-top: calc(var(--theme-spacing-xs) * 0.5); padding-top: calc(var(--theme-spacing-xs) * 0.5);
overflow: hidden;
border-radius: var(--theme-radius-md);
&::before {
position: absolute;
top: 0;
left: 0;
z-index: 5;
width: 100%;
height: 100%;
pointer-events: none;
content: '';
background-color: rgb(0 0 0);
opacity: 0;
transition: all 0.2s ease-in-out;
}
&:hover::before {
@mixin dark {
opacity: 0.7;
}
@mixin light {
opacity: 0.5;
}
}
} }
.info { .info {
@@ -27,6 +53,15 @@
font-size: var(--theme-font-size-sm); font-size: var(--theme-font-size-sm);
} }
.album-name a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.artist-name { .artist-name {
font-size: var(--theme-font-size-xs); font-size: var(--theme-font-size-xs);
opacity: 0.7; opacity: 0.7;
@@ -1,15 +1,19 @@
import { ReactElement, useState } from 'react'; import { ReactElement, useState } from 'react';
import { generatePath, Link } from 'react-router';
import imageColumnStyles from '../item-detail-list/columns/image-column.module.css'; import imageColumnStyles from '../item-detail-list/columns/image-column.module.css';
import { AlbumGroupControls } from './album-group-controls';
import styles from './album-group-header.module.css'; import styles from './album-group-header.module.css';
import { TableItemSize } from './item-table-list'; import { TableItemSize } from './item-table-list';
import { ItemImage } from '/@/renderer/components/item-image/item-image'; import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { PlayButton } from '/@/renderer/features/shared/components/play-button'; import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { import {
LONG_PRESS_PLAY_BEHAVIOR, LONG_PRESS_PLAY_BEHAVIOR,
PlayTooltip, PlayTooltip,
} from '/@/renderer/features/shared/components/play-button-group'; } from '/@/renderer/features/shared/components/play-button-group';
import { AppRoute } from '/@/renderer/router/routes';
import { useAlbumGroupImageSize, usePlayButtonBehavior } from '/@/renderer/store'; import { useAlbumGroupImageSize, usePlayButtonBehavior } from '/@/renderer/store';
import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types'; import { Play } from '/@/shared/types/types';
@@ -35,6 +39,11 @@ export const AlbumGroupHeader = ({
large: TableItemSize.LARGE, large: TableItemSize.LARGE,
normal: TableItemSize.DEFAULT, normal: TableItemSize.DEFAULT,
}[size]; }[size];
const albumPath = song?.albumId
? generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: song.albumId })
: null;
// The album group spans the combined row height, but when the image is // The album group spans the combined row height, but when the image is
// enlarged the group's last row is grown so the total reaches the img size. // enlarged the group's last row is grown so the total reaches the img size.
const infoHeight = const infoHeight =
@@ -58,13 +67,12 @@ export const AlbumGroupHeader = ({
: undefined; : undefined;
return ( return (
<div className={styles.container}> <div
<div className={styles.container}
className={styles.imageContainer} onMouseEnter={() => setIsHovered(true)}
onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}
onMouseLeave={() => setIsHovered(false)} >
style={imageContainerStyle} <div className={styles.imageContainer} style={imageContainerStyle}>
>
<ItemImage <ItemImage
className={imageColumnStyles.compactImage} className={imageColumnStyles.compactImage}
enableDebounce enableDebounce
@@ -93,8 +101,29 @@ export const AlbumGroupHeader = ({
)} )}
</div> </div>
<div className={styles.info} style={{ height: infoHeight }}> <div className={styles.info} style={{ height: infoHeight }}>
<div className={styles.albumName}>{song?.album ?? ''}</div> <div className={styles.albumName}>
<div className={styles.artistName}>{song?.albumArtistName ?? ''}</div> {song?.albumId && albumPath ? (
<Link state={{ item: song }} to={albumPath}>
{song.album ?? ''}
</Link>
) : (
(song?.album ?? '')
)}
</div>
<div className={styles.artistName}>
<JoinedArtists
artistName={song?.albumArtistName ?? ''}
artists={song?.albumArtists ?? []}
linkProps={{ fw: 400 }}
rootTextProps={{ fw: 400, size: 'xs' }}
/>
</div>
<AlbumGroupControls
albumId={song?.albumId}
isGroupHovered={isHovered}
serverId={song?._serverId}
serverType={song?._serverType}
/>
</div> </div>
</div> </div>
); );
@@ -84,7 +84,12 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => {
} }
return ( return (
<TableColumnContainer {...props} enableAlternateRowColors={false}> <TableColumnContainer
{...props}
dragRef={null}
enableAlternateRowColors={false}
isDraggedOver={null}
>
<AlbumGroupHeader <AlbumGroupHeader
groupRowCount={groupRowCount} groupRowCount={groupRowCount}
onPlay={handlePlay} onPlay={handlePlay}
@@ -20,7 +20,7 @@ export const SONG_TABLE_COLUMNS: DefaultTableColumn[] = [
label: i18n.t('table.config.label.albumGroup'), label: i18n.t('table.config.label.albumGroup'),
pinned: 'left', pinned: 'left',
value: TableColumn.ALBUM_GROUP, value: TableColumn.ALBUM_GROUP,
width: 200, width: 240,
}, },
{ {
align: 'center', align: 'center',
@@ -83,23 +83,46 @@ export const useRowInteractionDelegate = ({
let pending: null | { edge: 'bottom' | 'top' | null; rowKey: string } = null; let pending: null | { edge: 'bottom' | 'top' | null; rowKey: string } = null;
let rafId: null | number = null; let rafId: null | number = null;
const isExcludedFromDragBorder = (el: HTMLElement) =>
el.hasAttribute('data-exclude-row-drag-border');
const clearRow = (rowKey: string) => { const clearRow = (rowKey: string) => {
root.querySelectorAll(`[data-row-index="${rowKey}"]`).forEach((node) => { root.querySelectorAll(`[data-row-index="${rowKey}"]`).forEach((node) => {
const el = node as HTMLElement; const el = node as HTMLElement;
el.removeAttribute('data-row-dragged-over'); el.removeAttribute('data-row-dragged-over');
el.removeAttribute('data-row-dragged-over-first'); el.removeAttribute('data-row-dragged-over-first');
el.removeAttribute('data-exclude-row-drag-left');
}); });
}; };
const applyRow = (rowKey: string, edge: 'bottom' | 'top') => { const applyRow = (rowKey: string, edge: 'bottom' | 'top') => {
const nodes = root.querySelectorAll(`[data-row-index="${rowKey}"]`); const nodes = Array.from(root.querySelectorAll(`[data-row-index="${rowKey}"]`));
nodes.forEach((node, idx) => { const eligibleNodes = nodes.filter(
(node) => !isExcludedFromDragBorder(node as HTMLElement),
);
const hasExcludedBeforeFirst = eligibleNodes.length < nodes.length;
nodes.forEach((node) => {
if (!isExcludedFromDragBorder(node as HTMLElement)) return;
const el = node as HTMLElement;
el.removeAttribute('data-row-dragged-over');
el.removeAttribute('data-row-dragged-over-first');
el.removeAttribute('data-exclude-row-drag-left');
});
eligibleNodes.forEach((node, idx) => {
const el = node as HTMLElement; const el = node as HTMLElement;
el.setAttribute('data-row-dragged-over', edge); el.setAttribute('data-row-dragged-over', edge);
if (idx === 0) { if (idx === 0) {
el.setAttribute('data-row-dragged-over-first', 'true'); el.setAttribute('data-row-dragged-over-first', 'true');
if (hasExcludedBeforeFirst) {
el.setAttribute('data-exclude-row-drag-left', 'true');
} else {
el.removeAttribute('data-exclude-row-drag-left');
}
} else { } else {
el.removeAttribute('data-row-dragged-over-first'); el.removeAttribute('data-row-dragged-over-first');
el.removeAttribute('data-exclude-row-drag-left');
} }
}); });
}; };
@@ -166,6 +166,11 @@
margin-left: 9999px; margin-left: 9999px;
} }
.container.data-row[data-row-dragged-over='top'][data-row-dragged-over-first='true'][data-exclude-row-drag-left='true']::after {
left: 0;
margin-left: 0;
}
.container.data-row[data-row-dragged-over='bottom']::after { .container.data-row[data-row-dragged-over='bottom']::after {
position: absolute; position: absolute;
right: 0; right: 0;
@@ -185,6 +190,11 @@
margin-left: 9999px; margin-left: 9999px;
} }
.container.data-row[data-row-dragged-over='bottom'][data-row-dragged-over-first='true'][data-exclude-row-drag-left='true']::after {
left: 0;
margin-left: 0;
}
.container.data-row > * { .container.data-row > * {
position: relative; position: relative;
z-index: 2; z-index: 2;
@@ -842,6 +842,7 @@ export const TableColumnContainer = (
[styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null, [styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null,
[styles.withVerticalBorder]: showVerticalBorder, [styles.withVerticalBorder]: showVerticalBorder,
})} })}
data-exclude-row-drag-border={props.type === TableColumn.ALBUM_GROUP ? true : undefined}
data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined}
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
+1 -1
View File
@@ -2503,7 +2503,7 @@ export const useSettingsStore = createWithEqualityFn<SettingsSlice>()(
id: TableColumn.ALBUM_GROUP, id: TableColumn.ALBUM_GROUP,
isEnabled: false, isEnabled: false,
pinned: 'left', pinned: 'left',
width: 200, width: 240,
}); });
} }
} }