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

@ -1,4 +1,3 @@
import { Box, Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { useMemo, useState } from 'react';
@ -6,12 +5,17 @@ import { useTranslation } from 'react-i18next';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Button, MultiSelect, Switch, toast } from '/@/renderer/components';
import { getGenreSongsById } from '/@/renderer/features/player';
import { useAddToPlaylist } from '/@/renderer/features/playlists/mutations/add-to-playlist-mutation';
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { MultiSelect } from '/@/shared/components/multi-select/multi-select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { toast } from '/@/shared/components/toast/toast';
import {
PlaylistListSort,
SongListQuery,
@ -218,7 +222,7 @@ export const AddToPlaylistContextModal = ({
});
return (
<Box p="1rem">
<div style={{ padding: '1rem' }}>
<form onSubmit={handleSubmit}>
<Stack>
<MultiSelect
@ -240,29 +244,27 @@ export const AddToPlaylistContextModal = ({
})}
{...form.getInputProps('skipDuplicates', { type: 'checkbox' })}
/>
<Group position="right">
<Group>
<Button
disabled={addToPlaylistMutation.isLoading}
onClick={() => closeModal(id)}
size="md"
variant="subtle"
>
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
loading={isLoading}
size="md"
type="submit"
variant="filled"
>
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
<Group justify="flex-end">
<Button
disabled={addToPlaylistMutation.isLoading}
onClick={() => closeModal(id)}
size="md"
variant="subtle"
>
{t('common.cancel', { postProcess: 'titleCase' })}
</Button>
<Button
disabled={isSubmitDisabled}
loading={isLoading}
size="md"
type="submit"
variant="filled"
>
{t('common.add', { postProcess: 'titleCase' })}
</Button>
</Group>
</Stack>
</form>
</Box>
</div>
);
};

View file

@ -1,9 +1,7 @@
import { Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Switch, Text, TextInput, toast } from '/@/renderer/components';
import {
PlaylistQueryBuilder,
PlaylistQueryBuilderRef,
@ -12,6 +10,14 @@ import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/crea
import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Text } from '/@/shared/components/text/text';
import { Textarea } from '/@/shared/components/textarea/textarea';
import { toast } from '/@/shared/components/toast/toast';
import { CreatePlaylistBody, ServerType, SongListSort } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@ -104,11 +110,13 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
{...form.getInputProps('name')}
/>
{server?.type === ServerType.NAVIDROME && (
<TextInput
<Textarea
autosize
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
minRows={5}
{...form.getInputProps('comment')}
/>
)}
@ -146,7 +154,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
</Stack>
)}
<Group position="right">
<Group justify="flex-end">
<Button
onClick={onCancel}
variant="subtle"

View file

