diff --git a/src/renderer/api/albums.api.ts b/src/renderer/api/albums.api.ts index d20780aea..26564d869 100644 --- a/src/renderer/api/albums.api.ts +++ b/src/renderer/api/albums.api.ts @@ -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, diff --git a/src/renderer/features/albums/components/advanced-filters.tsx b/src/renderer/features/albums/components/advanced-filters.tsx index a8de03936..a59d21a84 100644 --- a/src/renderer/features/albums/components/advanced-filters.tsx +++ b/src/renderer/features/albums/components/advanced-filters.tsx @@ -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 + ) => { + const [filters, setFilters] = useState( + 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 ( + <> + + ); + } +); - 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 ( - <> - - - ); +AdvancedFilters.defaultProps = { + defaultFilters: undefined, }; diff --git a/src/renderer/features/albums/routes/album-list-route.tsx b/src/renderer/features/albums/routes/album-list-route.tsx index 025d6c67b..592f65291 100644 --- a/src/renderer/features/albums/routes/album-list-route.tsx +++ b/src/renderer/features/albums/routes/album-list-route.tsx @@ -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(null); - const [isAdvFilter, toggleAdvFilter] = useToggle(); - const [rawAdvFilters, setRawAdvFilters] = useState( - 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) => { 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) => { 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) => { 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) => { 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 ( @@ -418,8 +493,9 @@ export const AlbumListRoute = () => { @@ -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} /> )} diff --git a/src/renderer/store/app.store.ts b/src/renderer/store/app.store.ts index 3c855f0b9..db0f0c82d 100644 --- a/src/renderer/store/app.store.ts +++ b/src/renderer/store/app.store.ts @@ -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) => void; setPage: (page: 'albums', options: Partial) => void; @@ -47,7 +85,19 @@ export const useAppStore = create()( 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', }, diff --git a/src/renderer/types.ts b/src/renderer/types.ts index d5e2f7aae..d2d921df8 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -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',