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 { Text, View } from 'react-native';
|
||||||
import { ExplorerItem, getItemFilePath } from '@sd/client';
|
|
||||||
import Layout from '~/constants/Layout';
|
import Layout from '~/constants/Layout';
|
||||||
import { tw, twStyle } from '~/lib/tailwind';
|
import { tw, twStyle } from '~/lib/tailwind';
|
||||||
import { getExplorerStore } from '~/stores/explorerStore';
|
import { getExplorerStore } from '~/stores/explorerStore';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import FileThumb from './FileThumb';
|
import FileThumb from './FileThumb';
|
||||||
|
|
||||||
type FileItemProps = {
|
type FileItemProps = {
|
||||||
|
@ -14,6 +15,13 @@ const FileItem = ({ data }: FileItemProps) => {
|
||||||
const gridItemSize = Layout.window.width / getExplorerStore().gridNumColumns;
|
const gridItemSize = Layout.window.width / getExplorerStore().gridNumColumns;
|
||||||
|
|
||||||
const filePath = getItemFilePath(data);
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
|
@ -29,6 +37,21 @@ const FileItem = ({ data }: FileItemProps) => {
|
||||||
{filePath?.extension && `.${filePath.extension}`}
|
{filePath?.extension && `.${filePath.extension}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ExplorerItem, getItemFilePath } from '@sd/client';
|
import { ExplorerItem, Tag, getItemFilePath, getItemObject } from '@sd/client';
|
||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Text, View } from 'react-native';
|
import { Text, View } from 'react-native';
|
||||||
import { tw, twStyle } from '~/lib/tailwind';
|
import { tw, twStyle } from '~/lib/tailwind';
|
||||||
import { getExplorerStore } from '~/stores/explorerStore';
|
import { getExplorerStore } from '~/stores/explorerStore';
|
||||||
|
@ -12,21 +12,47 @@ type FileRowProps = {
|
||||||
|
|
||||||
const FileRow = ({ data }: FileRowProps) => {
|
const FileRow = ({ data }: FileRowProps) => {
|
||||||
const filePath = getItemFilePath(data);
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<View
|
<View
|
||||||
style={twStyle('flex flex-row items-center px-3', {
|
style={twStyle('flex flex-row items-center px-3', {
|
||||||
height: getExplorerStore().listItemSize
|
height: getExplorerStore().listItemSize
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FileThumb data={data} size={0.6} />
|
<FileThumb data={data} size={0.6} />
|
||||||
<View style={tw`ml-3 max-w-[80%]`}>
|
<View style={tw`mx-2 flex-1 flex-row items-center justify-between border-b border-white/10 pb-3`}>
|
||||||
<Text numberOfLines={1} style={tw`text-center text-xs font-medium text-ink-dull`}>
|
<View style={tw`max-w-[80%]`}>
|
||||||
|
<Text numberOfLines={1} style={tw`text-center text-sm font-medium text-ink`}>
|
||||||
{filePath?.name}
|
{filePath?.name}
|
||||||
{filePath?.extension && `.${filePath.extension}`}
|
{filePath?.extension && `.${filePath.extension}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -6,48 +6,100 @@ import {
|
||||||
isPath,
|
isPath,
|
||||||
useLibraryQuery
|
useLibraryQuery
|
||||||
} from '@sd/client';
|
} from '@sd/client';
|
||||||
import React from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Alert, Pressable, View, ViewStyle } from 'react-native';
|
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 { InfoPill, PlaceholderPill } from '~/components/primitive/InfoPill';
|
||||||
import { tw, twStyle } from '~/lib/tailwind';
|
import { tw, twStyle } from '~/lib/tailwind';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: ExplorerItem;
|
data: ExplorerItem;
|
||||||
style?: ViewStyle;
|
style?: ViewStyle;
|
||||||
|
contentContainerStyle?: ViewStyle;
|
||||||
|
columnCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const InfoTagPills = ({ data, style }: Props) => {
|
const InfoTagPills = ({ data, style, contentContainerStyle, columnCount = 3 }: Props) => {
|
||||||
|
|
||||||
const objectData = getItemObject(data);
|
const objectData = getItemObject(data);
|
||||||
const filePath = getItemFilePath(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], {
|
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;
|
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 (
|
return (
|
||||||
<View style={twStyle('mt-1 flex flex-row flex-wrap', style)}>
|
<>
|
||||||
{/* Kind */}
|
<View style={twStyle('mb-3 mt-2 flex-col flex-wrap items-start gap-1', style)}>
|
||||||
<InfoPill containerStyle={tw`mr-1`} text={isDir ? 'Folder' : getExplorerItemData(data).kind} />
|
<View style={tw`flex-row gap-1`}>
|
||||||
|
<Pressable style={tw`relative z-10`} onPress={() => ref.current?.present()}>
|
||||||
|
<PlaceholderPill
|
||||||
|
text={'Tags'}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
{/* Kind */}
|
||||||
|
<InfoPill text={isDir ? 'Folder' : getExplorerItemData(data).kind} />
|
||||||
{/* Extension */}
|
{/* Extension */}
|
||||||
{filePath?.extension && (
|
{filePath?.extension && (
|
||||||
<InfoPill text={filePath.extension} containerStyle={tw`mr-1`} />
|
<InfoPill text={filePath.extension} />
|
||||||
)}
|
)}
|
||||||
{/* TODO: What happens if I have too many? */}
|
</View>
|
||||||
{items?.map((tag) => (
|
<View onLayout={(e) => {
|
||||||
<InfoPill
|
if (e.nativeEvent.layout.height >= 80) {
|
||||||
key={tag.id}
|
setReachedBottom(false);
|
||||||
text={tag.name ?? 'Unnamed Tag'}
|
} else {
|
||||||
containerStyle={twStyle('mr-1', { backgroundColor: tag.color + 'CC' })}
|
setReachedBottom(true);
|
||||||
textStyle={tw`text-white`}
|
}
|
||||||
/>
|
}} style={twStyle(`relative flex-row flex-wrap gap-1 overflow-hidden`)}>
|
||||||
))}
|
<Fade
|
||||||
<Pressable onPress={() => Alert.alert('TODO')}>
|
fadeSides="top-bottom"
|
||||||
<PlaceholderPill text={'Add Tag'} />
|
orientation="vertical"
|
||||||
</Pressable>
|
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
|
||||||
|
text={item.name ?? 'Unnamed Tag'}
|
||||||
|
containerStyle={twStyle({ backgroundColor: item.color + 'CC' })}
|
||||||
|
textStyle={tw`text-white`}
|
||||||
|
/>
|
||||||
|
)}/>
|
||||||
|
</Fade>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
<AddTagModal ref={ref}/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -7,12 +7,13 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { tw, twStyle } from '~/lib/tailwind';
|
import { tw, twStyle } from '~/lib/tailwind';
|
||||||
import { getExplorerStore, useExplorerStore } from '~/stores/explorerStore';
|
import { getExplorerStore, useExplorerStore } from '~/stores/explorerStore';
|
||||||
|
|
||||||
|
import { FilterItem, TagItem, useSearchStore } from '~/stores/searchStore';
|
||||||
import { Icon } from '../icons/Icon';
|
import { Icon } from '../icons/Icon';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
headerRoute?: NativeStackHeaderProps; //supporting title from the options object of navigation
|
headerRoute?: NativeStackHeaderProps; //supporting title from the options object of navigation
|
||||||
optionsRoute?: RouteProp<any, any>; //supporting params passed
|
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
|
explorerMenu?: boolean; //whether to show the explorer menu
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -26,6 +27,28 @@ export default function DynamicHeader({
|
||||||
const headerHeight = useSafeAreaInsets().top;
|
const headerHeight = useSafeAreaInsets().top;
|
||||||
const isAndroid = Platform.OS === 'android';
|
const isAndroid = Platform.OS === 'android';
|
||||||
const explorerStore = useExplorerStore();
|
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 (
|
return (
|
||||||
<View
|
<View
|
||||||
|
@ -53,6 +76,7 @@ export default function DynamicHeader({
|
||||||
<Pressable
|
<Pressable
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
searchHandler(kind)
|
||||||
navigation.navigate('SearchStack', {
|
navigation.navigate('SearchStack', {
|
||||||
screen: 'Search'
|
screen: 'Search'
|
||||||
});
|
});
|
||||||
|
@ -94,12 +118,12 @@ interface HeaderIconKindProps {
|
||||||
|
|
||||||
const HeaderIconKind = ({ routeParams, kind }: HeaderIconKindProps) => {
|
const HeaderIconKind = ({ routeParams, kind }: HeaderIconKindProps) => {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'location':
|
case 'locations':
|
||||||
return <Icon size={24} name="Folder" />;
|
return <Icon size={24} name="Folder" />;
|
||||||
case 'tag':
|
case 'tags':
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={twStyle('h-[24px] w-[24px] rounded-full', {
|
style={twStyle('h-5 w-5 rounded-full', {
|
||||||
backgroundColor: routeParams.color
|
backgroundColor: routeParams.color
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { Location, arraysEqual, humanizeSize, useOnlineLocations } from '@sd/client';
|
||||||
import { DotsThreeVertical } from 'phosphor-react-native';
|
import { DotsThreeVertical } from 'phosphor-react-native';
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { Pressable, Text, View } from 'react-native';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { Swipeable } from 'react-native-gesture-handler';
|
import { Swipeable } from 'react-native-gesture-handler';
|
||||||
import { arraysEqual, humanizeSize, Location, useOnlineLocations } from '@sd/client';
|
|
||||||
import { tw, twStyle } from '~/lib/tailwind';
|
import { tw, twStyle } from '~/lib/tailwind';
|
||||||
import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack';
|
import { SettingsStackScreenProps } from '~/navigation/tabs/SettingsStack';
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ const ListLocation = ({ location }: ListLocationProps) => {
|
||||||
return (
|
return (
|
||||||
<Swipeable
|
<Swipeable
|
||||||
ref={swipeRef}
|
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
|
enableTrackpadTwoFingerGesture
|
||||||
renderRightActions={(progress, _, swipeable) => (
|
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 dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
|
@ -13,13 +20,6 @@ import {
|
||||||
import { PropsWithChildren, useRef } from 'react';
|
import { PropsWithChildren, useRef } from 'react';
|
||||||
import { Pressable, Text, View, ViewStyle } from 'react-native';
|
import { Pressable, Text, View, ViewStyle } from 'react-native';
|
||||||
import FileViewer from 'react-native-file-viewer';
|
import FileViewer from 'react-native-file-viewer';
|
||||||
import {
|
|
||||||
getIndexedItemFilePath,
|
|
||||||
getItemObject,
|
|
||||||
humanizeSize,
|
|
||||||
useLibraryMutation,
|
|
||||||
useLibraryQuery
|
|
||||||
} from '@sd/client';
|
|
||||||
import FileThumb from '~/components/explorer/FileThumb';
|
import FileThumb from '~/components/explorer/FileThumb';
|
||||||
import FavoriteButton from '~/components/explorer/sections/FavoriteButton';
|
import FavoriteButton from '~/components/explorer/sections/FavoriteButton';
|
||||||
import InfoTagPills from '~/components/explorer/sections/InfoTagPills';
|
import InfoTagPills from '~/components/explorer/sections/InfoTagPills';
|
||||||
|
@ -72,7 +72,6 @@ export const ActionsModal = () => {
|
||||||
const filePath = data && getIndexedItemFilePath(data);
|
const filePath = data && getIndexedItemFilePath(data);
|
||||||
|
|
||||||
// Open
|
// Open
|
||||||
|
|
||||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||||
const queriedFullPath = useLibraryQuery(['files.getPath', filePath?.id ?? -1], {
|
const queriedFullPath = useLibraryQuery(['files.getPath', filePath?.id ?? -1], {
|
||||||
enabled: filePath != null
|
enabled: filePath != null
|
||||||
|
@ -100,7 +99,7 @@ export const ActionsModal = () => {
|
||||||
<Modal ref={modalRef} snapPoints={['60', '90']}>
|
<Modal ref={modalRef} snapPoints={['60', '90']}>
|
||||||
{data && (
|
{data && (
|
||||||
<View style={tw`flex-1 px-4`}>
|
<View style={tw`flex-1 px-4`}>
|
||||||
<View style={tw`flex flex-row items-center`}>
|
<View style={tw`flex flex-row`}>
|
||||||
{/* Thumbnail/Icon */}
|
{/* Thumbnail/Icon */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleOpen}
|
onPress={handleOpen}
|
||||||
|
@ -111,7 +110,7 @@ export const ActionsModal = () => {
|
||||||
<View style={tw`ml-2 flex-1`}>
|
<View style={tw`ml-2 flex-1`}>
|
||||||
{/* Name + Extension */}
|
{/* Name + Extension */}
|
||||||
<Text
|
<Text
|
||||||
style={tw`text-base font-bold text-gray-200`}
|
style={tw`max-w-[220px] text-base font-bold text-gray-200`}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{filePath?.name}
|
{filePath?.name}
|
||||||
|
@ -128,9 +127,9 @@ export const ActionsModal = () => {
|
||||||
</View>
|
</View>
|
||||||
<InfoTagPills data={data} />
|
<InfoTagPills data={data} />
|
||||||
</View>
|
</View>
|
||||||
{objectData && <FavoriteButton style={tw`mr-4`} data={objectData} />}
|
{objectData && <FavoriteButton style={tw`mr-1 mt-2`} data={objectData} />}
|
||||||
</View>
|
</View>
|
||||||
<View style={tw`my-3`} />
|
<View />
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<ActionsContainer>
|
<ActionsContainer>
|
||||||
<ActionsItem title="Open" onPress={handleOpen} />
|
<ActionsItem title="Open" onPress={handleOpen} />
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
|
import { getItemFilePath, humanizeSize, type ExplorerItem } from '@sd/client';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { Barcode, CaretLeft, Clock, Cube, Icon, SealCheck, Snowflake } from 'phosphor-react-native';
|
import { Barcode, CaretLeft, Clock, Cube, Icon, SealCheck, Snowflake } from 'phosphor-react-native';
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import { Pressable, Text, View } from 'react-native';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { getItemFilePath, humanizeSize, type ExplorerItem } from '@sd/client';
|
|
||||||
import FileThumb from '~/components/explorer/FileThumb';
|
import FileThumb from '~/components/explorer/FileThumb';
|
||||||
import InfoTagPills from '~/components/explorer/sections/InfoTagPills';
|
import InfoTagPills from '~/components/explorer/sections/InfoTagPills';
|
||||||
import { Modal, ModalScrollView, type ModalRef } from '~/components/layout/Modal';
|
import { Modal, ModalScrollView, type ModalRef } from '~/components/layout/Modal';
|
||||||
|
import VirtualizedListWrapper from '~/components/layout/VirtualizedListWrapper';
|
||||||
import { Divider } from '~/components/primitive/Divider';
|
import { Divider } from '~/components/primitive/Divider';
|
||||||
import useForwardedRef from '~/hooks/useForwardedRef';
|
import useForwardedRef from '~/hooks/useForwardedRef';
|
||||||
import { tw } from '~/lib/tailwind';
|
import { tw } from '~/lib/tailwind';
|
||||||
|
@ -49,25 +50,26 @@ const FileInfoModal = forwardRef<ModalRef, FileInfoModalProps>((props, ref) => {
|
||||||
enablePanDownToClose={false}
|
enablePanDownToClose={false}
|
||||||
snapPoints={['70']}
|
snapPoints={['70']}
|
||||||
>
|
>
|
||||||
|
<VirtualizedListWrapper style={tw`flex-col p-4`} scrollEnabled={false} horizontal>
|
||||||
{data && (
|
{data && (
|
||||||
<ModalScrollView style={tw`flex-1 p-4`}>
|
<ModalScrollView>
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => modalRef.current?.close()}
|
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>
|
</Pressable>
|
||||||
{/* File Icon / Name */}
|
|
||||||
<View style={tw`items-center`}>
|
<View style={tw`items-center`}>
|
||||||
|
{/* File Icon / Name */}
|
||||||
<FileThumb data={data} size={1.6} />
|
<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}
|
{filePathData?.name}
|
||||||
</Text>
|
</Text>
|
||||||
<InfoTagPills data={data} style={tw`mt-3`} />
|
<InfoTagPills columnCount={4} contentContainerStyle={tw`mx-auto`} data={data} style={tw`mt-5 items-center`} />
|
||||||
</View>
|
</View>
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
<Divider style={tw`mb-4 mt-6`} />
|
<Divider style={tw`mb-4 mt-3`} />
|
||||||
<>
|
<>
|
||||||
{/* Size */}
|
{/* Size */}
|
||||||
<MetaItem
|
<MetaItem
|
||||||
|
@ -113,6 +115,7 @@ const FileInfoModal = forwardRef<ModalRef, FileInfoModalProps>((props, ref) => {
|
||||||
</>
|
</>
|
||||||
</ModalScrollView>
|
</ModalScrollView>
|
||||||
)}
|
)}
|
||||||
|
</VirtualizedListWrapper>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import * as RNFS from '@dr.pogodin/react-native-fs';
|
import * as RNFS from '@dr.pogodin/react-native-fs';
|
||||||
import { AlphaRSPCError } from '@oscartbeaumont-sd/rspc-client/v2';
|
import { AlphaRSPCError } from '@oscartbeaumont-sd/rspc-client/v2';
|
||||||
|
import { Statistics, StatisticsResponse, humanizeSize, useLibraryContext } from '@sd/client';
|
||||||
import { UseQueryResult } from '@tanstack/react-query';
|
import { UseQueryResult } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Platform, Text, View } from 'react-native';
|
import { Platform, Text, View } from 'react-native';
|
||||||
import { ClassInput } from 'twrnc/dist/esm/types';
|
import { ClassInput } from 'twrnc/dist/esm/types';
|
||||||
import { humanizeSize, Statistics, StatisticsResponse, useLibraryContext } from '@sd/client';
|
|
||||||
import useCounter from '~/hooks/useCounter';
|
import useCounter from '~/hooks/useCounter';
|
||||||
import { tw, twStyle } from '~/lib/tailwind';
|
import { tw, twStyle } from '~/lib/tailwind';
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ import Card from '../layout/Card';
|
||||||
const StatItemNames: Partial<Record<keyof Statistics, string>> = {
|
const StatItemNames: Partial<Record<keyof Statistics, string>> = {
|
||||||
total_local_bytes_capacity: 'Total capacity',
|
total_local_bytes_capacity: 'Total capacity',
|
||||||
total_library_preview_media_bytes: 'Preview media',
|
total_library_preview_media_bytes: 'Preview media',
|
||||||
|
total_library_bytes: 'Total library size',
|
||||||
library_db_size: 'Index size',
|
library_db_size: 'Index size',
|
||||||
total_local_bytes_free: 'Free space',
|
total_local_bytes_free: 'Free space',
|
||||||
total_local_bytes_used: 'Total used space'
|
total_local_bytes_used: 'Total used space'
|
||||||
|
@ -76,19 +77,18 @@ const OverviewStats = ({ stats }: Props) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderStatItems = (isTotalStat = true) => {
|
const renderStatItems = (isTotalStat = true) => {
|
||||||
|
const keysToFilter = ['total_local_bytes_capacity', 'total_local_bytes_used', 'total_library_bytes'];
|
||||||
if (!stats.data?.statistics) return null;
|
if (!stats.data?.statistics) return null;
|
||||||
return Object.entries(stats.data.statistics).map(([key, bytesRaw]) => {
|
return Object.entries(stats.data.statistics).map(([key, bytesRaw]) => {
|
||||||
if (!displayableStatItems.includes(key)) return null;
|
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);
|
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);
|
bytes = BigInt(sizeInfo.freeSpace);
|
||||||
} else if (key === 'total_bytes_capacity') {
|
} else if (key === 'total_local_bytes_capacity') {
|
||||||
bytes = BigInt(sizeInfo.totalSpace);
|
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);
|
bytes = BigInt(sizeInfo.totalSpace - sizeInfo.freeSpace);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
@ -97,7 +97,7 @@ const OverviewStats = ({ stats }: Props) => {
|
||||||
title={StatItemNames[key as keyof Statistics]!}
|
title={StatItemNames[key as keyof Statistics]!}
|
||||||
bytes={bytes}
|
bytes={bytes}
|
||||||
isLoading={stats.isLoading}
|
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 (
|
return (
|
||||||
<View style={tw`px-6`}>
|
<View style={tw`px-6`}>
|
||||||
<Text style={tw`pb-3 text-lg font-bold text-white`}>Statistics</Text>
|
<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`flex-row gap-2`}>
|
||||||
<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()}
|
{renderStatItems()}
|
||||||
</View>
|
</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)}
|
{renderStatItems(false)}
|
||||||
</View>
|
</View>
|
||||||
</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 { Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||||
import { twStyle } from '~/lib/tailwind';
|
import { twStyle } from '~/lib/tailwind';
|
||||||
|
|
||||||
|
@ -6,13 +7,14 @@ type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
containerStyle?: ViewStyle;
|
containerStyle?: ViewStyle;
|
||||||
textStyle?: TextStyle;
|
textStyle?: TextStyle;
|
||||||
|
icon?: ReactElement<IconProps, any>
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InfoPill = (props: Props) => {
|
export const InfoPill = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={twStyle(
|
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
|
props.containerStyle
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -27,11 +29,12 @@ export function PlaceholderPill(props: Props) {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={twStyle(
|
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
|
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}
|
{props.text}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -32,14 +32,13 @@ const Locations = () => {
|
||||||
/>
|
/>
|
||||||
<View>
|
<View>
|
||||||
<Fade color="black" width={30} height="100%">
|
<Fade color="black" width={30} height="100%">
|
||||||
<VirtualizedListWrapper contentContainerStyle={tw`w-full px-6`} horizontal>
|
<VirtualizedListWrapper contentContainerStyle={tw`px-6`} horizontal>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={locations}
|
data={locations}
|
||||||
renderItem={({ item }) => <LocationFilter data={item} />}
|
renderItem={({ item }) => <LocationFilter data={item} />}
|
||||||
numColumns={
|
numColumns={
|
||||||
locations ? Math.max(Math.ceil(locations.length / 2), 2) : 1
|
locations ? Math.max(Math.ceil(locations.length / 2), 2) : 1
|
||||||
}
|
}
|
||||||
contentContainerStyle={tw`w-full`}
|
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<Empty
|
<Empty
|
||||||
icon="Folder"
|
icon="Folder"
|
||||||
|
|
|
@ -30,20 +30,16 @@ const Tags = () => {
|
||||||
title="Tags"
|
title="Tags"
|
||||||
sub="What tags would you like to filter by?"
|
sub="What tags would you like to filter by?"
|
||||||
/>
|
/>
|
||||||
<View>
|
|
||||||
<Fade color="black" width={30} height="100%">
|
<Fade color="black" width={30} height="100%">
|
||||||
<VirtualizedListWrapper contentContainerStyle={tw`w-full px-6`} horizontal>
|
<VirtualizedListWrapper contentContainerStyle={tw`px-6`} horizontal>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={tagsData}
|
data={tagsData}
|
||||||
renderItem={({ item }) => <TagFilter tag={item} />}
|
renderItem={({ item }) => <TagFilter tag={item} />}
|
||||||
extraData={searchStore.filters.tags}
|
extraData={searchStore.filters.tags}
|
||||||
|
alwaysBounceVertical={false}
|
||||||
numColumns={tagsData ? Math.max(Math.ceil(tagsData.length / 2), 2) : 1}
|
numColumns={tagsData ? Math.max(Math.ceil(tagsData.length / 2), 2) : 1}
|
||||||
key={tagsData ? 'tagsSearch' : '_'}
|
key={tagsData ? 'tagsSearch' : '_'}
|
||||||
contentContainerStyle={tw`w-full`}
|
ListEmptyComponent={<Empty icon="Tags" description="You have not created any tags" />}
|
||||||
ListEmptyComponent={
|
|
||||||
<Empty icon="Tags" description="You have not created any tags" />
|
|
||||||
}
|
|
||||||
scrollEnabled={false}
|
|
||||||
ItemSeparatorComponent={() => <View style={tw`h-2 w-2`} />}
|
ItemSeparatorComponent={() => <View style={tw`h-2 w-2`} />}
|
||||||
keyExtractor={(item) => item.id.toString()}
|
keyExtractor={(item) => item.id.toString()}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
|
@ -51,7 +47,6 @@ const Tags = () => {
|
||||||
/>
|
/>
|
||||||
</VirtualizedListWrapper>
|
</VirtualizedListWrapper>
|
||||||
</Fade>
|
</Fade>
|
||||||
</View>
|
|
||||||
</MotiView>
|
</MotiView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { Tag } from '@sd/client';
|
||||||
import { DotsThreeOutlineVertical } from 'phosphor-react-native';
|
import { DotsThreeOutlineVertical } from 'phosphor-react-native';
|
||||||
import { Pressable, Text, View } from 'react-native';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { Tag } from '@sd/client';
|
|
||||||
import { tw, twStyle } from '~/lib/tailwind';
|
import { tw, twStyle } from '~/lib/tailwind';
|
||||||
|
|
||||||
import Card from '../layout/Card';
|
import Card from '../layout/Card';
|
||||||
|
|
|
@ -19,16 +19,16 @@ const ListTag = ({ tag, tagStyle }: ListTagProps) => {
|
||||||
return (
|
return (
|
||||||
<Swipeable
|
<Swipeable
|
||||||
ref={swipeRef}
|
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
|
enableTrackpadTwoFingerGesture
|
||||||
renderRightActions={(progress, _, swipeable) => (
|
renderRightActions={(progress, _, swipeable) => (
|
||||||
<RightActions progress={progress} swipeable={swipeable} tag={tag} />
|
<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={tw`flex-1 flex-row items-center gap-2`}>
|
||||||
<View
|
<View
|
||||||
style={twStyle('h-[28px] w-[28px] rounded-full', {
|
style={twStyle('h-5 w-5 rounded-full', {
|
||||||
backgroundColor: tag.color!
|
backgroundColor: tag.color!
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -32,7 +32,7 @@ export default function SearchStack() {
|
||||||
name="Location"
|
name="Location"
|
||||||
component={LocationScreen}
|
component={LocationScreen}
|
||||||
options={({route: optionsRoute}) => ({
|
options={({route: optionsRoute}) => ({
|
||||||
header: (route) => <DynamicHeader optionsRoute={optionsRoute} headerRoute={route} kind="location" />
|
header: (route) => <DynamicHeader optionsRoute={optionsRoute} headerRoute={route} kind="locations" />
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
|
|
|
@ -34,7 +34,7 @@ export default function BrowseStack() {
|
||||||
<DynamicHeader
|
<DynamicHeader
|
||||||
optionsRoute={optionsRoute}
|
optionsRoute={optionsRoute}
|
||||||
headerRoute={route}
|
headerRoute={route}
|
||||||
kind="location"
|
kind="locations"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
@ -58,7 +58,7 @@ export default function BrowseStack() {
|
||||||
component={TagScreen}
|
component={TagScreen}
|
||||||
options={({ route: optionsRoute }) => ({
|
options={({ route: optionsRoute }) => ({
|
||||||
header: (route) => (
|
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 = {
|
export type BrowseStackParamList = {
|
||||||
Browse: undefined;
|
Browse: undefined;
|
||||||
Location: { id: number; path?: string };
|
Location: { id: number; path?: string, name?: string };
|
||||||
Locations: undefined;
|
Locations: undefined;
|
||||||
Tag: { id: number; color: string };
|
Tag: { id: number; color: string };
|
||||||
Tags: undefined;
|
Tags: undefined;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client';
|
import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import Explorer from '~/components/explorer/Explorer';
|
import Explorer from '~/components/explorer/Explorer';
|
||||||
|
import { useSortBy } from '~/hooks/useSortBy';
|
||||||
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
|
import { BrowseStackScreenProps } from '~/navigation/tabs/BrowseStack';
|
||||||
import { getExplorerStore } from '~/stores/explorerStore';
|
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 location = useLibraryQuery(['locations.get', route.params.id]);
|
||||||
const locationData = location.data;
|
const locationData = location.data;
|
||||||
|
const order = useSortBy();
|
||||||
|
const title = useMemo(() => {
|
||||||
|
return path?.split('/')
|
||||||
|
.filter((x) => x !== '')
|
||||||
|
.pop();
|
||||||
|
}, [path])
|
||||||
|
|
||||||
const paths = usePathsExplorerQuery({
|
const paths = usePathsExplorerQuery({
|
||||||
arg: {
|
arg: {
|
||||||
filters: [
|
filters: [
|
||||||
// ...search.allFilters,
|
{ filePath: { hidden: false }},
|
||||||
{ filePath: { locations: { in: [id] } } },
|
{ filePath: { locations: { in: [id] } } },
|
||||||
{
|
{
|
||||||
filePath: {
|
filePath: {
|
||||||
|
@ -21,18 +28,13 @@ export default function LocationScreen({ navigation, route }: BrowseStackScreenP
|
||||||
location_id: id,
|
location_id: id,
|
||||||
path: path ?? '',
|
path: path ?? '',
|
||||||
include_descendants: false
|
include_descendants: false
|
||||||
// include_descendants:
|
|
||||||
// search.search !== '' ||
|
|
||||||
// search.dynamicFilters.length > 0 ||
|
|
||||||
// (layoutMode === 'media' && mediaViewWithDescendants)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// !showHiddenFiles && { filePath: { hidden: false } }
|
|
||||||
].filter(Boolean) as any,
|
].filter(Boolean) as any,
|
||||||
take: 30
|
take: 30
|
||||||
},
|
},
|
||||||
order: null,
|
order,
|
||||||
onSuccess: () => getExplorerStore().resetNewThumbnails()
|
onSuccess: () => getExplorerStore().resetNewThumbnails()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -41,17 +43,19 @@ export default function LocationScreen({ navigation, route }: BrowseStackScreenP
|
||||||
if (path && path !== '') {
|
if (path && path !== '') {
|
||||||
// Nested location.
|
// Nested location.
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
title: path
|
title
|
||||||
.split('/')
|
|
||||||
.filter((x) => x !== '')
|
|
||||||
.pop()
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
title: locationData?.name ?? 'Location'
|
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(() => {
|
useEffect(() => {
|
||||||
getExplorerStore().locationId = id;
|
getExplorerStore().locationId = id;
|
||||||
|
|
|
@ -46,6 +46,7 @@ export default function LocationsScreen({ viewStyle }: Props) {
|
||||||
>
|
>
|
||||||
<Plus size={20} weight="bold" style={tw`text-ink`} />
|
<Plus size={20} weight="bold" style={tw`text-ink`} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<View style={tw`min-h-full`}>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={filteredLocations}
|
data={filteredLocations}
|
||||||
contentContainerStyle={twStyle(
|
contentContainerStyle={twStyle(
|
||||||
|
@ -84,6 +85,7 @@ export default function LocationsScreen({ viewStyle }: Props) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
<ImportModal ref={modalRef} />
|
<ImportModal ref={modalRef} />
|
||||||
</ScreenContainer>
|
</ScreenContainer>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useLibraryQuery, useObjectsExplorerQuery } from '@sd/client';
|
import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import Explorer from '~/components/explorer/Explorer';
|
import Explorer from '~/components/explorer/Explorer';
|
||||||
import Empty from '~/components/layout/Empty';
|
import Empty from '~/components/layout/Empty';
|
||||||
|
@ -11,17 +11,26 @@ export default function TagScreen({ navigation, route }: BrowseStackScreenProps<
|
||||||
const tag = useLibraryQuery(['tags.get', id]);
|
const tag = useLibraryQuery(['tags.get', id]);
|
||||||
const tagData = tag.data;
|
const tagData = tag.data;
|
||||||
|
|
||||||
const objects = useObjectsExplorerQuery({
|
const objects = usePathsExplorerQuery({
|
||||||
arg: { filters: [{ object: { tags: { in: [id] } } }], take: 30 },
|
arg: { filters: [
|
||||||
|
{ object: { tags: { in: [id] } } },
|
||||||
|
], take: 30 },
|
||||||
|
enabled: typeof id === 'number',
|
||||||
order: null
|
order: null
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set screen title to tag name.
|
// Set screen title to tag name.
|
||||||
navigation.setOptions({
|
if (tagData) {
|
||||||
title: tagData?.name ?? 'Tag'
|
navigation.setParams({
|
||||||
});
|
id: tagData.id,
|
||||||
}, [tagData?.name, navigation]);
|
color: tagData.color as string
|
||||||
|
})
|
||||||
|
navigation.setOptions({
|
||||||
|
title: tagData.name ?? 'Tag',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [tagData, id, navigation]);
|
||||||
|
|
||||||
return <Explorer
|
return <Explorer
|
||||||
isEmpty={objects.count === 0}
|
isEmpty={objects.count === 0}
|
||||||
|
|
|
@ -46,6 +46,7 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
|
||||||
>
|
>
|
||||||
<Plus size={20} weight="bold" style={tw`text-ink`} />
|
<Plus size={20} weight="bold" style={tw`text-ink`} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<View style={tw`min-h-full`}>
|
||||||
<FlatList
|
<FlatList
|
||||||
data={filteredTags}
|
data={filteredTags}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
|
@ -75,10 +76,11 @@ export default function TagsScreen({ viewStyle = 'list' }: Props) {
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
ItemSeparatorComponent={() => <View style={tw`h-2`} />}
|
ItemSeparatorComponent={() => <View style={tw`h-2`} />}
|
||||||
contentContainerStyle={twStyle(
|
contentContainerStyle={twStyle(
|
||||||
`py-6`,
|
'py-6',
|
||||||
tagsData?.length === 0 && 'h-full items-center justify-center'
|
tagsData?.length === 0 && 'h-full items-center justify-center'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
<CreateTagModal ref={modalRef} />
|
<CreateTagModal ref={modalRef} />
|
||||||
</ScreenContainer>
|
</ScreenContainer>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,7 +6,7 @@ const FiltersScreen = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScreenContainer tabHeight={false}>
|
<ScreenContainer tabHeight={false}>
|
||||||
<FiltersList />
|
<FiltersList />
|
||||||
</ScreenContainer>
|
</ScreenContainer>
|
||||||
<SaveAdd />
|
<SaveAdd />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { resetStore } from '@sd/client';
|
||||||
import { proxy, useSnapshot } from 'valtio';
|
import { proxy, useSnapshot } from 'valtio';
|
||||||
import { proxySet } from 'valtio/utils';
|
import { proxySet } from 'valtio/utils';
|
||||||
import { resetStore, type Ordering } from '@sd/client';
|
|
||||||
|
|
||||||
export type ExplorerLayoutMode = 'list' | 'grid' | 'media';
|
export type ExplorerLayoutMode = 'list' | 'grid' | 'media';
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ const state = {
|
||||||
toggleMenu: false as boolean,
|
toggleMenu: false as boolean,
|
||||||
// Using gridNumColumns instead of fixed size. We dynamically calculate the item size.
|
// Using gridNumColumns instead of fixed size. We dynamically calculate the item size.
|
||||||
gridNumColumns: 3,
|
gridNumColumns: 3,
|
||||||
listItemSize: 65,
|
listItemSize: 60,
|
||||||
newThumbnails: proxySet() as Set<string>,
|
newThumbnails: proxySet() as Set<string>,
|
||||||
// sorting
|
// sorting
|
||||||
// we will display different sorting options based on the kind of explorer we are in
|
// 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 { createRef } from 'react';
|
||||||
import { proxy, ref, useSnapshot } from 'valtio';
|
import { proxy, ref, useSnapshot } from 'valtio';
|
||||||
import { ExplorerItem } from '@sd/client';
|
|
||||||
import { ModalRef } from '~/components/layout/Modal';
|
import { ModalRef } from '~/components/layout/Modal';
|
||||||
|
|
||||||
const store = proxy({
|
const store = proxy({
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { proxy, useSnapshot } from 'valtio';
|
|
||||||
import { SearchFilterArgs } from '@sd/client';
|
import { SearchFilterArgs } from '@sd/client';
|
||||||
|
import { proxy, useSnapshot } from 'valtio';
|
||||||
import { IconName } from '~/components/icons/Icon';
|
import { IconName } from '~/components/icons/Icon';
|
||||||
|
|
||||||
export type SearchFilters = 'locations' | 'tags' | 'name' | 'extension' | 'hidden' | 'kind';
|
export type SearchFilters = 'locations' | 'tags' | 'name' | 'extension' | 'hidden' | 'kind';
|
||||||
|
@ -74,7 +74,7 @@ function updateArrayOrObject<T>(
|
||||||
array: T[],
|
array: T[],
|
||||||
item: any,
|
item: any,
|
||||||
filterByKey: string = 'id',
|
filterByKey: string = 'id',
|
||||||
isObject: boolean = false
|
isObject: boolean = false,
|
||||||
): T[] {
|
): T[] {
|
||||||
if (isObject) {
|
if (isObject) {
|
||||||
const index = (array as any).findIndex((i: any) => i.id === item[filterByKey]);
|
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']>(
|
updateFilters: <K extends keyof State['filters']>(
|
||||||
filter: K,
|
filter: K,
|
||||||
value: State['filters'][K] extends Array<infer U> ? U : State['filters'][K],
|
value: State['filters'][K] extends Array<infer U> ? U : State['filters'][K],
|
||||||
apply?: boolean
|
apply?: boolean,
|
||||||
|
keepSame?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
searchFrom: (filter: 'tags' | 'locations', value: TagItem | FilterItem) => void;
|
||||||
applyFilters: () => void;
|
applyFilters: () => void;
|
||||||
setSearch: (search: string) => void;
|
setSearch: (search: string) => void;
|
||||||
resetFilter: <K extends keyof State['filters']>(filter: K, apply?: boolean) => void;
|
resetFilter: <K extends keyof State['filters']>(filter: K, apply?: boolean) => void;
|
||||||
|
@ -108,13 +110,15 @@ const searchStore = proxy<
|
||||||
...initialState,
|
...initialState,
|
||||||
//for updating the filters upon value selection
|
//for updating the filters upon value selection
|
||||||
updateFilters: (filter, value, apply = false) => {
|
updateFilters: (filter, value, apply = false) => {
|
||||||
|
const currentFilter = searchStore.filters[filter];
|
||||||
|
const arrayCheck = Array.isArray(currentFilter);
|
||||||
|
|
||||||
if (filter === 'hidden') {
|
if (filter === 'hidden') {
|
||||||
// Directly assign boolean values without an array operation
|
// Directly assign boolean values without an array operation
|
||||||
searchStore.filters['hidden'] = value as boolean;
|
searchStore.filters['hidden'] = value as boolean;
|
||||||
} else {
|
} else {
|
||||||
// Handle array-based filters with more specific type handling
|
// Handle array-based filters with more specific type handling
|
||||||
const currentFilter = searchStore.filters[filter];
|
if (arrayCheck) {
|
||||||
if (Array.isArray(currentFilter)) {
|
|
||||||
// Cast to the correct type based on the filter being updated
|
// Cast to the correct type based on the filter being updated
|
||||||
const updatedFilter = updateArrayOrObject(
|
const updatedFilter = updateArrayOrObject(
|
||||||
currentFilter,
|
currentFilter,
|
||||||
|
@ -129,6 +133,21 @@ const searchStore = proxy<
|
||||||
// useful when you want to apply the filters from another screen
|
// useful when you want to apply the filters from another screen
|
||||||
if (apply) searchStore.applyFilters();
|
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
|
//for clicking add filters and applying the selection
|
||||||
applyFilters: () => {
|
applyFilters: () => {
|
||||||
// loop through all filters and apply the ones with values
|
// 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 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 {
|
object: include {
|
||||||
|
tags: include { tag }
|
||||||
exif_data: select {
|
exif_data: select {
|
||||||
resolution
|
resolution
|
||||||
media_date
|
media_date
|
||||||
|
|
|
@ -13,7 +13,7 @@ use crate::{
|
||||||
|
|
||||||
use sd_core_indexer_rules::IndexerRuleCreateArgs;
|
use sd_core_indexer_rules::IndexerRuleCreateArgs;
|
||||||
use sd_core_prisma_helpers::{
|
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};
|
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
|
// this tells the frontend if a thumbnail actually exists or not
|
||||||
has_created_thumbnail: bool,
|
has_created_thumbnail: bool,
|
||||||
// we can't actually modify data from PCR types, thats why computed properties are used on ExplorerItem
|
// 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 {
|
Object {
|
||||||
thumbnail: Option<ThumbnailKey>,
|
thumbnail: Option<ThumbnailKey>,
|
||||||
|
|
|
@ -7,7 +7,7 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use prisma_client_rust::Operator;
|
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 sd_prisma::prisma::{self, PrismaClient};
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
@ -210,7 +210,7 @@ pub fn mount() -> AlphaRouter<Ctx> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_paths = query
|
let file_paths = query
|
||||||
.include(file_path_with_object::include())
|
.include(file_path_for_frontend::include())
|
||||||
.exec()
|
.exec()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { Plus } from '@phosphor-icons/react';
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { RefObject, useMemo, useRef } from 'react';
|
import { RefObject, useMemo, useRef } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { ExplorerItem, useLibraryQuery } from '@sd/client';
|
|
||||||
import { Button, dialogManager, ModifierKeys, tw } from '@sd/ui';
|
|
||||||
import CreateDialog, {
|
import CreateDialog, {
|
||||||
AssignTagItems,
|
AssignTagItems,
|
||||||
useAssignItemsToTag
|
useAssignItemsToTag
|
||||||
|
@ -38,6 +38,7 @@ function useData({ items }: Props) {
|
||||||
{ suspense: true }
|
{ suspense: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tags: {
|
tags: {
|
||||||
...tags,
|
...tags,
|
||||||
|
|
|
@ -12,6 +12,22 @@ import {
|
||||||
Icon as PhosphorIcon,
|
Icon as PhosphorIcon,
|
||||||
Snowflake
|
Snowflake
|
||||||
} from '@phosphor-icons/react';
|
} 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 clsx from 'clsx';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
|
@ -26,22 +42,6 @@ import {
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { Link as NavLink } from 'react-router-dom';
|
import { Link as NavLink } from 'react-router-dom';
|
||||||
import Sticky from 'react-sticky-el';
|
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 { LibraryIdParamsSchema } from '~/app/route-schemas';
|
||||||
import { Folder, Icon } from '~/components';
|
import { Folder, Icon } from '~/components';
|
||||||
import { useLocale, useZodRouteParams } from '~/hooks';
|
import { useLocale, useZodRouteParams } from '~/hooks';
|
||||||
|
@ -171,7 +171,7 @@ const Thumbnails = ({ items }: { items: ExplorerItem[] }) => {
|
||||||
|
|
||||||
export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
export const SingleItemMetadata = ({ item }: { item: ExplorerItem }) => {
|
||||||
let objectData: Object | ObjectWithFilePaths | null = null;
|
let objectData: Object | ObjectWithFilePaths | null = null;
|
||||||
let filePathData: FilePath | FilePathWithObject | null = null;
|
let filePathData: FilePath | FilePathForFrontend | null = null;
|
||||||
let ephemeralPathData: NonIndexedPathItem | null = null;
|
let ephemeralPathData: NonIndexedPathItem | null = null;
|
||||||
|
|
||||||
const { t, dateFormat } = useLocale();
|
const { t, dateFormat } = useLocale();
|
||||||
|
@ -484,7 +484,6 @@ const MultiItemMetadata = ({ items }: { items: ExplorerItem[] }) => {
|
||||||
(metadata, item) => {
|
(metadata, item) => {
|
||||||
const { kind, size, dateCreated, dateAccessed, dateModified, dateIndexed } =
|
const { kind, size, dateCreated, dateAccessed, dateModified, dateIndexed } =
|
||||||
getExplorerItemData(item);
|
getExplorerItemData(item);
|
||||||
|
|
||||||
if (item.type !== 'NonIndexedPath' || !item.item.is_dir) {
|
if (item.type !== 'NonIndexedPath' || !item.item.is_dir) {
|
||||||
metadata.size = (metadata.size ?? 0n) + size.bytes;
|
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 onlyNonIndexed = metadata.types.has('NonIndexedPath') && metadata.types.size === 1;
|
||||||
const filesSize = humanizeSize(metadata.size);
|
const filesSize = humanizeSize(metadata.size);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MetaContainer>
|
<MetaContainer>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { useSelector, type ExplorerItem } from '@sd/client';
|
||||||
import { HTMLAttributes, ReactNode, useMemo } from 'react';
|
import { HTMLAttributes, ReactNode, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useSelector, type ExplorerItem } from '@sd/client';
|
|
||||||
import { useOperatingSystem } from '~/hooks';
|
|
||||||
import { useRoutingContext } from '~/RoutingContext';
|
import { useRoutingContext } from '~/RoutingContext';
|
||||||
|
import { useOperatingSystem } from '~/hooks';
|
||||||
|
|
||||||
import { useExplorerContext } from '../../Context';
|
import { useExplorerContext } from '../../Context';
|
||||||
import { explorerStore, isCut } from '../../store';
|
import { explorerStore, isCut } from '../../store';
|
||||||
|
@ -37,6 +37,7 @@ export const GridItem = ({ children, item, index, ...props }: Props) => {
|
||||||
[explorer.selectedItems, item]
|
[explorer.selectedItems, item]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const canGoBack = currentIndex !== 0;
|
const canGoBack = currentIndex !== 0;
|
||||||
const canGoForward = currentIndex !== maxIndex;
|
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 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"
|
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 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 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 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 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 = {
|
export type Flash = {
|
||||||
/**
|
/**
|
||||||
* Specifies how flash was used (on, auto, off, forced, onvalid)
|
* Specifies how flash was used (on, auto, off, forced, onvalid)
|
||||||
|
|
Loading…
Reference in a new issue