diff --git a/apps/mobile/src/components/explorer/FileItem.tsx b/apps/mobile/src/components/explorer/FileItem.tsx index c583a36c33ed..62d30d29d6f5 100644 --- a/apps/mobile/src/components/explorer/FileItem.tsx +++ b/apps/mobile/src/components/explorer/FileItem.tsx @@ -1,9 +1,10 @@ +import { ExplorerItem, Tag, getItemFilePath, getItemObject } from '@sd/client'; import { Text, View } from 'react-native'; -import { ExplorerItem, getItemFilePath } from '@sd/client'; import Layout from '~/constants/Layout'; import { tw, twStyle } from '~/lib/tailwind'; import { getExplorerStore } from '~/stores/explorerStore'; +import { useMemo } from 'react'; import FileThumb from './FileThumb'; type FileItemProps = { @@ -14,6 +15,13 @@ const FileItem = ({ data }: FileItemProps) => { const gridItemSize = Layout.window.width / getExplorerStore().gridNumColumns; const filePath = getItemFilePath(data); + const object = getItemObject(data); + + const maxTags = 3; + const tags = useMemo(() => { + if (!object) return []; + return 'tags' in object ? object.tags.slice(0, maxTags) : []; + }, [object]); return ( { {filePath?.extension && `.${filePath.extension}`} + + {tags.map(({tag}: {tag: Tag}, idx: number) => { + return ( + + ) + })} + ); }; diff --git a/apps/mobile/src/components/explorer/FileRow.tsx b/apps/mobile/src/components/explorer/FileRow.tsx index 2e8cdcf4be2b..d376c0177e55 100644 --- a/apps/mobile/src/components/explorer/FileRow.tsx +++ b/apps/mobile/src/components/explorer/FileRow.tsx @@ -1,5 +1,5 @@ -import { ExplorerItem, getItemFilePath } from '@sd/client'; -import React from 'react'; +import { ExplorerItem, Tag, getItemFilePath, getItemObject } from '@sd/client'; +import React, { useMemo } from 'react'; import { Text, View } from 'react-native'; import { tw, twStyle } from '~/lib/tailwind'; import { getExplorerStore } from '~/stores/explorerStore'; @@ -12,21 +12,47 @@ type FileRowProps = { const FileRow = ({ data }: FileRowProps) => { const filePath = getItemFilePath(data); + const object = getItemObject(data); + + const maxTags = 3; + const tags = useMemo(() => { + if (!object) return []; + return 'tags' in object ? object.tags.slice(0, maxTags) : []; + }, [object]); return ( + <> - - + + + {filePath?.name} {filePath?.extension && `.${filePath.extension}`} + + {tags.map(({tag}: {tag: Tag}, idx: number) => { + return ( + + ) + })} + + + ); }; diff --git a/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx b/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx index 234ab84b5c12..a8782d7c55dc 100644 --- a/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx +++ b/apps/mobile/src/components/explorer/sections/InfoTagPills.tsx @@ -6,48 +6,100 @@ import { isPath, useLibraryQuery } from '@sd/client'; -import React from 'react'; -import { Alert, Pressable, View, ViewStyle } from 'react-native'; +import React, { useRef, useState } from 'react'; +import { FlatList, NativeScrollEvent, Pressable, View, ViewStyle } from 'react-native'; +import Fade from '~/components/layout/Fade'; +import { ModalRef } from '~/components/layout/Modal'; +import AddTagModal from '~/components/modal/AddTagModal'; import { InfoPill, PlaceholderPill } from '~/components/primitive/InfoPill'; import { tw, twStyle } from '~/lib/tailwind'; type Props = { data: ExplorerItem; style?: ViewStyle; + contentContainerStyle?: ViewStyle; + columnCount?: number; }; -const InfoTagPills = ({ data, style }: Props) => { +const InfoTagPills = ({ data, style, contentContainerStyle, columnCount = 3 }: Props) => { + const objectData = getItemObject(data); const filePath = getItemFilePath(data); + const [startedScrolling, setStartedScrolling] = useState(false); + const [reachedBottom, setReachedBottom] = useState(true); // needs to be set to true for initial rendering fade to be correct const tagsQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1], { - enabled: objectData != null + enabled: objectData != null, }); - const items = tagsQuery.data; + const ref = useRef(null); + const tags = tagsQuery.data; const isDir = data && isPath(data) ? data.item.is_dir : false; + // Fade the tag pills when scrolling + const fadeScroll = ({ layoutMeasurement, contentOffset, contentSize }: NativeScrollEvent) => { + const isScrolling = contentOffset.y > 0; + setStartedScrolling(isScrolling); + + const hasReachedBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height; + setReachedBottom(hasReachedBottom); + } + return ( - - {/* Kind */} - + <> + + + ref.current?.present()}> + + + {/* Kind */} + {/* Extension */} {filePath?.extension && ( - + )} - {/* TODO: What happens if I have too many? */} - {items?.map((tag) => ( - - ))} - Alert.alert('TODO')}> - - + + { + if (e.nativeEvent.layout.height >= 80) { + setReachedBottom(false); + } else { + setReachedBottom(true); + } + }} style={twStyle(`relative flex-row flex-wrap gap-1 overflow-hidden`)}> + + fadeScroll(e.nativeEvent)} + style={tw`max-h-20 w-full grow-0`} + data={tags} + scrollEventThrottle={1} + showsVerticalScrollIndicator={false} + numColumns={columnCount} + contentContainerStyle={twStyle(`gap-1`, contentContainerStyle)} + columnWrapperStyle={tags && twStyle(tags.length > 0 && `flex-wrap gap-1`)} + key={tags?.length} + keyExtractor={(item) => item.id.toString() + Math.floor(Math.random() * 10)} + renderItem={({ item }) => ( + + )}/> + + + + ); }; diff --git a/apps/mobile/src/components/header/DynamicHeader.tsx b/apps/mobile/src/components/header/DynamicHeader.tsx index 2bab43ce4815..2c27fdea4ae1 100644 --- a/apps/mobile/src/components/header/DynamicHeader.tsx +++ b/apps/mobile/src/components/header/DynamicHeader.tsx @@ -7,12 +7,13 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { tw, twStyle } from '~/lib/tailwind'; import { getExplorerStore, useExplorerStore } from '~/stores/explorerStore'; +import { FilterItem, TagItem, useSearchStore } from '~/stores/searchStore'; import { Icon } from '../icons/Icon'; type Props = { headerRoute?: NativeStackHeaderProps; //supporting title from the options object of navigation optionsRoute?: RouteProp; //supporting params passed - kind: 'tag' | 'location'; //the kind of icon to display + kind: 'tags' | 'locations'; //the kind of icon to display explorerMenu?: boolean; //whether to show the explorer menu }; @@ -26,6 +27,28 @@ export default function DynamicHeader({ const headerHeight = useSafeAreaInsets().top; const isAndroid = Platform.OS === 'android'; const explorerStore = useExplorerStore(); + const searchStore = useSearchStore(); + const params = headerRoute?.route.params as { + id: number; + color: string; + name: string; + } + + //pressing the search icon will add a filter + //based on the screen + + const searchHandler = (key: Props['kind']) => { + if (!params) return; + const keys: { + tags: TagItem; + locations: FilterItem; + } = { + tags: {id: params.id, color: params.color}, + locations: {id: params.id, name: params.name}, + } + searchStore.searchFrom(key, keys[key]) + } + return ( { + searchHandler(kind) navigation.navigate('SearchStack', { screen: 'Search' }); @@ -94,12 +118,12 @@ interface HeaderIconKindProps { const HeaderIconKind = ({ routeParams, kind }: HeaderIconKindProps) => { switch (kind) { - case 'location': + case 'locations': return ; - case 'tag': + case 'tags': return ( diff --git a/apps/mobile/src/components/locations/ListLocation.tsx b/apps/mobile/src/components/locations/ListLocation.tsx index 36774aa3b9df..90fc66b8b4da 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, humanizeSize, useOnlineLocations } from '@sd/client'; import { DotsThreeVertical } from 'phosphor-react-native'; import { useRef } from 'react'; import { Pressable, Text, View } from 'react-native'; import { Swipeable } from 'react-native-gesture-handler'; -import { arraysEqual, humanizeSize, Location, useOnlineLocations } from '@sd/client'; import { tw, twStyle } from '~/lib/tailwind'; import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack'; @@ -25,7 +25,7 @@ const ListLocation = ({ location }: ListLocationProps) => { return ( ( <> diff --git a/apps/mobile/src/components/modal/AddTagModal.tsx b/apps/mobile/src/components/modal/AddTagModal.tsx new file mode 100644 index 000000000000..463e151431f5 --- /dev/null +++ b/apps/mobile/src/components/modal/AddTagModal.tsx @@ -0,0 +1,177 @@ +import { Tag, getItemObject, useLibraryMutation, useLibraryQuery, useRspcContext } from "@sd/client"; +import { CaretLeft, Plus } from "phosphor-react-native"; +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FlatList, Pressable, Text, View } from "react-native"; +import useForwardedRef from "~/hooks/useForwardedRef"; +import { tw, twStyle } from "~/lib/tailwind"; +import { useActionsModalStore } from "~/stores/modalStore"; +import Card from "../layout/Card"; +import { Modal, ModalRef } from "../layout/Modal"; +import { Button } from "../primitive/Button"; +import CreateTagModal from "./tag/CreateTagModal"; + + +const AddTagModal = forwardRef((_, ref) => { + + const {data} = useActionsModalStore(); + + // Wrapped in memo to ensure that the data is not undefined on initial render + const objectData = data && getItemObject(data); + + const modalRef = useForwardedRef(ref); + const newTagRef = useRef(null); + + const rspc = useRspcContext(); + const tagsQuery = useLibraryQuery(['tags.list']); + const tagsObjectQuery = useLibraryQuery(['tags.getForObject', objectData?.id ?? -1]); + const mutation = useLibraryMutation(['tags.assign'], { + onSuccess: () => { + // this makes sure that the tags are updated in the UI + rspc.queryClient.invalidateQueries(['tags.getForObject']) + rspc.queryClient.invalidateQueries(['search.paths']) + modalRef.current?.dismiss(); + } + }); + + const tagsData = tagsQuery.data; + const tagsObject = tagsObjectQuery.data; + + const [selectedTags, setSelectedTags] = useState<{ + id: number; + unassign: boolean; + selected: boolean; + }[]>([]); + + // get the tags that are already applied to the object + const appliedTags = useMemo(() => { + if (!tagsObject) return []; + return tagsObject?.map((t) => t.id); + }, [tagsObject]); + + + // set selected tags when tagsOfObject.data is available + useEffect(() => { + if (!tagsObject) return; + //we want to set the selectedTags if there are applied tags + //this deals with an edge case of clearing the tags onDismiss of the Modal + if (selectedTags.length === 0 && appliedTags.length > 0) { + setSelectedTags((tagsObject ?? []).map((tag) => ({ + id: tag.id, + unassign: false, + selected: true + })))} + }, [tagsObject, appliedTags, selectedTags]) + + // check if tag is selected + const isSelected = useCallback((id: number) => { + const findTag = selectedTags.find((t) => t.id === id); + return findTag?.selected ?? false; + }, [selectedTags]); + + const selectTag = useCallback((id: number) => { + //check if tag is already selected + const findTag = selectedTags.find((t) => t.id === id); + if (findTag) { + //if tag is already selected, update its selected value + setSelectedTags((prev) => prev.map((t) => t.id === id ? { ...t, selected: !t.selected, unassign: !t.unassign } : t)); + } else { + //if tag is not selected, select it + setSelectedTags((prev) => [...prev, { id, unassign: false, selected: true }]); + } + }, [selectedTags]); + + const assignHandler = async () => { + const targets = data && 'id' in data.item && (data.type === 'Object' ? { + Object: data.item.id + } : { + FilePath: data.item.id + }); + + // in order to support assigning multiple tags + // we need to make multiple mutation calls + if (targets) await Promise.all([...selectedTags.map(async (tag) => await mutation.mutateAsync({ + targets: [targets], + tag_id: tag.id, + unassign: tag.unassign + })), + ] + ); + } + + return ( + <> + setSelectedTags([])} + enableContentPanningGesture={false} + enablePanDownToClose={false} + snapPoints={['50']} + title="Select Tags" + > + {/* Back Button */} + modalRef.current?.close()} + style={tw`absolute z-10 ml-6 rounded-full bg-app-button p-2`} + > + + + item.id.toString()} + contentContainerStyle={tw`mx-auto mt-4 p-4 pb-10`} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + isSelected(item.id)} select={() => selectTag(item.id)} tag={item} /> + )} + /> + + + + + + + + ) +}); + +interface Props { + tag: Tag; + select: () => void; + isSelected: () => boolean; +} + +const TagItem = ({tag, select, isSelected}: Props) => { + return ( + + + + {tag?.name} + + + ) +} + +export default AddTagModal; diff --git a/apps/mobile/src/components/modal/inspector/ActionsModal.tsx b/apps/mobile/src/components/modal/inspector/ActionsModal.tsx index a6a228a633c0..b0dcd79ee4cf 100644 --- a/apps/mobile/src/components/modal/inspector/ActionsModal.tsx +++ b/apps/mobile/src/components/modal/inspector/ActionsModal.tsx @@ -1,3 +1,10 @@ +import { + getIndexedItemFilePath, + getItemObject, + humanizeSize, + useLibraryMutation, + useLibraryQuery +} from '@sd/client'; import dayjs from 'dayjs'; import { Copy, @@ -13,13 +20,6 @@ import { import { PropsWithChildren, useRef } from 'react'; import { Pressable, Text, View, ViewStyle } from 'react-native'; import FileViewer from 'react-native-file-viewer'; -import { - getIndexedItemFilePath, - getItemObject, - humanizeSize, - useLibraryMutation, - useLibraryQuery -} from '@sd/client'; import FileThumb from '~/components/explorer/FileThumb'; import FavoriteButton from '~/components/explorer/sections/FavoriteButton'; import InfoTagPills from '~/components/explorer/sections/InfoTagPills'; @@ -72,7 +72,6 @@ export const ActionsModal = () => { const filePath = data && getIndexedItemFilePath(data); // Open - const updateAccessTime = useLibraryMutation('files.updateAccessTime'); const queriedFullPath = useLibraryQuery(['files.getPath', filePath?.id ?? -1], { enabled: filePath != null @@ -100,7 +99,7 @@ export const ActionsModal = () => { {data && ( - + {/* Thumbnail/Icon */} { {/* Name + Extension */} {filePath?.name} @@ -128,9 +127,9 @@ export const ActionsModal = () => { - {objectData && } + {objectData && } - + {/* Actions */} diff --git a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx index 1bd33253199c..21ef7955e9eb 100644 --- a/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx +++ b/apps/mobile/src/components/modal/inspector/FileInfoModal.tsx @@ -1,11 +1,12 @@ +import { getItemFilePath, humanizeSize, type ExplorerItem } from '@sd/client'; import dayjs from 'dayjs'; import { Barcode, CaretLeft, Clock, Cube, Icon, SealCheck, Snowflake } from 'phosphor-react-native'; import { forwardRef } from 'react'; import { Pressable, Text, View } from 'react-native'; -import { getItemFilePath, humanizeSize, type ExplorerItem } from '@sd/client'; import FileThumb from '~/components/explorer/FileThumb'; import InfoTagPills from '~/components/explorer/sections/InfoTagPills'; import { Modal, ModalScrollView, type ModalRef } from '~/components/layout/Modal'; +import VirtualizedListWrapper from '~/components/layout/VirtualizedListWrapper'; import { Divider } from '~/components/primitive/Divider'; import useForwardedRef from '~/hooks/useForwardedRef'; import { tw } from '~/lib/tailwind'; @@ -49,25 +50,26 @@ const FileInfoModal = forwardRef((props, ref) => { enablePanDownToClose={false} snapPoints={['70']} > + {data && ( - + {/* Back Button */} modalRef.current?.close()} - style={tw`absolute z-10 ml-4`} + style={tw`absolute left-2 z-10 rounded-full bg-app-button p-2`} > - + - {/* File Icon / Name */} + {/* File Icon / Name */} - + {filePathData?.name} - + {/* Details */} - + <> {/* Size */} ((props, ref) => { )} + ); }); diff --git a/apps/mobile/src/components/overview/OverviewStats.tsx b/apps/mobile/src/components/overview/OverviewStats.tsx index 967e0c85e31a..da17196297e3 100644 --- a/apps/mobile/src/components/overview/OverviewStats.tsx +++ b/apps/mobile/src/components/overview/OverviewStats.tsx @@ -1,10 +1,10 @@ import * as RNFS from '@dr.pogodin/react-native-fs'; import { AlphaRSPCError } from '@oscartbeaumont-sd/rspc-client/v2'; +import { Statistics, StatisticsResponse, humanizeSize, useLibraryContext } from '@sd/client'; import { UseQueryResult } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { Platform, Text, View } from 'react-native'; import { ClassInput } from 'twrnc/dist/esm/types'; -import { humanizeSize, Statistics, StatisticsResponse, useLibraryContext } from '@sd/client'; import useCounter from '~/hooks/useCounter'; import { tw, twStyle } from '~/lib/tailwind'; @@ -13,6 +13,7 @@ import Card from '../layout/Card'; const StatItemNames: Partial> = { total_local_bytes_capacity: 'Total capacity', total_library_preview_media_bytes: 'Preview media', + total_library_bytes: 'Total library size', library_db_size: 'Index size', total_local_bytes_free: 'Free space', total_local_bytes_used: 'Total used space' @@ -76,19 +77,18 @@ const OverviewStats = ({ stats }: Props) => { }, []); const renderStatItems = (isTotalStat = true) => { + const keysToFilter = ['total_local_bytes_capacity', 'total_local_bytes_used', 'total_library_bytes']; if (!stats.data?.statistics) return null; return Object.entries(stats.data.statistics).map(([key, bytesRaw]) => { if (!displayableStatItems.includes(key)) return null; - if (isTotalStat && !['total_bytes_capacity', 'total_bytes_used'].includes(key)) - return null; - if (!isTotalStat && ['total_bytes_capacity', 'total_bytes_used'].includes(key)) - return null; let bytes = BigInt(bytesRaw ?? 0); - if (key === 'total_bytes_free') { + if (isTotalStat && !keysToFilter.includes(key)) return null; + if (!isTotalStat && keysToFilter.includes(key)) return null; + if (key === 'total_local_bytes_free') { bytes = BigInt(sizeInfo.freeSpace); - } else if (key === 'total_bytes_capacity') { + } else if (key === 'total_local_bytes_capacity') { bytes = BigInt(sizeInfo.totalSpace); - } else if (key === 'total_bytes_used' && Platform.OS === 'android') { + } else if (key === 'total_local_bytes_used' && Platform.OS === 'android') { bytes = BigInt(sizeInfo.totalSpace - sizeInfo.freeSpace); } return ( @@ -97,7 +97,7 @@ const OverviewStats = ({ stats }: Props) => { title={StatItemNames[key as keyof Statistics]!} bytes={bytes} isLoading={stats.isLoading} - style={twStyle(isTotalStat && 'h-[101px]', 'w-full flex-1')} + style={tw`w-full`} /> ); }); @@ -106,11 +106,11 @@ const OverviewStats = ({ stats }: Props) => { return ( Statistics - - + + {renderStatItems()} - + {renderStatItems(false)} diff --git a/apps/mobile/src/components/primitive/InfoPill.tsx b/apps/mobile/src/components/primitive/InfoPill.tsx index 865b283cde96..bd01f4bb2c49 100644 --- a/apps/mobile/src/components/primitive/InfoPill.tsx +++ b/apps/mobile/src/components/primitive/InfoPill.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import { IconProps } from 'phosphor-react-native'; +import React, { ReactElement } from 'react'; import { Text, TextStyle, View, ViewStyle } from 'react-native'; import { twStyle } from '~/lib/tailwind'; @@ -6,13 +7,14 @@ type Props = { text: string; containerStyle?: ViewStyle; textStyle?: TextStyle; + icon?: ReactElement }; export const InfoPill = (props: Props) => { return ( @@ -27,11 +29,12 @@ export function PlaceholderPill(props: Props) { return ( - + {props.icon && props.icon} + {props.text} diff --git a/apps/mobile/src/components/search/filters/Locations.tsx b/apps/mobile/src/components/search/filters/Locations.tsx index 3ea6f753c1f5..8d812803a542 100644 --- a/apps/mobile/src/components/search/filters/Locations.tsx +++ b/apps/mobile/src/components/search/filters/Locations.tsx @@ -32,14 +32,13 @@ const Locations = () => { /> - + } numColumns={ locations ? Math.max(Math.ceil(locations.length / 2), 2) : 1 } - contentContainerStyle={tw`w-full`} ListEmptyComponent={ { title="Tags" sub="What tags would you like to filter by?" /> - - + } extraData={searchStore.filters.tags} + alwaysBounceVertical={false} numColumns={tagsData ? Math.max(Math.ceil(tagsData.length / 2), 2) : 1} key={tagsData ? 'tagsSearch' : '_'} - contentContainerStyle={tw`w-full`} - ListEmptyComponent={ - - } - scrollEnabled={false} + ListEmptyComponent={} ItemSeparatorComponent={() => } keyExtractor={(item) => item.id.toString()} showsHorizontalScrollIndicator={false} @@ -51,7 +47,6 @@ const Tags = () => { /> - ); }; diff --git a/apps/mobile/src/components/tags/GridTag.tsx b/apps/mobile/src/components/tags/GridTag.tsx index 364b3015c276..98fb764fb463 100644 --- a/apps/mobile/src/components/tags/GridTag.tsx +++ b/apps/mobile/src/components/tags/GridTag.tsx @@ -1,6 +1,6 @@ +import { Tag } from '@sd/client'; import { DotsThreeOutlineVertical } from 'phosphor-react-native'; import { Pressable, Text, View } from 'react-native'; -import { Tag } from '@sd/client'; import { tw, twStyle } from '~/lib/tailwind'; import Card from '../layout/Card'; diff --git a/apps/mobile/src/components/tags/ListTag.tsx b/apps/mobile/src/components/tags/ListTag.tsx index 93b8f65db0a2..d7e0e0441f40 100644 --- a/apps/mobile/src/components/tags/ListTag.tsx +++ b/apps/mobile/src/components/tags/ListTag.tsx @@ -19,16 +19,16 @@ const ListTag = ({ tag, tagStyle }: ListTagProps) => { return ( ( )} > - + diff --git a/apps/mobile/src/navigation/SearchStack.tsx b/apps/mobile/src/navigation/SearchStack.tsx index e014259c4620..84139f37df8f 100644 --- a/apps/mobile/src/navigation/SearchStack.tsx +++ b/apps/mobile/src/navigation/SearchStack.tsx @@ -32,7 +32,7 @@ export default function SearchStack() { name="Location" component={LocationScreen} options={({route: optionsRoute}) => ({ - header: (route) => + header: (route) => })} /> diff --git a/apps/mobile/src/navigation/tabs/BrowseStack.tsx b/apps/mobile/src/navigation/tabs/BrowseStack.tsx index 61080de23df8..50ca85a848f5 100644 --- a/apps/mobile/src/navigation/tabs/BrowseStack.tsx +++ b/apps/mobile/src/navigation/tabs/BrowseStack.tsx @@ -34,7 +34,7 @@ export default function BrowseStack() { ) })} @@ -58,7 +58,7 @@ export default function BrowseStack() { component={TagScreen} options={({ route: optionsRoute }) => ({ header: (route) => ( - + ) })} /> @@ -75,7 +75,7 @@ export default function BrowseStack() { export type BrowseStackParamList = { Browse: undefined; - Location: { id: number; path?: string }; + Location: { id: number; path?: string, name?: string }; Locations: undefined; Tag: { id: number; color: string }; Tags: undefined; diff --git a/apps/mobile/src/screens/browse/Location.tsx b/apps/mobile/src/screens/browse/Location.tsx index 944f1b2e1636..9c26c1a5a4d6 100644 --- a/apps/mobile/src/screens/browse/Location.tsx +++ b/apps/mobile/src/screens/browse/Location.tsx @@ -1,6 +1,7 @@ import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import Explorer from '~/components/explorer/Explorer'; +import { useSortBy } from '~/hooks/useSortBy'; import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack'; import { getExplorerStore } from '~/stores/explorerStore'; @@ -9,11 +10,17 @@ export default function LocationScreen({ navigation, route }: BrowseStackScreenP const location = useLibraryQuery(['locations.get', route.params.id]); const locationData = location.data; + const order = useSortBy(); + const title = useMemo(() => { + return path?.split('/') + .filter((x) => x !== '') + .pop(); + }, [path]) const paths = usePathsExplorerQuery({ arg: { filters: [ - // ...search.allFilters, + { filePath: { hidden: false }}, { filePath: { locations: { in: [id] } } }, { filePath: { @@ -21,18 +28,13 @@ export default function LocationScreen({ navigation, route }: BrowseStackScreenP location_id: id, path: path ?? '', include_descendants: false - // include_descendants: - // search.search !== '' || - // search.dynamicFilters.length > 0 || - // (layoutMode === 'media' && mediaViewWithDescendants) } } } - // !showHiddenFiles && { filePath: { hidden: false } } ].filter(Boolean) as any, take: 30 }, - order: null, + order, onSuccess: () => getExplorerStore().resetNewThumbnails() }); @@ -41,17 +43,19 @@ export default function LocationScreen({ navigation, route }: BrowseStackScreenP if (path && path !== '') { // Nested location. navigation.setOptions({ - title: path - .split('/') - .filter((x) => x !== '') - .pop() + title }); } else { navigation.setOptions({ title: locationData?.name ?? 'Location' }); } - }, [locationData?.name, navigation, path]); + // sets params for handling when clicking on search within header + navigation.setParams({ + id: id, + name: locationData?.name ?? 'Location' + }) + }, [id, locationData?.name, navigation, path, title]); useEffect(() => { getExplorerStore().locationId = id; diff --git a/apps/mobile/src/screens/browse/Locations.tsx b/apps/mobile/src/screens/browse/Locations.tsx index 3efc40b68868..0afe969f8c3e 100644 --- a/apps/mobile/src/screens/browse/Locations.tsx +++ b/apps/mobile/src/screens/browse/Locations.tsx @@ -46,6 +46,7 @@ export default function LocationsScreen({ viewStyle }: Props) { > + )} /> + ); diff --git a/apps/mobile/src/screens/browse/Tag.tsx b/apps/mobile/src/screens/browse/Tag.tsx index 9c2281ab51e7..012beb9ca759 100644 --- a/apps/mobile/src/screens/browse/Tag.tsx +++ b/apps/mobile/src/screens/browse/Tag.tsx @@ -1,4 +1,4 @@ -import { useLibraryQuery, useObjectsExplorerQuery } from '@sd/client'; +import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client'; import { useEffect } from 'react'; import Explorer from '~/components/explorer/Explorer'; import Empty from '~/components/layout/Empty'; @@ -11,17 +11,26 @@ export default function TagScreen({ navigation, route }: BrowseStackScreenProps< const tag = useLibraryQuery(['tags.get', id]); const tagData = tag.data; - const objects = useObjectsExplorerQuery({ - arg: { filters: [{ object: { tags: { in: [id] } } }], take: 30 }, + const objects = usePathsExplorerQuery({ + arg: { filters: [ + { object: { tags: { in: [id] } } }, + ], take: 30 }, + enabled: typeof id === 'number', order: null }); useEffect(() => { // Set screen title to tag name. - navigation.setOptions({ - title: tagData?.name ?? 'Tag' - }); - }, [tagData?.name, navigation]); + if (tagData) { + navigation.setParams({ + id: tagData.id, + color: tagData.color as string + }) + navigation.setOptions({ + title: tagData.name ?? 'Tag', + }); + } + }, [tagData, id, navigation]); return + ( @@ -75,10 +76,11 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) { showsHorizontalScrollIndicator={false} ItemSeparatorComponent={() => } contentContainerStyle={twStyle( - `py-6`, + 'py-6', tagsData?.length === 0 && 'h-full items-center justify-center' )} /> + ); diff --git a/apps/mobile/src/screens/search/Filters.tsx b/apps/mobile/src/screens/search/Filters.tsx index bed24f49200d..1de25e88f502 100644 --- a/apps/mobile/src/screens/search/Filters.tsx +++ b/apps/mobile/src/screens/search/Filters.tsx @@ -6,7 +6,7 @@ const FiltersScreen = () => { return ( <> - + diff --git a/apps/mobile/src/stores/explorerStore.ts b/apps/mobile/src/stores/explorerStore.ts index ffd26b453846..ca9bc9efad2f 100644 --- a/apps/mobile/src/stores/explorerStore.ts +++ b/apps/mobile/src/stores/explorerStore.ts @@ -1,6 +1,6 @@ +import { resetStore } from '@sd/client'; import { proxy, useSnapshot } from 'valtio'; import { proxySet } from 'valtio/utils'; -import { resetStore, type Ordering } from '@sd/client'; export type ExplorerLayoutMode = 'list' | 'grid' | 'media'; @@ -17,7 +17,7 @@ const state = { toggleMenu: false as boolean, // Using gridNumColumns instead of fixed size. We dynamically calculate the item size. gridNumColumns: 3, - listItemSize: 65, + listItemSize: 60, newThumbnails: proxySet() as Set, // sorting // we will display different sorting options based on the kind of explorer we are in diff --git a/apps/mobile/src/stores/modalStore.ts b/apps/mobile/src/stores/modalStore.ts index 12d46c2cb605..dcd87151b70b 100644 --- a/apps/mobile/src/stores/modalStore.ts +++ b/apps/mobile/src/stores/modalStore.ts @@ -1,6 +1,6 @@ +import { ExplorerItem } from '@sd/client'; import { createRef } from 'react'; import { proxy, ref, useSnapshot } from 'valtio'; -import { ExplorerItem } from '@sd/client'; import { ModalRef } from '~/components/layout/Modal'; const store = proxy({ diff --git a/apps/mobile/src/stores/searchStore.ts b/apps/mobile/src/stores/searchStore.ts index e8131364cab0..1ae39ec204c9 100644 --- a/apps/mobile/src/stores/searchStore.ts +++ b/apps/mobile/src/stores/searchStore.ts @@ -1,5 +1,5 @@ -import { proxy, useSnapshot } from 'valtio'; import { SearchFilterArgs } from '@sd/client'; +import { proxy, useSnapshot } from 'valtio'; import { IconName } from '~/components/icons/Icon'; export type SearchFilters = 'locations' | 'tags' | 'name' | 'extension' | 'hidden' | 'kind'; @@ -74,7 +74,7 @@ function updateArrayOrObject( array: T[], item: any, filterByKey: string = 'id', - isObject: boolean = false + isObject: boolean = false, ): T[] { if (isObject) { const index = (array as any).findIndex((i: any) => i.id === item[filterByKey]); @@ -94,8 +94,10 @@ const searchStore = proxy< updateFilters: ( filter: K, value: State['filters'][K] extends Array ? U : State['filters'][K], - apply?: boolean + apply?: boolean, + keepSame?: boolean ) => void; + searchFrom: (filter: 'tags' | 'locations', value: TagItem | FilterItem) => void; applyFilters: () => void; setSearch: (search: string) => void; resetFilter: (filter: K, apply?: boolean) => void; @@ -108,13 +110,15 @@ const searchStore = proxy< ...initialState, //for updating the filters upon value selection updateFilters: (filter, value, apply = false) => { + const currentFilter = searchStore.filters[filter]; + const arrayCheck = Array.isArray(currentFilter); + if (filter === 'hidden') { // Directly assign boolean values without an array operation searchStore.filters['hidden'] = value as boolean; } else { // Handle array-based filters with more specific type handling - const currentFilter = searchStore.filters[filter]; - if (Array.isArray(currentFilter)) { + if (arrayCheck) { // Cast to the correct type based on the filter being updated const updatedFilter = updateArrayOrObject( currentFilter, @@ -129,6 +133,21 @@ const searchStore = proxy< // useful when you want to apply the filters from another screen if (apply) searchStore.applyFilters(); }, + searchFrom: (filter, value) => { + //reset state first + searchStore.resetFilters(); + //update the filter with the value + switch (filter) { + case 'locations': + searchStore.filters[filter] = [value] as FilterItem[] + break; + case 'tags': + searchStore.filters[filter] = [value] as TagItem[] + break; + } + //apply the filters so it shows in the UI + searchStore.applyFilters(); + }, //for clicking add filters and applying the selection applyFilters: () => { // loop through all filters and apply the ones with values diff --git a/core/crates/prisma-helpers/src/lib.rs b/core/crates/prisma-helpers/src/lib.rs index a2748c9afc9e..d562ab534952 100644 --- a/core/crates/prisma-helpers/src/lib.rs +++ b/core/crates/prisma-helpers/src/lib.rs @@ -138,8 +138,10 @@ file_path::select!(file_path_to_full_path { }); // File Path includes! -file_path::include!(file_path_with_object { +file_path::include!(file_path_with_object { object }); +file_path::include!(file_path_for_frontend { object: include { + tags: include { tag } exif_data: select { resolution media_date diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index a6269f210f92..24d56e52b699 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -13,7 +13,7 @@ use crate::{ use sd_core_indexer_rules::IndexerRuleCreateArgs; use sd_core_prisma_helpers::{ - file_path_with_object, label_with_objects, location_with_indexer_rules, object_with_file_paths, + file_path_for_frontend, label_with_objects, location_with_indexer_rules, object_with_file_paths, }; use sd_prisma::prisma::{file_path, indexer_rule, indexer_rules_in_location, location, SortOrder}; @@ -42,7 +42,7 @@ pub enum ExplorerItem { // this tells the frontend if a thumbnail actually exists or not has_created_thumbnail: bool, // we can't actually modify data from PCR types, thats why computed properties are used on ExplorerItem - item: Box, + item: Box, }, Object { thumbnail: Option, diff --git a/core/src/api/search/mod.rs b/core/src/api/search/mod.rs index c203431ce607..a17d97997ebd 100644 --- a/core/src/api/search/mod.rs +++ b/core/src/api/search/mod.rs @@ -7,7 +7,7 @@ use crate::{ }; use prisma_client_rust::Operator; -use sd_core_prisma_helpers::{file_path_with_object, object_with_file_paths}; +use sd_core_prisma_helpers::{file_path_for_frontend, object_with_file_paths}; use sd_prisma::prisma::{self, PrismaClient}; use std::path::PathBuf; @@ -210,7 +210,7 @@ pub fn mount() -> AlphaRouter { } let file_paths = query - .include(file_path_with_object::include()) + .include(file_path_for_frontend::include()) .exec() .await?; diff --git a/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx b/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx index c4a82643611f..8c80dc54b608 100644 --- a/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx +++ b/interface/app/$libraryId/Explorer/ContextMenu/AssignTagMenuItems.tsx @@ -1,11 +1,11 @@ import { Plus } from '@phosphor-icons/react'; +import { ExplorerItem, useLibraryQuery } from '@sd/client'; +import { Button, ModifierKeys, dialogManager, tw } from '@sd/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import clsx from 'clsx'; import { RefObject, useMemo, useRef } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { ExplorerItem, useLibraryQuery } from '@sd/client'; -import { Button, dialogManager, ModifierKeys, tw } from '@sd/ui'; import CreateDialog, { AssignTagItems, useAssignItemsToTag @@ -38,6 +38,7 @@ function useData({ items }: Props) { { suspense: true } ); + return { tags: { ...tags, diff --git a/interface/app/$libraryId/Explorer/Inspector/index.tsx b/interface/app/$libraryId/Explorer/Inspector/index.tsx index 51ea58aba079..ea94f8c8fb12 100644 --- a/interface/app/$libraryId/Explorer/Inspector/index.tsx +++ b/interface/app/$libraryId/Explorer/Inspector/index.tsx @@ -12,23 +12,9 @@ import { Icon as PhosphorIcon, Snowflake } from '@phosphor-icons/react'; -import clsx from 'clsx'; -import dayjs from 'dayjs'; -import { - forwardRef, - useCallback, - useEffect, - useMemo, - useState, - type HTMLAttributes, - type ReactNode -} from 'react'; -import { useLocation } from 'react-router'; -import { Link as NavLink } from 'react-router-dom'; -import Sticky from 'react-sticky-el'; import { FilePath, - FilePathWithObject, + FilePathForFrontend, getExplorerItemData, getItemFilePath, humanizeSize, @@ -42,6 +28,20 @@ import { type ExplorerItem } from '@sd/client'; import { Button, Divider, DropdownMenu, toast, Tooltip, tw } from '@sd/ui'; +import clsx from 'clsx'; +import dayjs from 'dayjs'; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useState, + type HTMLAttributes, + type ReactNode +} from 'react'; +import { useLocation } from 'react-router'; +import { Link as NavLink } from 'react-router-dom'; +import Sticky from 'react-sticky-el'; import { LibraryIdParamsSchema } from '~/app/route-schemas'; import { Folder, Icon } from '~/components'; import { useLocale, useZodRouteParams } from '~/hooks'; @@ -171,7 +171,7 @@ const Thumbnails = ({ items }: { items: ExplorerItem[] }) => { export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => { let objectData: Object | ObjectWithFilePaths | null = null; - let filePathData: FilePath | FilePathWithObject | null = null; + let filePathData: FilePath | FilePathForFrontend | null = null; let ephemeralPathData: NonIndexedPathItem | null = null; const { t, dateFormat } = useLocale(); @@ -484,7 +484,6 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { (metadata, item) => { const { kind, size, dateCreated, dateAccessed, dateModified, dateIndexed } = getExplorerItemData(item); - if (item.type !== 'NonIndexedPath' || !item.item.is_dir) { metadata.size = (metadata.size ?? 0n) + size.bytes; } @@ -527,6 +526,7 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => { const onlyNonIndexed = metadata.types.has('NonIndexedPath') && metadata.types.size === 1; const filesSize = humanizeSize(metadata.size); + return ( <> diff --git a/interface/app/$libraryId/Explorer/View/Grid/Item.tsx b/interface/app/$libraryId/Explorer/View/Grid/Item.tsx index 390071052997..67bf05de07ae 100644 --- a/interface/app/$libraryId/Explorer/View/Grid/Item.tsx +++ b/interface/app/$libraryId/Explorer/View/Grid/Item.tsx @@ -1,8 +1,8 @@ +import { useSelector, type ExplorerItem } from '@sd/client'; import { HTMLAttributes, ReactNode, useMemo } from 'react'; import { useNavigate } from 'react-router'; -import { useSelector, type ExplorerItem } from '@sd/client'; -import { useOperatingSystem } from '~/hooks'; import { useRoutingContext } from '~/RoutingContext'; +import { useOperatingSystem } from '~/hooks'; import { useExplorerContext } from '../../Context'; import { explorerStore, isCut } from '../../store'; @@ -37,6 +37,7 @@ export const GridItem = ({ children, item, index, ...props }: Props) => { [explorer.selectedItems, item] ); + const canGoBack = currentIndex !== 0; const canGoForward = currentIndex !== maxIndex; diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index be57285d8b98..5c9c249a7200 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -265,7 +265,7 @@ export type ExifDataOrder = { field: "epochTime"; value: SortOrder } export type ExifMetadata = { resolution: Resolution; date_taken: MediaDate | null; location: MediaLocation | null; camera_data: CameraData; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } -export type ExplorerItem = { type: "Path"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: FilePathWithObject } | { type: "Object"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: ObjectWithFilePaths } | { type: "NonIndexedPath"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: NonIndexedPathItem } | { type: "Location"; item: Location } | { type: "SpacedropPeer"; item: PeerMetadata } | { type: "Label"; thumbnails: string[][]; item: LabelWithObjects } +export type ExplorerItem = { type: "Path"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: FilePathForFrontend } | { type: "Object"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: ObjectWithFilePaths } | { type: "NonIndexedPath"; thumbnail: string[] | null; has_created_thumbnail: boolean; item: NonIndexedPathItem } | { type: "Location"; item: Location } | { type: "SpacedropPeer"; item: PeerMetadata } | { type: "Label"; thumbnails: string[][]; item: LabelWithObjects } export type ExplorerLayout = "grid" | "list" | "media" @@ -291,14 +291,14 @@ export type FilePathCursorVariant = "none" | { name: CursorOrderItem } | export type FilePathFilterArgs = { locations: InOrNotIn } | { path: { location_id: number; path: string; include_descendants: boolean } } | { name: TextMatch } | { extension: InOrNotIn } | { createdAt: Range } | { modifiedAt: Range } | { indexedAt: Range } | { hidden: boolean } +export type FilePathForFrontend = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; tags: ({ object_id: number; tag_id: number; tag: Tag; date_created: string | null })[]; exif_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null } | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null } + export type FilePathObjectCursor = { dateAccessed: CursorOrderItem } | { kind: CursorOrderItem } export type FilePathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } | { field: "dateIndexed"; value: SortOrder } | { field: "object"; value: ObjectOrder } export type FilePathSearchArgs = { take?: number | null; orderAndPagination?: OrderAndPagination | null; filters?: SearchFilterArgs[]; groupDirectories?: boolean } -export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; object: { id: number; pub_id: number[]; kind: number | null; key_id: number | null; hidden: boolean | null; favorite: boolean | null; important: boolean | null; note: string | null; date_created: string | null; date_accessed: string | null; exif_data: { resolution: number[] | null; media_date: number[] | null; media_location: number[] | null; camera_data: number[] | null; artist: string | null; description: string | null; copyright: string | null; exif_version: string | null } | null } | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null } - export type Flash = { /** * Specifies how flash was used (on, auto, off, forced, onvalid)