@ -10,14 +10,13 @@ import type {
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence } from 'framer-motion';
import debounce from 'lodash/debounce';
import { AnimatePresence } from 'motion/react';
import { MutableRefObject, useCallback, useMemo } from 'react';
import { useParams } from 'react-router';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { toast } from '/@/renderer/components';
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table';
import { useCurrentSongRowStyles } from '/@/renderer/components/virtual-table/hooks/use-current-song-row-styles';
@ -40,6 +39,7 @@ import {
useSetPlaylistDetailTablePagination,
} from '/@/renderer/store';
import { PersistedTableColumn, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { toast } from '/@/shared/components/toast/toast';
import {
LibraryItem,
PlaylistSongListQuery,

View file

@ -1,45 +1,28 @@
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 { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import debounce from 'lodash/debounce';
import { MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
RiAddBoxFill,
RiAddCircleFill,
RiDeleteBinFill,
RiEditFill,
RiMoreFill,
RiPlayFill,
RiRefreshLine,
RiSettings3Fill,
} from 'react-icons/ri';
import { useNavigate, useParams } from 'react-router';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import {
Button,
ConfirmModal,
DropdownMenu,
MultiSelect,
Slider,
Switch,
Text,
toast,
} from '/@/renderer/components';
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { openUpdatePlaylistModal } from '/@/renderer/features/playlists/components/update-playlist-form';
import { useDeletePlaylist } from '/@/renderer/features/playlists/mutations/delete-playlist-mutation';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { OrderToggleButton } from '/@/renderer/features/shared';
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
import { MoreButton } from '/@/renderer/features/shared/components/more-button';
import { useContainerQuery } from '/@/renderer/hooks';
import { AppRoute } from '/@/renderer/router/routes';
import {
PersistedTableColumn,
SongListFilter,
useCurrentServer,
usePlaylistDetailStore,
@ -48,6 +31,15 @@ import {
useSetPlaylistStore,
useSetPlaylistTablePagination,
} 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 { ConfirmModal } from '/@/shared/components/modal/modal';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import {
LibraryItem,
PlaylistSongListQuery,
@ -55,7 +47,7 @@ import {
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ListDisplayType, Play, TableColumn } from '/@/shared/types/types';
import { ListDisplayType, Play } from '/@/shared/types/types';
const FILTERS = {
jellyfin: [
@ -303,6 +295,8 @@ export const PlaylistDetailSongListHeaderFilters = ({
setTable({ rowHeight: e });
};
const debouncedHandleItemSize = debounce(handleItemSize, 20);
const handleFilterChange = useCallback(
async (filters: SongListFilter) => {
if (server?.type !== ServerType.SUBSONIC) {
@ -392,14 +386,13 @@ export const PlaylistDetailSongListHeaderFilters = ({
}, [filters.sortOrder, handleFilterChange, playlistId, setFilter]);
const handleSetViewType = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setPage({ detail: { ...page, display: e.currentTarget.value as ListDisplayType } });
(displayType: ListDisplayType) => {
setPage({ detail: { ...page, display: displayType } });
},
[page, setPage],
);
const handleTableColumns = (values: TableColumn[]) => {
const handleTableColumns = (values: string[]) => {
const existingColumns = page.table.columns;
if (values.length === 0) {
@ -410,7 +403,10 @@ export const PlaylistDetailSongListHeaderFilters = ({
// 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({ columns: [...existingColumns, newColumn] });
} else {
@ -424,10 +420,10 @@ export const PlaylistDetailSongListHeaderFilters = ({
return tableRef.current?.api.sizeColumnsToFit();
};
const handleAutoFitColumns = (e: ChangeEvent<HTMLInputElement>) => {
setTable({ autoFit: e.currentTarget.checked });
const handleAutoFitColumns = (autoFitColumns: boolean) => {
setTable({ autoFit: autoFitColumns });
if (e.currentTarget.checked) {
if (autoFitColumns) {
tableRef.current?.api.sizeColumnsToFit();
}
};
@ -474,16 +470,13 @@ export const PlaylistDetailSongListHeaderFilters = ({
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"
tooltip={{
label: t('page.playlist.reorder', { postProcess: 'sentenceCase' }),
}}
@ -495,7 +488,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
<DropdownMenu.Dropdown>
{FILTERS[server?.type as keyof typeof FILTERS].map((filter) => (
<DropdownMenu.Item
$isActive={filter.value === filters.sortBy}
isSelected={filter.value === filters.sortBy}
key={`filter-${filter.name}`}
onClick={handleSetSortBy}
value={filter.value}
@ -511,40 +504,32 @@ export const PlaylistDetailSongListHeaderFilters = ({
onToggle={handleToggleSortOrder}
sortOrder={filters.sortOrder || SortOrder.ASC}
/>
<Divider orientation="vertical" />
<DropdownMenu position="bottom-start">
<DropdownMenu.Target>
<Button
compact
fw="600"
size="md"
variant="subtle"
>
<RiMoreFill size="1.3rem" />
</Button>
<MoreButton />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiPlayFill />}
leftSection={<Icon icon="mediaPlay" />}
onClick={() => handlePlay(Play.NOW)}
>
{t('player.play', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddBoxFill />}
leftSection={<Icon icon="mediaPlayLast" />}
onClick={() => handlePlay(Play.LAST)}
>
{t('player.addLast', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiAddCircleFill />}
leftSection={<Icon icon="mediaPlayNext" />}
onClick={() => handlePlay(Play.NEXT)}
>
{t('player.addNext', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiEditFill />}
leftSection={<Icon icon="edit" />}
onClick={() =>
openUpdatePlaylistModal({
playlist: detailQuery.data!,
@ -555,14 +540,14 @@ export const PlaylistDetailSongListHeaderFilters = ({
{t('action.editPlaylist', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
icon={<RiDeleteBinFill />}
leftSection={<Icon icon="delete" />}
onClick={openDeletePlaylistModal}
>
{t('action.deletePlaylist', { postProcess: 'sentenceCase' })}
</DropdownMenu.Item>
<DropdownMenu.Divider />
<DropdownMenu.Item
icon={<RiRefreshLine />}
leftSection={<Icon icon="refresh" />}
onClick={handleRefresh}
>
{t('action.refresh', { postProcess: 'sentenceCase' })}
@ -571,7 +556,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
<>
<DropdownMenu.Divider />
<DropdownMenu.Item
$danger
isDanger
onClick={handleToggleShowQueryBuilder}
>
{t('action.toggleSmartPlaylistEditor', {
@ -584,82 +569,18 @@ export const PlaylistDetailSongListHeaderFilters = ({
</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={page.display === ListDisplayType.TABLE}
onClick={handleSetViewType}
value={ListDisplayType.TABLE}
>
Table
</DropdownMenu.Item>
{/* <DropdownMenu.Item
$isActive={page.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}>
<Slider
defaultValue={page.table.rowHeight}
label={null}
max={100}
min={25}
onChangeEnd={handleItemSize}
/>
</DropdownMenu.Item>
{(page.display === ListDisplayType.TABLE ||
page.display === ListDisplayType.TABLE_PAGINATED) && (
<>
<DropdownMenu.Label>Table Columns</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={SONG_TABLE_COLUMNS}
defaultValue={page.table?.columns.map(
(column) => column.column,
)}
onChange={handleTableColumns}
width={300}
/>
<Group position="apart">
<Text>Auto Fit Columns</Text>
<Switch
defaultChecked={page.table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
<ListConfigMenu
autoFitColumns={page.table.autoFit}
disabledViewTypes={[ListDisplayType.GRID, ListDisplayType.LIST]}
displayType={page.display}
itemSize={page.table.rowHeight}
onChangeAutoFitColumns={handleAutoFitColumns}
onChangeDisplayType={handleSetViewType}
onChangeItemSize={debouncedHandleItemSize}
onChangeTableColumns={handleTableColumns}
tableColumns={page.table.columns.map((column) => column.column)}
tableColumnsData={SONG_TABLE_COLUMNS}
/>
</Group>
</Flex>
);

View file

@ -1,17 +1,19 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Stack } from '@mantine/core';
import { MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { Badge, PageHeader, Paper, SpinnerIcon } from '/@/renderer/components';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { usePlayQueueAdd } from '/@/renderer/features/player';
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
import { useCurrentServer } from '/@/renderer/store';
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { Badge } from '/@/shared/components/badge/badge';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { LibraryItem } from '/@/shared/types/domain-types';
import { Play } from '/@/shared/types/types';
@ -45,32 +47,27 @@ export const PlaylistDetailSongListHeader = ({
const isSmartPlaylist = detailQuery?.data?.rules;
return (
<Stack spacing={0}>
<PageHeader backgroundColor="var(--titlebar-bg)">
<Stack gap={0}>
<PageHeader>
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton onClick={() => handlePlay(playButtonBehavior)} />
<LibraryHeaderBar.Title>{detailQuery?.data?.name}</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
<Badge>
{itemCount === null || itemCount === undefined ? (
<SpinnerIcon />
) : (
itemCount
)}
</Paper>
</Badge>
{isSmartPlaylist && <Badge size="lg">{t('entity.smartPlaylist')}</Badge>}
</LibraryHeaderBar>
</PageHeader>
<Paper p="1rem">
<FilterBar>
<PlaylistDetailSongListHeaderFilters
handleToggleShowQueryBuilder={handleToggleShowQueryBuilder}
tableRef={tableRef}
/>
</Paper>
</FilterBar>
</Stack>
);
};

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/list.store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { ListDisplayType } from '/@/shared/types/types';
const PlaylistListTableView = lazy(() =>
@ -32,7 +32,7 @@ export const PlaylistListContent = ({ gridRef, itemCount, tableRef }: PlaylistLi
return (
<Suspense fallback={<Spinner container />}>
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
{display === ListDisplayType.CARD || display === ListDisplayType.GRID ? (
<PlaylistListGridView
gridRef={gridRef}
itemCount={itemCount}

View file

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

View file

@ -1,30 +1,45 @@
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 { closeAllModals, openModal } from '@mantine/modals';
import { useQueryClient } from '@tanstack/react-query';
import { ChangeEvent, MouseEvent, MutableRefObject, useCallback } from 'react';
import debounce from 'lodash/debounce';
import { MouseEvent, MutableRefObject, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { 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 { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
import { useListContext } from '/@/renderer/context/list-context';
import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form';
import { OrderToggleButton } 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 { PlaylistListFilter, useCurrentServer, useListStoreActions } from '/@/renderer/store';
import {
PersistedTableColumn,
PlaylistListFilter,
useCurrentServer,
useListStoreActions,
} from '/@/renderer/store';
import { useListStoreByKey } from '/@/renderer/store/list.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 {
LibraryItem,
PlaylistListQuery,
PlaylistListSort,
ServerType,
SortOrder,
} from '/@/shared/types/domain-types';
import { ListDisplayType, TableColumn } from '/@/shared/types/types';
import { ListDisplayType } from '/@/shared/types/types';
const FILTERS = {
jellyfin: [
@ -128,7 +143,7 @@ export const PlaylistListHeaderFilters = ({
const { display, filter, grid, table } = useListStoreByKey<PlaylistListQuery>({ key: pageKey });
const cq = useContainerQuery();
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.POSTER;
const isGrid = display === ListDisplayType.CARD || display === ListDisplayType.GRID;
const sortByLabel =
(server?.type &&
@ -267,14 +282,13 @@ export const PlaylistListHeaderFilters = ({
}, [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) {
@ -286,7 +300,10 @@ export const PlaylistListHeaderFilters = ({
// 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;
return setTable({ data: { columns: [...existingColumns, newColumn] }, key: pageKey });
}
@ -298,10 +315,10 @@ export const PlaylistListHeaderFilters = ({
return setTable({ data: { columns: newColumns }, key: pageKey });
};
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();
}
};
@ -314,6 +331,8 @@ export const PlaylistListHeaderFilters = ({
}
};
const debouncedHandleItemSize = debounce(handleItemSize, 20);
const handleItemGap = (e: number) => {
setGrid({ data: { itemGap: e }, key: pageKey });
};
@ -323,28 +342,32 @@ export const PlaylistListHeaderFilters = ({
handleFilterChange(filter);
};
const handleCreatePlaylistModal = () => {
openModal({
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
onClose: () => {
tableRef?.current?.api?.purgeInfiniteCache();
},
size: server?.type === ServerType?.NAVIDROME ? 'lg' : 'sm',
title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }),
});
};
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}
@ -359,31 +382,14 @@ export const PlaylistListHeaderFilters = ({
onToggle={handleToggleSortOrder}
sortOrder={filter.sortOrder}
/>
<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
fw="600"
size="md"
variant="subtle"
>
<RiMoreFill size="1.3rem" />
</Button>
<MoreButton />
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
icon={<RiRefreshLine />}
leftSection={<Icon icon="refresh" />}
onClick={handleRefresh}
>
{t('common.refresh', { postProcess: 'titleCase' })}
@ -391,120 +397,29 @@ export const PlaylistListHeaderFilters = ({
</DropdownMenu.Dropdown>
</DropdownMenu>
</Group>
<Group>
<DropdownMenu
position="bottom-end"
width={425}
<Group
gap="xs"
wrap="nowrap"
>
<Button
onClick={handleCreatePlaylistModal}
variant="subtle"
>
<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: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.POSTER}
onClick={handleSetViewType}
value={ListDisplayType.POSTER}
>
{t('table.config.view.poster', { postProcess: 'titleCase' })}
</DropdownMenu.Item>
<DropdownMenu.Item
$isActive={display === ListDisplayType.TABLE}
onClick={handleSetViewType}
value={ListDisplayType.TABLE}
>
{t('table.config.view.table', { postProcess: 'titleCase' })}
</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}>
<Slider
defaultValue={isGrid ? grid?.itemSize || 0 : table.rowHeight}
max={isGrid ? 300 : 100}
min={isGrid ? 100 : 25}
onChangeEnd={handleItemSize}
/>
</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.generaltableColumns', {
postProcess: 'titleCase',
})}
</DropdownMenu.Label>
<DropdownMenu.Item
closeMenuOnClick={false}
component="div"
sx={{ cursor: 'default' }}
>
<Stack>
<MultiSelect
clearable
data={PLAYLIST_TABLE_COLUMNS}
defaultValue={table?.columns.map(
(column) => column.column,
)}
onChange={handleTableColumns}
width={300}
/>
<Group position="apart">
<Text>
{t('table.config.general.autoFitColumns', {
postProcess: 'titleCase',
})}
</Text>
<Switch
defaultChecked={table.autoFit}
onChange={handleAutoFitColumns}
/>
</Group>
</Stack>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Dropdown>
</DropdownMenu>
{t('action.createPlaylist', { postProcess: 'sentenceCase' })}
</Button>
<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={PLAYLIST_TABLE_COLUMNS}
/>
</Group>
</Flex>
);

View file

@ -1,21 +1,23 @@
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
import { Flex, Group, Stack } from '@mantine/core';
import { closeAllModals, openModal } from '@mantine/modals';
import debounce from 'lodash/debounce';
import { ChangeEvent, MutableRefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { RiFileAddFill } from 'react-icons/ri';
import { Button, PageHeader, Paper, SearchInput, SpinnerIcon } from '/@/renderer/components';
import { PageHeader } from '/@/renderer/components/page-header/page-header';
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
import { CreatePlaylistForm } from '/@/renderer/features/playlists/components/create-playlist-form';
import { PlaylistListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-list-header-filters';
import { LibraryHeaderBar } from '/@/renderer/features/shared';
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 { PlaylistListFilter, useCurrentServer } from '/@/renderer/store';
import { LibraryItem, PlaylistListQuery, ServerType } from '/@/shared/types/domain-types';
import { Badge } from '/@/shared/components/badge/badge';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { SpinnerIcon } from '/@/shared/components/spinner/spinner';
import { Stack } from '/@/shared/components/stack/stack';
import { LibraryItem, PlaylistListQuery } from '/@/shared/types/domain-types';
interface PlaylistListHeaderProps {
gridRef: MutableRefObject<null | VirtualInfiniteGridRef>;
@ -28,17 +30,6 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis
const cq = useContainerQuery();
const server = useCurrentServer();
const handleCreatePlaylistModal = () => {
openModal({
children: <CreatePlaylistForm onCancel={() => closeAllModals()} />,
onClose: () => {
tableRef?.current?.api?.purgeInfiniteCache();
},
size: server?.type === ServerType?.NAVIDROME ? 'xl' : 'sm',
title: t('form.createPlaylist.title', { postProcess: 'sentenceCase' }),
});
};
const { filter, refresh, search } = useDisplayRefresh<PlaylistListQuery>({
gridRef,
itemCount,
@ -54,10 +45,10 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis
return (
<Stack
gap={0}
ref={cq.ref}
spacing={0}
>
<PageHeader backgroundColor="var(--titlebar-bg)">
<PageHeader>
<Flex
align="center"
justify="space-between"
@ -67,44 +58,28 @@ export const PlaylistListHeader = ({ gridRef, itemCount, tableRef }: PlaylistLis
<LibraryHeaderBar.Title>
{t('page.playlistList.title', { postProcess: 'titleCase' })}
</LibraryHeaderBar.Title>
<Paper
fw="600"
px="1rem"
py="0.3rem"
radius="sm"
>
<Badge>
{itemCount === null || itemCount === undefined ? (
<SpinnerIcon />
) : (
itemCount
)}
</Paper>
<Button
onClick={handleCreatePlaylistModal}
tooltip={{
label: t('action.createPlaylist', { postProcess: 'sentenceCase' }),
openDelay: 500,
}}
variant="filled"
>
<RiFileAddFill />
</Button>
</Badge>
</LibraryHeaderBar>
<Group>
<SearchInput
defaultValue={filter.searchTerm}
onChange={handleSearch}
openedWidth={cq.isMd ? 250 : cq.isSm ? 200 : 150}
/>
</Group>
</Flex>
</PageHeader>
<Paper p="1rem">
<FilterBar>
<PlaylistListHeaderFilters
gridRef={gridRef}
tableRef={tableRef}
/>
</Paper>
</FilterBar>
</Stack>
);
};

View file

@ -1,4 +1,3 @@
import { Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import { openModal } from '@mantine/modals';
import clone from 'lodash/clone';
@ -7,17 +6,8 @@ import setWith from 'lodash/setWith';
import { nanoid } from 'nanoid';
import { forwardRef, Ref, useImperativeHandle, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RiMore2Fill, RiSaveLine } from 'react-icons/ri';
import {
Button,
DropdownMenu,
MotionFlex,
NumberInput,
QueryBuilder,
ScrollArea,
Select,
} from '/@/renderer/components';
import { QueryBuilder } from '/@/renderer/components/query-builder';
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
import {
convertNDQueryToQueryGroup,
@ -33,6 +23,15 @@ import {
NDSongQueryPlaylistOperators,
NDSongQueryStringOperators,
} from '/@/shared/api/navidrome.types';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Button } from '/@/shared/components/button/button';
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 { NumberInput } from '/@/shared/components/number-input/number-input';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { Select } from '/@/shared/components/select/select';
import { PlaylistListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types';
@ -411,15 +410,12 @@ export const PlaylistQueryBuilder = forwardRef(
];
return (
<MotionFlex
<Flex
direction="column"
h="calc(100% - 3.5rem)"
h="calc(100% - 2rem)"
justify="space-between"
>
<ScrollArea
h="100%"
p="1rem"
>
<ScrollArea>
<QueryBuilder
data={filters}
filters={NDSongQueryFields}
@ -448,14 +444,14 @@ export const PlaylistQueryBuilder = forwardRef(
</ScrollArea>
<Group
align="flex-end"
justify="space-between"
m="1rem"
noWrap
position="apart"
wrap="nowrap"
>
<Group
noWrap
spacing="sm"
gap="sm"
w="100%"
wrap="nowrap"
>
<Select
data={sortOptions}
@ -490,37 +486,38 @@ export const PlaylistQueryBuilder = forwardRef(
</Group>
{onSave && onSaveAs && (
<Group
noWrap
spacing="sm"
gap="sm"
wrap="nowrap"
>
<Button
loading={isSaving}
onClick={handleSaveAs}
variant="filled"
>
{t('common.saveAs', { postProcess: 'titleCase' })}
</Button>
<Button
onClick={openPreviewModal}
p="0.5em"
variant="default"
variant="subtle"
>
{t('common.preview', { postProcess: 'titleCase' })}
</Button>
<DropdownMenu position="bottom-end">
<DropdownMenu.Target>
<Button
<ActionIcon
disabled={isSaving}
p="0.5em"
variant="default"
>
<RiMore2Fill size={15} />
</Button>
icon="ellipsisHorizontal"
variant="subtle"
/>
</DropdownMenu.Target>
<DropdownMenu.Dropdown>
<DropdownMenu.Item
$danger
icon={<RiSaveLine color="var(--danger-color)" />}
isDanger
leftSection={
<Icon
color="error"
icon="save"
/>
}
onClick={handleSave}
>
{t('common.saveAndReplace', { postProcess: 'titleCase' })}
@ -530,7 +527,7 @@ export const PlaylistQueryBuilder = forwardRef(
</Group>
)}
</Group>
</MotionFlex>
</Flex>
);
},
);

View file

@ -1,11 +1,15 @@
import { Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'react-i18next';
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { toast } from '/@/shared/components/toast/toast';
import {
CreatePlaylistBody,
CreatePlaylistResponse,
@ -98,7 +102,7 @@ export const SaveAsPlaylistForm = ({
{...form.getInputProps('public', { type: 'checkbox' })}
/>
)}
<Group position="right">
<Group justify="flex-end">
<Button
onClick={onCancel}
variant="subtle"

View file

@ -1,4 +1,3 @@
import { Group, Stack } from '@mantine/core';
import { useForm } from '@mantine/form';
import { closeAllModals, openModal } from '@mantine/modals';
import { useTranslation } from 'react-i18next';
@ -6,11 +5,17 @@ import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { Button, Select, Switch, TextInput, toast } from '/@/renderer/components';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { queryClient } from '/@/renderer/lib/react-query';
import { useCurrentServer } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Button } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { toast } from '/@/shared/components/toast/toast';
import {
PlaylistDetailResponse,
ServerListItem,
@ -134,7 +139,7 @@ export const UpdatePlaylistForm = ({ body, onCancel, query, users }: UpdatePlayl
/>
</>
)}
<Group position="right">
<Group justify="flex-end">
<Button
onClick={onCancel}
variant="subtle"