From 8fc30e693601158deb6d241d2f6b10f899adca9b Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 10 Jul 2026 00:59:07 -0700 Subject: [PATCH] add links, favorite, rating to album group column (#2139) - additionally fix drop and drop interactions --- .../album-group-controls.module.css | 10 ++ .../item-table-list/album-group-controls.tsx | 114 ++++++++++++++++++ .../album-group-header.module.css | 35 ++++++ .../item-table-list/album-group-header.tsx | 47 ++++++-- .../columns/album-group-column.tsx | 7 +- .../item-table-list/default-columns.ts | 2 +- .../hooks/use-row-interaction-delegate.ts | 27 ++++- .../item-table-list-column.module.css | 10 ++ .../item-table-list-column.tsx | 1 + src/renderer/store/settings.store.ts | 2 +- 10 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 src/renderer/components/item-list/item-table-list/album-group-controls.module.css create mode 100644 src/renderer/components/item-list/item-table-list/album-group-controls.tsx diff --git a/src/renderer/components/item-list/item-table-list/album-group-controls.module.css b/src/renderer/components/item-list/item-table-list/album-group-controls.module.css new file mode 100644 index 000000000..92112684c --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/album-group-controls.module.css @@ -0,0 +1,10 @@ +.controls { + display: flex; + gap: var(--theme-spacing-xs); + align-items: center; + margin-top: var(--theme-spacing-xxs); +} + +.hidden { + display: none; +} diff --git a/src/renderer/components/item-list/item-table-list/album-group-controls.tsx b/src/renderer/components/item-list/item-table-list/album-group-controls.tsx new file mode 100644 index 000000000..e8c10d35e --- /dev/null +++ b/src/renderer/components/item-list/item-table-list/album-group-controls.tsx @@ -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) => { + 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 ( +
+ { + event.stopPropagation(); + event.preventDefault(); + }} + size="xs" + variant="subtle" + /> + {showRating && ( + + )} +
+ ); +}; diff --git a/src/renderer/components/item-list/item-table-list/album-group-header.module.css b/src/renderer/components/item-list/item-table-list/album-group-header.module.css index 90203d8b2..92488f672 100644 --- a/src/renderer/components/item-list/item-table-list/album-group-header.module.css +++ b/src/renderer/components/item-list/item-table-list/album-group-header.module.css @@ -13,6 +13,32 @@ height: 100%; aspect-ratio: 1; 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 { @@ -27,6 +53,15 @@ font-size: var(--theme-font-size-sm); } +.album-name a { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + .artist-name { font-size: var(--theme-font-size-xs); opacity: 0.7; diff --git a/src/renderer/components/item-list/item-table-list/album-group-header.tsx b/src/renderer/components/item-list/item-table-list/album-group-header.tsx index 7036a7742..520377304 100644 --- a/src/renderer/components/item-list/item-table-list/album-group-header.tsx +++ b/src/renderer/components/item-list/item-table-list/album-group-header.tsx @@ -1,15 +1,19 @@ import { ReactElement, useState } from 'react'; +import { generatePath, Link } from 'react-router'; 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 { TableItemSize } from './item-table-list'; 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 { LONG_PRESS_PLAY_BEHAVIOR, PlayTooltip, } from '/@/renderer/features/shared/components/play-button-group'; +import { AppRoute } from '/@/renderer/router/routes'; import { useAlbumGroupImageSize, usePlayButtonBehavior } from '/@/renderer/store'; import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; @@ -35,6 +39,11 @@ export const AlbumGroupHeader = ({ large: TableItemSize.LARGE, normal: TableItemSize.DEFAULT, }[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 // enlarged the group's last row is grown so the total reaches the img size. const infoHeight = @@ -58,13 +67,12 @@ export const AlbumGroupHeader = ({ : undefined; return ( -
-
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - style={imageContainerStyle} - > +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
-
{song?.album ?? ''}
-
{song?.albumArtistName ?? ''}
+
+ {song?.albumId && albumPath ? ( + + {song.album ?? ''} + + ) : ( + (song?.album ?? '') + )} +
+
+ +
+
); diff --git a/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx b/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx index 00ee8c13c..65d829472 100644 --- a/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx +++ b/src/renderer/components/item-list/item-table-list/columns/album-group-column.tsx @@ -84,7 +84,12 @@ export const AlbumGroupColumn = (props: ItemTableListInnerColumn) => { } return ( - + + el.hasAttribute('data-exclude-row-drag-border'); + const clearRow = (rowKey: string) => { root.querySelectorAll(`[data-row-index="${rowKey}"]`).forEach((node) => { 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'); }); }; const applyRow = (rowKey: string, edge: 'bottom' | 'top') => { - const nodes = root.querySelectorAll(`[data-row-index="${rowKey}"]`); - nodes.forEach((node, idx) => { + const nodes = Array.from(root.querySelectorAll(`[data-row-index="${rowKey}"]`)); + 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; el.setAttribute('data-row-dragged-over', edge); if (idx === 0) { 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 { el.removeAttribute('data-row-dragged-over-first'); + el.removeAttribute('data-exclude-row-drag-left'); } }); }; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css index 782a49e12..99db441bf 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.module.css @@ -166,6 +166,11 @@ 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 { position: absolute; right: 0; @@ -185,6 +190,11 @@ 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 > * { position: relative; z-index: 2; diff --git a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx index 37fe02cc5..d07293429 100644 --- a/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx +++ b/src/renderer/components/item-list/item-table-list/item-table-list-column.tsx @@ -842,6 +842,7 @@ export const TableColumnContainer = ( [styles.withHorizontalBorder]: showHorizontalBorder && clampHeight === null, [styles.withVerticalBorder]: showVerticalBorder, })} + data-exclude-row-drag-border={props.type === TableColumn.ALBUM_GROUP ? true : undefined} data-row-index={isDataRow ? `${props.tableId}-${props.rowIndex}` : undefined} onClick={handleClick} onContextMenu={handleContextMenu} diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index 1d5f4eaf1..4ad54bd58 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -2503,7 +2503,7 @@ export const useSettingsStore = createWithEqualityFn()( id: TableColumn.ALBUM_GROUP, isEnabled: false, pinned: 'left', - width: 200, + width: 240, }); } }