[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:
ameer2468 2024-05-17 16:55:29 +01:00 committed by GitHub
parent c091ccacfd
commit 9d47af8bd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 487 additions and 146 deletions

View file

@ -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>
);
};

View file

@ -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>
</>
);
};

View file

@ -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)}>
{/* Kind */}
<InfoPill containerStyle={tw`mr-1`} text={isDir ? 'Folder' : getExplorerItemData(data).kind} />
<>
<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 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) => (
<InfoPill
key={tag.id}
text={tag.name ?? 'Unnamed Tag'}
containerStyle={twStyle('mr-1', { backgroundColor: tag.color + 'CC' })}
textStyle={tw`text-white`}
/>
))}
<Pressable onPress={() => Alert.alert('TODO')}>
<PlaceholderPill text={'Add Tag'} />
</Pressable>
</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
text={item.name ?? 'Unnamed Tag'}
containerStyle={twStyle({ backgroundColor: item.color + 'CC' })}
textStyle={tw`text-white`}
/>
)}/>
</Fade>
</View>
</View>
<AddTagModal ref={ref}/>
</>
);
};

View file

@ -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
})}
/>

View file

@ -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) => (
<>

View 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;

View file

@ -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} />

View file

@ -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>
);
});

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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>
);
};

View file

@ -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';

View file

@ -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!
})}
/>

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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>
);

View file

@ -1,4 +1,4 @@
import { useLibraryQuery, useObjectsExplorerQuery } from '@sd/client';
import { useLibraryQuery, usePathsExplorerQuery } from '@sd/client';
import { useEffect } from 'react';
import Explorer from '~/components/explorer/Explorer';
import Empty from '~/components/layout/Empty';
@ -11,17 +11,26 @@ export default function TagScreen({ navigation, route }: BrowseStackScreenProps<
const tag = useLibraryQuery(['tags.get', id]);
const tagData = tag.data;
const objects = useObjectsExplorerQuery({
arg: { filters: [{ object: { tags: { in: [id] } } }], take: 30 },
const objects = usePathsExplorerQuery({
arg: { filters: [
{ object: { tags: { in: [id] } } },
], take: 30 },
enabled: typeof id === 'number',
order: null
});
useEffect(() => {
// Set screen title to tag name.
navigation.setOptions({
title: tagData?.name ?? 'Tag'
});
}, [tagData?.name, navigation]);
if (tagData) {
navigation.setParams({
id: tagData.id,
color: tagData.color as string
})
navigation.setOptions({
title: tagData.name ?? 'Tag',
});
}
}, [tagData, id, navigation]);
return <Explorer
isEmpty={objects.count === 0}

View file

@ -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>
);

View file

@ -6,7 +6,7 @@ const FiltersScreen = () => {
return (
<>
<ScreenContainer tabHeight={false}>
<FiltersList />
<FiltersList />
</ScreenContainer>
<SaveAdd />
</>

View file

@ -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

View file

@ -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({

View file

@ -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

View file

@ -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

View file

@ -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>,

View file

@ -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?;

View file

@ -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,

View file

@ -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>

View file

@ -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;

View file

@ -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)