diff --git a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx index 918af62c96a5..3cd96dfd3761 100644 --- a/interface/app/$libraryId/Explorer/QuickPreview/index.tsx +++ b/interface/app/$libraryId/Explorer/QuickPreview/index.tsx @@ -167,6 +167,7 @@ export const QuickPreview = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [explorer.selectedItemHashes, explorerView.updateActiveItem]); + // TODO: look here - jam const handleMoveBetweenItems = (step: number) => { const nextPreviewItem = items[itemIndex + step]; if (nextPreviewItem) { @@ -557,6 +558,7 @@ export const QuickPreview = () => { {(items) => ( {items} diff --git a/interface/app/$libraryId/search/Filters.tsx b/interface/app/$libraryId/search/Filters.tsx deleted file mode 100644 index f71f28877075..000000000000 --- a/interface/app/$libraryId/search/Filters.tsx +++ /dev/null @@ -1,673 +0,0 @@ -import { - CircleDashed, - Cube, - Folder, - Heart, - Icon, - SelectionSlash, - Textbox -} from '@phosphor-icons/react'; -import { keepPreviousData } from '@tanstack/react-query'; -import { useState } from 'react'; -import { InOrNotIn, ObjectKind, SearchFilterArgs, TextMatch, useLibraryQuery } from '@sd/client'; -import { Button, Input } from '@sd/ui'; -import i18n from '~/app/I18n'; -import { Icon as SDIcon } from '~/components'; -import { useLocale } from '~/hooks'; - -import { SearchOptionItem, SearchOptionSubMenu } from '.'; -import { translateKindName } from '../Explorer/util'; -import { AllKeys, FilterOption, getKey } from './store'; -import { UseSearch } from './useSearch'; -import { FilterTypeCondition, filterTypeCondition } from './util'; - -export interface SearchFilter< - TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any -> { - name: string; - icon: Icon; - conditions: TConditions; - translationKey?: string; -} - -export interface SearchFilterCRUD< - TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, - T = any -> extends SearchFilter { - getCondition: (args: T) => AllKeys; - setCondition: (args: T, condition: keyof TConditions) => void; - applyAdd: (args: T, option: FilterOption) => void; - applyRemove: (args: T, option: FilterOption) => T | undefined; - argsToOptions: (args: T, options: Map) => FilterOption[]; - extract: (arg: SearchFilterArgs) => T | undefined; - create: (data: any) => SearchFilterArgs; - merge: (left: T, right: T) => T; -} - -export interface RenderSearchFilter< - TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, - T = any -> extends SearchFilterCRUD { - // Render is responsible for fetching the filter options and rendering them - Render: (props: { - filter: SearchFilterCRUD; - options: (FilterOption & { type: string })[]; - search: UseSearch; - }) => JSX.Element; - // Apply is responsible for applying the filter to the search args - useOptions: (props: { search: string }) => FilterOption[]; -} - -export function useToggleOptionSelected({ search }: { search: UseSearch }) { - return ({ - filter, - option, - select - }: { - filter: SearchFilterCRUD; - option: FilterOption; - select: boolean; - }) => { - search.setFilters?.((filters = []) => { - const rawArg = filters.find((arg) => filter.extract(arg)); - - if (!rawArg) { - const arg = filter.create(option.value); - filters.push(arg); - } else { - const rawArgIndex = filters.findIndex((arg) => filter.extract(arg))!; - - const arg = filter.extract(rawArg)!; - - if (select) { - if (rawArg) filter.applyAdd(arg, option); - } else { - if (!filter.applyRemove(arg, option)) filters.splice(rawArgIndex, 1); - } - } - - return filters; - }); - }; -} - -const FilterOptionList = ({ - filter, - options, - search, - empty -}: { - filter: SearchFilterCRUD; - options: FilterOption[]; - search: UseSearch; - empty?: () => JSX.Element; -}) => { - const { allFiltersKeys } = search; - - const toggleOptionSelected = useToggleOptionSelected({ search }); - - return ( - - {empty?.() && options.length === 0 - ? empty() - : options?.map((option) => { - const optionKey = getKey({ - ...option, - type: filter.name - }); - - return ( - { - toggleOptionSelected({ - filter, - option, - select: value - }); - }} - key={option.value} - icon={option.icon} - > - {option.name} - - ); - })} - - ); -}; - -const FilterOptionText = ({ - filter, - search -}: { - filter: SearchFilterCRUD; - search: UseSearch; -}) => { - const [value, setValue] = useState(''); - - const { allFiltersKeys } = search; - const key = getKey({ - type: filter.name, - name: value, - value - }); - - const { t } = useLocale(); - - return ( - -
{ - e.preventDefault(); - search.setFilters?.((filters) => { - if (allFiltersKeys.has(key)) return filters; - - const arg = filter.create(value); - filters?.push(arg); - setValue(''); - - return filters; - }); - }} - > - setValue(e.target.value)} /> - -
-
- ); -}; - -const FilterOptionBoolean = ({ - filter, - search -}: { - filter: SearchFilterCRUD; - search: UseSearch; -}) => { - const { allFiltersKeys } = search; - - const key = getKey({ - type: filter.name, - name: filter.name, - value: true - }); - - return ( - { - search.setFilters?.((filters = []) => { - const index = filters.findIndex((f) => filter.extract(f) !== undefined); - - if (index !== -1) { - filters.splice(index, 1); - } else { - const arg = filter.create(true); - filters.push(arg); - } - - return filters; - }); - }} - > - {filter.name} - - ); -}; - -function createFilter( - filter: RenderSearchFilter -) { - return filter; -} - -function createInOrNotInFilter( - filter: Omit< - ReturnType>>, - | 'conditions' - | 'getCondition' - | 'argsToOptions' - | 'setCondition' - | 'applyAdd' - | 'applyRemove' - | 'create' - | 'merge' - > & { - create(value: InOrNotIn): SearchFilterArgs; - argsToOptions(values: T[], options: Map): FilterOption[]; - } -): ReturnType>> { - return { - ...filter, - create: (data) => { - if (typeof data === 'number' || typeof data === 'string') - return filter.create({ - in: [data as any] - }); - else if (data) return filter.create(data); - else return filter.create({ in: [] }); - }, - conditions: filterTypeCondition.inOrNotIn, - getCondition: (data) => { - if ('in' in data) return 'in'; - else return 'notIn'; - }, - setCondition: (data, condition) => { - const contents = 'in' in data ? data.in : data.notIn; - - return condition === 'in' ? { in: contents } : { notIn: contents }; - }, - argsToOptions: (data, options) => { - let values: T[]; - - if ('in' in data) values = data.in; - else values = data.notIn; - - return filter.argsToOptions(values, options); - }, - applyAdd: (data, option) => { - if ('in' in data) data.in = [...new Set([...data.in, option.value])]; - else data.notIn = [...new Set([...data.notIn, option.value])]; - - return data; - }, - applyRemove: (data, option) => { - if ('in' in data) { - data.in = data.in.filter((id) => id !== option.value); - - if (data.in.length === 0) return; - } else { - data.notIn = data.notIn.filter((id) => id !== option.value); - - if (data.notIn.length === 0) return; - } - - return data; - }, - merge: (left, right) => { - if ('in' in left && 'in' in right) { - return { - in: [...new Set([...left.in, ...right.in])] - }; - } else if ('notIn' in left && 'notIn' in right) { - return { - notIn: [...new Set([...left.notIn, ...right.notIn])] - }; - } - - throw new Error('Cannot merge InOrNotIns with different conditions'); - } - }; -} - -function createTextMatchFilter( - filter: Omit< - ReturnType>, - | 'conditions' - | 'getCondition' - | 'argsToOptions' - | 'setCondition' - | 'applyAdd' - | 'applyRemove' - | 'create' - | 'merge' - > & { - create(value: TextMatch): SearchFilterArgs; - } -): ReturnType> { - return { - ...filter, - conditions: filterTypeCondition.textMatch, - create: (contains) => filter.create({ contains }), - getCondition: (data) => { - if ('contains' in data) return 'contains'; - else if ('startsWith' in data) return 'startsWith'; - else if ('endsWith' in data) return 'endsWith'; - else return 'equals'; - }, - setCondition: (data, condition) => { - let value: string; - - if ('contains' in data) value = data.contains; - else if ('startsWith' in data) value = data.startsWith; - else if ('endsWith' in data) value = data.endsWith; - else value = data.equals; - - return { - [condition]: value - }; - }, - argsToOptions: (data) => { - let value: string; - - if ('contains' in data) value = data.contains; - else if ('startsWith' in data) value = data.startsWith; - else if ('endsWith' in data) value = data.endsWith; - else value = data.equals; - - return [ - { - type: filter.name, - name: value, - value - } - ]; - }, - applyAdd: (data, { value }) => { - if ('contains' in data) return { contains: value }; - else if ('startsWith' in data) return { startsWith: value }; - else if ('endsWith' in data) return { endsWith: value }; - else if ('equals' in data) return { equals: value }; - }, - applyRemove: () => undefined, - merge: (left) => left - }; -} - -function createBooleanFilter( - filter: Omit< - ReturnType>, - | 'conditions' - | 'getCondition' - | 'argsToOptions' - | 'setCondition' - | 'applyAdd' - | 'applyRemove' - | 'create' - | 'merge' - > & { - create(value: boolean): SearchFilterArgs; - } -): ReturnType> { - return { - ...filter, - conditions: filterTypeCondition.trueOrFalse, - create: () => filter.create(true), - getCondition: (data) => (data ? 'true' : 'false'), - setCondition: (_, condition) => condition === 'true', - argsToOptions: (value) => { - if (!value) return []; - - return [ - { - type: filter.name, - name: filter.name, - value - } - ]; - }, - applyAdd: (_, { value }) => value, - applyRemove: () => undefined, - merge: (left) => left - }; -} - -export const filterRegistry = [ - createInOrNotInFilter({ - name: i18n.t('location'), - translationKey: 'location', - icon: Folder, // Phosphor folder icon - extract: (arg) => { - if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations; - }, - create: (locations) => ({ filePath: { locations } }), - argsToOptions(values, options) { - return values - .map((value) => { - const option = options.get(this.name)?.find((o) => o.value === value); - - if (!option) return; - - return { - ...option, - type: this.name - }; - }) - .filter(Boolean) as any; - }, - useOptions: () => { - const query = useLibraryQuery(['locations.list'], { - placeholderData: keepPreviousData - }); - const locations = query.data; - - return (locations ?? []).map((location) => ({ - name: location.name!, - value: location.id, - icon: 'Folder' // Spacedrive folder icon - })); - }, - Render: ({ filter, options, search }) => ( - - ) - }), - createInOrNotInFilter({ - name: i18n.t('tags'), - translationKey: 'tag', - icon: CircleDashed, - extract: (arg) => { - if ('object' in arg && 'tags' in arg.object) return arg.object.tags; - }, - create: (tags) => ({ object: { tags } }), - argsToOptions(values, options) { - return values - .map((value) => { - const option = options.get(this.name)?.find((o) => o.value === value); - - if (!option) return; - - return { - ...option, - type: this.name - }; - }) - .filter(Boolean) as any; - }, - useOptions: () => { - const query = useLibraryQuery(['tags.list']); - const tags = query.data; - return (tags ?? []).map((tag) => ({ - name: tag.name!, - value: tag.id, - icon: tag.color || 'CircleDashed' - })); - }, - Render: ({ filter, options, search }) => { - return ( - ( -
- -

- {i18n.t('no_tags')} -

-
- )} - filter={filter} - options={options} - search={search} - /> - ); - } - }), - createInOrNotInFilter({ - name: i18n.t('kind'), - translationKey: 'kind', - icon: Cube, - extract: (arg) => { - if ('object' in arg && 'kind' in arg.object) return arg.object.kind; - }, - create: (kind) => ({ object: { kind } }), - argsToOptions(values, options) { - return values - .map((value) => { - const option = options.get(this.name)?.find((o) => o.value === value); - - if (!option) return; - - return { - ...option, - type: this.name - }; - }) - .filter(Boolean) as any; - }, - useOptions: () => - Object.keys(ObjectKind) - .filter((key) => !isNaN(Number(key)) && ObjectKind[Number(key)] !== undefined) - .map((key) => { - const kind = ObjectKind[Number(key)] as string; - return { - name: translateKindName(kind), - value: Number(key), - icon: kind + '20' - }; - }), - Render: ({ filter, options, search }) => ( - - ) - }), - createTextMatchFilter({ - name: i18n.t('name'), - translationKey: 'name', - icon: Textbox, - extract: (arg) => { - if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name; - }, - create: (name) => ({ filePath: { name } }), - useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], - Render: ({ filter, search }) => - }), - createInOrNotInFilter({ - name: i18n.t('extension'), - translationKey: 'extension', - icon: Textbox, - extract: (arg) => { - if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension; - }, - create: (extension) => ({ filePath: { extension } }), - argsToOptions(values) { - return values.map((value) => ({ - type: this.name, - name: value, - value - })); - }, - useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], - Render: ({ filter, search }) => - }), - createBooleanFilter({ - name: i18n.t('hidden'), - translationKey: 'hidden', - icon: SelectionSlash, - extract: (arg) => { - if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden; - }, - create: (hidden) => ({ filePath: { hidden } }), - useOptions: () => { - return [ - { - name: 'Hidden', - value: true, - icon: 'SelectionSlash' // Spacedrive folder icon - } - ]; - }, - Render: ({ filter, search }) => - }), - createBooleanFilter({ - name: i18n.t('favorite'), - translationKey: 'favorite', - icon: Heart, - extract: (arg) => { - if ('object' in arg && 'favorite' in arg.object) return arg.object.favorite; - }, - create: (favorite) => ({ object: { favorite } }), - useOptions: () => { - return [ - { - name: 'Favorite', - value: true, - icon: 'Heart' // Spacedrive folder icon - } - ]; - }, - Render: ({ filter, search }) => - }) - // createInOrNotInFilter({ - // name: i18n.t('label'), - // icon: Tag, - // extract: (arg) => { - // if ('object' in arg && 'labels' in arg.object) return arg.object.labels; - // }, - // create: (labels) => ({ object: { labels } }), - // argsToOptions(values, options) { - // return values - // .map((value) => { - // const option = options.get(this.name)?.find((o) => o.value === value); - - // if (!option) return; - - // return { - // ...option, - // type: this.name - // }; - // }) - // .filter(Boolean) as any; - // }, - // useOptions: () => { - // const query = useLibraryQuery(['labels.list']); - - // return (query.data ?? []).map((label) => ({ - // name: label.name!, - // value: label.id - // })); - // }, - // Render: ({ filter, options, search }) => ( - // - // ) - // }) - // idk how to handle this rn since include_descendants is part of 'path' now - // - // createFilter({ - // name: i18n.t('with_descendants'), - // icon: SelectionSlash, - // conditions: filterTypeCondition.trueOrFalse, - // setCondition: (args, condition: 'true' | 'false') => { - // const filePath = (args.filePath ??= {}); - - // filePath.withDescendants = condition === 'true'; - // }, - // applyAdd: () => {}, - // applyRemove: (args) => { - // delete args.filePath?.withDescendants; - // }, - // useOptions: () => { - // return [ - // { - // name: 'With Descendants', - // value: true, - // icon: 'SelectionSlash' // Spacedrive folder icon - // } - // ]; - // }, - // Render: ({ filter }) => { - // return ; - // }, - // apply(filter, args) { - // (args.filePath ??= {}).withDescendants = filter.condition; - // } - // }) -] as const satisfies ReadonlyArray>; - -export type FilterType = (typeof filterRegistry)[number]['name']; diff --git a/interface/app/$libraryId/search/Filters/FilterRegistry.tsx b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx new file mode 100644 index 000000000000..82fed0b1f3bd --- /dev/null +++ b/interface/app/$libraryId/search/Filters/FilterRegistry.tsx @@ -0,0 +1,31 @@ +import { RenderSearchFilter } from '.'; +import { favoriteFilter, hiddenFilter } from './registry/BooleanFilters'; +import { + filePathDateCreated, + filePathDateIndexed, + filePathDateModified, + mediaDateTaken, + objectDateAccessed +} from './registry/DateFilters'; +import { kindFilter } from './registry/KindFilter'; +import { locationFilter } from './registry/LocationFilter'; +import { tagsFilter } from './registry/TagsFilter'; +import { extensionFilter, nameFilter } from './registry/TextFilters'; + +export const filterRegistry: ReadonlyArray> = [ + // Put filters here + locationFilter, + tagsFilter, + kindFilter, + nameFilter, + extensionFilter, + filePathDateCreated, + filePathDateModified, + objectDateAccessed, + filePathDateIndexed, + mediaDateTaken, + favoriteFilter, + hiddenFilter +] as const; + +export type FilterType = (typeof filterRegistry)[number]['name']; diff --git a/interface/app/$libraryId/search/AppliedFilters.tsx b/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx similarity index 52% rename from interface/app/$libraryId/search/AppliedFilters.tsx rename to interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx index 2a70412dafed..6babb2cf2036 100644 --- a/interface/app/$libraryId/search/AppliedFilters.tsx +++ b/interface/app/$libraryId/search/Filters/components/AppliedFilters.tsx @@ -1,14 +1,16 @@ import { MagnifyingGlass, X } from '@phosphor-icons/react'; +import clsx from 'clsx'; import { forwardRef } from 'react'; import { SearchFilterArgs } from '@sd/client'; -import { tw } from '@sd/ui'; +import { Dropdown, DropdownMenu, tw } from '@sd/ui'; import { useLocale } from '~/hooks'; -import { useSearchContext } from '.'; -import HorizontalScroll from '../overview/Layout/HorizontalScroll'; -import { filterRegistry } from './Filters'; -import { useSearchStore } from './store'; -import { RenderIcon } from './util'; +import { SearchOptionItem, useSearchContext } from '../..'; +import HorizontalScroll from '../../../overview/Layout/HorizontalScroll'; +import { filterRegistry } from '../../Filters/index'; +import { RenderIcon } from '../../util'; +import { useFilterOptionStore } from '../store'; +import { FilterOptionList } from './FilterOptionList'; export const FilterContainer = tw.div`flex flex-row items-center rounded bg-app-box overflow-hidden shrink-0 h-6`; @@ -30,6 +32,8 @@ export const CloseTab = forwardRef void }>(({ o ); }); +const MENU_STYLES = `!rounded-md border !border-app-line !bg-app-box`; + export const AppliedFilters = () => { const search = useSearchContext(); @@ -75,17 +79,22 @@ export const AppliedFilters = () => { }; export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: () => void }) { - const searchStore = useSearchStore(); + const search = useSearchContext(); + + const filterStore = useFilterOptionStore(); const { t } = useLocale(); const filter = filterRegistry.find((f) => f.extract(arg)); if (!filter) return; - const activeOptions = filter.argsToOptions( + const activeOptions = filter.argsToFilterOptions( filter.extract(arg)! as any, - searchStore.filterOptions + filterStore.filterOptions ); + // get all options for this filter + const options = filterStore.filterOptions.get(filter.name) || []; + function isFilterDescriptionDisplayed() { if (filter?.translationKey === 'hidden' || filter?.translationKey === 'favorite') { return false; @@ -102,44 +111,63 @@ export function FilterArg({ arg, onDelete }: { arg: SearchFilterArgs; onDelete?: {isFilterDescriptionDisplayed() && ( <> - - {/* {Object.entries(filter.conditions).map(([value, displayName]) => ( -
{displayName}
- ))} */} - { - (filter.conditions as any)[ - filter.getCondition(filter.extract(arg) as any) as any - ] + e.stopPropagation()} + className={clsx(MENU_STYLES, 'explorer-scroll max-w-fit')} + trigger={ + + { + (filter.conditions as any)[ + filter.getCondition(filter.extract(arg) as any) as any + ] + } + } -
- - - {activeOptions && ( - <> - {activeOptions.length === 1 ? ( - - ) : ( -
- {activeOptions.map((option, index) => ( -
- + > + Is + Is Not + + e.stopPropagation()} + className={clsx(MENU_STYLES, 'explorer-scroll max-w-fit')} + trigger={ + + {activeOptions && ( + <> + {activeOptions.length === 1 ? ( + + ) : ( +
+ {activeOptions.map((option, index) => ( +
+ +
+ ))}
- ))} -
+ )} + + {activeOptions.length > 1 + ? `${activeOptions.length} ${t(`${filter.translationKey}`, { count: activeOptions.length })}` + : activeOptions[0]?.name} + + )} - - {activeOptions.length > 1 - ? `${activeOptions.length} ${t(`${filter.translationKey}`, { count: activeOptions.length })}` - : activeOptions[0]?.name} - - - )} - + + } + > + + )} diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx new file mode 100644 index 000000000000..979a668647b0 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionBoolean.tsx @@ -0,0 +1,43 @@ +import { SearchFilterCRUD } from '..'; +import { SearchOptionItem } from '../../SearchOptions'; +import { UseSearch } from '../../useSearch'; +import { getKey } from '../store'; + +export const FilterOptionBoolean = ({ + filter, + search +}: { + filter: SearchFilterCRUD; + search: UseSearch; +}) => { + const { allFiltersKeys } = search; + + const key = getKey({ + type: filter.name, + name: filter.name, + value: true + }); + + return ( + { + search.setFilters?.((filters = []) => { + const index = filters.findIndex((f) => filter.extract(f) !== undefined); + + if (index !== -1) { + filters.splice(index, 1); + } else { + const arg = filter.create(true); + filters.push(arg); + } + + return filters; + }); + }} + > + {filter.name} + + ); +}; diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx new file mode 100644 index 000000000000..d8ef97a71a92 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionDateRange.tsx @@ -0,0 +1,45 @@ +// import { Range } from '@sd/client'; + +// import { SearchFilterCRUD } from '..'; +// import { SearchOptionItem } from '../../SearchOptions'; +// import { UseSearch } from '../../useSearch'; +// import { getKey } from '../store'; + +// export const FilterOptionDateRange = ({ +// filter, +// search +// }: { +// filter: SearchFilterCRUD; +// search: UseSearch; +// }) => { +// const { allFiltersKeys } = search; + +// const key = getKey({ +// type: filter.name, +// name: filter.name, +// value: { start: new Date(), end: new Date() } // Example default range +// }); + +// return ( +// { +// search.setFilters?.((filters = []) => { +// const index = filters.findIndex((f) => filter.extract(f) !== undefined); + +// if (index !== -1) { +// filters.splice(index, 1); +// } else { +// const arg = filter.create({ start: new Date(), end: new Date() }); // Example default range +// filters.push(arg); +// } + +// return filters; +// }); +// }} +// > +// {filter.name} +// +// ); +// }; diff --git a/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx b/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx new file mode 100644 index 000000000000..7750e19727fb --- /dev/null +++ b/interface/app/$libraryId/search/Filters/components/FilterOptionList.tsx @@ -0,0 +1,51 @@ +import { SearchFilterCRUD } from '..'; +import { SearchOptionItem, SearchOptionSubMenu } from '../../SearchOptions'; +import { UseSearch } from '../../useSearch'; +import { useToggleOptionSelected } from '../hooks/useToggleOptionSelected'; +import { FilterOption, getKey } from '../store'; + +export const FilterOptionList = ({ + filter, + options, + search, + empty +}: { + filter: SearchFilterCRUD; + options: FilterOption[]; + search: UseSearch; + empty?: () => JSX.Element; +}) => { + const { allFiltersKeys } = search; + + const toggleOptionSelected = useToggleOptionSelected({ search }); + + return ( + <> + {empty?.() && options.length === 0 + ? empty() + : options?.map((option) => { + const optionKey = getKey({ + ...option, + type: filter.name + }); + + return ( + { + toggleOptionSelected({ + filter, + option, + select: value + }); + }} + key={option.value} + icon={option.icon} + > + {option.name} + + ); + })} + + ); +}; diff --git a/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts b/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts new file mode 100644 index 000000000000..cc0dee3cfb6d --- /dev/null +++ b/interface/app/$libraryId/search/Filters/factories/createBooleanFilter.ts @@ -0,0 +1,43 @@ +import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..'; + +// TODO: Move these factories to @sd/client +/** + * Creates a boolean filter to handle conditions like `true` or `false`. + * This function leverages the generic factory structure to keep the logic reusable and consistent. + * + * @param filter - The initial filter configuration, including the create method, argsToFilterOptions, and other specific behaviors. + * @returns A filter object that supports CRUD operations for boolean conditions. + */ +export function createBooleanFilter( + filter: CreateFilterFunction +): ReturnType> { + return { + ...filter, + conditions: filterTypeCondition.trueOrFalse, + + create: (value: boolean) => filter.create(value), + + getCondition: (data) => (data ? 'true' : 'false'), + + setCondition: (_, condition) => condition === 'true', + + argsToFilterOptions: (data) => { + if (filter.argsToFilterOptions) { + return filter.argsToFilterOptions([data], new Map()); + } + return [ + { + type: filter.name, + name: filter.name, + value: data + } + ]; + }, + + applyAdd: (_, option) => option.value, + + applyRemove: () => undefined, // Boolean filters don't have multiple values, so nothing to remove + + merge: (left) => left // Boolean filters don't require merging; return the existing value + }; +} diff --git a/interface/app/$libraryId/search/Filters/factories/createDateRangeFilter.ts b/interface/app/$libraryId/search/Filters/factories/createDateRangeFilter.ts new file mode 100644 index 000000000000..0d74f0356ba5 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/factories/createDateRangeFilter.ts @@ -0,0 +1,93 @@ +import { Range } from '@sd/client'; + +import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..'; + +/** + * Creates a range filter to handle conditions such as `from` and `to`. + * This function leverages the generic factory structure to keep the logic reusable and consistent. + * + * @param filter - The initial filter configuration, including the create method, argsToFilterOptions, and other specific behaviors. + * @returns A filter object that supports CRUD operations for range conditions. + */ +export function createDateRangeFilter( + filter: CreateFilterFunction> +): ReturnType>> { + return { + ...filter, + conditions: filterTypeCondition.dateRange, + + create: (data) => { + if ('from' in data) { + return filter.create({ from: data.from }); + } else if ('to' in data) { + return filter.create({ to: data.to }); + } else { + throw new Error('Invalid Range data'); + } + }, + + getCondition: (data) => { + if ('from' in data) return 'from'; + else if ('to' in data) return 'to'; + else throw new Error('Invalid Range data'); + }, + + setCondition: (data, condition) => { + if (condition === 'from' && 'from' in data) { + return { from: data.from }; + } else if (condition === 'to' && 'to' in data) { + return { to: data.to }; + } else { + throw new Error('Invalid condition or missing data'); + } + }, + + argsToFilterOptions: (data, options) => { + const values: T[] = []; + if ('from' in data) values.push(data.from); + if ('to' in data) values.push(data.to); + + if (filter.argsToFilterOptions) { + return filter.argsToFilterOptions(values, options); + } + + return values.map((value) => ({ + type: filter.name, + name: String(value), + value + })); + }, + + applyAdd: (data, option) => { + if ('from' in data) { + data.from = option.value; + } else if ('to' in data) { + data.to = option.value; + } else { + throw new Error('Invalid Range data'); + } + return data; + }, + + applyRemove: (data, option): Range | undefined => { + if ('from' in data && data.from === option.value) { + const { from, ...rest } = data; // Omit `from` + return Object.keys(rest).length ? (rest as Range) : undefined; + } else if ('to' in data && data.to === option.value) { + const { to, ...rest } = data; // Omit `to` + return Object.keys(rest).length ? (rest as Range) : undefined; + } + + return data; + }, + + merge: (left, right): Range => { + return { + ...('from' in left ? { from: left.from } : {}), + ...('to' in left ? { to: left.to } : {}), + ...('from' in right ? { from: right.from } : {}), + ...('to' in right ? { to: right.to } : {}) + } as Range; + } + }; +} diff --git a/interface/app/$libraryId/search/Filters/factories/createInOrNotInFilter.ts b/interface/app/$libraryId/search/Filters/factories/createInOrNotInFilter.ts new file mode 100644 index 000000000000..abe81a23a80b --- /dev/null +++ b/interface/app/$libraryId/search/Filters/factories/createInOrNotInFilter.ts @@ -0,0 +1,81 @@ +import { InOrNotIn } from '@sd/client'; + +import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..'; + +/** + * Creates an "In or Not In" filter to handle conditions like `in` or `notIn`. + * This function leverages the generic factory structure to keep the logic reusable and consistent. + * + * @param filter - The initial filter configuration, including the create method, argsToFilterOptions, and other specific behaviors. + * @returns A filter object that supports CRUD operations for in/notIn conditions. + */ +export function createInOrNotInFilter( + filter: CreateFilterFunction> +): ReturnType>> { + return { + ...filter, + conditions: filterTypeCondition.inOrNotIn, + + create: (data) => { + if (typeof data === 'number' || typeof data === 'string') { + return filter.create({ in: [data as any] }); + } else if (data) { + return filter.create(data); + } else { + return filter.create({ in: [] }); + } + }, + + getCondition: (data) => { + if ('in' in data) return 'in'; + else return 'notIn'; + }, + + setCondition: (data, condition) => { + const contents = 'in' in data ? data.in : data.notIn; + return condition === 'in' ? { in: contents } : { notIn: contents }; + }, + + argsToFilterOptions: (data, options) => { + let values: T[]; + if ('in' in data) { + values = data.in; + } else { + values = data.notIn; + } + if (filter.argsToFilterOptions) { + return filter.argsToFilterOptions(values, options); + } + return []; + }, + + applyAdd: (data, option) => { + if ('in' in data) { + data.in = [...new Set([...data.in, option.value])]; + } else { + data.notIn = [...new Set([...data.notIn, option.value])]; + } + return data; + }, + + applyRemove: (data, option) => { + if ('in' in data) { + data.in = data.in.filter((id) => id !== option.value); + if (data.in.length === 0) return; + } else { + data.notIn = data.notIn.filter((id) => id !== option.value); + if (data.notIn.length === 0) return; + } + return data; + }, + + merge: (left, right) => { + if ('in' in left && 'in' in right) { + return { in: [...new Set([...left.in, ...right.in])] }; + } else if ('notIn' in left && 'notIn' in right) { + return { notIn: [...new Set([...left.notIn, ...right.notIn])] }; + } + throw new Error('Cannot merge InOrNotIns with different conditions'); + } + }; +} diff --git a/interface/app/$libraryId/search/Filters/factories/createTextMatchFilter.ts b/interface/app/$libraryId/search/Filters/factories/createTextMatchFilter.ts new file mode 100644 index 000000000000..cf5baa7202e8 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/factories/createTextMatchFilter.ts @@ -0,0 +1,59 @@ +import { TextMatch } from '@sd/client'; + +import { createFilter, CreateFilterFunction, filterTypeCondition, FilterTypeCondition } from '..'; + +/** + * Creates a text match filter to handle search conditions such as `contains`, `startsWith`, `endsWith`, and `equals`. + * This function leverages the generic factory structure to keep the logic reusable and consistent. + * + * @param filter - The initial filter configuration, including the create method, argsToFilterOptions, and other specific behaviors. + * @returns A filter object that supports CRUD operations for text matching conditions. + */ +export function createTextMatchFilter( + filter: CreateFilterFunction +): ReturnType> { + return { + ...filter, + conditions: filterTypeCondition.textMatch, + create: (contains) => filter.create({ contains }), + + getCondition: (data) => { + if ('contains' in data) return 'contains'; + else if ('startsWith' in data) return 'startsWith'; + else if ('endsWith' in data) return 'endsWith'; + else return 'equals'; + }, + + setCondition: (data, condition) => { + let value: string; + if ('contains' in data) value = data.contains; + else if ('startsWith' in data) value = data.startsWith; + else if ('endsWith' in data) value = data.endsWith; + else value = data.equals; + + return { [condition]: value }; + }, + + argsToFilterOptions: (data) => { + let value: string; + if ('contains' in data) value = data.contains; + else if ('startsWith' in data) value = data.startsWith; + else if ('endsWith' in data) value = data.endsWith; + else value = data.equals; + + return [ + { + type: filter.name, + name: value, + value + } + ]; + }, + + applyAdd: (data, { value }) => ({ contains: value }), + + applyRemove: () => undefined, + + merge: (left) => left + }; +} diff --git a/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx b/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx new file mode 100644 index 000000000000..65764af14c15 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/hooks/useToggleOptionSelected.tsx @@ -0,0 +1,36 @@ +import { SearchFilterCRUD } from '..'; +import { FilterOption } from '../'; +import { UseSearch } from '../../useSearch'; + +export function useToggleOptionSelected({ search }: { search: UseSearch }) { + return ({ + filter, + option, + select + }: { + filter: SearchFilterCRUD; + option: FilterOption; + select: boolean; + }) => { + search.setFilters?.((filters = []) => { + const rawArg = filters.find((arg) => filter.extract(arg)); + + if (!rawArg) { + const arg = filter.create(option.value); + filters.push(arg); + } else { + const rawArgIndex = filters.findIndex((arg) => filter.extract(arg))!; + + const arg = filter.extract(rawArg)!; + + if (select) { + if (rawArg) filter.applyAdd(arg, option); + } else { + if (!filter.applyRemove(arg, option)) filters.splice(rawArgIndex, 1); + } + } + + return filters; + }); + }; +} diff --git a/interface/app/$libraryId/search/Filters/index.tsx b/interface/app/$libraryId/search/Filters/index.tsx new file mode 100644 index 000000000000..76c3f3e06f05 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/index.tsx @@ -0,0 +1,112 @@ +/** + * This module defines the logic for creating and managing search filters. + * Please keep this index file clean and avoid adding any logic here. + * + * Instead of duplicating logic for every type of filter, we use generic factory patterns to create filters dynamically. + * The core idea is to define reusable "conditions" for each filter type (e.g., `TextMatch`, `DateRange`, `InOrNotIn`) and + * allow filters to be created via factory functions. The interface for CRUD operations remains the same across all filters, + * but the condition logic varies depending on the type of filter. + * + * Key components: + * - `SearchFilter`: Base interface for all filters. + * - `SearchFilterCRUD`: Extends `SearchFilter` to handle conditions, CRUD operations, and UI rendering for filter options. + * - `RenderSearchFilter`: Extends `SearchFilterCRUD` with rendering logic specific to each filter type. + * - `createFilter`: A factory function to instantiate filters dynamically. + * - `CreateFilterFunction`: A utility type for defining the structure of filter factories. + * + * This system allows the easy addition of new filters without repeating logic. + */ +import { Icon } from '@phosphor-icons/react'; +import { SearchFilterArgs } from '@sd/client'; +import i18n from '~/app/I18n'; + +import { UseSearch } from '../useSearch'; +import { AllKeys, type FilterOption } from './store'; +import { OmitCommonFilterProperties } from './typeGuards'; + +export { filterRegistry, type FilterType } from './FilterRegistry'; + +export type { FilterOption }; + +export { useToggleOptionSelected } from './hooks/useToggleOptionSelected'; + +// Base interface for any search filter +export interface SearchFilter< + TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any +> { + name: string; + icon: Icon; + conditions: TConditions; + translationKey?: string; +} + +// Extended interface for filters supporting CRUD operations +export interface SearchFilterCRUD< + TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, // Available conditions for the filter + T = any // The data type being filtered +> extends SearchFilter { + getCondition: (args: T) => AllKeys; // Gets the current filter condition + setCondition: (args: T, condition: keyof TConditions) => void; // Sets a specific condition + applyAdd: (args: T, option: FilterOption) => void; // Adds a filter option + applyRemove: (args: T, option: FilterOption) => T | undefined; // Removes a filter option + argsToFilterOptions: (args: T, options: Map) => FilterOption[]; // Converts args to options for UI + extract: (arg: SearchFilterArgs) => T | undefined; // Extracts relevant filter data + create: (data: any) => SearchFilterArgs; // Creates a new filter argument + merge: (left: T, right: T) => T; // Merges two sets of filter args +} + +// Renderable search filter interface +export interface RenderSearchFilter< + TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, + T = any +> extends SearchFilterCRUD { + Render: (props: { + filter: SearchFilterCRUD; + options: (FilterOption & { type: string })[]; + search: UseSearch; + }) => JSX.Element; + useOptions: (props: { search: string }) => FilterOption[]; +} + +// Factory function to create filters dynamically +export function createFilter( + filter: RenderSearchFilter +) { + return filter; +} + +// Interface for filters that handle the `create` method +export interface FilterWithCreate { + create: (value: Value) => SearchFilterArgs; + argsToFilterOptions?: (values: T[], options: Map) => FilterOption[]; +} + +// General factory type for creating filters +export type CreateFilterFunction< + Conditions extends FilterTypeCondition[keyof FilterTypeCondition], + Value +> = OmitCommonFilterProperties>> & + FilterWithCreate; + +export const filterTypeCondition = { + inOrNotIn: { + in: i18n.t('is'), + notIn: i18n.t('is_not') + }, + textMatch: { + contains: i18n.t('contains'), + startsWith: i18n.t('starts_with'), + endsWith: i18n.t('ends_with'), + equals: i18n.t('equals') + }, + trueOrFalse: { + true: i18n.t('is'), + false: i18n.t('is_not') + }, + dateRange: { + from: i18n.t('from'), + to: i18n.t('to') + } +} as const; + +export type FilterTypeCondition = typeof filterTypeCondition; diff --git a/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx b/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx new file mode 100644 index 000000000000..ebe2b4f924ed --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/BooleanFilters.tsx @@ -0,0 +1,31 @@ +import { Heart, SelectionSlash } from '@phosphor-icons/react'; +import i18n from '~/app/I18n'; + +import { FilterOptionBoolean } from '../components/FilterOptionBoolean'; +import { createBooleanFilter } from '../factories/createBooleanFilter'; + +// Hidden Filter +export const hiddenFilter = createBooleanFilter({ + name: i18n.t('hidden'), + translationKey: 'hidden', + icon: SelectionSlash, + extract: (arg) => { + if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden; + }, + create: (hidden) => ({ filePath: { hidden } }), + useOptions: () => [{ name: 'Hidden', value: true, icon: SelectionSlash }], + Render: ({ filter, options, search }) => +}); + +// Favorite Filter +export const favoriteFilter = createBooleanFilter({ + name: i18n.t('favorite'), + translationKey: 'favorite', + icon: Heart, + extract: (arg) => { + if ('object' in arg && 'favorite' in arg.object) return arg.object.favorite; + }, + create: (favorite) => ({ object: { favorite } }), + useOptions: () => [{ name: 'Favorite', value: true, icon: Heart }], + Render: ({ filter, options, search }) => +}); diff --git a/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx b/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx new file mode 100644 index 000000000000..50832d634998 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/DateFilters.tsx @@ -0,0 +1,169 @@ +import type {} from '@sd/client'; // required for type inference of createDateRangeFilter + +import { + Calendar, + CalendarDot, + CalendarDots, + CalendarPlus, + CalendarStar, + Camera, + ClockCounterClockwise +} from '@phosphor-icons/react'; +import i18n from '~/app/I18n'; + +import { FilterOption } from '..'; +import { SearchOptionSubMenu } from '../../SearchOptions'; +import { FilterOptionList } from '../components/FilterOptionList'; +import { createDateRangeFilter } from '../factories/createDateRangeFilter'; + +export const useCommonDateOptions = (): FilterOption[] => { + return [ + { + name: i18n.t('Today'), + value: { from: new Date(new Date().setHours(0, 0, 0, 0)).toISOString() }, + icon: ClockCounterClockwise + }, + { + name: i18n.t('Past 7 Days'), + value: { from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() }, + icon: ClockCounterClockwise + }, + { + name: i18n.t('Past 30 Days'), + value: { from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() }, + icon: ClockCounterClockwise + }, + { + name: i18n.t('Last Month'), + value: { + from: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).toISOString() + }, + icon: Calendar + }, + { + name: i18n.t('This Year'), + value: { from: new Date(new Date().getFullYear(), 0, 1).toISOString() }, + icon: Calendar + } + ]; +}; + +export const filePathDateCreated = createDateRangeFilter({ + name: i18n.t('Date Created'), + translationKey: 'dateCreated', + icon: CalendarStar, + create: (dateRange) => ({ filePath: { createdAt: dateRange } }), + extract: (arg) => { + if ('filePath' in arg && 'createdAt' in arg.filePath) return arg.filePath.createdAt; + }, + argsToFilterOptions: (dateRange) => { + return dateRange.map((value) => ({ + name: 'custom-date-range', + value: value + })); + }, + useOptions: (): FilterOption[] => useCommonDateOptions(), + Render: ({ filter, options, search }) => ( + + + + ) +}); + +export const filePathDateModified = createDateRangeFilter({ + name: i18n.t('Date Modified'), + translationKey: 'dateModified', + icon: CalendarDots, + create: (dateRange) => ({ filePath: { modifiedAt: dateRange } }), + extract: (arg) => { + if ('filePath' in arg && 'modifiedAt' in arg.filePath) return arg.filePath.modifiedAt; + }, + argsToFilterOptions: (dateRange) => { + return dateRange.map((value) => ({ + name: value, + value: value + })); + }, + useOptions: (): FilterOption[] => useCommonDateOptions(), + Render: ({ filter, options, search }) => ( + + + + ) +}); + +export const filePathDateIndexed = createDateRangeFilter({ + name: i18n.t('Date Indexed'), + translationKey: 'dateIndexed', + icon: CalendarPlus, + create: (dateRange) => ({ filePath: { indexedAt: dateRange } }), + extract: (arg) => { + if ('filePath' in arg && 'indexedAt' in arg.filePath) return arg.filePath.indexedAt; + }, + argsToFilterOptions: (dateRange) => { + return dateRange.map((value) => ({ + name: value, + value: value + })); + }, + useOptions: (): FilterOption[] => useCommonDateOptions(), + Render: ({ filter, options, search }) => ( + + + + ) +}); + +export const objectDateAccessed = createDateRangeFilter({ + name: i18n.t('Date Last Accessed'), + translationKey: 'dateLastAccessed', + icon: CalendarDot, + create: (dateRange) => ({ object: { dateAccessed: dateRange } }), + extract: (arg) => { + if ('object' in arg && 'dateAccessed' in arg.object) return arg.object.dateAccessed; + }, + argsToFilterOptions: (dateRange) => { + return dateRange.map((value) => ({ + name: value, + value: value + })); + }, + useOptions: (): FilterOption[] => useCommonDateOptions(), + Render: ({ filter, options, search }) => ( + + + + ) +}); + +export const mediaDateTaken = createDateRangeFilter({ + name: i18n.t('Date Taken'), + translationKey: 'dateTaken', + icon: Camera, + create: (dateRange) => ({ object: { dateAccessed: dateRange } }), + extract: (arg) => { + if ('object' in arg && 'dateAccessed' in arg.object) return arg.object.dateAccessed; + }, + argsToFilterOptions: (dateRange) => { + return dateRange.map((value) => ({ + name: value, + value: value + })); + }, + useOptions: (): FilterOption[] => useCommonDateOptions(), + Render: ({ filter, options, search }) => ( + + + + ) +}); + +// export const filePathDateModified = createDateRangeFilter({}); +// export const filePathDateAccessed = createDateRangeFilter({}); +// export const objectDateAccessed = createDateRangeFilter({}); + +// export const dateFilters = [ +// filePathDateCreated, +// filePathDateModified, +// filePathDateAccessed +// ] as const; diff --git a/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx b/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx new file mode 100644 index 000000000000..457f0f00a48a --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/KindFilter.tsx @@ -0,0 +1,47 @@ +import { Cube } from '@phosphor-icons/react'; +import { ObjectKind } from '@sd/client'; // Assuming ObjectKind is an enum or set of constants +import i18n from '~/app/I18n'; + +import { translateKindName } from '../../../Explorer/util'; +import { SearchOptionSubMenu } from '../../SearchOptions'; +import { FilterOptionList } from '../components/FilterOptionList'; +import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; + +export const kindFilter = createInOrNotInFilter({ + name: i18n.t('kind'), + translationKey: 'kind', + icon: Cube, + extract: (arg) => { + if ('object' in arg && 'kind' in arg.object) return arg.object.kind; + }, + create: (kind) => ({ object: { kind } }), + argsToFilterOptions(values, options) { + return values + .map((value) => { + const option = options.get(this.name)?.find((o) => o.value === value); + if (!option) return; + + return { + ...option, + type: this.name + }; + }) + .filter(Boolean) as any; + }, + useOptions: () => + Object.keys(ObjectKind) + .filter((key) => !isNaN(Number(key)) && ObjectKind[Number(key)] !== undefined) + .map((key) => { + const kind = ObjectKind[Number(key)] as string; + return { + name: translateKindName(kind), + value: Number(key), + icon: kind + '20' + }; + }), + Render: ({ filter, options, search }) => ( + + + + ) +}); diff --git a/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx b/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx new file mode 100644 index 000000000000..e0cf9e57daa4 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/LocationFilter.tsx @@ -0,0 +1,45 @@ +// Import icons +import { Folder } from '@phosphor-icons/react'; +import { useLibraryQuery } from '@sd/client'; +import i18n from '~/app/I18n'; + +import { SearchOptionSubMenu } from '../../SearchOptions'; +import { FilterOptionList } from '../components/FilterOptionList'; +import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; + +export const locationFilter = createInOrNotInFilter({ + name: i18n.t('location'), + translationKey: 'location', + icon: Folder, + create: (locations) => ({ filePath: { locations } }), + extract: (arg) => { + if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations; + }, + argsToFilterOptions(values, options) { + return values + .map((value) => { + const option = options.get(this.name)?.find((o) => o.value === value); + if (!option) return; + return { + ...option, + type: this.name + }; + }) + .filter(Boolean) as any; + }, + useOptions: () => { + const query = useLibraryQuery(['locations.list'], { keepPreviousData: true }); + const locations = query.data; + + return (locations ?? []).map((location) => ({ + name: location.name!, + value: location.id, + icon: 'Folder' + })); + }, + Render: ({ filter, options, search }) => ( + + + + ) +}); diff --git a/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx b/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx new file mode 100644 index 000000000000..5964ede62c9f --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/TagsFilter.tsx @@ -0,0 +1,56 @@ +import { CircleDashed } from '@phosphor-icons/react'; +import { useLibraryQuery } from '@sd/client'; +import i18n from '~/app/I18n'; + +import { SearchOptionSubMenu } from '../../SearchOptions'; +import { FilterOptionList } from '../components/FilterOptionList'; +import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; + +export const tagsFilter = createInOrNotInFilter({ + name: i18n.t('tags'), + translationKey: 'tag', + icon: CircleDashed, + extract: (arg) => { + if ('object' in arg && 'tags' in arg.object) return arg.object.tags; + }, + create: (tags) => ({ object: { tags } }), + argsToFilterOptions(values, options) { + return values + .map((value) => { + const option = options.get(this.name)?.find((o) => o.value === value); + if (!option) return; + return { + ...option, + type: this.name + }; + }) + .filter(Boolean) as any; + }, + useOptions: () => { + const query = useLibraryQuery(['tags.list'], { keepPreviousData: true }); + const tags = query.data; + + return (tags ?? []).map((tag) => ({ + name: tag.name!, + value: tag.id, + icon: tag.color || 'CircleDashed' + })); + }, + Render: ({ filter, options, search }) => ( + + ( +
+ +

