diff --git a/apps/mobile/src/components/drawer/DrawerTags.tsx b/apps/mobile/src/components/drawer/DrawerTags.tsx index f29e0267e..6a1aa8239 100644 --- a/apps/mobile/src/components/drawer/DrawerTags.tsx +++ b/apps/mobile/src/components/drawer/DrawerTags.tsx @@ -1,8 +1,8 @@ import { DrawerNavigationHelpers } from '@react-navigation/drawer/lib/typescript/src/types'; import { useNavigation } from '@react-navigation/native'; +import { Tag, useLibraryQuery } from '@sd/client'; import { useRef } from 'react'; import { ColorValue, Pressable, Text, View } from 'react-native'; -import { Tag, useLibraryQuery } from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; import { tw, twStyle } from '~/lib/tailwind'; @@ -19,11 +19,9 @@ type DrawerTagItemProps = { const DrawerTagItem: React.FC = (props) => { const { tagName, tagColor, onPress } = props; return ( - + @@ -50,7 +48,7 @@ const DrawerTags = () => { > - + {tagData?.length > 2 && } {/* Add Tag */} @@ -92,8 +90,10 @@ interface TagColumnProps { const TagColumn = ({ tags, dataAmount }: TagColumnProps) => { const navigation = useNavigation(); return ( - - {tags?.slice(dataAmount[0], dataAmount[1]).map((tag: any) => ( + 2 ? 'w-[49%] flex-col' : 'flex-1 flex-row' + )}> + {tags?.slice(dataAmount[0], dataAmount[1]).map((tag: Tag) => ( void; query: UseInfiniteQueryResult>; count?: number; -}; + empty?: never; + isEmpty?: never; +} -const Explorer = (props: ExplorerProps) => { +type Props = | +ExplorerProps +| ({ + // isEmpty and empty are mutually exclusive + emptyComponent: React.ReactElement; // component to show when FlashList has no data + isEmpty: boolean; // if true - show empty component +} & Omit); + +const Explorer = (props: Props) => { const navigation = useNavigation['navigation']>(); - const store = useExplorerStore(); - const { modalRef, setData } = useActionsModalStore(); function handlePress(data: ExplorerItem) { if (isPath(data) && data.item.is_dir && data.item.location_id !== null) { - navigation.push('Location', { - id: data.item.location_id, - path: `${data.item.materialized_path}${data.item.name}/` - }); + navigation.push('Location', { + id: data.item.location_id, + path: `${data.item.materialized_path}${data.item.name}/` + }); } else { setData(data); modalRef.current?.present(); @@ -45,38 +53,44 @@ const Explorer = (props: ExplorerProps) => { return ( + {/* Flashlist not supporting empty centering: https://github.com/Shopify/flash-list/discussions/517 + So needs to be done this way */} {/* Items */} - - item.type === 'NonIndexedPath' - ? item.item.path - : item.type === 'SpacedropPeer' - ? item.item.name - : item.item.id.toString() - } - renderItem={({ item }) => ( - handlePress(item)}> - {store.layoutMode === 'grid' ? ( - - ) : ( - + {props.isEmpty ? ( + props.emptyComponent + ) : + + item.type === 'NonIndexedPath' + ? item.item.path + : item.type === 'SpacedropPeer' + ? item.item.name + : item.item.id.toString() + } + renderItem={({ item }) => ( + handlePress(item)}> + {store.layoutMode === 'grid' ? ( + + ) : ( + + )} + )} - - )} - contentContainerStyle={tw`px-2 py-5`} - extraData={store.layoutMode} - estimatedItemSize={ - store.layoutMode === 'grid' - ? Layout.window.width / store.gridNumColumns - : store.listItemSize - } - onEndReached={() => props.loadMore?.()} - onEndReachedThreshold={0.6} - ListFooterComponent={props.query.isFetchingNextPage ? : null} - /> + contentContainerStyle={tw`px-2 py-5`} + extraData={store.layoutMode} + estimatedItemSize={ + store.layoutMode === 'grid' + ? Layout.window.width / store.gridNumColumns + : store.listItemSize + } + onEndReached={() => props.loadMore?.()} + onEndReachedThreshold={0.6} + ListFooterComponent={props.query.isFetchingNextPage ? : null} + /> + } ); }; diff --git a/apps/mobile/src/components/locations/ListLocation.tsx b/apps/mobile/src/components/locations/ListLocation.tsx index 7565c453f..6d0564d32 100644 --- a/apps/mobile/src/components/locations/ListLocation.tsx +++ b/apps/mobile/src/components/locations/ListLocation.tsx @@ -1,9 +1,9 @@ import { useNavigation } from '@react-navigation/native'; +import { Location, arraysEqual, byteSize, useOnlineLocations } from '@sd/client'; import { DotsThreeOutlineVertical } from 'phosphor-react-native'; import { useRef } from 'react'; import { Pressable, Text, View } from 'react-native'; import { Swipeable } from 'react-native-gesture-handler'; -import { arraysEqual, byteSize, Location, useOnlineLocations } from '@sd/client'; import { tw, twStyle } from '~/lib/tailwind'; import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack'; @@ -63,7 +63,7 @@ const ListLocation = ({ location }: ListLocationProps) => { ((_, ref) => { + const [searchName, setSearchName] = useState(''); + const navigation = useNavigation(); + const searchStore = useSearchStore(); + const saveSearch = useLibraryMutation('search.saved.create', { + onSuccess: () => { + searchStore.applyFilters(); + navigation.navigate('SearchStack', { + screen: 'Search' + }); + } + }); + return ( + + + setSearchName(text)} + placeholder="Search Name..." + /> + + + + ); +}); + +export default SaveSearchModal; diff --git a/apps/mobile/src/components/search/filters/FiltersBar.tsx b/apps/mobile/src/components/search/filters/FiltersBar.tsx index 0dd249da4..9501b5c91 100644 --- a/apps/mobile/src/components/search/filters/FiltersBar.tsx +++ b/apps/mobile/src/components/search/filters/FiltersBar.tsx @@ -20,20 +20,22 @@ import { KindItem, SearchFilters, TagItem, + getSearchStore, useSearchStore } from '~/stores/searchStore'; const FiltersBar = () => { - const { filters, appliedFilters } = useSearchStore(); + const searchStore = useSearchStore(); const navigation = useNavigation['navigation']>(); const flatListRef = useRef(null); + const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length; - // Scroll to start when there are less than 2 filters. useEffect(() => { - if (Object.entries(appliedFilters).length < 2) { - flatListRef.current?.scrollToOffset({ animated: true, offset: 0 }); + // If there are applied filters, update the searchStore filters + if (appliedFiltersLength > 0) { + Object.assign(getSearchStore().filters, searchStore.appliedFilters); } - }, [appliedFilters]); + }, [appliedFiltersLength, searchStore.appliedFilters]); return ( { ref={flatListRef} showsHorizontalScrollIndicator={false} horizontal - data={Object.entries(appliedFilters)} - extraData={filters} + onContentSizeChange={() => { + if (flatListRef.current && appliedFiltersLength < 2) { + flatListRef.current.scrollToOffset({ animated: true, offset: 0 }); + } + }} + data={Object.entries(searchStore.appliedFilters)} + extraData={searchStore.filters} keyExtractor={(item) => item[0]} renderItem={({ item }) => ( @@ -75,6 +82,10 @@ const FilterItem = ({ filter, value }: FilterItemProps) => { const boxStyle = tw`w-auto flex-row items-center gap-1.5 border border-app-cardborder bg-app-card p-2`; const filterCapital = filter.charAt(0).toUpperCase() + filter.slice(1); const searchStore = useSearchStore(); + + // if the filter value is false or empty, return null i.e "Hidden" + if (!value) return null; + return ( diff --git a/apps/mobile/src/components/search/filters/FiltersList.tsx b/apps/mobile/src/components/search/filters/FiltersList.tsx index abbf5ba0e..cc58b151b 100644 --- a/apps/mobile/src/components/search/filters/FiltersList.tsx +++ b/apps/mobile/src/components/search/filters/FiltersList.tsx @@ -1,4 +1,4 @@ -import { AnimatePresence } from 'moti'; +import { AnimatePresence, MotiView } from 'moti'; import { MotiPressable } from 'moti/interactions'; import { CircleDashed, @@ -13,7 +13,7 @@ import { Text, View } from 'react-native'; import Card from '~/components/layout/Card'; import SectionTitle from '~/components/layout/SectionTitle'; import { tw, twStyle } from '~/lib/tailwind'; -import { getSearchStore, SearchFilters, useSearchStore } from '~/stores/searchStore'; +import { SearchFilters, getSearchStore, useSearchStore } from '~/stores/searchStore'; import Extension from './Extension'; import Kind from './Kind'; @@ -51,12 +51,15 @@ const FiltersList = () => { const [selectedOptions, setSelectedOptions] = useState( Object.keys(searchStore.appliedFilters) as SearchFilters[] ); + const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length; + - // If any filters are applied - we need to update the store - // so the UI can reflect the applied filters useEffect(() => { - Object.assign(getSearchStore().filters, getSearchStore().appliedFilters); - }, []); + //if there are selected filters but not applied reset them + if (appliedFiltersLength === 0) { + getSearchStore().resetFilters(); + } + }, [appliedFiltersLength]); const selectedHandler = useCallback( (option: Capitalize) => { @@ -80,13 +83,16 @@ const FiltersList = () => { searchStore.resetFilter(searchFiltersLowercase); } }, - [selectedOptions, searchStore] - ); + [selectedOptions, searchStore]) return ( - + { ))} - + {/* conditionally render the selected options - this approach makes sure the animation is right by not relying on the index position of the object */} diff --git a/apps/mobile/src/components/search/filters/Locations.tsx b/apps/mobile/src/components/search/filters/Locations.tsx index 7017acf28..3ea6f753c 100644 --- a/apps/mobile/src/components/search/filters/Locations.tsx +++ b/apps/mobile/src/components/search/filters/Locations.tsx @@ -1,8 +1,8 @@ +import { Location, useLibraryQuery } from '@sd/client'; import { MotiView } from 'moti'; import { memo, useCallback, useMemo } from 'react'; import { FlatList, Pressable, Text, View } from 'react-native'; import { LinearTransition } from 'react-native-reanimated'; -import { Location, useLibraryQuery } from '@sd/client'; import { Icon } from '~/components/icons/Icon'; import Card from '~/components/layout/Card'; import Empty from '~/components/layout/Empty'; @@ -77,6 +77,7 @@ const LocationFilter = memo(({ data }: Props) => { name: data.name as string }); }, [data.id, data.name, searchStore]); + return ( { const searchStore = useSearchStore(); const navigation = useNavigation['navigation']>(); + const modalRef = useRef(null); + const filtersApplied = Object.keys(searchStore.appliedFilters).length > 0; const buttonDisable = !filtersApplied && searchStore.disableActionButtons; const isAndroid = Platform.OS === 'android'; @@ -38,6 +42,7 @@ const SaveAdd = () => { opacity: buttonDisable ? 0.5 : 1 })} variant="dashed" + onPress={() => modalRef.current?.present()} > Save search @@ -58,6 +63,7 @@ const SaveAdd = () => { {filtersApplied ? 'Update filters' : 'Add filters'} + ); }; diff --git a/apps/mobile/src/components/search/filters/SavedSearches.tsx b/apps/mobile/src/components/search/filters/SavedSearches.tsx index c27719871..2d5dd4900 100644 --- a/apps/mobile/src/components/search/filters/SavedSearches.tsx +++ b/apps/mobile/src/components/search/filters/SavedSearches.tsx @@ -1,15 +1,27 @@ +import { useNavigation } from '@react-navigation/native'; +import { + SavedSearch as ISavedSearch, + useLibraryMutation, + useLibraryQuery, + useRspcLibraryContext +} from '@sd/client'; import { MotiView } from 'moti'; import { MotiPressable } from 'moti/interactions'; -import { FlatList, Text, View } from 'react-native'; +import { X } from 'phosphor-react-native'; +import { FlatList, Pressable, Text, View } from 'react-native'; import { Icon } from '~/components/icons/Icon'; import Card from '~/components/layout/Card'; +import Empty from '~/components/layout/Empty'; import Fade from '~/components/layout/Fade'; import SectionTitle from '~/components/layout/SectionTitle'; import VirtualizedListWrapper from '~/components/layout/VirtualizedListWrapper'; import DottedDivider from '~/components/primitive/DottedDivider'; +import { useSavedSearch } from '~/hooks/useSavedSearch'; import { tw } from '~/lib/tailwind'; +import { getSearchStore } from '~/stores/searchStore'; const SavedSearches = () => { + const { data: savedSearches } = useLibraryQuery(['search.saved.list']); return ( { title="Saved searches" sub="Tap a saved search for searching quickly" /> - + } + data={savedSearches} + ListEmptyComponent={() => { + return ( + + ); + }} + renderItem={({ item }) => } keyExtractor={(_, index) => index.toString()} numColumns={Math.ceil(6 / 2)} scrollEnabled={false} @@ -41,16 +59,37 @@ const SavedSearches = () => { ); }; -const SavedSearch = () => { +interface Props { + search: ISavedSearch; +} + +const SavedSearch = ({ search }: Props) => { + const navigation = useNavigation(); + const dataForSearch = useSavedSearch(search); + const rspc = useRspcLibraryContext(); + const deleteSearch = useLibraryMutation('search.saved.delete', { + onSuccess: () => rspc.queryClient.invalidateQueries(['search.saved.list']) + }); return ( { + getSearchStore().appliedFilters = dataForSearch; + navigation.navigate('SearchStack', { + screen: 'Search' + }); + }} > - + + await deleteSearch.mutateAsync(search.id)} + > + + - Saved search + {search.name} ); diff --git a/apps/mobile/src/components/search/filters/Tags.tsx b/apps/mobile/src/components/search/filters/Tags.tsx index 20c1585fb..0a7b5fa82 100644 --- a/apps/mobile/src/components/search/filters/Tags.tsx +++ b/apps/mobile/src/components/search/filters/Tags.tsx @@ -1,9 +1,9 @@ +import { Tag, useLibraryQuery } from '@sd/client'; import { MotiView } from 'moti'; import { memo, useCallback, useMemo } from 'react'; import { Pressable, Text, View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { LinearTransition } from 'react-native-reanimated'; -import { Tag, useLibraryQuery } from '@sd/client'; import Card from '~/components/layout/Card'; import Empty from '~/components/layout/Empty'; import Fade from '~/components/layout/Fade'; @@ -63,10 +63,7 @@ interface Props { const TagFilter = memo(({ tag }: Props) => { const searchStore = useSearchStore(); const isSelected = useMemo( - () => - searchStore.filters.tags.some( - (filter) => filter.id === tag.id && filter.color === tag.color - ), + () => searchStore.filters.tags.some((filter) => filter.id === tag.id), [searchStore.filters.tags, tag] ); const onPress = useCallback(() => { @@ -74,7 +71,8 @@ const TagFilter = memo(({ tag }: Props) => { id: tag.id, color: tag.color! }); - }, [searchStore, tag.id, tag.color]); + }, [searchStore, tag]); + return ( { + + //hidden is the only boolean filter - so we can return it directly + //Rest of the filters are arrays, so we map them to the correct format + const filterValue = Array.isArray(value) ? value.map((v: any) => { + return v.id ? v.id : v; + }) : value; + + //switch case for each filter + //This makes it easier to add new filters in the future and setup + //the correct object of each filter accordingly and easily + + switch (key) { + case 'locations': + return { filePath: { locations: { in: filterValue } } }; + case 'name': + return Array.isArray(filterValue) && filterValue.map((v: string) => { + return { filePath: { [key]: { contains: v } } }; + }) + case 'hidden': + return { filePath: { hidden: filterValue } }; + case 'extension': + return Array.isArray(filterValue) && filterValue.map((v: string) => { + return { filePath: { [key]: { in: [v] } } }; + }) + case 'tags': + return { object: { tags: { in: filterValue } } }; + case 'kind': + return { object: { kind: { in: filterValue } } }; + default: + return {}; + } + } + + + const mergedFilters = useMemo(() => { + + const filters = [] as SearchFilterArgs[]; + + for (const key in searchStore.filters) { + + const filterKey = key as SearchFilters; + //due to an issue with Valtio and Hermes Engine - need to do getSearchStore() + //https://github.com/pmndrs/valtio/issues/765 + const filterValue = getSearchStore().filters[filterKey]; + + // no need to add empty filters + if (Array.isArray(filterValue)) { + const realValues = filterValue.filter((v) => v !== ''); + if (realValues.length === 0) { + continue; + } + } + + // create the filter object + const filter = filterFactory(filterKey, filterValue); + + // add the filter to the mergedFilters + filters.push(filter as SearchFilterArgs); + + } + + // makes sure the array is not 2D + return filters.flat(); + + }, [searchStore.filters]); + + + useEffect(() => { + getSearchStore().mergedFilters = mergedFilters; + }, [searchStore.filters]); +}; diff --git a/apps/mobile/src/hooks/useSavedSearch.ts b/apps/mobile/src/hooks/useSavedSearch.ts new file mode 100644 index 000000000..3434621e7 --- /dev/null +++ b/apps/mobile/src/hooks/useSavedSearch.ts @@ -0,0 +1,134 @@ +import { SavedSearch, SearchFilterArgs, useLibraryQuery } from '@sd/client'; +import { useCallback, useMemo } from 'react'; +import { kinds } from '~/components/search/filters/Kind'; +import { Filters, SearchFilters } from '~/stores/searchStore'; + +/** + * This hook takes in the JSON of a Saved Search + * and returns the data of its filters for rendering in the UI + */ + +export function useSavedSearch(search: SavedSearch) { + const parseFilters = JSON.parse(search.filters as string); + + // returns an array of keys of the filters being used in the Saved Search + //i.e locations, tags, kind, etc... + const filterKeys: SearchFilters[] = parseFilters.reduce((acc: SearchFilters[], curr: keyof SearchFilterArgs) => { + const objectOrFilePath = Object.keys(curr)[0] as 'filePath' | 'object'; + const key = Object.keys(curr[objectOrFilePath])[0] as SearchFilters; + if (!acc.includes(key)) { + acc.push(key as SearchFilters); + } + return acc; + }, []); + + // this util function extracts the data of a filter from the Saved Search + const extractDataFromSavedSearch = (key: SearchFilters, filterTag: 'contains' | 'in', type: 'filePath' | 'object') => { + // Iterate through each item in the data array + for (const item of parseFilters) { + // Check if 'filePath' | 'object' exists and contains a the key + if (item[type] && key in item[type]) { + // Return the data of the filters + return item.filePath[key][filterTag]; + } + } + return null; + } + + const locations = useLibraryQuery(['locations.list'], { + keepPreviousData: true, + enabled: filterKeys.includes('locations'), + }); + const tags = useLibraryQuery(['tags.list'], { + keepPreviousData: true, + enabled: filterKeys.includes('tags'), + }); + + // Filters like locations, tags, and kind require data to be rendered as a Filter + // We prepare the data in the same format as the "filters" object in the "SearchStore" + // it is then 'matched' with the data from the "Saved Search" + + const prepFilters = useCallback(() => { + const data = {} as Record; + filterKeys.forEach((key: SearchFilters) => { + switch (key) { + case 'locations': + data.locations = locations.data?.map((location) => { + return { + id: location.id, + name: location.name + }; + }); + break; + case 'tags': + data.tags = tags.data?.map((tag) => { + return { + id: tag.id, + color: tag.color + }; + }); + break; + case 'kind': + data.kind = kinds.map((kind) => { + return { + name: kind.name, + id: kind.value, + icon: kind.icon + }; + }); + break; + case 'name': + data.name = extractDataFromSavedSearch(key, 'contains', 'filePath'); + break; + case 'extension': + data.extension = extractDataFromSavedSearch(key, 'contains', 'filePath'); + break; + } + }); + return data; + }, [locations, tags]); + + const filters: Partial = useMemo(() => { + return parseFilters.reduce((acc: Record, curr: keyof SearchFilterArgs) => { + + const objectOrFilePath = Object.keys(curr)[0] as 'filePath' | 'object'; + const key = Object.keys(curr[objectOrFilePath])[0] as SearchFilters; //locations, tags, kind, etc... + + // this function extracts the data from the result of the "filters" object in the Saved Search + // and matches it with the values of the filters + const extractData = (key: SearchFilters) => { + const values: { + contains?: string; + in?: number[]; + } = curr[objectOrFilePath][key]; + const type = Object.keys(values)[0]; + + switch (type) { + case 'contains': + // some filters have a name property and some are just strings + return prepFilters()[key].filter((item: any) => { + return item.name ? item.name === values[type] : + item + }); + case 'in': + return prepFilters()[key].filter((item: any) => values[type]?.includes(item.id)); + default: + return values; + } + }; + + // the data being setup for the filters so it can be rendered + if (!acc[key]) { + acc[key] = extractData(key); + //don't include false values i.e if the "Hidden" filter is false + if (acc[key] === false) { + delete acc[key]; + } + } + return acc; + }, {}); + + }, [parseFilters]); + + return filters; +} diff --git a/apps/mobile/src/navigation/SearchStack.tsx b/apps/mobile/src/navigation/SearchStack.tsx index 647e7b550..e014259c4 100644 --- a/apps/mobile/src/navigation/SearchStack.tsx +++ b/apps/mobile/src/navigation/SearchStack.tsx @@ -1,6 +1,8 @@ import { createNativeStackNavigator, NativeStackScreenProps } from '@react-navigation/native-stack'; import React from 'react'; +import DynamicHeader from '~/components/header/DynamicHeader'; import Header from '~/components/header/Header'; +import LocationScreen from '~/screens/browse/Location'; import FiltersScreen from '~/screens/search/Filters'; import SearchScreen from '~/screens/search/Search'; @@ -25,6 +27,14 @@ export default function SearchStack() { } }} /> + {/** This screen is already in BrowseStack - but added here as it offers the UX needed */} + ({ + header: (route) => + })} + /> ); } @@ -32,6 +42,7 @@ export default function SearchStack() { export type SearchStackParamList = { Search: undefined; Filters: undefined; + Location: { id: number; path: string }; }; export type SearchStackScreenProps = diff --git a/apps/mobile/src/navigation/TabNavigator.tsx b/apps/mobile/src/navigation/TabNavigator.tsx index 2465ddd7c..3ce7aea7d 100644 --- a/apps/mobile/src/navigation/TabNavigator.tsx +++ b/apps/mobile/src/navigation/TabNavigator.tsx @@ -146,7 +146,7 @@ export default function TabNavigator() { listeners={() => ({ focus: () => { setActiveIndex(index); - } + }, })} /> ))} diff --git a/apps/mobile/src/navigation/tabs/BrowseStack.tsx b/apps/mobile/src/navigation/tabs/BrowseStack.tsx index 2126f1937..61080de23 100644 --- a/apps/mobile/src/navigation/tabs/BrowseStack.tsx +++ b/apps/mobile/src/navigation/tabs/BrowseStack.tsx @@ -16,7 +16,9 @@ const Stack = createNativeStackNavigator(); export default function BrowseStack() { return ( - + - tagData?.filter((location) => + tagsData?.filter((location) => location.name?.toLowerCase().includes(debouncedSearch.toLowerCase()) ) ?? [], - [debouncedSearch, tagData] + [debouncedSearch, tagsData] ); return ( @@ -76,7 +76,7 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) { ItemSeparatorComponent={() => } contentContainerStyle={twStyle( `py-6`, - tagData.length === 0 && 'h-full items-center justify-center' + tagsData?.length === 0 && 'h-full items-center justify-center' )} /> diff --git a/apps/mobile/src/screens/search/Search.tsx b/apps/mobile/src/screens/search/Search.tsx index 56ac99730..d98c7c97f 100644 --- a/apps/mobile/src/screens/search/Search.tsx +++ b/apps/mobile/src/screens/search/Search.tsx @@ -1,10 +1,13 @@ +import { useIsFocused } from '@react-navigation/native'; +import { SearchFilterArgs, useLibraryQuery, usePathsExplorerQuery } from '@sd/client'; import { ArrowLeft, DotsThreeOutline, FunnelSimple, MagnifyingGlass } from 'phosphor-react-native'; import { Suspense, useDeferredValue, useMemo, useState } from 'react'; import { ActivityIndicator, Platform, Pressable, TextInput, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { SearchFilterArgs, useObjectsExplorerQuery } from '@sd/client'; import Explorer from '~/components/explorer/Explorer'; +import Empty from '~/components/layout/Empty'; import FiltersBar from '~/components/search/filters/FiltersBar'; +import { useFiltersSearch } from '~/hooks/useFiltersSearch'; import { tw, twStyle } from '~/lib/tailwind'; import { SearchStackScreenProps } from '~/navigation/SearchStack'; import { getExplorerStore, useExplorerStore } from '~/stores/explorerStore'; @@ -15,11 +18,12 @@ const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => { const [loading, setLoading] = useState(false); const searchStore = useSearchStore(); const explorerStore = useExplorerStore(); - const appliedFiltersLength = useMemo( - () => Object.keys(searchStore.appliedFilters).length, - [searchStore.appliedFilters] - ); + const isFocused = useIsFocused(); + const appliedFiltersLength = Object.keys(searchStore.appliedFilters).length; const isAndroid = Platform.OS === 'android'; + const locations = useLibraryQuery(['locations.list'], { + keepPreviousData: true, + }); const [search, setSearch] = useState(''); const deferredSearch = useDeferredValue(search); @@ -32,20 +36,32 @@ const SearchScreen = ({ navigation }: SearchStackScreenProps<'Search'>) => { if (name) filters.push({ filePath: { name: { contains: name } } }); if (ext) filters.push({ filePath: { extension: { in: [ext] } } }); - return filters; - }, [deferredSearch]); + if (name || ext) { + // Add locations filter to search all locations + if (locations.data && locations.data.length > 0) filters.push({ filePath: { locations: { in: + locations.data?.map((location) => location.id) } } }); + } - const objects = useObjectsExplorerQuery({ + return searchStore.mergedFilters.concat(filters); + }, [deferredSearch, searchStore.mergedFilters, locations.data]); + + const objects = usePathsExplorerQuery({ arg: { take: 30, filters }, - order: null, + enabled: isFocused && filters.length > 1, // only fetch when screen is focused & filters are applied suspense: true, - enabled: !!deferredSearch, + order: null, onSuccess: () => getExplorerStore().resetNewThumbnails() }); + // Check if there are no objects or no search + const noObjects = objects.items?.length === 0 || !objects.items; + const noSearch = deferredSearch.length === 0 && appliedFiltersLength === 0; + + useFiltersSearch(); + return ( ) => { /> - {appliedFiltersLength > 0 && } + {appliedFiltersLength > 0 && } {/* Content */} }> - + + } + tabHeight={false} /> diff --git a/apps/mobile/src/stores/searchStore.ts b/apps/mobile/src/stores/searchStore.ts index 92e20001a..1328c1e06 100644 --- a/apps/mobile/src/stores/searchStore.ts +++ b/apps/mobile/src/stores/searchStore.ts @@ -1,3 +1,4 @@ +import { SearchFilterArgs } from '@sd/client'; import { proxy, useSnapshot } from 'valtio'; import { IconName } from '~/components/icons/Icon'; @@ -19,29 +20,20 @@ export interface KindItem { icon: IconName; } +export interface Filters { + locations: FilterItem[]; + tags: TagItem[]; + name: string[]; + extension: string[]; + hidden: boolean; + kind: KindItem[]; +} + interface State { search: string; - filters: { - locations: FilterItem[]; - tags: TagItem[]; - name: string[]; - extension: string[]; - hidden: boolean; - kind: KindItem[]; - }; - appliedFilters: Partial< - Record< - SearchFilters, - { - locations: FilterItem[]; - tags: TagItem[]; - name: string[]; - extension: string[]; - hidden: boolean; - kind: KindItem[]; - } - > - >; + filters: Filters; + appliedFilters: Partial; + mergedFilters: SearchFilterArgs[], disableActionButtons: boolean; } @@ -56,6 +48,7 @@ const initialState: State = { kind: [] }, appliedFilters: {}, + mergedFilters: [], disableActionButtons: true }; @@ -88,6 +81,7 @@ const searchStore = proxy< applyFilters: () => void; setSearch: (search: string) => void; resetFilter: (filter: K, apply?: boolean) => void; + resetFilters: () => void; setInput: (index: number, value: string, key: 'name' | 'extension') => void; addInput: (key: 'name' | 'extension') => void; removeInput: (index: number, key: 'name' | 'extension') => void; @@ -120,8 +114,9 @@ const searchStore = proxy< searchStore.appliedFilters = Object.entries(searchStore.filters).reduce( (acc, [key, value]) => { if (Array.isArray(value)) { - if (value.length > 0 && value[0] !== '') { - acc[key as SearchFilters] = value.filter((v) => v !== ''); // Remove empty values i.e empty inputs + const realValues = value.filter((v) => v !== ''); + if (realValues.length > 0) { + acc[key as SearchFilters] = realValues; } } else if (typeof value === 'boolean') { // Only apply the hidden filter if it's true @@ -144,7 +139,9 @@ const searchStore = proxy< //instead of a useEffect or subscription - we can call applyFilters directly if (apply) searchStore.applyFilters(); }, - + resetFilters: () => { + searchStore.filters = { ...initialState.filters }; + }, setInput: (index, value, key) => { const newValues = [...searchStore.filters[key]]; newValues[index] = value; diff --git a/interface/app/$libraryId/search/SearchOptions.tsx b/interface/app/$libraryId/search/SearchOptions.tsx index f863a0f37..42fa91fb8 100644 --- a/interface/app/$libraryId/search/SearchOptions.tsx +++ b/interface/app/$libraryId/search/SearchOptions.tsx @@ -1,7 +1,5 @@ import { FunnelSimple, Icon, Plus } from '@phosphor-icons/react'; import { IconTypes } from '@sd/assets/util'; -import clsx from 'clsx'; -import { memo, PropsWithChildren, useDeferredValue, useMemo, useState } from 'react'; import { useFeatureFlag, useLibraryMutation } from '@sd/client'; import { Button, @@ -13,9 +11,11 @@ import { tw, usePopover } from '@sd/ui'; +import clsx from 'clsx'; +import { memo, PropsWithChildren, useDeferredValue, useMemo, useState } from 'react'; import { useIsDark, useKeybind } from '~/hooks'; -import { AppliedFilters, FilterContainer, InteractiveSection } from './AppliedFilters'; +import { AppliedFilters, InteractiveSection } from './AppliedFilters'; import { useSearchContext } from './context'; import { filterRegistry, SearchFilterCRUD, useToggleOptionSelected } from './Filters'; import { diff --git a/packages/client/src/explorer/usePathsExplorerQuery.ts b/packages/client/src/explorer/usePathsExplorerQuery.ts index 18100643b..f64f56504 100644 --- a/packages/client/src/explorer/usePathsExplorerQuery.ts +++ b/packages/client/src/explorer/usePathsExplorerQuery.ts @@ -6,6 +6,8 @@ import { usePathsOffsetInfiniteQuery } from './usePathsOffsetInfiniteQuery'; export function usePathsExplorerQuery(props: { arg: FilePathSearchArgs; order: FilePathOrder | null; + enabled?: boolean; + suspense?: boolean; /** This callback will fire any time the query successfully fetches new data. (NOTE: This will be removed on the next major version (react-query)) */ onSuccess?: () => void; }) { diff --git a/packages/client/src/hooks/useClientContext.tsx b/packages/client/src/hooks/useClientContext.tsx index 4b15607c6..97601142c 100644 --- a/packages/client/src/hooks/useClientContext.tsx +++ b/packages/client/src/hooks/useClientContext.tsx @@ -1,5 +1,5 @@ import { AlphaClient } from '@oscartbeaumont-sd/rspc-client/v2'; -import { createContext, PropsWithChildren, useContext, useEffect, useMemo } from 'react'; +import { createContext, PropsWithChildren, useContext, useMemo } from 'react'; import { LibraryConfigWrapped, Procedures } from '../core'; import { valtioPersist } from '../lib';