Persist album list config

This commit is contained in:
jeffvli
2022-11-20 01:53:20 -08:00
parent b51a79c3cd
commit 6f0c523559
5 changed files with 442 additions and 262 deletions
+9 -20
View File
@@ -1,30 +1,11 @@
import { ax } from '@/renderer/lib/axios';
import { SortOrder } from '@/renderer/types';
import { AlbumSort, SortOrder } from '@/renderer/types';
import {
AlbumDetailResponse,
AlbumListResponse,
PaginationParams,
} from './types';
export enum AlbumSort {
DATE_ADDED = 'added',
DATE_ADDED_REMOTE = 'addedRemote',
DATE_RELEASED = 'released',
DATE_RELEASED_YEAR = 'year',
FAVORITE = 'favorite',
NAME = 'name',
RANDOM = 'random',
RATING = 'rating',
}
export type AlbumListParams = PaginationParams & {
advancedFilters?: string;
orderBy: SortOrder;
serverFolderId?: string[];
serverUrlId?: string;
sortBy: AlbumSort;
};
const getAlbumDetail = async (
query: { albumId: string; serverId: string },
signal?: AbortSignal
@@ -42,6 +23,14 @@ const getAlbumDetail = async (
return data;
};
export type AlbumListParams = PaginationParams & {
advancedFilters?: string;
orderBy: SortOrder;
serverFolderId?: string[];
serverUrlId?: string;
sortBy: AlbumSort;
};
const getAlbumList = async (
query: { serverId: string },
params: AlbumListParams,
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { forwardRef, Ref, useImperativeHandle, useMemo, useState } from 'react';
import { Stack, Group } from '@mantine/core';
import dayjs from 'dayjs';
import { AnimatePresence, motion } from 'framer-motion';
@@ -15,25 +15,11 @@ import {
TextInput,
} from '@/renderer/components';
import { useGenreList } from '@/renderer/features/genres';
export enum FilterGroupType {
AND = 'AND',
OR = 'OR',
}
export type AdvancedFilterRule = {
field?: string | null;
operator?: string | null;
uniqueId: string;
value?: string | number | Date | undefined | null | any;
};
export type AdvancedFilterGroup = {
group: AdvancedFilterGroup[];
rules: AdvancedFilterRule[];
type: FilterGroupType;
uniqueId: string;
};
import {
AdvancedFilterGroup,
AdvancedFilterRule,
FilterGroupType,
} from '@/renderer/types';
const DATE_FILTER_OPTIONS_DATA = [
{ label: 'is before', value: '<' },
@@ -787,227 +773,274 @@ const FilterGroup = ({
);
};
export const AdvancedFilters = ({ filters, setFilters }: any) => {
const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = { ...filters };
const DEFAULT_ADVANCED_FILTERS = {
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: FilterGroupType.AND,
uniqueId: nanoid(),
};
const getPath = (level: number) => {
if (level === 0) return 'group';
interface AdvancedFiltersProps {
defaultFilters?: AdvancedFilterGroup;
onChange: (filters: AdvancedFilterGroup) => void;
}
export interface AdvancedFiltersRef {
reset: () => void;
}
export const AdvancedFilters = forwardRef(
(
{ defaultFilters, onChange }: AdvancedFiltersProps,
ref: Ref<AdvancedFiltersRef>
) => {
const [filters, setFilters] = useState<AdvancedFilterGroup>(
defaultFilters || DEFAULT_ADVANCED_FILTERS
);
useImperativeHandle(ref, () => ({
reset() {
setFilters(DEFAULT_ADVANCED_FILTERS);
},
}));
const setFilterHandler = (newFilters: AdvancedFilterGroup) => {
setFilters(newFilters);
onChange(newFilters);
};
const handleAddRuleGroup = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = { ...filters };
const getPath = (level: number) => {
if (level === 0) return 'group';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.group`;
};
const path = getPath(level);
const updatedFilters = set(filtersCopy, path, [
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: FilterGroupType.AND,
uniqueId: nanoid(),
},
]);
setFilterHandler(updatedFilters);
};
const handleDeleteRuleGroup = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = { ...filters };
const getPath = (level: number) => {
if (level === 0) return 'group';
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
if (i !== groupIndex.length - 1) {
str.push(`group[${groupIndex[i]}]`);
} else {
str.push(`group`);
}
}
return `${str.join('.')}`;
};
const path = getPath(level);
const updatedFilters = set(filtersCopy, path, [
...get(filtersCopy, path).filter(
(group: AdvancedFilterGroup) => group.uniqueId !== uniqueId
),
]);
setFilterHandler(updatedFilters);
};
const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return 'rules';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.group`;
return `${str.join('.')}.rules`;
};
const path = getPath(level);
const updatedFilters = set(filtersCopy, path, [
...get(filtersCopy, path),
{
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
const handleAddRule = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(filtersCopy, path, [
...get(filtersCopy, path),
{
field: null,
operator: null,
uniqueId: nanoid(),
value: null,
},
]);
setFilterHandler(updatedFilters);
};
const handleDeleteRule = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).filter(
(rule: AdvancedFilterRule) => rule.uniqueId !== uniqueId
)
);
setFilterHandler(updatedFilters);
};
const handleChangeField = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
const defaultOperator = FILTER_OPTIONS_DATA.find(
(option) => option.value === value
)?.default;
return {
...rule,
field: value,
operator: defaultOperator || '',
value: '',
},
],
type: FilterGroupType.AND,
uniqueId: nanoid(),
},
]);
};
})
);
setFilters(updatedFilters);
};
setFilterHandler(updatedFilters);
};
const handleDeleteRuleGroup = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = { ...filters };
const handleChangeType = (args: any) => {
const { level, groupIndex, value } = args;
const getPath = (level: number) => {
if (level === 0) return 'group';
const filtersCopy = { ...filters };
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
if (i !== groupIndex.length - 1) {
if (level === 0) {
return setFilterHandler({ ...filtersCopy, type: value });
}
const getTypePath = () => {
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
str.push(`group[${groupIndex[i]}]`);
} else {
str.push(`group`);
}
}
return `${str.join('.')}`;
return `${str.join('.')}`;
};
const path = getTypePath();
const updatedFilters = set(filtersCopy, path, {
...get(filtersCopy, path),
type: value,
});
return setFilterHandler(updatedFilters);
};
const path = getPath(level);
const handleChangeOperator = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
const updatedFilters = set(filtersCopy, path, [
...get(filtersCopy, path).filter(
(group: AdvancedFilterGroup) => group.uniqueId !== uniqueId
),
]);
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
operator: value,
};
})
);
setFilters(updatedFilters);
};
const getRulePath = (level: number, groupIndex: number[]) => {
if (level === 0) return 'rules';
const str = [];
for (const index of groupIndex) {
str.push(`group[${index}]`);
}
return `${str.join('.')}.rules`;
};
const handleAddRule = (args: AddArgs) => {
const { level, groupIndex } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(filtersCopy, path, [
...get(filtersCopy, path),
{
field: null,
operator: null,
uniqueId: nanoid(),
value: null,
},
]);
setFilters(updatedFilters);
};
const handleDeleteRule = (args: DeleteArgs) => {
const { uniqueId, level, groupIndex } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).filter(
(rule: AdvancedFilterRule) => rule.uniqueId !== uniqueId
)
);
setFilters(updatedFilters);
};
const handleChangeField = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
const defaultOperator = FILTER_OPTIONS_DATA.find(
(option) => option.value === value
)?.default;
return {
...rule,
field: value,
operator: defaultOperator || '',
value: '',
};
})
);
setFilters(updatedFilters);
};
const handleChangeType = (args: any) => {
const { level, groupIndex, value } = args;
const filtersCopy = { ...filters };
if (level === 0) {
return setFilters({ ...filtersCopy, type: value });
}
const getTypePath = () => {
const str = [];
for (let i = 0; i < groupIndex.length; i += 1) {
str.push(`group[${groupIndex[i]}]`);
}
return `${str.join('.')}`;
setFilterHandler(updatedFilters);
};
const path = getTypePath();
const updatedFilters = set(filtersCopy, path, {
...get(filtersCopy, path),
type: value,
});
const handleChangeValue = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
return setFilters(updatedFilters);
};
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
value,
};
})
);
const handleChangeOperator = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
setFilterHandler(updatedFilters);
};
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
operator: value,
};
})
return (
<>
<FilterGroup
data={filters}
groupIndex={[]}
level={0}
uniqueId={filters.uniqueId}
onAddRule={handleAddRule}
onAddRuleGroup={handleAddRuleGroup}
onChangeField={handleChangeField}
onChangeOperator={handleChangeOperator}
onChangeType={handleChangeType}
onChangeValue={handleChangeValue}
onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup}
/>
</>
);
}
);
setFilters(updatedFilters);
};
const handleChangeValue = (args: any) => {
const { uniqueId, level, groupIndex, value } = args;
const filtersCopy = { ...filters };
const path = getRulePath(level, groupIndex);
const updatedFilters = set(
filtersCopy,
path,
get(filtersCopy, path).map((rule: AdvancedFilterRule) => {
if (rule.uniqueId !== uniqueId) return rule;
return {
...rule,
value,
};
})
);
setFilters(updatedFilters);
};
return (
<>
<FilterGroup
data={filters}
groupIndex={[]}
level={0}
uniqueId={filters.uniqueId}
onAddRule={handleAddRule}
onAddRuleGroup={handleAddRuleGroup}
onChangeField={handleChangeField}
onChangeOperator={handleChangeOperator}
onChangeType={handleChangeType}
onChangeValue={handleChangeValue}
onDeleteRule={handleDeleteRule}
onDeleteRuleGroup={handleDeleteRuleGroup}
/>
</>
);
AdvancedFilters.defaultProps = {
defaultFilters: undefined,
};
@@ -1,9 +1,10 @@
/* eslint-disable no-plusplus */
import { useState, useCallback, useMemo, MouseEvent } from 'react';
import { useCallback, useMemo, MouseEvent, useRef } from 'react';
import { Group, Box } from '@mantine/core';
import { useDebouncedValue, useSetState, useToggle } from '@mantine/hooks';
import { useDebouncedValue } from '@mantine/hooks';
import { useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import { nanoid } from 'nanoid';
import {
@@ -12,8 +13,8 @@ import {
RiSettings2Fill,
} from 'react-icons/ri';
import AutoSizer from 'react-virtualized-auto-sizer';
import { ListOnScrollProps } from 'react-window';
import { api } from '@/renderer/api';
import { AlbumSort } from '@/renderer/api/albums.api';
import { queryKeys } from '@/renderer/api/query-keys';
import { SortOrder } from '@/renderer/api/types';
import {
@@ -29,17 +30,22 @@ import {
VirtualInfiniteGrid,
} from '@/renderer/components';
import {
AdvancedFilterGroup,
AdvancedFilters,
FilterGroupType,
encodeAdvancedFiltersQuery,
AdvancedFiltersRef,
} from '@/renderer/features/albums/components/advanced-filters';
import { useAlbumList } from '@/renderer/features/albums/queries/get-album-list';
import { useServerList } from '@/renderer/features/servers';
import { AnimatedPage, useServerCredential } from '@/renderer/features/shared';
import { AppRoute } from '@/renderer/router/routes';
import { useAppStore, useAuthStore } from '@/renderer/store';
import { LibraryItem, CardDisplayType } from '@/renderer/types';
import {
LibraryItem,
CardDisplayType,
AlbumSort,
FilterGroupType,
AdvancedFilterGroup,
} from '@/renderer/types';
const FILTERS = [
{ name: 'Title', value: AlbumSort.NAME },
@@ -81,19 +87,16 @@ export const AlbumListRoute = () => {
const setPage = useAppStore((state) => state.setPage);
const serverId = useAuthStore((state) => state.currentServer?.id) || '';
const serverListQuery = useServerList({ enabled: true });
const [filters, setFilters] = useSetState({
orderBy: SortOrder.ASC,
serverFolderId: [] as string[],
sortBy: AlbumSort.NAME,
});
const filters = page.list.filter;
const advancedFiltersRef = useRef<AdvancedFiltersRef>(null);
const [isAdvFilter, toggleAdvFilter] = useToggle();
const [rawAdvFilters, setRawAdvFilters] = useState<AdvancedFilterGroup>(
DEFAULT_ADVANCED_FILTERS
const isAdvFilter = page.list.advancedFilter.enabled;
const [debouncedAdvFilters] = useDebouncedValue(
page.list.advancedFilter.filter,
500
);
const [debouncedAdvFilters] = useDebouncedValue(rawAdvFilters, 500);
const advancedFilters = useMemo(() => {
if (!isAdvFilter) {
return encodeAdvancedFiltersQuery(DEFAULT_ADVANCED_FILTERS);
@@ -103,7 +106,17 @@ export const AlbumListRoute = () => {
}, [debouncedAdvFilters, isAdvFilter]);
const handleResetAdvancedFilters = () => {
setRawAdvFilters(DEFAULT_ADVANCED_FILTERS);
setPage('albums', {
...page,
list: {
...page.list,
advancedFilter: {
...page.list.advancedFilter,
filter: DEFAULT_ADVANCED_FILTERS,
},
},
});
advancedFiltersRef.current?.reset();
};
const serverFolders = useMemo(() => {
@@ -173,32 +186,84 @@ export const AlbumListRoute = () => {
const handleSetFilter = (e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setFilters({ sortBy: e.currentTarget.value as AlbumSort });
setPage('albums', {
list: {
...page.list,
filter: {
...page.list.filter,
sortBy: e.currentTarget.value as AlbumSort,
},
},
});
};
const handleSetOrder = (e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
setFilters({ orderBy: e.currentTarget.value as SortOrder });
setPage('albums', {
list: {
...page.list,
filter: {
...page.list.filter,
orderBy: e.currentTarget.value as SortOrder,
},
},
});
};
const handleSetServerFolder = (e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
const value = e.currentTarget.value as string;
if (filters.serverFolderId.includes(value)) {
setFilters({
serverFolderId: filters.serverFolderId.filter((id) => id !== value),
setPage('albums', {
list: {
...page.list,
filter: {
...page.list.filter,
serverFolderId: filters.serverFolderId.filter((id) => id !== value),
},
},
});
} else {
setFilters({
serverFolderId: [...filters.serverFolderId, value],
setPage('albums', {
list: {
...page.list,
filter: {
...page.list.filter,
serverFolderId: [...filters.serverFolderId, value],
},
},
});
}
};
const handleToggleAdvancedFilters = () => {
toggleAdvFilter();
const enabled = !page.list.advancedFilter.enabled;
setPage('albums', {
...page,
list: {
...page.list,
advancedFilter: {
...page.list.advancedFilter,
enabled,
...(!enabled && { filter: DEFAULT_ADVANCED_FILTERS }),
},
},
});
};
const handleUpdateAdvancedFilters = debounce((e: AdvancedFilterGroup) => {
setPage('albums', {
...page,
list: {
...page.list,
advancedFilter: {
...page.list.advancedFilter,
filter: e,
},
},
});
}, 150);
const handleSetViewType = (e: MouseEvent<HTMLButtonElement>) => {
if (!e.currentTarget?.value) return;
const type = e.currentTarget.value;
@@ -231,6 +296,16 @@ export const AlbumListRoute = () => {
}
};
const handleGridScroll = debounce((e: ListOnScrollProps) => {
setPage('albums', {
...page,
list: {
...page.list,
gridScrollOffset: e.scrollOffset,
},
});
}, 50);
return (
<AnimatedPage>
<VirtualGridContainer>
@@ -418,8 +493,9 @@ export const AlbumListRoute = () => {
</Group>
<Box p={10}>
<AdvancedFilters
filters={rawAdvFilters}
setFilters={setRawAdvFilters}
ref={advancedFiltersRef}
defaultFilters={page.list.advancedFilter.filter}
onChange={handleUpdateAdvancedFilters}
/>
</Box>
</ScrollArea>
@@ -456,6 +532,7 @@ export const AlbumListRoute = () => {
display={page.list?.display || CardDisplayType.CARD}
fetchFn={fetch}
height={height}
initialScrollOffset={page.list?.gridScrollOffset || 0}
itemCount={albumListQuery?.data?.pagination.totalEntries || 0}
itemGap={20}
itemSize={150 + page.list?.size}
@@ -467,6 +544,7 @@ export const AlbumListRoute = () => {
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
}}
width={width}
onScroll={handleGridScroll}
/>
)}
</AutoSizer>
+51 -1
View File
@@ -1,8 +1,16 @@
import merge from 'lodash/merge';
import { nanoid } from 'nanoid/non-secure';
import create from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { CardDisplayType, Platform } from '@/renderer/types';
import {
AdvancedFilterGroup,
AlbumSort,
CardDisplayType,
FilterGroupType,
Platform,
SortOrder,
} from '@/renderer/types';
type SidebarProps = {
expanded: string[];
@@ -16,8 +24,24 @@ type LibraryPageProps = {
list: ListProps;
};
type ListFilter = {
orderBy: SortOrder;
search?: string;
serverFolderId: string[];
sortBy: AlbumSort;
};
type ListAdvancedFilter = {
enabled: boolean;
filter: AdvancedFilterGroup;
};
type ListProps = {
advancedFilter: ListAdvancedFilter;
display: CardDisplayType;
filter: ListFilter;
gridScrollOffset: number;
listScrollOffset: number;
size: number;
type: 'list' | 'grid';
};
@@ -35,6 +59,20 @@ export interface AppState {
};
}
const DEFAULT_ADVANCED_FILTERS = {
group: [],
rules: [
{
field: '',
operator: '',
uniqueId: nanoid(),
value: '',
},
],
type: FilterGroupType.AND,
uniqueId: nanoid(),
};
export interface AppSlice extends AppState {
setAppStore: (data: Partial<AppSlice>) => void;
setPage: (page: 'albums', options: Partial<LibraryPageProps>) => void;
@@ -47,7 +85,19 @@ export const useAppStore = create<AppSlice>()(
immer((set, get) => ({
albums: {
list: {
advancedFilter: {
enabled: false,
filter: DEFAULT_ADVANCED_FILTERS,
},
display: CardDisplayType.CARD,
filter: {
orderBy: SortOrder.DESC,
search: '',
serverFolderId: [],
sortBy: AlbumSort.DATE_ADDED_REMOTE,
},
gridScrollOffset: 0,
listScrollOffset: 0,
size: 50,
type: 'grid',
},
+30
View File
@@ -91,11 +91,41 @@ export interface UniqueId {
uniqueId: string;
}
export enum AlbumSort {
DATE_ADDED = 'added',
DATE_ADDED_REMOTE = 'addedRemote',
DATE_RELEASED = 'released',
DATE_RELEASED_YEAR = 'year',
FAVORITE = 'favorite',
NAME = 'name',
RANDOM = 'random',
RATING = 'rating',
}
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export enum FilterGroupType {
AND = 'AND',
OR = 'OR',
}
export type AdvancedFilterRule = {
field?: string | null;
operator?: string | null;
uniqueId: string;
value?: string | number | Date | undefined | null | any;
};
export type AdvancedFilterGroup = {
group: AdvancedFilterGroup[];
rules: AdvancedFilterRule[];
type: FilterGroupType;
uniqueId: string;
};
export enum TableColumn {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',