+ {i18n.t('no_tags')} +

+
+ )} + filter={filter} + options={options} + search={search} + /> +
+ ) +}); diff --git a/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx b/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx new file mode 100644 index 000000000000..dc54fe964a75 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/registry/TextFilters.tsx @@ -0,0 +1,33 @@ +import type {} from '@sd/client'; // required for type inference of createDateRangeFilter + +import { Textbox } from '@phosphor-icons/react'; +import i18n from '~/app/I18n'; + +import { createInOrNotInFilter } from '../factories/createInOrNotInFilter'; +import { createTextMatchFilter } from '../factories/createTextMatchFilter'; + +// Name Filter +export const nameFilter = createTextMatchFilter({ + name: i18n.t('name'), + translationKey: 'name', + icon: Textbox, + extract: (arg) => { + if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name; + }, + create: (name) => ({ filePath: { name } }), + useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], + Render: ({ filter, search }) => <> +}); + +// Extension Filter +export const extensionFilter = createInOrNotInFilter({ + name: i18n.t('extension'), + translationKey: 'extension', + icon: Textbox, + extract: (arg) => { + if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension; + }, + create: (extension) => ({ filePath: { extension } }), + useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], + Render: ({ filter, search }) => <> +}); diff --git a/interface/app/$libraryId/search/Filters/store.ts b/interface/app/$libraryId/search/Filters/store.ts new file mode 100644 index 000000000000..d7281647fedc --- /dev/null +++ b/interface/app/$libraryId/search/Filters/store.ts @@ -0,0 +1,100 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { Icon } from '@phosphor-icons/react'; +import { useEffect, useMemo } from 'react'; +import { proxy, ref, useSnapshot } from 'valtio'; +import { proxyMap } from 'valtio/utils'; +import { Range, SearchFilterArgs } from '@sd/client'; + +import { FilterType, RenderSearchFilter } from '.'; +import { filterRegistry } from './FilterRegistry'; + +// TODO: this store should be in @sd/client + +// Define filter option interface +export interface FilterOption { + name: string; + value: string | number | Range | any; + icon?: string | Icon; +} + +// Filter type is the `name` field of a filter inferred from the filter registry +export interface FilterOptionWithType extends FilterOption { + type: FilterType; +} + +const filterOptionStore = proxy({ + filterOptions: ref(new Map()), + registeredFilters: proxyMap() as Map +}); + +// Generate a unique key for a filter option +export const getKey = (filter: FilterOptionWithType) => + `${filter.type}-${filter.name}-${filter.value}`; + +// Hook to register filter options into the local store +export const useRegisterFilterOptions = ( + filter: RenderSearchFilter, + options: (FilterOption & { type: FilterType })[] +) => { + const optionsAsKeys = useMemo(() => options.map(getKey), [options]); + + useEffect(() => { + filterOptionStore.filterOptions.set(filter.name, options); + filterOptionStore.filterOptions = ref(new Map(filterOptionStore.filterOptions)); + }, [optionsAsKeys]); + + useEffect(() => { + const keys = options.map((option) => { + const key = getKey(option); + if (!filterOptionStore.registeredFilters.has(key)) { + filterOptionStore.registeredFilters.set(key, option); + return key; + } + }); + + return () => { + keys.forEach((key) => { + if (key) filterOptionStore.registeredFilters.delete(key); + }); + }; + }, [optionsAsKeys]); +}; + +// Function to retrieve registered filters based on a query +export const useSearchRegisteredFilters = (query: string) => { + const { registeredFilters } = useFilterOptionStore(); + + return useMemo(() => { + if (!query) return []; + // Filter the registered filters by matching the query string + return [...registeredFilters.entries()] + .filter(([key, _]) => key.toLowerCase().includes(query.toLowerCase())) + .map(([key, filter]) => ({ ...filter, key })); + }, [registeredFilters, query]); +}; + +// Get snapshot of the filter option store +export const useFilterOptionStore = () => useSnapshot(filterOptionStore); + +// Function to reset filter options (if needed) +export const resetFilterOptionStore = () => { + filterOptionStore.filterOptions.clear(); + filterOptionStore.registeredFilters.clear(); +}; + +// Helper to convert arguments to filter options +export function argsToFilterOptions( + args: SearchFilterArgs[], + options: Map +) { + return args.flatMap((fixedArg) => { + const filter = filterRegistry.find((f) => f.extract(fixedArg)); + if (!filter) return []; + + return filter + .argsToFilterOptions(filter.extract(fixedArg) as any, options) + .map((arg) => ({ arg, filter })); + }); +} + +export type AllKeys = T extends any ? keyof T : never; diff --git a/interface/app/$libraryId/search/Filters/typeGuards.ts b/interface/app/$libraryId/search/Filters/typeGuards.ts new file mode 100644 index 000000000000..a440776d6fb5 --- /dev/null +++ b/interface/app/$libraryId/search/Filters/typeGuards.ts @@ -0,0 +1,78 @@ +import { Range, SearchFilterArgs } from '@sd/client'; + +// Type guard to check if arg contains the 'filePath' with the appropriate field. +function isFilePathWithRange( + arg: SearchFilterArgs, + field: 'createdAt' | 'modifiedAt' | 'indexedAt' +): arg is { filePath: { [key in typeof field]: Range } } { + return 'filePath' in arg && typeof arg.filePath === 'object' && field in arg.filePath; +} + +// Type guard to check if arg contains the 'object' with the appropriate field. +function isObjectWithRange( + arg: SearchFilterArgs, + field: 'dateAccessed' +): arg is { object: { [key in typeof field]: Range } } { + return 'object' in arg && typeof arg.object === 'object' && field in arg.object; +} + +/** + * Extracts a range (from and to) from the filePath part of SearchFilterArgs. + * Handles fields like 'createdAt', 'modifiedAt', and 'indexedAt'. + * + * @param arg The search filter arguments. + * @param field The specific range field to extract. + * @returns A Range object with from and to values, or undefined if not found. + */ +export function extractFilePathRange( + arg: SearchFilterArgs, + field: 'createdAt' | 'modifiedAt' | 'indexedAt' +): Range | undefined { + if (isFilePathWithRange(arg, field)) { + const range = arg.filePath[field]; + + // Handle cases where only `from` or `to` exists + const from = 'from' in range ? range.from : ''; + const to = 'to' in range ? range.to : ''; + + return { from, to }; + } + return undefined; +} + +/** + * Extracts a range (from and to) from the object part of SearchFilterArgs. + * Handles the 'dateAccessed' field. + * + * @param arg The search filter arguments. + * @param field The specific range field to extract. + * @returns A Range object with from and to values, or undefined if not found. + */ +export function extractObjectRange( + arg: SearchFilterArgs, + field: 'dateAccessed' +): Range | undefined { + if (isObjectWithRange(arg, field)) { + const range = arg.object[field]; + + // Handle cases where only `from` or `to` exists + const from = 'from' in range ? range.from : ''; + const to = 'to' in range ? range.to : ''; + + return { from, to }; + } + return undefined; +} + +// Utility type that omits common properties from the filter +export type OmitCommonFilterProperties = Omit< + T, + | 'conditions' + | 'getCondition' + | 'argsToFilterOptions' + | 'setCondition' + | 'applyAdd' + | 'applyRemove' + | 'create' + | 'merge' +>; diff --git a/interface/app/$libraryId/search/FiltersOld.tsx b/interface/app/$libraryId/search/FiltersOld.tsx new file mode 100644 index 000000000000..77229ecd02dc --- /dev/null +++ b/interface/app/$libraryId/search/FiltersOld.tsx @@ -0,0 +1,890 @@ +// import { +// Calendar, +// CircleDashed, +// Cube, +// Folder, +// Heart, +// Icon, +// SelectionSlash, +// Textbox +// } from '@phosphor-icons/react'; +// import { useState } from 'react'; +// import { +// InOrNotIn, +// ObjectKind, +// Range, +// SearchFilterArgs, +// TextMatch, +// useLibraryQuery +// } from '@sd/client'; +// import { Button, Input } from '@sd/ui'; +// import i18n from '~/app/I18n'; +// import { Icon as SDIcon } from '~/components'; +// import { useLocale } from '~/hooks'; + +// import { SearchOptionItem, SearchOptionSubMenu } from '.'; +// import { translateKindName } from '../Explorer/util'; +// import { FilterTypeCondition, filterTypeCondition } from './FiltersOld'; +// import { AllKeys, FilterOption, getKey } from './store'; +// import { UseSearch } from './useSearch'; + +// interface SearchFilter< +// TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any +// > { +// name: string; +// icon: Icon; +// conditions: TConditions; +// translationKey?: string; +// } + +// interface SearchFilterCRUD< +// TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, // TConditions represents the available conditions for a specific filter, it defaults to any condition from the FilterTypeCondition +// T = any // T is the type of the data that is being filtered. This can be any type. +// > extends SearchFilter { +// // Extends the base SearchFilter interface, adding CRUD operations specific to handling filters + +// // Returns the current filter condition for a given set of arguments (args). +// // This is used to determine which condition the filter is currently using (e.g., in, out, equals). +// getCondition: (args: T) => AllKeys; + +// // Sets a specific filter condition (e.g., in, out, equals) for the given arguments (args). +// // The condition will be one of the predefined conditions in TConditions. +// setCondition: (args: T, condition: keyof TConditions) => void; + +// // Adds a filter option to the current filter. +// // For example, if you are adding a tag, this method adds that tag to the filter’s arguments (args). +// applyAdd: (args: T, option: FilterOption) => void; + +// // Removes a filter option from the current filter. +// // For example, if you are removing a tag, this method removes that tag from the filter's arguments (args). +// // Returns undefined if there are no more valid filters after removal. +// applyRemove: (args: T, option: FilterOption) => T | undefined; + +// // Converts the filter arguments (args) into filter options that can be rendered in the UI. +// // It maps the provided arguments to an array of FilterOption objects, which are typically used in the dropdown or selectable options UI. +// argsToOptions: (args: T, options: Map) => FilterOption[]; + +// // Extracts the relevant filter data from the larger SearchFilterArgs structure. +// // This is used to isolate the specific part of the filter (e.g., tag filter, date filter) that this filter instance is responsible for. +// extract: (arg: SearchFilterArgs) => T | undefined; + +// // Creates a new SearchFilterArgs object based on the provided data. +// // This method builds the arguments used to represent the filter in the search request. +// create: (data: any) => SearchFilterArgs; + +// // Merges two sets of filter arguments (left and right) into one. +// // This is useful when combining two different filter conditions for the same filter (e.g., merging two date ranges or tag selections). +// merge: (left: T, right: T) => T; +// } + +// interface RenderSearchFilter< +// TConditions extends FilterTypeCondition[keyof FilterTypeCondition] = any, +// T = any +// > extends SearchFilterCRUD { +// // Render is responsible for fetching the filter options and rendering them +// Render: (props: { +// filter: SearchFilterCRUD; +// options: (FilterOption & { type: string })[]; +// search: UseSearch; +// }) => JSX.Element; +// // Apply is responsible for applying the filter to the search args +// useOptions: (props: { search: string }) => FilterOption[]; +// } + +// function useToggleOptionSelected({ search }: { search: UseSearch }) { +// return ({ +// filter, +// option, +// select +// }: { +// filter: SearchFilterCRUD; +// option: FilterOption; +// select: boolean; +// }) => { +// search.setFilters?.((filters = []) => { +// const rawArg = filters.find((arg) => filter.extract(arg)); + +// if (!rawArg) { +// const arg = filter.create(option.value); +// filters.push(arg); +// } else { +// const rawArgIndex = filters.findIndex((arg) => filter.extract(arg))!; + +// const arg = filter.extract(rawArg)!; + +// if (select) { +// if (rawArg) filter.applyAdd(arg, option); +// } else { +// if (!filter.applyRemove(arg, option)) filters.splice(rawArgIndex, 1); +// } +// } + +// return filters; +// }); +// }; +// } + +// const FilterOptionList = ({ +// filter, +// options, +// search, +// empty +// }: { +// filter: SearchFilterCRUD; +// options: FilterOption[]; +// search: UseSearch; +// empty?: () => JSX.Element; +// }) => { +// const { allFiltersKeys } = search; + +// const toggleOptionSelected = useToggleOptionSelected({ search }); + +// return ( +// +// {empty?.() && options.length === 0 +// ? empty() +// : options?.map((option) => { +// const optionKey = getKey({ +// ...option, +// type: filter.name +// }); + +// return ( +// { +// toggleOptionSelected({ +// filter, +// option, +// select: value +// }); +// }} +// key={option.value} +// icon={option.icon} +// > +// {option.name} +// +// ); +// })} +// +// ); +// }; + +// const FilterOptionText = ({ +// filter, +// search +// }: { +// filter: SearchFilterCRUD; +// search: UseSearch; +// }) => { +// const [value, setValue] = useState(''); + +// const { allFiltersKeys } = search; +// const key = getKey({ +// type: filter.name, +// name: value, +// value +// }); + +// const { t } = useLocale(); + +// return ( +// +//
{ +// e.preventDefault(); +// search.setFilters?.((filters) => { +// if (allFiltersKeys.has(key)) return filters; + +// const arg = filter.create(value); +// filters?.push(arg); +// setValue(''); + +// return filters; +// }); +// }} +// > +// setValue(e.target.value)} /> +// +//
+//
+// ); +// }; + +// const FilterOptionBoolean = ({ +// filter, +// search +// }: { +// filter: SearchFilterCRUD; +// search: UseSearch; +// }) => { +// const { allFiltersKeys } = search; + +// const key = getKey({ +// type: filter.name, +// name: filter.name, +// value: true +// }); + +// return ( +// { +// search.setFilters?.((filters = []) => { +// const index = filters.findIndex((f) => filter.extract(f) !== undefined); + +// if (index !== -1) { +// filters.splice(index, 1); +// } else { +// const arg = filter.create(true); +// filters.push(arg); +// } + +// return filters; +// }); +// }} +// > +// {filter.name} +// +// ); +// }; + +// function createFilter( +// filter: RenderSearchFilter +// ) { +// return filter; +// } + +// function createInOrNotInFilter( +// filter: Omit< +// ReturnType>>, +// | 'conditions' +// | 'getCondition' +// | 'argsToOptions' +// | 'setCondition' +// | 'applyAdd' +// | 'applyRemove' +// | 'create' +// | 'merge' +// > & { +// create(value: InOrNotIn): SearchFilterArgs; +// argsToOptions(values: T[], options: Map): FilterOption[]; +// } +// ): ReturnType>> { +// return { +// ...filter, +// create: (data) => { +// if (typeof data === 'number' || typeof data === 'string') +// return filter.create({ +// in: [data as any] +// }); +// else if (data) return filter.create(data); +// else return filter.create({ in: [] }); +// }, +// conditions: filterTypeCondition.inOrNotIn, +// getCondition: (data) => { +// if ('in' in data) return 'in'; +// else return 'notIn'; +// }, +// setCondition: (data, condition) => { +// const contents = 'in' in data ? data.in : data.notIn; + +// return condition === 'in' ? { in: contents } : { notIn: contents }; +// }, +// argsToOptions: (data, options) => { +// let values: T[]; + +// if ('in' in data) values = data.in; +// else values = data.notIn; + +// return filter.argsToOptions(values, options); +// }, +// applyAdd: (data, option) => { +// if ('in' in data) data.in = [...new Set([...data.in, option.value])]; +// else data.notIn = [...new Set([...data.notIn, option.value])]; + +// return data; +// }, +// applyRemove: (data, option) => { +// if ('in' in data) { +// data.in = data.in.filter((id) => id !== option.value); + +// if (data.in.length === 0) return; +// } else { +// data.notIn = data.notIn.filter((id) => id !== option.value); + +// if (data.notIn.length === 0) return; +// } + +// return data; +// }, +// merge: (left, right) => { +// if ('in' in left && 'in' in right) { +// return { +// in: [...new Set([...left.in, ...right.in])] +// }; +// } else if ('notIn' in left && 'notIn' in right) { +// return { +// notIn: [...new Set([...left.notIn, ...right.notIn])] +// }; +// } + +// throw new Error('Cannot merge InOrNotIns with different conditions'); +// } +// }; +// } + +// function createTextMatchFilter( +// filter: Omit< +// ReturnType>, +// | 'conditions' +// | 'getCondition' +// | 'argsToOptions' +// | 'setCondition' +// | 'applyAdd' +// | 'applyRemove' +// | 'create' +// | 'merge' +// > & { +// create(value: TextMatch): SearchFilterArgs; +// } +// ): ReturnType> { +// return { +// ...filter, +// conditions: filterTypeCondition.textMatch, +// create: (contains) => filter.create({ contains }), +// getCondition: (data) => { +// if ('contains' in data) return 'contains'; +// else if ('startsWith' in data) return 'startsWith'; +// else if ('endsWith' in data) return 'endsWith'; +// else return 'equals'; +// }, +// setCondition: (data, condition) => { +// let value: string; + +// if ('contains' in data) value = data.contains; +// else if ('startsWith' in data) value = data.startsWith; +// else if ('endsWith' in data) value = data.endsWith; +// else value = data.equals; + +// return { +// [condition]: value +// }; +// }, +// argsToOptions: (data) => { +// let value: string; + +// if ('contains' in data) value = data.contains; +// else if ('startsWith' in data) value = data.startsWith; +// else if ('endsWith' in data) value = data.endsWith; +// else value = data.equals; + +// return [ +// { +// type: filter.name, +// name: value, +// value +// } +// ]; +// }, +// applyAdd: (data, { value }) => { +// if ('contains' in data) return { contains: value }; +// else if ('startsWith' in data) return { startsWith: value }; +// else if ('endsWith' in data) return { endsWith: value }; +// else if ('equals' in data) return { equals: value }; +// }, +// applyRemove: () => undefined, +// merge: (left) => left +// }; +// } + +// function createBooleanFilter( +// filter: Omit< +// ReturnType>, +// | 'conditions' +// | 'getCondition' +// | 'argsToOptions' +// | 'setCondition' +// | 'applyAdd' +// | 'applyRemove' +// | 'create' +// | 'merge' +// > & { +// create(value: boolean): SearchFilterArgs; +// } +// ): ReturnType> { +// return { +// ...filter, +// conditions: filterTypeCondition.trueOrFalse, +// create: () => filter.create(true), +// getCondition: (data) => (data ? 'true' : 'false'), +// setCondition: (_, condition) => condition === 'true', +// argsToOptions: (value) => { +// if (!value) return []; + +// return [ +// { +// type: filter.name, +// name: filter.name, +// value +// } +// ]; +// }, +// applyAdd: (_, { value }) => value, +// applyRemove: () => undefined, +// merge: (left) => left +// }; +// } + +// function createRangeFilter( +// filter: Omit< +// ReturnType>>, +// | 'conditions' +// | 'getCondition' +// | 'argsToOptions' +// | 'setCondition' +// | 'applyAdd' +// | 'applyRemove' +// | 'create' +// | 'merge' +// > & { +// create(value: Range): SearchFilterArgs; +// argsToOptions(values: T[], options: Map): FilterOption[]; +// } +// ): ReturnType>> { +// return { +// ...filter, +// conditions: filterTypeCondition.range, +// create: (data) => { +// if ('from' in data) { +// return filter.create({ from: data.from }); +// } else if ('to' in data) { +// return filter.create({ to: data.to }); +// } else { +// throw new Error('Invalid Range data'); +// } +// }, +// getCondition: (data) => { +// if ('from' in data) return 'from'; +// else if ('to' in data) return 'to'; +// else throw new Error('Invalid Range data'); +// }, +// setCondition: (data, condition) => { +// return condition === 'from' && 'from' in data +// ? { from: data.from } +// : condition === 'to' && 'to' in data +// ? { to: data.to } +// : (() => { +// throw new Error('Invalid condition or missing data'); +// })(); +// }, +// argsToOptions: (data, options) => { +// const values: T[] = []; +// if ('from' in data) values.push(data.from); +// if ('to' in data) values.push(data.to); + +// return values.map((value) => ({ +// type: filter.name, +// name: String(value), +// value +// })); +// }, +// applyAdd: (data, option) => { +// if ('from' in data) { +// data.from = option.value; +// } else if ('to' in data) { +// data.to = option.value; +// } else { +// throw new Error('Invalid Range data'); +// } +// return data; +// }, +// applyRemove: (data, option): Range | undefined => { +// if ('from' in data && data.from === option.value) { +// const { from, ...rest } = data; // Omit `from` +// return Object.keys(rest).length ? (rest as Range) : undefined; +// } else if ('to' in data && data.to === option.value) { +// const { to, ...rest } = data; // Omit `to` +// return Object.keys(rest).length ? (rest as Range) : undefined; +// } + +// return data; +// }, +// merge: (left, right): Range => { +// const result = { +// ...('from' in left +// ? { from: left.from } +// : 'from' in right +// ? { from: right.from } +// : {}), +// ...('to' in left ? { to: left.to } : 'to' in right ? { to: right.to } : {}) +// }; + +// return result as Range; +// } +// }; +// } + +// function createGenericRangeFilter( +// name: string, +// translationKey: string, +// icon: Icon, +// extractFn: (arg: SearchFilterArgs) => Range | undefined, +// createFn: (range: Range) => SearchFilterArgs +// ): ReturnType>> { +// return createRangeFilter({ +// name, +// translationKey, +// icon, +// extract: extractFn, +// create: createFn, +// Render: ({ filter, options, search }) => ( +// +// ), +// useOptions: (): FilterOption[] => { +// // Predefined date range options, or you can make it dynamic based on type T +// return [ +// { +// name: 'Last 7 Days', +// value: { from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() }, +// icon: Calendar +// }, +// { +// name: 'Last 30 Days', +// value: { from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() }, +// icon: Calendar +// }, +// { +// name: 'This Year', +// value: { from: new Date(new Date().getFullYear(), 0, 1).toISOString() }, +// icon: Calendar +// } +// ]; +// }, +// argsToOptions: (values: T[], options: Map): FilterOption[] => { +// return values.map((value) => ({ +// type: name, +// name: String(value), +// value, +// icon: Calendar +// })); +// } +// }); +// } + +// const filterRegistry = [ +// createGenericRangeFilter( +// i18n.t('date_created_range'), +// 'date_created_range', +// Calendar, +// // extract +// (arg) => { +// if ('filePath' in arg && 'date_created' in arg.filePath) { +// return { +// from: arg.filePath.date_created, +// to: arg.filePath.date_created +// } as Range; +// } +// }, +// // create +// (dateRange: Range) => { +// return { +// filePath: { +// createdAt: { +// from: 'from' in dateRange ? dateRange.from : undefined, +// to: 'to' in dateRange ? dateRange.to : undefined +// } +// } +// } as SearchFilterArgs; +// } +// ), +// createGenericRangeFilter( +// i18n.t('date_accessed_range'), +// 'date_accessed_range', +// Calendar, +// // extract +// (arg) => { +// if ('object' in arg && 'date_accessed' in arg.object) { +// return { +// from: arg.object.date_accessed, +// to: arg.object.date_accessed +// } as Range; +// } +// }, +// // create +// (dateRange: Range) => { +// return { +// object: { +// dateAccessed: { +// from: 'from' in dateRange ? dateRange.from : undefined, +// to: 'to' in dateRange ? dateRange.to : undefined +// } +// } +// } as SearchFilterArgs; +// } +// ), +// createInOrNotInFilter({ +// name: i18n.t('location'), +// translationKey: 'location', +// icon: Folder, // Phosphor folder icon +// extract: (arg) => { +// if ('filePath' in arg && 'locations' in arg.filePath) return arg.filePath.locations; +// }, +// create: (locations) => ({ filePath: { locations } }), +// argsToOptions(values, options) { +// return values +// .map((value) => { +// const option = options.get(this.name)?.find((o) => o.value === value); + +// if (!option) return; + +// return { +// ...option, +// type: this.name +// }; +// }) +// .filter(Boolean) as any; +// }, +// useOptions: () => { +// const query = useLibraryQuery(['locations.list'], { keepPreviousData: true }); +// const locations = query.data; + +// return (locations ?? []).map((location) => ({ +// name: location.name!, +// value: location.id, +// icon: 'Folder' // Spacedrive folder icon +// })); +// }, +// Render: ({ filter, options, search }) => ( +// +// ) +// }), +// createInOrNotInFilter({ +// name: i18n.t('tags'), +// translationKey: 'tag', +// icon: CircleDashed, +// extract: (arg) => { +// if ('object' in arg && 'tags' in arg.object) return arg.object.tags; +// }, +// create: (tags) => ({ object: { tags } }), +// argsToOptions(values, options) { +// return values +// .map((value) => { +// const option = options.get(this.name)?.find((o) => o.value === value); + +// if (!option) return; + +// return { +// ...option, +// type: this.name +// }; +// }) +// .filter(Boolean) as any; +// }, +// useOptions: () => { +// const query = useLibraryQuery(['tags.list']); +// const tags = query.data; +// return (tags ?? []).map((tag) => ({ +// name: tag.name!, +// value: tag.id, +// icon: tag.color || 'CircleDashed' +// })); +// }, +// Render: ({ filter, options, search }) => { +// return ( +// ( +//
+// +//

+// {i18n.t('no_tags')} +//

+//
+// )} +// filter={filter} +// options={options} +// search={search} +// /> +// ); +// } +// }), +// createInOrNotInFilter({ +// name: i18n.t('kind'), +// translationKey: 'kind', +// icon: Cube, +// extract: (arg) => { +// if ('object' in arg && 'kind' in arg.object) return arg.object.kind; +// }, +// create: (kind) => ({ object: { kind } }), +// argsToOptions(values, options) { +// return values +// .map((value) => { +// const option = options.get(this.name)?.find((o) => o.value === value); + +// if (!option) return; + +// return { +// ...option, +// type: this.name +// }; +// }) +// .filter(Boolean) as any; +// }, +// useOptions: () => +// Object.keys(ObjectKind) +// .filter((key) => !isNaN(Number(key)) && ObjectKind[Number(key)] !== undefined) +// .map((key) => { +// const kind = ObjectKind[Number(key)] as string; +// return { +// name: translateKindName(kind), +// value: Number(key), +// icon: kind + '20' +// }; +// }), +// Render: ({ filter, options, search }) => ( +// +// ) +// }), +// createTextMatchFilter({ +// name: i18n.t('name'), +// translationKey: 'name', +// icon: Textbox, +// extract: (arg) => { +// if ('filePath' in arg && 'name' in arg.filePath) return arg.filePath.name; +// }, +// create: (name) => ({ filePath: { name } }), +// useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], +// Render: ({ filter, search }) => +// }), +// createInOrNotInFilter({ +// name: i18n.t('extension'), +// translationKey: 'extension', +// icon: Textbox, +// extract: (arg) => { +// if ('filePath' in arg && 'extension' in arg.filePath) return arg.filePath.extension; +// }, +// create: (extension) => ({ filePath: { extension } }), +// argsToOptions(values) { +// return values.map((value) => ({ +// type: this.name, +// name: value, +// value +// })); +// }, +// useOptions: ({ search }) => [{ name: search, value: search, icon: Textbox }], +// Render: ({ filter, search }) => +// }), +// createBooleanFilter({ +// name: i18n.t('hidden'), +// translationKey: 'hidden', +// icon: SelectionSlash, +// extract: (arg) => { +// if ('filePath' in arg && 'hidden' in arg.filePath) return arg.filePath.hidden; +// }, +// create: (hidden) => ({ filePath: { hidden } }), +// useOptions: () => { +// return [ +// { +// name: 'Hidden', +// value: true, +// icon: 'SelectionSlash' // Spacedrive folder icon +// } +// ]; +// }, +// Render: ({ filter, search }) => +// }), +// createBooleanFilter({ +// name: i18n.t('favorite'), +// translationKey: 'favorite', +// icon: Heart, +// extract: (arg) => { +// if ('object' in arg && 'favorite' in arg.object) return arg.object.favorite; +// }, +// create: (favorite) => ({ object: { favorite } }), +// useOptions: () => { +// return [ +// { +// name: 'Favorite', +// value: true, +// icon: 'Heart' // Spacedrive folder icon +// } +// ]; +// }, +// Render: ({ filter, search }) => +// }) +// // createInOrNotInFilter({ +// // name: i18n.t('label'), +// // icon: Tag, +// // extract: (arg) => { +// // if ('object' in arg && 'labels' in arg.object) return arg.object.labels; +// // }, +// // create: (labels) => ({ object: { labels } }), +// // argsToOptions(values, options) { +// // return values +// // .map((value) => { +// // const option = options.get(this.name)?.find((o) => o.value === value); + +// // if (!option) return; + +// // return { +// // ...option, +// // type: this.name +// // }; +// // }) +// // .filter(Boolean) as any; +// // }, +// // useOptions: () => { +// // const query = useLibraryQuery(['labels.list']); + +// // return (query.data ?? []).map((label) => ({ +// // name: label.name!, +// // value: label.id +// // })); +// // }, +// // Render: ({ filter, options, search }) => ( +// // +// // ) +// // }) +// // idk how to handle this rn since include_descendants is part of 'path' now +// // +// // createFilter({ +// // name: i18n.t('with_descendants'), +// // icon: SelectionSlash, +// // conditions: filterTypeCondition.trueOrFalse, +// // setCondition: (args, condition: 'true' | 'false') => { +// // const filePath = (args.filePath ??= {}); + +// // filePath.withDescendants = condition === 'true'; +// // }, +// // applyAdd: () => {}, +// // applyRemove: (args) => { +// // delete args.filePath?.withDescendants; +// // }, +// // useOptions: () => { +// // return [ +// // { +// // name: 'With Descendants', +// // value: true, +// // icon: 'SelectionSlash' // Spacedrive folder icon +// // } +// // ]; +// // }, +// // Render: ({ filter }) => { +// // return ; +// // }, +// // apply(filter, args) { +// // (args.filePath ??= {}).withDescendants = filter.condition; +// // } +// // }) +// ] as const satisfies ReadonlyArray>; + +// type FilterType = (typeof filterRegistry)[number]['name']; diff --git a/interface/app/$libraryId/search/SearchOptions.tsx b/interface/app/$libraryId/search/SearchOptions.tsx index ecd521232770..b9904a1e9575 100644 --- a/interface/app/$libraryId/search/SearchOptions.tsx +++ b/interface/app/$libraryId/search/SearchOptions.tsx @@ -19,15 +19,15 @@ import { import { useIsDark, useKeybind, useLocale, useShortcut } from '~/hooks'; import { getQuickPreviewStore, useQuickPreviewStore } from '../Explorer/QuickPreview/store'; -import { AppliedFilters, InteractiveSection } from './AppliedFilters'; import { useSearchContext } from './context'; -import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters'; +import { AppliedFilters, InteractiveSection } from './Filters/components/AppliedFilters'; +import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters/index'; import { - getSearchStore, - useRegisterSearchFilterOptions, - useSearchRegisteredFilters, - useSearchStore -} from './store'; + useFilterOptionStore, + useRegisterFilterOptions, + useSearchRegisteredFilters +} from './Filters/store'; +import { getSearchStore, useSearchStore } from './store'; import { UseSearch } from './useSearch'; import { RenderIcon } from './util'; @@ -209,7 +209,7 @@ const SearchResults = memo( function AddFilterButton() { const search = useSearchContext(); - const searchState = useSearchStore(); + const filterStore = useFilterOptionStore(); const [searchQuery, setSearch] = useState(''); @@ -261,7 +261,7 @@ function AddFilterButton() { )) @@ -373,7 +373,7 @@ function RegisterSearchFilterOptions(props: { }) { const options = props.filter.useOptions({ search: props.searchQuery }); - useRegisterSearchFilterOptions( + useRegisterFilterOptions( props.filter, useMemo( () => options.map((o) => ({ ...o, type: props.filter.name })), diff --git a/interface/app/$libraryId/search/store.tsx b/interface/app/$libraryId/search/store.tsx index e05cc8ab3027..3ce79da6e5a7 100644 --- a/interface/app/$libraryId/search/store.tsx +++ b/interface/app/$libraryId/search/store.tsx @@ -1,98 +1,27 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { Icon } from '@phosphor-icons/react'; -import { useEffect, useMemo } from 'react'; -import { proxy, ref, useSnapshot } from 'valtio'; -import { proxyMap } from 'valtio/utils'; -import { SearchFilterArgs } from '@sd/client'; - -import { filterRegistry, FilterType, RenderSearchFilter } from './Filters'; +import { proxy, useSnapshot } from 'valtio'; export type SearchType = 'paths' | 'objects'; -export type SearchScope = 'directory' | 'location' | 'device' | 'library'; - -export interface FilterOption { - value: string | any; - name: string; - icon?: string | Icon; // "Folder" or "#efefef" -} - -export interface FilterOptionWithType extends FilterOption { - type: FilterType; -} - -export type AllKeys = T extends any ? keyof T : never; - const searchStore = proxy({ interactingWithSearchOptions: false, searchType: 'paths' as SearchType, - filterOptions: ref(new Map()), - // we register filters so we can search them - registeredFilters: proxyMap() as Map + searchQuery: '' // Search query to track user input + // Any other search-specific state can go here }); -// this makes the filter unique and easily searchable using .includes -export const getKey = (filter: FilterOptionWithType) => - `${filter.type}-${filter.name}-${filter.value}`; - -// this hook allows us to register filters to the search store -// and returns the filters with the correct type -export const useRegisterSearchFilterOptions = ( - filter: RenderSearchFilter, - options: (FilterOption & { type: FilterType })[] -) => { - const optionsAsKeys = useMemo(() => options.map(getKey), [options]); - - useEffect(() => { - searchStore.filterOptions.set(filter.name, options); - searchStore.filterOptions = ref(new Map(searchStore.filterOptions)); - }, [optionsAsKeys]); - - useEffect(() => { - const keys = options.map((option) => { - const key = getKey(option); - - if (!searchStore.registeredFilters.has(key)) { - searchStore.registeredFilters.set(key, option); - - return key; - } - }); +// Hook to interact with the search store +export const useSearchStore = () => useSnapshot(searchStore); - return () => - keys.forEach((key) => { - if (key) searchStore.registeredFilters.delete(key); - }); - }, [optionsAsKeys]); +// Function to set the search query +export const setSearchQuery = (query: string) => { + searchStore.searchQuery = query; }; -export function argsToOptions(args: SearchFilterArgs[], options: Map) { - return args.flatMap((fixedArg) => { - const filter = filterRegistry.find((f) => f.extract(fixedArg)); - if (!filter) return []; - - return filter - .argsToOptions(filter.extract(fixedArg) as any, options) - .map((arg) => ({ arg, filter })); - }); -} - -export const useSearchRegisteredFilters = (query: string) => { - const { registeredFilters } = useSearchStore(); - - return useMemo( - () => - !query - ? [] - : [...registeredFilters.entries()] - .filter(([key, _]) => key.toLowerCase().includes(query.toLowerCase())) - .map(([key, filter]) => ({ ...filter, key })), - [registeredFilters, query] - ); +// Function to reset search state (if needed) +export const resetSearchStore = () => { + searchStore.interactingWithSearchOptions = false; + searchStore.searchQuery = ''; }; -export const resetSearchStore = () => {}; - -export const useSearchStore = () => useSnapshot(searchStore); - +// Function to retrieve the search store directly export const getSearchStore = () => searchStore; diff --git a/interface/app/$libraryId/search/useSearch.ts b/interface/app/$libraryId/search/useSearch.ts index 9b7b29996b33..a72e2f5e88c5 100644 --- a/interface/app/$libraryId/search/useSearch.ts +++ b/interface/app/$libraryId/search/useSearch.ts @@ -4,7 +4,7 @@ import { useSearchParams as useRawSearchParams } from 'react-router-dom'; import { useDebouncedValue } from 'rooks'; import { SearchFilterArgs } from '@sd/client'; -import { argsToOptions, getKey, useSearchStore } from './store'; +import { argsToFilterOptions, getKey, useFilterOptionStore } from './Filters/store'; export type SearchTarget = 'paths' | 'objects'; @@ -120,11 +120,11 @@ export function useSearch(props: UseSearchProps const [searchBarFocused, setSearchBarFocused] = useState(false); - const searchState = useSearchStore(); + const filterStore = useFilterOptionStore(); const filtersAsOptions = useMemo( - () => argsToOptions(filters ?? [], searchState.filterOptions), - [filters, searchState.filterOptions] + () => argsToFilterOptions(filters ?? [], filterStore.filterOptions), + [filters, filterStore.filterOptions] ); const filtersKeys: Set = useMemo(() => { @@ -140,7 +140,6 @@ export function useSearch(props: UseSearchProps }, [filtersAsOptions]); // Merging of filters that should be ORed - const mergedFilters = useMemo( () => filters?.map((arg, removalIndex) => ({ arg, removalIndex })), [filters] @@ -166,8 +165,8 @@ export function useSearch(props: UseSearchProps ); const allFiltersAsOptions = useMemo( - () => argsToOptions(allFilters, searchState.filterOptions), - [searchState.filterOptions, allFilters] + () => argsToFilterOptions(allFilters, filterStore.filterOptions), + [filterStore.filterOptions, allFilters] ); const allFiltersKeys: Set = useMemo(() => { diff --git a/interface/app/$libraryId/search/util.tsx b/interface/app/$libraryId/search/util.tsx index 3bf56d1e77cf..27a6f514292b 100644 --- a/interface/app/$libraryId/search/util.tsx +++ b/interface/app/$libraryId/search/util.tsx @@ -4,29 +4,6 @@ import clsx from 'clsx'; import i18n from '~/app/I18n'; import { Icon as SDIcon } from '~/components'; -export const filterTypeCondition = { - inOrNotIn: { - in: i18n.t('is'), - notIn: i18n.t('is_not') - }, - textMatch: { - contains: i18n.t('contains'), - startsWith: i18n.t('starts_with'), - endsWith: i18n.t('ends_with'), - equals: i18n.t('equals') - }, - optionalRange: { - from: i18n.t('from'), - to: i18n.t('to') - }, - trueOrFalse: { - true: i18n.t('is'), - false: i18n.t('is_not') - } -} as const; - -export type FilterTypeCondition = typeof filterTypeCondition; - export const RenderIcon = ({ className, icon diff --git a/interface/package.json b/interface/package.json index d3b132e7f645..13749384a828 100644 --- a/interface/package.json +++ b/interface/package.json @@ -46,6 +46,7 @@ "react": "^18.2.0", "react-cmdk": "^1.3.9", "react-colorful": "^5.6.1", + "react-date-picker": "^11.0.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.11", "react-force-graph-2d": "^1.25.5", @@ -86,4 +87,4 @@ "vite": "^5.4.9", "vite-plugin-svgr": "^3.3.0" } -} +} \ No newline at end of file diff --git a/packages/assets/icons/Document_srt.png b/packages/assets/icons/Document_srt.png new file mode 100644 index 000000000000..2269ef7f061c Binary files /dev/null and b/packages/assets/icons/Document_srt.png differ diff --git a/packages/assets/icons/index.ts b/packages/assets/icons/index.ts index 0561b0280d60..3eeef25bbec3 100644 --- a/packages/assets/icons/index.ts +++ b/packages/assets/icons/index.ts @@ -47,6 +47,7 @@ import Document_doc from './Document_doc.png'; import Document_Light from './Document_Light.png'; import Document_pdf_Light from './Document_pdf_Light.png'; import Document_pdf from './Document_pdf.png'; +import Document_srt from './Document_srt.png'; import Document_xls_Light from './Document_xls_Light.png'; import Document_xls from './Document_xls.png'; import Document_xmp from './Document_xmp.png'; @@ -242,6 +243,7 @@ export { Document_doc_Light, Document_pdf, Document_pdf_Light, + Document_srt, Document_xls, Document_xls_Light, Document_xmp, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 098f140c281e..5413162a3a3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,7 +202,7 @@ importers: version: 12.1.2 '@phosphor-icons/react': specifier: ^2.0.14 - version: 2.0.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 2.1.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -729,8 +729,8 @@ importers: specifier: ^9.1.0 version: 9.4.0(react@18.2.0) '@phosphor-icons/react': - specifier: ^2.0.13 - version: 2.0.15(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + specifier: ^2.1.7 + version: 2.1.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -827,6 +827,9 @@ importers: react-colorful: specifier: ^5.6.1 version: 5.6.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-date-picker: + specifier: ^11.0.0 + version: 11.0.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -4055,6 +4058,13 @@ packages: react: '>= 16.8' react-dom: '>= 16.8' + '@phosphor-icons/react@2.1.7': + resolution: {integrity: sha512-g2e2eVAn1XG2a+LI09QU3IORLhnFNAFkNbo2iwbX6NOKSLOwvEMmTa7CgOzEbgNWR47z8i8kwjdvYZ5fkGx1mQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -6461,6 +6471,9 @@ packages: '@webassemblyjs/wast-printer@1.12.1': resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + '@wojtekmaj/date-utils@1.5.1': + resolution: {integrity: sha512-+i7+JmNiE/3c9FKxzWFi2IjRJ+KzZl1QPu6QNrsgaa2MuBgXvUy4gA1TVzf/JMdIIloB76xSKikTWuyYAIVLww==} + '@xmldom/xmldom@0.7.13': resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} engines: {node: '>=10.0.0'} @@ -7991,6 +8004,9 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-element-overflow@1.4.2: + resolution: {integrity: sha512-4m6cVOtvm/GJLjo7WFkPfwXoEIIbM7GQwIh4WEa4g7IsNi1YzwUsGL5ApNLrrHL29bHeNeQ+/iZhw+YHqgE2Fw==} + detect-gpu@5.0.38: resolution: {integrity: sha512-36QeGHSXYcJ/RfrnPEScR8GDprbXFG4ZhXsfVNVHztZr38+fRxgHnJl3CjYXXjbeRUhu3ZZBJh6Lg0A9v0Qd8A==} @@ -9180,8 +9196,8 @@ packages: get-tsconfig@4.7.3: resolution: {integrity: sha512-ZvkrzoUA0PQZM6fy6+/Hce561s+faD1rsNwhnO5FelNjyy7EMGJ3Rz1AQ8GYDWjhRs/7dBLOEJvhK8MiEJOAFg==} - get-tsconfig@4.8.1: - resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + get-user-locale@2.3.2: + resolution: {integrity: sha512-O2GWvQkhnbDoWFUJfaBlDIKUEdND8ATpBXD6KXcbhxlfktyD/d8w6mkzM/IlQEqGZAMz/PW6j6Hv53BiigKLUQ==} getenv@1.0.0: resolution: {integrity: sha512-7yetJWqbS9sbn0vIfliPsFgoXMKn/YMF+Wuiog97x+urnSRRRZ7xB+uVkwGKzRgq9CDFfMQnE9ruL5DHv9c6Xg==} @@ -10729,6 +10745,10 @@ packages: resolution: {integrity: sha512-qvwipnozMohxLXG1pOqoLiZKNkC4r4qqRucSoDwXowsNGDSULiqFTRUF05vcZWnwJSG22qTsynQhxbaMtnX9gw==} engines: {node: '>=8'} + mem@8.1.1: + resolution: {integrity: sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==} + engines: {node: '>=10'} + memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -11116,6 +11136,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -12207,6 +12231,16 @@ packages: react: '>=0.14.0' react-dom: '>=0.14.0' + react-calendar@5.0.0: + resolution: {integrity: sha512-bHcE5e5f+VUKLd4R19BGkcSQLpuwjKBVG0fKz74cwPW5xDfNsReHdDbfd4z3mdjuUuZzVtw4Q920mkwK5/ZOEg==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-cmdk@1.3.9: resolution: {integrity: sha512-MSVmAQZ9iqY7hO3r++XP6yWSHzGfMDGMvY3qlDT8k5RiWoRFwO1CGPlsWzhvcUbPilErzsMKK7uB4McEcX4B6g==} peerDependencies: @@ -12224,6 +12258,16 @@ packages: peerDependencies: react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-date-picker@11.0.0: + resolution: {integrity: sha512-l+siu5HSZ/ciGL1293KCAHl4o9aD5rw16V4tB0C43h7QbMv2dWGgj7Dxgt8iztLaPVtEfOt/+sxNiTYw4WVq6A==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-device-detect@2.2.3: resolution: {integrity: sha512-buYY3qrCnQVlIFHrC5UcUoAj7iANs/+srdkwsnNjI7anr3Tt7UY6MqNxtMLlr0tMBied0O49UZVK8XKs3ZIiPw==} peerDependencies: @@ -12261,6 +12305,19 @@ packages: peerDependencies: react: '>=16.13.1' + react-fit@2.0.1: + resolution: {integrity: sha512-Eip6ALs/+6Jv82Si0I9UnfysdwVlAhkkZRycgmMdnj7jwUg69SVFp84ICxwB8zszkfvJJ2MGAAo9KAYM8ZUykQ==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + react-force-graph-2d@1.25.5: resolution: {integrity: sha512-3u8WjZZorpwZSDs3n3QeOS9ZoxFPM+IR9SStYJVQ/qKECydMHarxnf7ynV/MKJbC6kUsc60soD0V+Uq/r2vz7Q==} engines: {node: '>=12'} @@ -14196,6 +14253,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-input-width@1.4.2: + resolution: {integrity: sha512-/p0XLhrQQQ4bMWD7bL9duYObwYCO1qGr8R19xcMmoMSmXuQ7/1//veUnCObQ7/iW6E2pGS6rFkS4TfH4ur7e/g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -14492,6 +14552,9 @@ packages: warn-once@0.1.1: resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==} + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + watchpack@2.4.1: resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==} engines: {node: '>=10.13.0'} @@ -18508,6 +18571,11 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + '@phosphor-icons/react@2.1.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + '@pkgjs/parseargs@0.11.0': optional: true @@ -22196,6 +22264,8 @@ snapshots: '@webassemblyjs/ast': 1.12.1 '@xtuc/long': 4.2.2 + '@wojtekmaj/date-utils@1.5.1': {} + '@xmldom/xmldom@0.7.13': {} '@xmldom/xmldom@0.8.10': {} @@ -24051,6 +24121,8 @@ snapshots: destroy@1.2.0: {} + detect-element-overflow@1.4.2: {} + detect-gpu@5.0.38: dependencies: webgl-constants: 1.1.1 @@ -24746,7 +24818,7 @@ snapshots: builtins: 5.1.0 eslint: 8.57.1 eslint-plugin-es-x: 7.8.0(eslint@8.57.1) - get-tsconfig: 4.8.1 + get-tsconfig: 4.7.3 globals: 13.24.0 ignore: 5.3.2 is-builtin-module: 3.2.1 @@ -25657,9 +25729,9 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-tsconfig@4.8.1: + get-user-locale@2.3.2: dependencies: - resolve-pkg-maps: 1.0.0 + mem: 8.1.1 getenv@1.0.0: {} @@ -27525,6 +27597,11 @@ snapshots: mimic-fn: 2.1.0 p-is-promise: 2.1.0 + mem@8.1.1: + dependencies: + map-age-cleaner: 0.1.3 + mimic-fn: 3.1.0 + memfs@3.5.3: dependencies: fs-monkey: 1.0.5 @@ -28414,6 +28491,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@3.1.0: {} + mimic-fn@4.0.0: {} mimic-response@2.1.0: {} @@ -29515,6 +29594,17 @@ snapshots: react-dom: 18.2.0(react@18.2.0) snapsvg-cjs: 0.0.6(eve@0.5.4) + react-calendar@5.0.0(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + '@wojtekmaj/date-utils': 1.5.1 + clsx: 2.1.0 + get-user-locale: 2.3.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + warning: 4.0.3 + optionalDependencies: + '@types/react': 18.2.67 + react-cmdk@1.3.9(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(webpack@5.90.3): dependencies: '@headlessui/react': 1.7.18(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -29536,6 +29626,22 @@ snapshots: prop-types: 15.8.1 react: 18.2.0 + react-date-picker@11.0.0(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + '@wojtekmaj/date-utils': 1.5.1 + clsx: 2.1.0 + get-user-locale: 2.3.2 + make-event-props: 1.6.2 + react: 18.2.0 + react-calendar: 5.0.0(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-fit: 2.0.1(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + update-input-width: 1.4.2 + optionalDependencies: + '@types/react': 18.2.67 + transitivePeerDependencies: + - '@types/react-dom' + react-device-detect@2.2.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: react: 18.2.0 @@ -29596,6 +29702,16 @@ snapshots: '@babel/runtime': 7.24.0 react: 18.2.0 + react-fit@2.0.1(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + detect-element-overflow: 1.4.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + warning: 4.0.3 + optionalDependencies: + '@types/react': 18.2.67 + '@types/react-dom': 18.2.22 + react-force-graph-2d@1.25.5(react@18.2.0): dependencies: force-graph: 1.43.5 @@ -31994,6 +32110,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.0 + update-input-width@1.4.2: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -32321,6 +32439,10 @@ snapshots: warn-once@0.1.1: {} + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + watchpack@2.4.1: dependencies: glob-to-regexp: 0.4.1