support multiple items in item details modal

This commit is contained in:
jeffvli
2025-12-02 17:23:18 -08:00
parent a35577444b
commit 7701ea0a8c
9 changed files with 88 additions and 42 deletions
@@ -11,25 +11,35 @@ import { ContextMenu } from '/@/shared/components/context-menu/context-menu';
interface GetInfoActionProps { interface GetInfoActionProps {
disabled?: boolean; disabled?: boolean;
item: ItemDetailsModalProps['item']; items: ItemDetailsModalProps['item'][];
} }
export const GetInfoAction = ({ disabled, item }: GetInfoActionProps) => { export const GetInfoAction = ({ disabled, items }: GetInfoActionProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
const onSelect = useCallback(async () => { const onSelect = useCallback(async () => {
if (!server) return; if (!server || items.length === 0) return;
const filteredItems = items.filter(
(item): item is NonNullable<typeof item> => item !== undefined,
);
if (filteredItems.length === 0) return;
openModal({ openModal({
children: <ItemDetailsModal item={item} />, children: <ItemDetailsModal items={filteredItems} />,
size: 'lg', size: 'lg',
styles: { styles: {
body: { paddingBottom: 'var(--theme-spacing-xl)' }, body: { paddingBottom: 'var(--theme-spacing-xl)' },
}, },
title: item.name || t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }), title:
filteredItems.length === 1
? filteredItems[0]?.name ||
t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' })
: t('page.contextMenu.showDetails', { postProcess: 'sentenceCase' }),
}); });
}, [item, server, t]); }, [items, server, t]);
return ( return (
<ContextMenu.Item disabled={disabled} leftIcon="info" onSelect={onSelect}> <ContextMenu.Item disabled={disabled} leftIcon="info" onSelect={onSelect}>
@@ -39,7 +39,7 @@ export const AlbumArtistContextMenu = ({ items, type }: AlbumArtistContextMenuPr
<ContextMenu.Divider /> <ContextMenu.Divider />
<GoToAction items={items} /> <GoToAction items={items} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} item={items[0]} /> <GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content> </ContextMenu.Content>
); );
}; };
@@ -39,7 +39,7 @@ export const AlbumContextMenu = ({ items, type }: AlbumContextMenuProps) => {
<ContextMenu.Divider /> <ContextMenu.Divider />
<GoToAction items={items} /> <GoToAction items={items} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} item={items[0]} /> <GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content> </ContextMenu.Content>
); );
}; };
@@ -39,7 +39,7 @@ export const ArtistContextMenu = ({ items, type }: ArtistContextMenuProps) => {
<ContextMenu.Divider /> <ContextMenu.Divider />
<GoToAction items={items} /> <GoToAction items={items} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} item={items[0]} /> <GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content> </ContextMenu.Content>
); );
}; };
@@ -28,7 +28,7 @@ export const PlaylistContextMenu = ({ items, type }: PlaylistContextMenuProps) =
<ContextMenu.Divider /> <ContextMenu.Divider />
<AddToPlaylistAction items={ids} itemType={LibraryItem.ALBUM} /> <AddToPlaylistAction items={ids} itemType={LibraryItem.ALBUM} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} item={items[0]} /> <GetInfoAction disabled={items.length === 0} items={items} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<EditPlaylistAction items={items} /> <EditPlaylistAction items={items} />
<DeletePlaylistAction items={items} /> <DeletePlaylistAction items={items} />
@@ -42,7 +42,7 @@ export const PlaylistSongContextMenu = ({ items, type }: PlaylistSongContextMenu
<ContextMenu.Divider /> <ContextMenu.Divider />
<GoToAction items={items} /> <GoToAction items={items} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} item={items[0]} /> <GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content> </ContextMenu.Content>
); );
}; };
@@ -43,7 +43,7 @@ export const QueueContextMenu = ({ items }: QueueContextMenuProps) => {
<ContextMenu.Divider /> <ContextMenu.Divider />
<GoToAction items={items} /> <GoToAction items={items} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} item={items[0]} /> <GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content> </ContextMenu.Content>
); );
}; };
@@ -39,7 +39,7 @@ export const SongContextMenu = ({ items, type }: SongContextMenuProps) => {
<ContextMenu.Divider /> <ContextMenu.Divider />
<GoToAction items={items} /> <GoToAction items={items} />
<ContextMenu.Divider /> <ContextMenu.Divider />
<GetInfoAction disabled={items.length === 0} item={items[0]} /> <GetInfoAction disabled={items.length === 0} items={items} />
</ContextMenu.Content> </ContextMenu.Content>
); );
}; };
@@ -1,5 +1,5 @@
import { TFunction } from 'i18next'; import { TFunction } from 'i18next';
import { ReactNode } from 'react'; import { ReactNode, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generatePath, Link } from 'react-router'; import { generatePath, Link } from 'react-router';
@@ -12,8 +12,10 @@ import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types
import { sanitize } from '/@/renderer/utils/sanitize'; import { sanitize } from '/@/renderer/utils/sanitize';
import { SEPARATOR_STRING } from '/@/shared/api/utils'; import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { Select } from '/@/shared/components/select/select';
import { Separator } from '/@/shared/components/separator/separator'; import { Separator } from '/@/shared/components/separator/separator';
import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Spoiler } from '/@/shared/components/spoiler/spoiler';
import { Stack } from '/@/shared/components/stack/stack';
import { Table } from '/@/shared/components/table/table'; import { Table } from '/@/shared/components/table/table';
import { Text } from '/@/shared/components/text/text'; import { Text } from '/@/shared/components/text/text';
import { import {
@@ -29,7 +31,8 @@ import {
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
export type ItemDetailsModalProps = { export type ItemDetailsModalProps = {
item: Album | AlbumArtist | Artist | Playlist | Song; item?: Album | AlbumArtist | Artist | Playlist | Song;
items?: (Album | AlbumArtist | Artist | Playlist | Song)[];
}; };
type ItemDetailRow<T> = { type ItemDetailRow<T> = {
@@ -404,48 +407,81 @@ const handleParticipants = (item: Album | Song, t: TFunction) => {
return []; return [];
}; };
export const ItemDetailsModal = ({ item }: ItemDetailsModalProps) => { export const ItemDetailsModal = ({ item, items }: ItemDetailsModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const allItems = useMemo(() => items || (item ? [item] : []), [item, items]);
const [selectedIndex, setSelectedIndex] = useState(0);
const selectedItem = useMemo(() => {
return allItems[selectedIndex] || null;
}, [allItems, selectedIndex]);
const selectData = useMemo(() => {
return allItems.map((it, index) => ({
label:
it.name ||
`${t('common.item', { defaultValue: 'Item', postProcess: 'sentenceCase' })} ${index + 1}`,
value: String(index),
}));
}, [allItems, t]);
if (!selectedItem) {
return null;
}
let body: ReactNode[] = []; let body: ReactNode[] = [];
switch (item._itemType) { switch (selectedItem._itemType) {
case LibraryItem.ALBUM: case LibraryItem.ALBUM:
body = AlbumPropertyMapping.map((rule) => handleRow(t, item, rule)); body = AlbumPropertyMapping.map((rule) => handleRow(t, selectedItem, rule));
body.push(...handleParticipants(item, t)); body.push(...handleParticipants(selectedItem, t));
body.push(...handleTags(item, t)); body.push(...handleTags(selectedItem, t));
break; break;
case LibraryItem.ALBUM_ARTIST: case LibraryItem.ALBUM_ARTIST:
body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, item, rule)); body = AlbumArtistPropertyMapping.map((rule) => handleRow(t, selectedItem, rule));
break; break;
case LibraryItem.PLAYLIST: case LibraryItem.PLAYLIST:
body = PlaylistPropertyMapping.map((rule) => handleRow(t, item, rule)); body = PlaylistPropertyMapping.map((rule) => handleRow(t, selectedItem, rule));
break; break;
case LibraryItem.SONG: case LibraryItem.SONG:
body = SongPropertyMapping.map((rule) => handleRow(t, item, rule)); body = SongPropertyMapping.map((rule) => handleRow(t, selectedItem, rule));
body.push(...handleParticipants(item, t)); body.push(...handleParticipants(selectedItem, t));
body.push(...handleTags(item, t)); body.push(...handleTags(selectedItem, t));
break; break;
default: default:
body = []; body = [];
} }
return ( return (
<Table <Stack gap="md">
highlightOnHover={false} {allItems.length > 1 && (
styles={{ <Select
th: { data={selectData}
color: 'var(--theme-colors-foreground-muted)', onChange={(value) => {
fontWeight: 500, if (value) {
padding: 'var(--theme-spacing-sm)', setSelectedIndex(Number(value));
}, }
tr: { }}
color: 'var(--theme-colors-foreground-muted)', value={String(selectedIndex)}
padding: 'var(--theme-spacing-xl)', />
}, )}
}} <Table
withRowBorders={true} highlightOnHover={false}
> styles={{
<Table.Tbody>{body}</Table.Tbody> th: {
</Table> color: 'var(--theme-colors-foreground-muted)',
fontWeight: 500,
padding: 'var(--theme-spacing-sm)',
},
tr: {
color: 'var(--theme-colors-foreground-muted)',
padding: 'var(--theme-spacing-xl)',
},
}}
withRowBorders={true}
>
<Table.Tbody>{body}</Table.Tbody>
</Table>
</Stack>
); );
}; };