mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-04 12:13:27 +00:00
[MOB-95] Tags support & more (#2494)
* tags support wip * Mob: tags in explorer, ui adjustments, add filter based on search click and more * Fix tags scroll on filters * Set heights so UI updates correctly * Update Tags.tsx * remove console logs * remove console logs * Update Locations.tsx * type * Update locations.rs * make tags abit smaller * list view visual improvements * Remove plus
This commit is contained in:
parent
c091ccacfd
commit
9d47af8bd1
|
@ -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 (
|
||||
<View
|
||||
|
@ -29,6 +37,21 @@ const FileItem = ({ data }: FileItemProps) => {
|
|||
{filePath?.extension && `.${filePath.extension}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={twStyle(`mx-auto flex-row justify-center pt-1.5`, {
|
||||
left: tags.length * 2 //for every tag we add 2px to the left
|
||||
})}>
|
||||
{tags.map(({tag}: {tag: Tag}, idx: number) => {
|
||||
return (
|
||||
<View
|
||||
key={tag.id}
|
||||
style={twStyle(`relative h-3.5 w-3.5 rounded-full border-2 border-black`, {
|
||||
backgroundColor: tag.color!,
|
||||
right: idx * 6,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
<View
|
||||
style={twStyle('flex flex-row items-center px-3', {
|
||||
height: getExplorerStore().listItemSize
|
||||
})}
|
||||
>
|
||||
<FileThumb data={data} size={0.6} />
|
||||
<View style={tw`ml-3 max-w-[80%]`}>
|
||||
<Text numberOfLines={1} style={tw`text-center text-xs font-medium text-ink-dull`}>
|
||||
<View style={tw`mx-2 flex-1 flex-row items-center justify-between border-b border-white/10 pb-3`}>
|
||||
<View style={tw`max-w-[80%]`}>
|
||||
<Text numberOfLines={1} style={tw`text-center text-sm font-medium text-ink`}>
|
||||
{filePath?.name}
|
||||
{filePath?.extension && `.${filePath.extension}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={twStyle(`mr-1 flex-row`, {
|
||||
left: tags.length * 6 //for every tag we add 2px to the left,
|
||||
})}>
|
||||
{tags.map(({tag}: {tag: Tag}, idx: number) => {
|
||||
return (
|
||||
<View
|
||||
key={tag.id}
|
||||
style={twStyle(`relative h-3.5 w-3.5 rounded-full border-2 border-black`, {
|
||||
backgroundColor: tag.color!,
|
||||
right: idx * 6,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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<ModalRef>(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 (
|
||||
<View style={twStyle('mt-1 flex flex-row flex-wrap', style)}>
|
||||
<>
|
||||
<View style={twStyle('mb-3 mt-2 flex-col flex-wrap items-start gap-1', style)}>
|
||||
<View style={tw`flex-row gap-1`}>
|
||||
<Pressable style={tw`relative z-10`} onPress={() => ref.current?.present()}>
|
||||
<PlaceholderPill
|
||||
text={'Tags'}
|
||||
/>
|
||||
</Pressable>
|
||||
{/* Kind */}
|
||||
<InfoPill containerStyle={tw`mr-1`} text={isDir ? 'Folder' : getExplorerItemData(data).kind} />
|
||||
<InfoPill text={isDir ? 'Folder' : getExplorerItemData(data).kind} />
|
||||
{/* Extension */}
|
||||
{filePath?.extension && (
|
||||
<InfoPill text={filePath.extension} containerStyle={tw`mr-1`} />
|
||||
<InfoPill text={filePath.extension} />
|
||||
)}
|
||||
{/* TODO: What happens if I have too many? */}
|
||||
{items?.map((tag) => (
|
||||
</View>
|
||||
<View onLayout={(e) => {
|
||||
if (e.nativeEvent.layout.height >= 80) {
|
||||
setReachedBottom(false);
|
||||
} else {
|
||||
setReachedBottom(true);
|
||||
}
|
||||
}} style={twStyle(`relative flex-row flex-wrap gap-1 overflow-hidden`)}>
|
||||
<Fade
|
||||
fadeSides="top-bottom"
|
||||
orientation="vertical"
|
||||
color="bg-app-modal"
|
||||
width={20}
|
||||
topFadeStyle={twStyle(startedScrolling ? 'mt-0' : 'h-0')}
|
||||
bottomFadeStyle={twStyle(reachedBottom ? 'h-0' : 'h-6')}
|
||||
height="100%"
|
||||
>
|
||||
<FlatList
|
||||
onScroll={(e) => 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 }) => (
|
||||
<InfoPill
|
||||
key={tag.id}
|
||||
text={tag.name ?? 'Unnamed Tag'}
|
||||
containerStyle={twStyle('mr-1', { backgroundColor: tag.color + 'CC' })}
|
||||
text={item.name ?? 'Unnamed Tag'}
|
||||
containerStyle={twStyle({ backgroundColor: item.color + 'CC' })}
|
||||
textStyle={tw`text-white`}
|
||||
/>
|
||||
))}
|
||||
<Pressable onPress={() => Alert.alert('TODO')}>
|
||||
<PlaceholderPill text={'Add Tag'} />
|
||||
</Pressable>
|
||||
)}/>
|
||||
</Fade>
|
||||
</View>
|
||||
</View>
|
||||
<AddTagModal ref={ref}/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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<any, any>; //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 (
|
||||
<View
|
||||
|
@ -53,6 +76,7 @@ export default function DynamicHeader({
|
|||
<Pressable
|
||||
hitSlop={12}
|
||||
onPress={() => {
|
||||
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 <Icon size={24} name="Folder" />;
|
||||
case 'tag':
|
||||
case 'tags':
|
||||
return (
|
||||
<View
|
||||
style={twStyle('h-[24px] w-[24px] rounded-full', {
|
||||
style={twStyle('h-5 w-5 rounded-full', {
|
||||
backgroundColor: routeParams.color
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -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 (
|
||||
<Swipeable
|
||||
ref={swipeRef}
|
||||
containerStyle={tw`rounded-md border border-app-cardborder bg-app-card`}
|
||||
containerStyle={tw`h-16 rounded-md border border-app-cardborder bg-app-card`}
|
||||
enableTrackpadTwoFingerGesture
|
||||
renderRightActions={(progress, _, swipeable) => (
|
||||
<>
|
||||
|
|
177
apps/mobile/src/components/modal/AddTagModal.tsx
Normal file
177
apps/mobile/src/components/modal/AddTagModal.tsx
Normal file
|
@ -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<ModalRef, unknown>((_, 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<ModalRef>(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 (
|
||||
<>
|
||||
<Modal
|
||||
ref={modalRef}
|
||||
onDismiss={() => setSelectedTags([])}
|
||||
enableContentPanningGesture={false}
|
||||
enablePanDownToClose={false}
|
||||
snapPoints={['50']}
|
||||
title="Select Tags"
|
||||
>
|
||||
{/* Back Button */}
|
||||
<Pressable
|
||||
onPress={() => modalRef.current?.close()}
|
||||
style={tw`absolute z-10 ml-6 rounded-full bg-app-button p-2`}
|
||||
>
|
||||
<CaretLeft color={tw.color('ink')} size={16} weight="bold" />
|
||||
</Pressable>
|
||||
<FlatList
|
||||
data={tagsData}
|
||||
numColumns={3}
|
||||
extraData={selectedTags}
|
||||
key={tagsData ? 'tags' : '_'}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={tw`mx-auto mt-4 p-4 pb-10`}
|
||||
ItemSeparatorComponent={() => <View style={tw`h-2`} />}
|
||||
renderItem={({ item }) => (
|
||||
<TagItem isSelected={() => isSelected(item.id)} select={() => selectTag(item.id)} tag={item} />
|
||||
)}
|
||||
/>
|
||||
<View style={tw`flex-row gap-2 px-5`}>
|
||||
<Button
|
||||
onPress={() => newTagRef.current?.present()}
|
||||
style={tw`mb-10 h-10 flex-1 flex-row gap-1`} variant="dashed">
|
||||
<Plus weight="bold" size={12} color={tw.color('text-ink-dull')} />
|
||||
<Text style={tw`text-sm font-medium text-ink-dull`}>Add New Tag</Text>
|
||||
</Button>
|
||||
<Button
|
||||
style={tw`mb-10 h-10 flex-1`}
|
||||
onPress={assignHandler}
|
||||
variant="accent">
|
||||
<Text style={tw`text-sm font-medium text-white`}>
|
||||
{appliedTags.length === 0 ? 'Confirm' : 'Update'}
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</Modal>
|
||||
<CreateTagModal ref={newTagRef} />
|
||||
</>
|
||||
)
|
||||
});
|
||||
|
||||
interface Props {
|
||||
tag: Tag;
|
||||
select: () => void;
|
||||
isSelected: () => boolean;
|
||||
}
|
||||
|
||||
const TagItem = ({tag, select, isSelected}: Props) => {
|
||||
return (
|
||||
<Pressable onPress={select}>
|
||||
<Card
|
||||
style={twStyle(`mr-2 w-auto flex-row items-center gap-2 border bg-app-card p-2`, {
|
||||
borderColor: isSelected() ? tw.color('accent') : tw.color('app-cardborder'),
|
||||
})}
|
||||
>
|
||||
<View
|
||||
style={twStyle(`h-4 w-4 rounded-full`, {
|
||||
backgroundColor: tag.color!
|
||||
})}
|
||||
/>
|
||||
<Text style={tw`text-sm font-medium text-ink`}>{tag?.name}</Text>
|
||||
</Card>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddTagModal;
|
|
@ -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 = () => {
|
|||
<Modal ref={modalRef} snapPoints={['60', '90']}>
|
||||
{data && (
|
||||
<View style={tw`flex-1 px-4`}>
|
||||
<View style={tw`flex flex-row items-center`}>
|
||||
<View style={tw`flex flex-row`}>
|
||||
{/* Thumbnail/Icon */}
|
||||
<Pressable
|
||||
onPress={handleOpen}
|
||||
|
@ -111,7 +110,7 @@ export const ActionsModal = () => {
|
|||
<View style={tw`ml-2 flex-1`}>
|
||||
{/* Name + Extension */}
|
||||
<Text
|
||||
style={tw`text-base font-bold text-gray-200`}
|
||||
style={tw`max-w-[220px] text-base font-bold text-gray-200`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{filePath?.name}
|
||||
|
@ -128,9 +127,9 @@ export const ActionsModal = () => {
|
|||
</View>
|
||||
<InfoTagPills data={data} />
|
||||
</View>
|
||||
{objectData && <FavoriteButton style={tw`mr-4`} data={objectData} />}
|
||||
{objectData && <FavoriteButton style={tw`mr-1 mt-2`} data={objectData} />}
|
||||
</View>
|
||||
<View style={tw`my-3`} />
|
||||
<View />
|
||||
{/* Actions */}
|
||||
<ActionsContainer>
|
||||
<ActionsItem title="Open" onPress={handleOpen} />
|
||||
|
|
|
@ -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<ModalRef, FileInfoModalProps>((props, ref) => {
|
|||
enablePanDownToClose={false}
|
||||
snapPoints={['70']}
|
||||
>
|
||||
<VirtualizedListWrapper style={tw`flex-col p-4`} scrollEnabled={false} horizontal>
|
||||
{data && (
|
||||
<ModalScrollView style={tw`flex-1 p-4`}>
|
||||
<ModalScrollView>
|
||||
{/* Back Button */}
|
||||
<Pressable
|
||||
onPress={() => modalRef.current?.close()}
|
||||
style={tw`absolute z-10 ml-4`}
|
||||
style={tw`absolute left-2 z-10 rounded-full bg-app-button p-2`}
|
||||
>
|
||||
<CaretLeft color={tw.color('accent')} size={20} weight="bold" />
|
||||
<CaretLeft color={tw.color('ink')} size={16} weight="bold" />
|
||||
</Pressable>
|
||||
{/* File Icon / Name */}
|
||||
<View style={tw`items-center`}>
|
||||
{/* File Icon / Name */}
|
||||
<FileThumb data={data} size={1.6} />
|
||||
<Text style={tw`mt-2 text-base font-bold text-gray-200`}>
|
||||
<Text style={tw`text-base font-bold text-gray-200`}>
|
||||
{filePathData?.name}
|
||||
</Text>
|
||||
<InfoTagPills data={data} style={tw`mt-3`} />
|
||||
<InfoTagPills columnCount={4} contentContainerStyle={tw`mx-auto`} data={data} style={tw`mt-5 items-center`} />
|
||||
</View>
|
||||
{/* Details */}
|
||||
<Divider style={tw`mb-4 mt-6`} />
|
||||
<Divider style={tw`mb-4 mt-3`} />
|
||||
<>
|
||||
{/* Size */}
|
||||
<MetaItem
|
||||
|
@ -113,6 +115,7 @@ const FileInfoModal = forwardRef<ModalRef, FileInfoModalProps>((props, ref) => {
|
|||
</>
|
||||
</ModalScrollView>
|
||||
)}
|
||||
</VirtualizedListWrapper>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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<Record<keyof Statistics, string>> = {
|
||||
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 (
|
||||
<View style={tw`px-6`}>
|
||||
<Text style={tw`pb-3 text-lg font-bold text-white`}>Statistics</Text>
|
||||
<View style={tw`h-[250px] w-full flex-row justify-between gap-2`}>
|
||||
<View style={tw`h-full w-[49%] flex-col justify-between gap-2`}>
|
||||
<View style={tw`flex-row gap-2`}>
|
||||
<View style={tw`h-full flex-1 flex-col gap-2`}>
|
||||
{renderStatItems()}
|
||||
</View>
|
||||
<View style={tw`h-full w-[49%] flex-col justify-between gap-2`}>
|
||||
<View style={tw`h-full flex-1 flex-col gap-2`}>
|
||||
{renderStatItems(false)}
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
@ -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<IconProps, any>
|
||||
};
|
||||
|
||||
export const InfoPill = (props: Props) => {
|
||||
return (
|
||||
<View
|
||||
style={twStyle(
|
||||
'rounded-md border border-transparent bg-app-highlight px-[6px] py-px shadow shadow-app-shade/5',
|
||||
'rounded-md border border-transparent bg-app-highlight px-[6px] py-px',
|
||||
props.containerStyle
|
||||
)}
|
||||
>
|
||||
|
@ -27,11 +29,12 @@ export function PlaceholderPill(props: Props) {
|
|||
return (
|
||||
<View
|
||||
style={twStyle(
|
||||
'rounded-md border border-dashed border-app-highlight bg-transparent px-[6px] py-px shadow shadow-app-shade/10',
|
||||
'flex-row items-center gap-0.5 rounded-md border border-dashed border-app-lightborder bg-transparent px-[6px] py-px',
|
||||
props.containerStyle
|
||||
)}
|
||||
>
|
||||
<Text style={twStyle('text-xs font-medium text-ink-faint/70', props.textStyle)}>
|
||||
{props.icon && props.icon}
|
||||
<Text style={twStyle('text-xs font-medium text-ink-faint', props.textStyle)}>
|
||||
{props.text}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
|
@ -32,14 +32,13 @@ const Locations = () => {
|
|||
/>
|
||||
<View>
|
||||
<Fade color="black" width={30} height="100%">
|
||||
<VirtualizedListWrapper contentContainerStyle={tw`w-full px-6`} horizontal>
|
||||
<VirtualizedListWrapper contentContainerStyle={tw`px-6`} horizontal>
|
||||
<FlatList
|
||||
data={locations}
|
||||
renderItem={({ item }) => <LocationFilter data={item} />}
|
||||
numColumns={
|
||||
locations ? Math.max(Math.ceil(locations.length / 2), 2) : 1
|
||||
}
|
||||
contentContainerStyle={tw`w-full`}
|
||||
ListEmptyComponent={
|
||||
<Empty
|
||||
icon="Folder"
|
||||
|
|
|
@ -30,20 +30,16 @@ const Tags = () => {
|
|||
title="Tags"
|
||||
sub="What tags would you like to filter by?"
|
||||
/>
|
||||
<View>
|
||||
<Fade color="black" width={30} height="100%">
|
||||
<VirtualizedListWrapper contentContainerStyle={tw`w-full px-6`} horizontal>
|
||||
<VirtualizedListWrapper contentContainerStyle={tw`px-6`} horizontal>
|
||||
<FlatList
|
||||
data={tagsData}
|
||||
renderItem={({ item }) => <TagFilter tag={item} />}
|
||||
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={
|
||||
<Empty icon="Tags" description="You have not created any tags" />
|
||||
}
|
||||
scrollEnabled={false}
|
||||
ListEmptyComponent={<Empty icon="Tags" description="You have not created any tags" />}
|
||||
ItemSeparatorComponent={() => <View style={tw`h-2 w-2`} />}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
|
@ -51,7 +47,6 @@ const Tags = () => {
|
|||
/>
|
||||
</VirtualizedListWrapper>
|
||||
</Fade>
|
||||
</View>
|
||||
</MotiView>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -19,16 +19,16 @@ const ListTag = ({ tag, tagStyle }: ListTagProps) => {
|
|||
return (
|
||||
<Swipeable
|
||||
ref={swipeRef}
|
||||
containerStyle={tw`rounded-md border border-app-cardborder bg-app-card p-3`}
|
||||
containerStyle={tw`h-12 flex-col justify-center rounded-md border border-app-cardborder bg-app-card`}
|
||||
enableTrackpadTwoFingerGesture
|
||||
renderRightActions={(progress, _, swipeable) => (
|
||||
<RightActions progress={progress} swipeable={swipeable} tag={tag} />
|
||||
)}
|
||||
>
|
||||
<View style={twStyle('h-auto flex-row items-center justify-between', tagStyle)}>
|
||||
<View style={twStyle('flex-row items-center justify-between px-3', tagStyle)}>
|
||||
<View style={tw`flex-1 flex-row items-center gap-2`}>
|
||||
<View
|
||||
style={twStyle('h-[28px] w-[28px] rounded-full', {
|
||||
style={twStyle('h-5 w-5 rounded-full', {
|
||||
backgroundColor: tag.color!
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -32,7 +32,7 @@ export default function SearchStack() {
|
|||
name="Location"
|
||||
component={LocationScreen}
|
||||
options={({route: optionsRoute}) => ({
|
||||
header: (route) => <DynamicHeader optionsRoute={optionsRoute} headerRoute={route} kind="location" />
|
||||
header: (route) => <DynamicHeader optionsRoute={optionsRoute} headerRoute={route} kind="locations" />
|
||||
})}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
|
|
|
@ -34,7 +34,7 @@ export default function BrowseStack() {
|
|||
<DynamicHeader
|
||||
optionsRoute={optionsRoute}
|
||||
headerRoute={route}
|
||||
kind="location"
|
||||
kind="locations"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
@ -58,7 +58,7 @@ export default function BrowseStack() {
|
|||
component={TagScreen}
|
||||
options={({ route: optionsRoute }) => ({
|
||||
header: (route) => (
|
||||
<DynamicHeader optionsRoute={optionsRoute} headerRoute={route} kind="tag" />
|
||||
<DynamicHeader optionsRoute={optionsRoute} headerRoute={route} kind="tags" />
|
||||
)
|
||||
})}
|
||||
/>
|
||||
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -46,6 +46,7 @@ export default function LocationsScreen({ viewStyle }: Props) {
|
|||
>
|
||||
<Plus size={20} weight="bold" style={tw`text-ink`} />
|
||||
</Pressable>
|
||||
<View style={tw`min-h-full`}>
|
||||
<FlatList
|
||||
data={filteredLocations}
|
||||
contentContainerStyle={twStyle(
|
||||
|
@ -84,6 +85,7 @@ export default function LocationsScreen({ viewStyle }: Props) {
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<ImportModal ref={modalRef} />
|
||||
</ScreenContainer>
|
||||
);
|
||||
|
|
|
@ -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.
|
||||
if (tagData) {
|
||||
navigation.setParams({
|
||||
id: tagData.id,
|
||||
color: tagData.color as string
|
||||
})
|
||||
navigation.setOptions({
|
||||
title: tagData?.name ?? 'Tag'
|
||||
title: tagData.name ?? 'Tag',
|
||||
});
|
||||
}, [tagData?.name, navigation]);
|
||||
}
|
||||
}, [tagData, id, navigation]);
|
||||
|
||||
return <Explorer
|
||||
isEmpty={objects.count === 0}
|
||||
|
|
|
@ -46,6 +46,7 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
|
|||
>
|
||||
<Plus size={20} weight="bold" style={tw`text-ink`} />
|
||||
</Pressable>
|
||||
<View style={tw`min-h-full`}>
|
||||
<FlatList
|
||||
data={filteredTags}
|
||||
renderItem={({ item }) => (
|
||||
|
@ -75,10 +76,11 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
|
|||
showsHorizontalScrollIndicator={false}
|
||||
ItemSeparatorComponent={() => <View style={tw`h-2`} />}
|
||||
contentContainerStyle={twStyle(
|
||||
`py-6`,
|
||||
'py-6',
|
||||
tagsData?.length === 0 && 'h-full items-center justify-center'
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<CreateTagModal ref={modalRef} />
|
||||
</ScreenContainer>
|
||||
);
|
||||
|
|
|
@ -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<string>,
|
||||
// sorting
|
||||
// we will display different sorting options based on the kind of explorer we are in
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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<T>(
|
|||
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: <K extends keyof State['filters']>(
|
||||
filter: K,
|
||||
value: State['filters'][K] extends Array<infer U> ? 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: <K extends keyof State['filters']>(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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<file_path_with_object::Data>,
|
||||
item: Box<file_path_for_frontend::Data>,
|
||||
},
|
||||
Object {
|
||||
thumbnail: Option<ThumbnailKey>,
|
||||
|
|
|
@ -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<Ctx> {
|
|||
}
|
||||
|
||||
let file_paths = query
|
||||
.include(file_path_with_object::include())
|
||||
.include(file_path_for_frontend::include())
|
||||
.exec()
|
||||
.await?;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -12,6 +12,22 @@ import {
|
|||
Icon as PhosphorIcon,
|
||||
Snowflake
|
||||
} from '@phosphor-icons/react';
|
||||
import {
|
||||
FilePath,
|
||||
FilePathForFrontend,
|
||||
getExplorerItemData,
|
||||
getItemFilePath,
|
||||
humanizeSize,
|
||||
NonIndexedPathItem,
|
||||
Object,
|
||||
ObjectWithFilePaths,
|
||||
useBridgeQuery,
|
||||
useItemsAsObjects,
|
||||
useLibraryQuery,
|
||||
useSelector,
|
||||
type ExplorerItem
|
||||
} from '@sd/client';
|
||||
import { Button, Divider, DropdownMenu, toast, Tooltip, tw } from '@sd/ui';
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
|
@ -26,22 +42,6 @@ import {
|
|||
import { useLocation } from 'react-router';
|
||||
import { Link as NavLink } from 'react-router-dom';
|
||||
import Sticky from 'react-sticky-el';
|
||||
import {
|
||||
FilePath,
|
||||
FilePathWithObject,
|
||||
getExplorerItemData,
|
||||
getItemFilePath,
|
||||
humanizeSize,
|
||||
NonIndexedPathItem,
|
||||
Object,
|
||||
ObjectWithFilePaths,
|
||||
useBridgeQuery,
|
||||
useItemsAsObjects,
|
||||
useLibraryQuery,
|
||||
useSelector,
|
||||
type ExplorerItem
|
||||
} from '@sd/client';
|
||||
import { Button, Divider, DropdownMenu, toast, Tooltip, tw } from '@sd/ui';
|
||||
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 (
|
||||
<>
|
||||
<MetaContainer>
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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<string> } |
|
|||
|
||||
export type FilePathFilterArgs = { locations: InOrNotIn<number> } | { path: { location_id: number; path: string; include_descendants: boolean } } | { name: TextMatch } | { extension: InOrNotIn<string> } | { createdAt: Range<string> } | { modifiedAt: Range<string> } | { indexedAt: Range<string> } | { 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<string> } | { kind: CursorOrderItem<number> }
|
||||
|
||||
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<number, FilePathOrder, FilePathCursor> | 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)
|
||||
|
|
Loading…
Reference in a new issue