Migrate to Mantine v8 and Design Changes (#961)

* mantine v8 migration

* various design changes and improvements
This commit is contained in:
Jeff 2025-06-24 00:04:36 -07:00 committed by GitHub
parent bea55d48a8
commit c1330d92b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
473 changed files with 12469 additions and 11607 deletions

View file

@ -0,0 +1,16 @@
.content-container {
position: relative;
z-index: 0;
}
.detail-container {
display: flex;
flex-direction: column;
gap: var(--theme-spacing-lg);
padding: 1rem 2rem 5rem;
overflow: hidden;
:global(.ag-theme-alpine-dark) {
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
}
}

View file

@ -1,16 +1,12 @@
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
import { Box, Grid, Group, Stack } from '@mantine/core';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaLastfmSquare } from 'react-icons/fa';
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
import { SiMusicbrainz } from 'react-icons/si';
import { generatePath, useParams } from 'react-router';
import { createSearchParams, Link } from 'react-router-dom';
import styled from 'styled-components';
import { Button, Spoiler, TextTitle } from '/@/renderer/components';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel';
import styles from './album-artist-detail-content.module.css';
import { MemoizedSwiperGridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel';
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
@ -32,6 +28,13 @@ import { AppRoute } from '/@/renderer/router/routes';
import { ArtistItem, useCurrentServer } from '/@/renderer/store';
import { useGeneralSettings, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { sanitize } from '/@/renderer/utils/sanitize';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Grid } from '/@/shared/components/grid/grid';
import { Group } from '/@/shared/components/group/group';
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
import { Stack } from '/@/shared/components/stack/stack';
import { TextTitle } from '/@/shared/components/text-title/text-title';
import {
Album,
AlbumArtist,
@ -43,23 +46,6 @@ import {
} from '/@/shared/types/domain-types';
import { CardRow, Play, TableColumn } from '/@/shared/types/types';
const ContentContainer = styled.div`
position: relative;
z-index: 0;
`;
const DetailContainer = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
padding: 1rem 2rem 5rem;
overflow: hidden;
.ag-theme-alpine-dark {
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
}
`;
interface AlbumArtistDetailContentProps {
background?: string;
}
@ -216,21 +202,20 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
title: (
<Group align="flex-end">
<TextTitle
fw={700}
order={2}
weight={700}
>
{t('page.albumArtistDetail.recentReleases', {
postProcess: 'sentenceCase',
})}
</TextTitle>
<Button
compact
component={Link}
size="compact-md"
to={artistDiscographyLink}
uppercase
variant="subtle"
>
{t('page.albumArtistDetail.viewDiscography')}
{String(t('page.albumArtistDetail.viewDiscography')).toUpperCase()}
</Button>
</Group>
),
@ -247,8 +232,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order: itemOrder.compilations,
title: (
<TextTitle
fw={700}
order={2}
weight={700}
>
{t('page.albumArtistDetail.appearsOn', { postProcess: 'sentenceCase' })}
</TextTitle>
@ -262,8 +247,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order: itemOrder.similarArtists,
title: (
<TextTitle
fw={700}
order={2}
weight={700}
>
{t('page.albumArtistDetail.relatedArtists', {
postProcess: 'sentenceCase',
@ -369,77 +354,77 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
detailQuery?.isLoading ||
(server?.type === ServerType.NAVIDROME && enabledItem.topSongs && topSongsQuery?.isLoading);
if (isLoading) return <ContentContainer ref={cq.ref} />;
if (isLoading)
return (
<div
className={styles.contentContainer}
ref={cq.ref}
/>
);
return (
<ContentContainer ref={cq.ref}>
<LibraryBackgroundOverlay $backgroundColor={background} />
<DetailContainer>
<Group spacing="md">
<div
className={styles.contentContainer}
ref={cq.ref}
>
<LibraryBackgroundOverlay backgroundColor={background} />
<div className={styles.detailContainer}>
<Group gap="md">
<PlayButton
disabled={albumCount === 0}
onClick={() => handlePlay(playButtonBehavior)}
/>
<Group spacing="xs">
<Button
compact
<Group gap="xs">
<ActionIcon
icon="favorite"
iconProps={{
fill: detailQuery?.data?.userFavorite ? 'primary' : undefined,
}}
loading={
createFavoriteMutation.isLoading || deleteFavoriteMutation.isLoading
}
onClick={handleFavorite}
variant="subtle"
>
{detailQuery?.data?.userFavorite ? (
<RiHeartFill
color="red"
size={20}
/>
) : (
<RiHeartLine size={20} />
)}
</Button>
<Button
compact
size="lg"
variant="transparent"
/>
<ActionIcon
icon="ellipsisHorizontal"
onClick={(e) => {
if (!detailQuery?.data) return;
handleGeneralContextMenu(e, [detailQuery.data!]);
}}
variant="subtle"
>
<RiMoreFill size={20} />
</Button>
size="lg"
variant="transparent"
/>
</Group>
</Group>
<Group spacing="md">
<Group gap="md">
<Button
compact
component={Link}
size="compact-md"
to={artistDiscographyLink}
uppercase
variant="subtle"
>
{t('page.albumArtistDetail.viewDiscography')}
{String(t('page.albumArtistDetail.viewDiscography')).toUpperCase()}
</Button>
<Button
compact
component={Link}
size="compact-md"
to={artistSongsLink}
uppercase
variant="subtle"
>
{t('page.albumArtistDetail.viewAllTracks')}
{String(t('page.albumArtistDetail.viewAllTracks')).toUpperCase()}
</Button>
</Group>
{showGenres ? (
<Box component="section">
<Group spacing="sm">
<section>
<Group gap="sm">
{detailQuery?.data?.genres?.map((genre) => (
<Button
compact
component={Link}
key={`genre-${genre.id}`}
radius="md"
size="md"
size="compact-md"
to={generatePath(genrePath, {
genreId: genre.id,
})}
@ -449,70 +434,65 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
</Button>
))}
</Group>
</Box>
</section>
) : null}
{externalLinks && (lastFM || musicBrainz) ? (
<Box component="section">
<Group spacing="sm">
{lastFM && (
<Button
compact
component="a"
href={`https://www.last.fm/music/${encodeURIComponent(
detailQuery?.data?.name || '',
)}`}
radius="md"
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.lastfm'),
}}
variant="subtle"
>
<FaLastfmSquare size={25} />
</Button>
)}
{musicBrainz && mbzId ? (
<Button
compact
<section>
<Group gap="sm">
<ActionIcon
component="a"
href={`https://www.last.fm/music/${encodeURIComponent(
detailQuery?.data?.name || '',
)}`}
icon="brandLastfm"
iconProps={{
fill: 'default',
size: 'xl',
}}
rel="noopener noreferrer"
target="_blank"
tooltip={{
label: t('action.openIn.lastfm'),
}}
variant="subtle"
/>
{mbzId ? (
<ActionIcon
component="a"
href={`https://musicbrainz.org/artist/${mbzId}`}
radius="md"
icon="brandMusicBrainz"
iconProps={{
fill: 'default',
size: 'xl',
}}
rel="noopener noreferrer"
size="md"
target="_blank"
tooltip={{
label: t('action.openIn.musicbrainz'),
}}
variant="subtle"
>
<SiMusicbrainz size={25} />
</Button>
/>
) : null}
</Group>
</Box>
</section>
) : null}
<Grid>
<Grid gutter="xl">
{biography ? (
<Grid.Col
order={itemOrder.biography}
span={12}
>
<Box
component="section"
maw="1280px"
>
<section style={{ maxWidth: '1280px' }}>
<TextTitle
fw={700}
order={2}
weight={700}
>
{t('page.albumArtistDetail.about', {
artist: detailQuery?.data?.name,
})}
</TextTitle>
<Spoiler dangerouslySetInnerHTML={{ __html: biography }} />
</Box>
</section>
</Grid.Col>
) : null}
{showTopSongs ? (
@ -520,26 +500,26 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order={itemOrder.topSongs}
span={12}
>
<Box component="section">
<section>
<Group
noWrap
position="apart"
justify="space-between"
wrap="nowrap"
>
<Group
align="flex-end"
noWrap
wrap="nowrap"
>
<TextTitle
fw={700}
order={2}
weight={700}
>
{t('page.albumArtistDetail.topSongs', {
postProcess: 'sentenceCase',
})}
</TextTitle>
<Button
compact
component={Link}
size="compact-md"
to={generatePath(
AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS,
{
@ -577,7 +557,7 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
suppressLoadingOverlay
suppressRowDrag
/>
</Box>
</section>
</Grid.Col>
) : null}
@ -589,8 +569,8 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
order={carousel.order}
span={12}
>
<Box component="section">
<Stack spacing="xl">
<section>
<Stack gap="xl">
<MemoizedSwiperGridCarousel
cardRows={
cardRows[carousel.itemType as keyof typeof cardRows]
@ -614,11 +594,11 @@ export const AlbumArtistDetailContent = ({ background }: AlbumArtistDetailConten
uniqueId={carousel.uniqueId}
/>
</Stack>
</Box>
</section>
</Grid.Col>
))}
</Grid>
</DetailContainer>
</ContentContainer>
</div>
</div>
);
};

View file

@ -1,14 +1,16 @@
import { Group, Rating, Stack } from '@mantine/core';
import { forwardRef, Fragment, Ref } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { Text } from '/@/renderer/components';
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
import { Group } from '/@/shared/components/group/group';
import { Rating } from '/@/shared/components/rating/rating';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { LibraryItem, ServerType } from '/@/shared/types/domain-types';
interface AlbumArtistDetailHeaderProps {
@ -87,13 +89,13 @@ export const AlbumArtistDetailHeader = forwardRef(
.filter((i) => i.enabled)
.map((item, index) => (
<Fragment key={`item-${item.id}-${index}`}>
{index > 0 && <Text $noSelect></Text>}
<Text $secondary={item.secondary}>{item.value}</Text>
{index > 0 && <Text isNoSelect></Text>}
<Text isMuted={item.secondary}>{item.value}</Text>
</Fragment>
))}
{showRating && (
<>
<Text $noSelect></Text>
<Text isNoSelect></Text>
<Rating
onChange={handleUpdateRating}
readOnly={

View file

@ -1,10 +1,11 @@
import { useTranslation } from 'react-i18next';
import { RiAddBoxFill, RiAddCircleFill, RiMoreFill, RiPlayFill } from 'react-icons/ri';
import { Button, DropdownMenu, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Badge } from '/@/shared/components/badge/badge';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { QueueSong } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@ -35,47 +36,14 @@ export const AlbumArtistDetailTopSongsListHeader = ({
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>
{t('page.albumArtistDetail.topSongsFrom', { title })}
{t('page.albumArtistDetail.topSongsFrom', {
postProcess: 'titleCase',
title,
})}
</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
<Badge>
{itemCount === null || itemCount === undefined ? <SpinnerIcon /> : itemCount}
</Paper>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiPlayFill />}
onClick={() => handlePlay(Play.NOW)}
>
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
onClick={() => handlePlay(Play.LAST)}
>
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
onClick={() => handlePlay(Play.NEXT)}
>
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
</DropdownMenu.Dropdown>
</DropdownMenu>
</Badge>
</LibraryHeaderBar>
</PageHeader>
);

View file

@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { lazy, MutableRefObject, Suspense } from 'react';
import { Spinner } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { useListStoreByKey } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { ListDisplayType } from '/@/shared/types/types';
const AlbumArtistListGridView = lazy(() =>
@ -37,7 +37,7 @@ export const AlbumArtistListContent = ({
}: AlbumArtistListContentProps) => {
const { pageKey } = useListContext();
const { display } = useListStoreByKey({ key: pageKey });
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
return (
<Suspense fallback={<Spinner container />}>

View file

@ -5,7 +5,7 @@ import { ListOnScrollProps } from 'react-window';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components/card/card-rows';
import {
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,

View file

@ -1,28 +1,36 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { IDatasource } from '@ag-grid-community/core';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { MouseEvent, MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { FolderButton } from '/@/renderer/features/shared/components/folder-button';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button';
import { useContainerQuery } from '/@/renderer/hooks';
import {
AlbumArtistListFilter,
PersistedTableColumn,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import {
AlbumArtistListQuery,
AlbumArtistListSort,
@ -30,7 +38,7 @@ import {
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { ListDisplayType, TableColumn } from '/@/shared/types/types';
import { ListDisplayType } from '/@/shared/types/types';
const FILTERS = {
jellyfin: [
@ -137,7 +145,7 @@ export const AlbumArtistListHeaderFilters = ({
useListStoreActions();
const cq = useContainerQuery();
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
const sortByLabel =
@ -308,15 +316,13 @@ export const AlbumArtistListHeaderFilters = ({
}, [filter.sortOrder, handleFilterChange, pageKey, setFilter]);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
(displayType: ListDisplayType) => {
setDisplayType({ data: displayType, key: pageKey });
},
[pageKey, setDisplayType],
);
const handleTableColumns = (values: TableColumn[]) => {
const handleTableColumns = (values: string[]) => {
const existingColumns = table.columns;
if (values.length === 0) {
@ -330,7 +336,10 @@ export const AlbumArtistListHeaderFilters = ({
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
const newColumn = {
column: values[values.length - 1],
width: 100,
} as PersistedTableColumn;
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
} else {
@ -344,10 +353,10 @@ export const AlbumArtistListHeaderFilters = ({
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
const handleAutoFitColumns = (autoFitColumns: boolean) => {
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
if (e.currentTarget.checked) {
if (autoFitColumns) {
tableRef.current?.api.sizeColumnsToFit();
}
};
@ -357,28 +366,25 @@ export const AlbumArtistListHeaderFilters = ({
handleFilterChange(filter);
}, [filter, handleFilterChange, queryClient, server?.id]);
const isFolderFilterApplied = useMemo(() => {
return filter.musicFolderId !== undefined;
}, [filter.musicFolderId]);
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
spacing="sm"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{sortByLabel}
</Button>
<Button variant="subtle">{sortByLabel}</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
<DropdownMenu.Item
$isActive={f.value === filter.sortBy}
isSelected={f.value === filter.sortBy}
key={`filter-${f.name}`}
onClick={handleSetSortBy}
value={f.value}
@ -395,22 +401,14 @@ export const AlbumArtistListHeaderFilters = ({
/>
{server?.type === ServerType.JELLYFIN && (
<>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
</Button>
<FolderButton isActive={!!isFolderFilterApplied} />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
$isActive={filter.musicFolderId === folder.id}
isSelected={filter.musicFolderId === folder.id}
key={`musicFolder-${folder.id}`}
onClick={handleSetMusicFolder}
value={folder.id}
@ -422,30 +420,14 @@ export const AlbumArtistListHeaderFilters = ({
</DropdownMenu>
</>
)}
<Divider orientation="vertical" />
<Button
compact
onClick={handleRefresh}
size="md"
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
>
<RiRefreshLine size="1.3rem" />
</Button>
<Divider orientation="vertical" />
<RefreshButton onClick={handleRefresh} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
<MoreButton />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
leftSection={<Icon icon="refresh" />}
onClick={handleRefresh}
>
{t('common.refresh', {
@ -455,136 +437,24 @@ export const AlbumArtistListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu
position="bottom-end"
width={425}
>
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
onClick={handleSetViewType}
value={ListDisplayType.CARD}
>
{t('table.config.view.card', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
onClick={handleSetViewType}
value={ListDisplayType.POSTER}
>
{t('table.config.view.poster', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
onClick={handleSetViewType}
value={ListDisplayType.TABLE}
>
{t('table.config.view.table', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE_PAGINATED}
value={ListDisplayType.TABLE_PAGINATED}
onClick={handleSetViewType}
>
Table (paginated)
</DropdownMenu.Item> */}
<DropdownMenu.Divider />
<DropdownMenu.Label>
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
{display === ListDisplayType.CARD ||
display === ListDisplayType.POSTER ? (
<Slider
defaultValue={grid?.itemSize}
max={300}
min={150}
onChange={debouncedHandleItemSize}
/>
) : (
<Slider
defaultValue={table.rowHeight}
max={100}
min={30}
onChange={debouncedHandleItemSize}
/>
)}
</DropdownMenu.Item>
{isGrid && (
<>
<DropdownMenu.Label>
{t('table.config.general.itemGap', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={grid?.itemGap || 0}
max={30}
min={0}
onChangeEnd={handleItemGap}
/>
</DropdownMenu.Item>
</>
)}
{!isGrid && (
<>
<DropdownMenu.Label>
{t('table.config.general.tableColumns', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={ALBUMARTIST_TABLE_COLUMNS}
defaultValue={table?.columns.map(
(column) => column.column,
)}
onChange={handleTableColumns}
width={300}
/>
<Group position="apart">
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'sentenceCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Group
gap="sm"
wrap="nowrap"
>
<ListConfigMenu
autoFitColumns={table.autoFit}
disabledViewTypes={[ListDisplayType.LIST]}
displayType={display}
itemGap={grid?.itemGap || 0}
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
onChangeAutoFitColumns={handleAutoFitColumns}
onChangeDisplayType={handleSetViewType}
onChangeItemGap={handleItemGap}
onChangeItemSize={debouncedHandleItemSize}
onChangeTableColumns={handleTableColumns}
tableColumns={table?.columns.map((column) => column.column)}
tableColumnsData={ALBUMARTIST_TABLE_COLUMNS}
/>
</Group>
</Flex>
);

View file

@ -1,17 +1,20 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import type { ChangeEvent, MutableRefObject } from 'react';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
import { useContainerQuery } from '/@/renderer/hooks';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
import { AlbumArtistListFilter, useCurrentServer } from '/@/renderer/store';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { AlbumArtistListQuery, LibraryItem } from '/@/shared/types/domain-types';
interface AlbumArtistListHeaderProps {
@ -44,10 +47,10 @@ export const AlbumArtistListHeader = ({
return (
<Stack
gap={0}
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<PageHeader>
<Flex
justify="space-between"
w="100%"
@ -66,7 +69,6 @@ export const AlbumArtistListHeader = ({
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
/>
</Group>
</Flex>

View file

@ -2,10 +2,10 @@ import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/li
import { lazy, MutableRefObject, Suspense } from 'react';
import { Spinner } from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { useListContext } from '/@/renderer/context/list-context';
import { useListStoreByKey } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { ListDisplayType } from '/@/shared/types/types';
const ArtistListGridView = lazy(() =>
@ -29,7 +29,7 @@ interface ArtistListContentProps {
export const ArtistListContent = ({ gridRef, itemCount, tableRef }: ArtistListContentProps) => {
const { pageKey } = useListContext();
const { display } = useListStoreByKey({ key: pageKey });
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
return (
<Suspense fallback={<Spinner container />}>

View file

@ -5,7 +5,7 @@ import { ListOnScrollProps } from 'react-window';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components/card/card-rows';
import {
VirtualGridAutoSizerContainer,
VirtualInfiniteGrid,

View file

@ -1,37 +1,38 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { IDatasource } from '@ag-grid-community/core';
import { Divider, Flex, Group, Stack } from '@mantine/core';
import { useQueryClient } from '@tanstack/react-query';
import debounce from 'lodash/debounce';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import { MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { RiFolder2Line, RiMoreFill, RiRefreshLine, RiSettings3Fill } from 'react-icons/ri';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
Button,
DropdownMenu,
MultiSelect,
Select,
Slider,
Switch,
Text,
} from '/@/renderer/components';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { useRoles } from '/@/renderer/features/artists/queries/roles-query';
import { OrderToggleButton, useMusicFolders } from '/@/renderer/features/shared';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { RefreshButton } from '/@/renderer/features/shared/components/refresh-button';
import { useContainerQuery } from '/@/renderer/hooks';
import {
ArtistListFilter,
PersistedTableColumn,
useCurrentServer,
useListStoreActions,
useListStoreByKey,
} from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
import { Divider } from '/@/shared/components/divider/divider';
import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { Select } from '/@/shared/components/select/select';
import {
ArtistListQuery,
ArtistListSort,
@ -39,7 +40,7 @@ import {
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { ListDisplayType, TableColumn } from '/@/shared/types/types';
import { ListDisplayType } from '/@/shared/types/types';
const FILTERS = {
jellyfin: [
@ -150,7 +151,7 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
serverId: server?.id,
});
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
const sortByLabel =
@ -321,15 +322,13 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
}, [filter.sortOrder, handleFilterChange, pageKey, setFilter]);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setDisplayType({ data: e.currentTarget.value as ListDisplayType, key: pageKey });
(displayType: ListDisplayType) => {
setDisplayType({ data: displayType, key: pageKey });
},
[pageKey, setDisplayType],
);
const handleTableColumns = (values: TableColumn[]) => {
const handleTableColumns = (values: string[]) => {
const existingColumns = table.columns;
if (values.length === 0) {
@ -343,7 +342,10 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
// If adding a column
if (values.length > existingColumns.length) {
const newColumn = { column: values[values.length - 1], width: 100 };
const newColumn = {
column: values[values.length - 1],
width: 100,
} as PersistedTableColumn;
setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
} else {
@ -357,10 +359,10 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ data: { autoFit: e.currentTarget.checked }, key: pageKey });
const handleAutoFitColumns = (autoFitColumns: boolean) => {
setTable({ data: { autoFit: autoFitColumns }, key: pageKey });
if (e.currentTarget.checked) {
if (autoFitColumns) {
tableRef.current?.api.sizeColumnsToFit();
}
};
@ -387,25 +389,18 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
return (
<Flex justify="space-between">
<Group
gap="sm"
ref={cq.ref}
spacing="sm"
w="100%"
>
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
{sortByLabel}
</Button>
<Button variant="subtle">{sortByLabel}</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((f) => (
<DropdownMenu.Item
$isActive={f.value === filter.sortBy}
isSelected={f.value === filter.sortBy}
key={`filter-${f.name}`}
onClick={handleSetSortBy}
value={f.value}
@ -425,19 +420,15 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
<ActionIcon
icon="folder"
variant="subtle"
>
{cq.isMd ? 'Folder' : <RiFolder2Line size={15} />}
</Button>
/>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
{musicFoldersQuery.data?.items.map((folder) => (
<DropdownMenu.Item
$isActive={filter.musicFolderId === folder.id}
isSelected={filter.musicFolderId === folder.id}
key={`musicFolder-${folder.id}`}
onClick={handleSetMusicFolder}
value={folder.id}
@ -451,7 +442,6 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
)}
{roles.data?.length && (
<>
<Divider orientation="vertical" />
<Select
data={roles.data}
onChange={handleSetRole}
@ -459,30 +449,14 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
/>
</>
)}
<Divider orientation="vertical" />
<Button
compact
onClick={handleRefresh}
size="md"
tooltip={{ label: t('common.refresh', { postProcess: 'titleCase' }) }}
variant="subtle"
>
<RiRefreshLine size="1.3rem" />
</Button>
<Divider orientation="vertical" />
<RefreshButton onClick={handleRefresh} />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiMoreFill size={15} />
</Button>
<MoreButton />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
leftSection={<Icon icon="refresh" />}
onClick={handleRefresh}
>
{t('common.refresh', {
@ -492,129 +466,23 @@ export const ArtistListHeaderFilters = ({ gridRef, tableRef }: ArtistListHeaderF
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu
position="bottom-end"
width={425}
>
<DropdownMenu.Target>
<Button
compact
size="md"
variant="subtle"
>
<RiSettings3Fill size="1.3rem" />
</Button>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Label>
{t('table.config.general.displayType', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item
$isActive={display === ListDisplayType.CARD}
onClick={handleSetViewType}
value={ListDisplayType.CARD}
>
{t('table.config.view.card', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
onClick={handleSetViewType}
value={ListDisplayType.POSTER}
>
{t('table.config.view.poster', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
onClick={handleSetViewType}
value={ListDisplayType.TABLE}
>
{t('table.config.view.table', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Label>
{t('table.config.general.itemSize', { postProcess: 'sentenceCase' })}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
{display === ListDisplayType.CARD ||
display === ListDisplayType.POSTER ? (
<Slider
defaultValue={grid?.itemSize}
max={300}
min={150}
onChange={debouncedHandleItemSize}
/>
) : (
<Slider
defaultValue={table.rowHeight}
max={100}
min={30}
onChange={debouncedHandleItemSize}
/>
)}
</DropdownMenu.Item>
{isGrid && (
<>
<DropdownMenu.Label>
{t('table.config.general.itemGap', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item closeMenuOnClick={false}>
<Slider
defaultValue={grid?.itemGap || 0}
max={30}
min={0}
onChangeEnd={handleItemGap}
/>
</DropdownMenu.Item>
</>
)}
{!isGrid && (
<>
<DropdownMenu.Label>
{t('table.config.general.tableColumns', {
postProcess: 'sentenceCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={ALBUMARTIST_TABLE_COLUMNS}
defaultValue={table?.columns.map(
(column) => column.column,
)}
onChange={handleTableColumns}
width={300}
/>
<Group position="apart">
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'sentenceCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
<Group
gap="xs"
wrap="nowrap"
>
<ListConfigMenu
autoFitColumns={table.autoFit}
displayType={display}
itemGap={grid?.itemGap || 0}
itemSize={isGrid ? grid?.itemSize || 0 : table.rowHeight}
onChangeAutoFitColumns={handleAutoFitColumns}
onChangeDisplayType={handleSetViewType}
onChangeItemGap={handleItemGap}
onChangeItemSize={debouncedHandleItemSize}
onChangeTableColumns={handleTableColumns}
tableColumns={table?.columns.map((column) => column.column)}
tableColumnsData={ALBUMARTIST_TABLE_COLUMNS}
/>
</Group>
</Flex>
);

View file

@ -1,17 +1,20 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import type { ChangeEvent, MutableRefObject } from 'react';
import { Flex, Group, Stack } from '@mantine/core';
import debounce from 'lodash/debounce';
import { useTranslation } from 'react-i18next';
import { PageHeader, SearchInput } from '/@/renderer/components';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { ArtistListHeaderFilters } from '/@/renderer/features/artists/components/artist-list-header-filters';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
import { useContainerQuery } from '/@/renderer/hooks';
import { useDisplayRefresh } from '/@/renderer/hooks/use-display-refresh';
import { ArtistListFilter, useCurrentServer } from '/@/renderer/store';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { ArtistListQuery, LibraryItem } from '/@/shared/types/domain-types';
interface ArtistListHeaderProps {
@ -40,10 +43,10 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea
return (
<Stack
gap={0}
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<PageHeader>
<Flex
justify="space-between"
w="100%"
@ -62,7 +65,6 @@ export const ArtistListHeader = ({ gridRef, itemCount, tableRef }: ArtistListHea
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
/>
</Group>
</Flex>