load local files
This commit is contained in:
+11
-2
@@ -18,7 +18,9 @@
|
||||
"permissions": [
|
||||
"CAMERA",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE"
|
||||
"WRITE_EXTERNAL_STORAGE",
|
||||
"READ_MEDIA_IMAGES",
|
||||
"READ_MEDIA_VIDEO"
|
||||
],
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
@@ -33,7 +35,14 @@
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-secure-store"
|
||||
"expo-secure-store",
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "VaultDrop a besoin d'accéder à vos photos pour les afficher et les organiser.",
|
||||
"videosPermission": "VaultDrop a besoin d'accéder à vos vidéos pour les afficher et les organiser."
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+89
-10
@@ -10,14 +10,19 @@ import {
|
||||
ScrollView,
|
||||
Modal,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { RouteProp, useRoute } from '@react-navigation/native';
|
||||
import { useFile, useFileImage } from '../hooks/useFiles';
|
||||
import { useDownloadFile } from '../hooks/useUnifiedFiles';
|
||||
import { localFileRegistry } from '../services/localFileRegistry';
|
||||
import { TagChip } from '../components/TagChip';
|
||||
import { FileThumbnail } from '../components/FileThumbnail';
|
||||
import { SyncStatusBadge } from '../components/SyncStatusBadge';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { ZoomableImage } from '../components/ZoomableImage';
|
||||
import type { Thumbnail } from '../types';
|
||||
import type { Thumbnail, SyncStatus } from '../types';
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
|
||||
@@ -34,15 +39,43 @@ type FileDetailRouteProp = RouteProp<RootStackParamList, 'FileDetail'>;
|
||||
function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?: (state: ModalState) => void }) {
|
||||
const { data: imageData, isLoading: imageLoading } = useFileImage(fileId);
|
||||
const { data: fileData } = useFile(fileId);
|
||||
const downloadFile = useDownloadFile();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const uri = imageData?.data?.url;
|
||||
const file = fileData as any;
|
||||
|
||||
const localEntry = localFileRegistry.getByBackendId(fileId);
|
||||
const syncStatus: SyncStatus = localEntry
|
||||
? (localEntry.syncStatus === 'cloud' ? 'cloud' : 'synced')
|
||||
: (uri ? 'synced' : 'cloud');
|
||||
|
||||
const fullThumbnails: Thumbnail[] = (file?.data?.thumbnails ?? [])
|
||||
.filter((t: Thumbnail) => t.resolutionLabel === 'full')
|
||||
.sort((a: Thumbnail, b: Thumbnail) => a.pageNumber - b.pageNumber);
|
||||
|
||||
const hasPages = fullThumbnails.length > 0;
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!file) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
await downloadFile.mutateAsync({
|
||||
id: fileId,
|
||||
backendFileId: fileId,
|
||||
name: file.data?.name ?? fileId,
|
||||
mimeType: file.mimeType ?? 'application/octet-stream',
|
||||
size: file.data?.size ?? 0,
|
||||
createdAt: file.createdAt ?? new Date().toISOString(),
|
||||
syncStatus: 'cloud',
|
||||
tags: file.data?.tags ?? [],
|
||||
isFolder: false,
|
||||
});
|
||||
} catch {} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}, [file, fileId, downloadFile]);
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.page} contentContainerStyle={styles.pageContent}>
|
||||
{hasPages ? (
|
||||
@@ -75,13 +108,31 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
|
||||
<Image source={{ uri }} style={styles.image} resizeMode="contain" />
|
||||
</Pressable>
|
||||
) : file ? (
|
||||
<FileThumbnail
|
||||
thumbnailUrl={file.data?.thumbnailUrl}
|
||||
mimeType={file.mimeType ?? 'application/pdf'}
|
||||
fileName={file.name ?? fileId}
|
||||
size={SCREEN_WIDTH * 0.6}
|
||||
isLoading={imageLoading}
|
||||
/>
|
||||
<View style={styles.cloudOnlyContainer}>
|
||||
<FileThumbnail
|
||||
thumbnailUrl={file.data?.thumbnailUrl}
|
||||
mimeType={file.mimeType ?? 'application/pdf'}
|
||||
fileName={file.name ?? fileId}
|
||||
size={SCREEN_WIDTH * 0.6}
|
||||
isLoading={imageLoading}
|
||||
/>
|
||||
{syncStatus === 'cloud' && (
|
||||
<TouchableOpacity
|
||||
style={styles.downloadBtn}
|
||||
onPress={handleDownload}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<MaterialIcons name="cloud-download" size={20} color="#fff" />
|
||||
)}
|
||||
<Text style={styles.downloadBtnText}>
|
||||
{downloading ? 'Téléchargement...' : 'Télécharger'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<ActivityIndicator size="large" color="#1976D2" />
|
||||
)}
|
||||
@@ -89,7 +140,10 @@ function DetailItem({ fileId, onSelectImage }: { fileId: string; onSelectImage?:
|
||||
)}
|
||||
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.fileName}>{imageData?.data?.name ?? file?.name ?? fileId}</Text>
|
||||
<View style={styles.detailHeader}>
|
||||
<Text style={styles.fileName}>{imageData?.data?.name ?? file?.name ?? fileId}</Text>
|
||||
<SyncStatusBadge status={syncStatus} size={20} />
|
||||
</View>
|
||||
|
||||
{imageData?.data?.size != null && (
|
||||
<Text style={styles.meta}>
|
||||
@@ -250,11 +304,36 @@ const styles = StyleSheet.create({
|
||||
marginTop: -16,
|
||||
padding: 20,
|
||||
},
|
||||
detailHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
fileName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
marginBottom: 8,
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
cloudOnlyContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
downloadBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1976D2',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
gap: 8,
|
||||
},
|
||||
downloadBtnText: {
|
||||
color: '#fff',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
meta: {
|
||||
fontSize: 14,
|
||||
|
||||
+11
-16
@@ -3,8 +3,10 @@ import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, Alert }
|
||||
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useFilesByParent, useFileImage, useDeleteFile } from '../hooks/useFiles';
|
||||
import { FileItem, isFolder } from '../types';
|
||||
import { useDeleteFile, useFileImage } from '../hooks/useFiles';
|
||||
import { useUnifiedFilesByParent } from '../hooks/useUnifiedFiles';
|
||||
import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
|
||||
import { isFolder } from '../types';
|
||||
import { FileThumbnail } from '../components/FileThumbnail';
|
||||
|
||||
const NUM_COLUMNS = 3;
|
||||
@@ -21,7 +23,7 @@ type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type FolderRouteProp = RouteProp<RootStackParamList, 'Folder'>;
|
||||
|
||||
function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }: {
|
||||
file: FileItem;
|
||||
file: UnifiedFileItem;
|
||||
onPress?: () => void;
|
||||
onLongPress?: () => void;
|
||||
selected?: boolean;
|
||||
@@ -39,12 +41,13 @@ function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }:
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FileThumbnail
|
||||
uri={data?.data?.url}
|
||||
uri={data?.data?.url ?? file.localUri}
|
||||
thumbnailUrl={file.thumbnailUrl}
|
||||
mimeType={file.mimeType}
|
||||
fileName={file.name}
|
||||
size={ITEM_SIZE}
|
||||
isLoading={isLoading}
|
||||
syncStatus={file.syncStatus}
|
||||
/>
|
||||
{selected && (
|
||||
<View style={styles.selectedOverlay}>
|
||||
@@ -62,7 +65,7 @@ export function FolderScreen() {
|
||||
const route = useRoute<FolderRouteProp>();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const { folderId, folderName } = route.params;
|
||||
const { data, isLoading, error } = useFilesByParent(folderId);
|
||||
const { data, isLoading } = useUnifiedFilesByParent(folderId);
|
||||
const deleteFile = useDeleteFile();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -73,7 +76,7 @@ export function FolderScreen() {
|
||||
}, [navigation, folderName]);
|
||||
|
||||
const files = useMemo(() => {
|
||||
return data?.data ?? [];
|
||||
return data ?? [];
|
||||
}, [data]);
|
||||
|
||||
const fileIdToIndex = useMemo(() => {
|
||||
@@ -117,7 +120,7 @@ export function FolderScreen() {
|
||||
);
|
||||
}, [selectedIds, deleteFile]);
|
||||
|
||||
const handleItemPress = useCallback((file: FileItem) => {
|
||||
const handleItemPress = useCallback((file: UnifiedFileItem) => {
|
||||
if (selectionMode) {
|
||||
toggleSelection(file.id);
|
||||
} else if (isFolder(file)) {
|
||||
@@ -130,7 +133,7 @@ export function FolderScreen() {
|
||||
}
|
||||
}, [selectionMode, toggleSelection, navigation, files, fileIdToIndex]);
|
||||
|
||||
const handleItemLongPress = useCallback((file: FileItem) => {
|
||||
const handleItemLongPress = useCallback((file: UnifiedFileItem) => {
|
||||
if (!selectionMode) toggleSelection(file.id);
|
||||
}, [selectionMode, toggleSelection]);
|
||||
|
||||
@@ -142,14 +145,6 @@ export function FolderScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.infoText}>Erreur de chargement</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
|
||||
+98
-14
@@ -4,7 +4,9 @@ import { useNavigation } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useFiles, useFileImage, useDeleteFile, useAddTags, useMoveFiles, useFolders } from '../hooks/useFiles';
|
||||
import { useDeleteFile, useAddTags, useMoveFiles, useFolders, useFileImage } from '../hooks/useFiles';
|
||||
import { useUnifiedFiles, useFreeLocalSpace } from '../hooks/useUnifiedFiles';
|
||||
import { UnifiedFileItem } from '../hooks/useUnifiedFiles';
|
||||
import { FileItem, isFolder } from '../types';
|
||||
import { SearchBar, SearchFilters } from '../components/SearchBar';
|
||||
import { FileThumbnail } from '../components/FileThumbnail';
|
||||
@@ -24,7 +26,7 @@ type RootStackParamList = {
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
type GroupedFiles = Record<string, FileItem[]>;
|
||||
type GroupedFiles = Record<string, UnifiedFileItem[]>;
|
||||
|
||||
function parseBackendDate(dateStr: string): Date | null {
|
||||
if (!dateStr) return null;
|
||||
@@ -33,7 +35,7 @@ function parseBackendDate(dateStr: string): Date | null {
|
||||
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), Number(match[6]));
|
||||
}
|
||||
|
||||
function groupByDate(files: FileItem[]): GroupedFiles {
|
||||
function groupByDate(files: UnifiedFileItem[]): GroupedFiles {
|
||||
const groups: GroupedFiles = {};
|
||||
for (const file of files) {
|
||||
const d = parseBackendDate(file.createdAt);
|
||||
@@ -47,7 +49,7 @@ function groupByDate(files: FileItem[]): GroupedFiles {
|
||||
return groups;
|
||||
}
|
||||
|
||||
function groupByTag(files: FileItem[]): GroupedFiles {
|
||||
function groupByTag(files: UnifiedFileItem[]): GroupedFiles {
|
||||
const groups: GroupedFiles = {};
|
||||
for (const file of files) {
|
||||
const tags = file.tags ?? [];
|
||||
@@ -76,7 +78,7 @@ function formatDateLabel(key: string): string {
|
||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||
}
|
||||
|
||||
function FileGridItem({ file, onPress, onLongPress, selected }: { file: FileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
|
||||
function FileGridItem({ file, onPress, onLongPress, selected }: { file: UnifiedFileItem; onPress?: () => void; onLongPress?: () => void; selected?: boolean }) {
|
||||
const { data, isLoading } = useFileImage(file.id);
|
||||
|
||||
return (
|
||||
@@ -88,12 +90,13 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: FileItem
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FileThumbnail
|
||||
uri={data?.data?.url}
|
||||
uri={data?.data?.url ?? file.localUri}
|
||||
thumbnailUrl={file.thumbnailUrl}
|
||||
mimeType={file.mimeType}
|
||||
fileName={file.name}
|
||||
size={ITEM_SIZE}
|
||||
isLoading={isLoading}
|
||||
syncStatus={file.syncStatus}
|
||||
/>
|
||||
{selected && (
|
||||
<View style={styles.selectedOverlay}>
|
||||
@@ -107,7 +110,7 @@ function FileGridItem({ file, onPress, onLongPress, selected }: { file: FileItem
|
||||
);
|
||||
}
|
||||
|
||||
function matchesQuery(file: FileItem, query: string, filters: SearchFilters): boolean {
|
||||
function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
if (filters.name && file.name.toLowerCase().includes(q)) return true;
|
||||
@@ -130,8 +133,9 @@ function matchesQuery(file: FileItem, query: string, filters: SearchFilters): bo
|
||||
export function HomeScreen() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { data, isLoading, error } = useFiles();
|
||||
const { data, isLoading, error, hasPermission, requestPermission } = useUnifiedFiles();
|
||||
const deleteFile = useDeleteFile();
|
||||
const freeLocalSpace = useFreeLocalSpace();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true, tags: true });
|
||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||
@@ -153,15 +157,13 @@ export function HomeScreen() {
|
||||
|
||||
const selectionMode = selectedIds.size > 0;
|
||||
|
||||
const files = data?.data ?? [];
|
||||
const files = data ?? [];
|
||||
|
||||
const filteredFiles = useMemo(
|
||||
() => searchQuery.trim() ? files.filter((f) => matchesQuery(f, searchQuery, filters)) : files,
|
||||
[files, searchQuery, filters]
|
||||
);
|
||||
|
||||
console.log(filteredFiles.map(f => f.tags))
|
||||
|
||||
const groups = groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles);
|
||||
const groupKeys = Object.keys(groups);
|
||||
|
||||
@@ -208,6 +210,33 @@ export function HomeScreen() {
|
||||
);
|
||||
}, [selectedIds, deleteFile]);
|
||||
|
||||
const handleFreeSpace = useCallback(() => {
|
||||
const ids = Array.from(selectedIds);
|
||||
const syncedIds = ids.filter((id) => {
|
||||
const f = files.find((fi) => fi.id === id);
|
||||
return f?.syncStatus === 'synced';
|
||||
});
|
||||
if (syncedIds.length === 0) {
|
||||
Alert.alert('Info', 'Aucun fichier avec copie locale sélectionné.');
|
||||
return;
|
||||
}
|
||||
const label = syncedIds.length === 1 ? 'ce fichier' : `ces ${syncedIds.length} fichiers`;
|
||||
Alert.alert(
|
||||
'Libérer l\'espace',
|
||||
`${label} sera supprimé de votre appareil mais restera disponible sur le serveur. Vous pourrez le re-télécharger ultérieurement.`,
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Libérer',
|
||||
onPress: async () => {
|
||||
await freeLocalSpace.mutateAsync(syncedIds);
|
||||
setSelectedIds(new Set());
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [selectedIds, files, freeLocalSpace]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
@@ -240,7 +269,7 @@ export function HomeScreen() {
|
||||
setSelectedIds(new Set());
|
||||
}, [selectedIds, moveFiles]);
|
||||
|
||||
const handleItemPress = useCallback((file: FileItem) => {
|
||||
const handleItemPress = useCallback((file: UnifiedFileItem) => {
|
||||
if (selectionMode) {
|
||||
toggleSelection(file.id);
|
||||
} else if (isFolder(file)) {
|
||||
@@ -253,7 +282,7 @@ export function HomeScreen() {
|
||||
}
|
||||
}, [selectionMode, toggleSelection, navigation, filteredFiles, fileIdToIndex]);
|
||||
|
||||
const handleItemLongPress = useCallback((file: FileItem) => {
|
||||
const handleItemLongPress = useCallback((file: UnifiedFileItem) => {
|
||||
if (!selectionMode) {
|
||||
toggleSelection(file.id);
|
||||
}
|
||||
@@ -262,7 +291,19 @@ export function HomeScreen() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.infoText}>Chargement...</Text>
|
||||
{!hasPermission ? (
|
||||
<View style={styles.permissionBanner}>
|
||||
<MaterialIcons name="photo-library" size={28} color="#1976D2" />
|
||||
<Text style={styles.permissionText}>
|
||||
Autorisez l'accès à vos photos pour les afficher ici
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.permissionBtn} onPress={requestPermission}>
|
||||
<Text style={styles.permissionBtnText}>Autoriser</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.infoText}>Chargement...</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -281,6 +322,17 @@ export function HomeScreen() {
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 96 : 0}
|
||||
>
|
||||
{!hasPermission && (
|
||||
<View style={styles.permissionBanner}>
|
||||
<MaterialIcons name="photo-library" size={28} color="#1976D2" />
|
||||
<Text style={styles.permissionText}>
|
||||
Autorisez l'accès à vos photos pour les afficher ici
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.permissionBtn} onPress={requestPermission}>
|
||||
<Text style={styles.permissionBtnText}>Autoriser</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<FlatList
|
||||
data={groupKeys}
|
||||
keyExtractor={(item) => item}
|
||||
@@ -386,6 +438,13 @@ export function HomeScreen() {
|
||||
<MaterialIcons name="drive-file-move" size={18} color="#00897B" />
|
||||
<Text style={[styles.chipText, { color: '#00897B' }]}>Déplacer</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.selectionChip, styles.freeSpaceChip]}
|
||||
onPress={handleFreeSpace}
|
||||
>
|
||||
<MaterialIcons name="free-cancellation" size={18} color="#FF8F00" />
|
||||
<Text style={[styles.chipText, { color: '#FF8F00' }]}>Libérer</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
@@ -478,6 +537,30 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
permissionBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#E3F2FD',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
gap: 10,
|
||||
},
|
||||
permissionText: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
},
|
||||
permissionBtn: {
|
||||
backgroundColor: '#1976D2',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
permissionBtnText: {
|
||||
fontSize: 14,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
@@ -625,6 +708,7 @@ const styles = StyleSheet.create({
|
||||
tagChip: { backgroundColor: '#F3E5F5' },
|
||||
folderChip: { backgroundColor: '#FFF3E0' },
|
||||
moveChip: { backgroundColor: '#E0F2F1' },
|
||||
freeSpaceChip: { backgroundColor: '#FFF8E1' },
|
||||
folderOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { View, Text, Image, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { SyncStatusBadge } from './SyncStatusBadge';
|
||||
import type { SyncStatus } from '../types';
|
||||
|
||||
type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||
|
||||
@@ -35,9 +37,10 @@ interface FileThumbnailProps {
|
||||
fileName: string;
|
||||
size: number;
|
||||
isLoading?: boolean;
|
||||
syncStatus?: SyncStatus;
|
||||
}
|
||||
|
||||
export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading }: FileThumbnailProps) {
|
||||
export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus }: FileThumbnailProps) {
|
||||
const info = getFileInfo(mimeType, fileName);
|
||||
const ext = getExtension(fileName);
|
||||
|
||||
@@ -52,7 +55,12 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
|
||||
const imageUri = thumbnailUrl || (uri && mimeType.startsWith('image/') ? uri : undefined);
|
||||
|
||||
if (imageUri) {
|
||||
return <Image source={{ uri: imageUri }} style={[styles.image, { width: size, height: size }]} />;
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
<Image source={{ uri: imageUri }} style={[styles.image, { width: size, height: size }]} />
|
||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -61,6 +69,7 @@ export function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isL
|
||||
{ext.length <= 4 && (
|
||||
<Text style={[styles.ext, { color: info.color }]}>{ext}</Text>
|
||||
)}
|
||||
{syncStatus && <SyncStatusBadge status={syncStatus} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { SyncStatus } from '../types';
|
||||
|
||||
interface SyncStatusBadgeProps {
|
||||
status: SyncStatus;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<SyncStatus, { icon: string; color: string; bg: string }> = {
|
||||
local: { icon: 'phone-android', color: '#757575', bg: 'rgba(245,245,245,0.9)' },
|
||||
synced: { icon: 'sync', color: '#4CAF50', bg: 'rgba(232,245,233,0.9)' },
|
||||
cloud: { icon: 'cloud', color: '#1976D2', bg: 'rgba(227,242,253,0.9)' },
|
||||
};
|
||||
|
||||
export function SyncStatusBadge({ status, size = 16 }: SyncStatusBadgeProps) {
|
||||
const config = STATUS_CONFIG[status];
|
||||
const iconSize = Math.round(size * 0.7);
|
||||
|
||||
return (
|
||||
<View style={[styles.badge, { width: size, height: size, borderRadius: size / 2, backgroundColor: config.bg }]}>
|
||||
<MaterialIcons name={config.icon as any} size={iconSize} color={config.color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as MediaLibrary from 'expo-media-library/legacy';
|
||||
import { LocalFileEntry } from '../types';
|
||||
|
||||
export interface DeviceFile {
|
||||
id: string;
|
||||
uri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function useDeviceFiles() {
|
||||
const [files, setFiles] = useState<DeviceFile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
const req = await MediaLibrary.requestPermissionsAsync();
|
||||
console.log('[useDeviceFiles] requestPermission status:', req.status, 'granted:', req.granted, 'canAskAgain:', req.canAskAgain);
|
||||
if (req.granted) {
|
||||
setHasPermission(true);
|
||||
return true;
|
||||
}
|
||||
const check = await MediaLibrary.getPermissionsAsync();
|
||||
console.log('[useDeviceFiles] getPermissionsAsync status:', check.status, 'granted:', check.granted);
|
||||
const granted = check.granted;
|
||||
setHasPermission(granted);
|
||||
return granted;
|
||||
}, []);
|
||||
|
||||
const loadAssets = useCallback(async () => {
|
||||
console.log('[useDeviceFiles] loadAssets called');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await MediaLibrary.getAssetsAsync({
|
||||
first: 500,
|
||||
mediaType: ['photo', 'video'],
|
||||
sortBy: 'creationTime',
|
||||
});
|
||||
|
||||
const deviceFiles: DeviceFile[] = result.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
uri: asset.uri,
|
||||
name: asset.filename,
|
||||
mimeType: asset.mediaType === 'video' ? 'video/mp4' : 'image/jpeg',
|
||||
size: 0,
|
||||
createdAt: asset.creationTime
|
||||
? new Date(asset.creationTime).toISOString()
|
||||
: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
setFiles(deviceFiles);
|
||||
console.log('[useDeviceFiles] loaded', deviceFiles.length, 'files');
|
||||
} catch (err) {
|
||||
console.error('[useDeviceFiles] scan error:', err);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
requestPermission().then((granted) => {
|
||||
console.log('[useDeviceFiles] mount permission result:', granted);
|
||||
if (granted) {
|
||||
loadAssets();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const rescan = useCallback(async () => {
|
||||
const granted = await requestPermission();
|
||||
if (granted) {
|
||||
await loadAssets();
|
||||
}
|
||||
}, [requestPermission, loadAssets]);
|
||||
|
||||
return { files, isLoading, hasPermission, requestPermission, rescan };
|
||||
}
|
||||
|
||||
export function deviceFileToLocalEntry(deviceFile: DeviceFile): LocalFileEntry {
|
||||
return {
|
||||
id: deviceFile.id,
|
||||
localUri: deviceFile.uri,
|
||||
name: deviceFile.name,
|
||||
mimeType: deviceFile.mimeType,
|
||||
size: deviceFile.size,
|
||||
syncStatus: 'local',
|
||||
createdAt: deviceFile.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useDeviceFiles } from './useDeviceFiles';
|
||||
import { localFileRegistry } from '../services/localFileRegistry';
|
||||
import { LocalFileEntry } from '../types';
|
||||
|
||||
export function useLocalFiles() {
|
||||
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan } = useDeviceFiles();
|
||||
|
||||
const registryEntries = useMemo(() => localFileRegistry.getAll(), []);
|
||||
|
||||
const localFiles = useMemo(() => {
|
||||
const merged = new Map<string, LocalFileEntry>();
|
||||
|
||||
for (const entry of registryEntries) {
|
||||
merged.set(entry.id, entry);
|
||||
}
|
||||
|
||||
for (const df of deviceFiles) {
|
||||
if (!merged.has(df.id)) {
|
||||
merged.set(df.id, {
|
||||
id: df.id,
|
||||
localUri: df.uri,
|
||||
name: df.name,
|
||||
mimeType: df.mimeType,
|
||||
size: df.size,
|
||||
syncStatus: 'local',
|
||||
createdAt: df.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = Array.from(merged.values());
|
||||
console.log(`[useLocalFiles] registry=${registryEntries.length} device=${deviceFiles.length} merged=${result.length}`);
|
||||
return result;
|
||||
}, [deviceFiles, registryEntries]);
|
||||
|
||||
return {
|
||||
localFiles,
|
||||
isLoading: deviceLoading,
|
||||
hasPermission,
|
||||
requestPermission,
|
||||
rescan,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { downloadAsync, documentDirectory, makeDirectoryAsync, deleteAsync } from 'expo-file-system/legacy';
|
||||
import { useFiles } from './useFiles';
|
||||
import { useLocalFiles } from './useLocalFiles';
|
||||
import { localFileRegistry } from '../services/localFileRegistry';
|
||||
import { apiClient } from '../api/client';
|
||||
import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types';
|
||||
|
||||
export interface UnifiedFileItem {
|
||||
id: string;
|
||||
backendFileId?: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
syncStatus: SyncStatus;
|
||||
localUri?: string;
|
||||
ocrText?: string;
|
||||
tags: Tag[];
|
||||
isFolder: boolean;
|
||||
parentFileId?: string;
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
isDeviceFile?: boolean;
|
||||
}
|
||||
|
||||
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
|
||||
|
||||
function getCacheKey(name: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
function getExtension(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot >= 0 ? name.slice(dot) : '';
|
||||
}
|
||||
|
||||
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
|
||||
const { localFiles, isLoading: localLoading, hasPermission, requestPermission } = useLocalFiles();
|
||||
|
||||
const unifiedFiles = useMemo(() => {
|
||||
const backendFiles = backendData?.data ?? [];
|
||||
const merged = new Map<string, UnifiedFileItem>();
|
||||
|
||||
const registryAll = localFileRegistry.getAll();
|
||||
const backendIdToLocal = new Map<string, LocalFileEntry>();
|
||||
for (const entry of registryAll) {
|
||||
if (entry.backendFileId) {
|
||||
backendIdToLocal.set(entry.backendFileId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bf of backendFiles) {
|
||||
const localEntry = backendIdToLocal.get(bf.id);
|
||||
merged.set(bf.id, {
|
||||
id: bf.id,
|
||||
backendFileId: bf.id,
|
||||
name: bf.name,
|
||||
mimeType: bf.mimeType,
|
||||
size: bf.size,
|
||||
createdAt: bf.createdAt,
|
||||
updatedAt: bf.updatedAt,
|
||||
syncStatus: localEntry ? 'synced' : 'cloud',
|
||||
localUri: localEntry?.localUri,
|
||||
ocrText: bf.ocrText,
|
||||
tags: bf.tags,
|
||||
isFolder: bf.isFolder,
|
||||
parentFileId: bf.parentFileId,
|
||||
url: bf.url,
|
||||
thumbnailUrl: bf.thumbnailUrl,
|
||||
});
|
||||
}
|
||||
|
||||
for (const lf of localFiles) {
|
||||
if (lf.backendFileId && merged.has(lf.backendFileId)) continue;
|
||||
if (merged.has(lf.id)) continue;
|
||||
|
||||
merged.set(lf.id, {
|
||||
id: lf.id,
|
||||
backendFileId: lf.backendFileId,
|
||||
name: lf.name,
|
||||
mimeType: lf.mimeType,
|
||||
size: lf.size,
|
||||
createdAt: lf.createdAt,
|
||||
syncStatus: 'local',
|
||||
localUri: lf.localUri,
|
||||
tags: lf.tags ?? [],
|
||||
isFolder: false,
|
||||
isDeviceFile: true,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((a, b) => {
|
||||
const dateA = new Date(a.createdAt).getTime();
|
||||
const dateB = new Date(b.createdAt).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [backendData, localFiles]);
|
||||
|
||||
return {
|
||||
data: unifiedFiles,
|
||||
isLoading: backendLoading || localLoading,
|
||||
error: backendError,
|
||||
hasPermission,
|
||||
requestPermission,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUnifiedFilesByParent(parentId: string) {
|
||||
const { data: backendData, isLoading: backendLoading } = useQuery({
|
||||
queryKey: ['files', 'parent', parentId],
|
||||
queryFn: () =>
|
||||
apiClient.get<{ data: FileItem[] }>(`/files/folders/${parentId}/files?thumbnail=thumbnail`),
|
||||
enabled: !!parentId,
|
||||
});
|
||||
|
||||
const { localFiles, isLoading: localLoading } = useLocalFiles();
|
||||
|
||||
const unifiedFiles = useMemo(() => {
|
||||
const backendFiles = backendData?.data ?? [];
|
||||
const merged = new Map<string, UnifiedFileItem>();
|
||||
|
||||
const registryAll = localFileRegistry.getAll();
|
||||
const backendIdToLocal = new Map<string, LocalFileEntry>();
|
||||
for (const entry of registryAll) {
|
||||
if (entry.backendFileId) backendIdToLocal.set(entry.backendFileId, entry);
|
||||
}
|
||||
|
||||
for (const bf of backendFiles) {
|
||||
const localEntry = backendIdToLocal.get(bf.id);
|
||||
merged.set(bf.id, {
|
||||
id: bf.id,
|
||||
backendFileId: bf.id,
|
||||
name: bf.name,
|
||||
mimeType: bf.mimeType,
|
||||
size: bf.size,
|
||||
createdAt: bf.createdAt,
|
||||
updatedAt: bf.updatedAt,
|
||||
syncStatus: localEntry ? 'synced' : 'cloud',
|
||||
localUri: localEntry?.localUri,
|
||||
ocrText: bf.ocrText,
|
||||
tags: bf.tags,
|
||||
isFolder: bf.isFolder,
|
||||
parentFileId: bf.parentFileId,
|
||||
url: bf.url,
|
||||
thumbnailUrl: bf.thumbnailUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((a, b) => {
|
||||
if (a.isFolder && !b.isFolder) return -1;
|
||||
if (!a.isFolder && b.isFolder) return 1;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
}, [backendData, localFiles]);
|
||||
|
||||
return {
|
||||
data: unifiedFiles,
|
||||
isLoading: backendLoading || localLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDownloadFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (file: UnifiedFileItem): Promise<string> => {
|
||||
if (file.syncStatus !== 'cloud') {
|
||||
return file.localUri ?? '';
|
||||
}
|
||||
|
||||
const res = await apiClient.get<{ data: { url: string; name: string } }>(
|
||||
`/files/${file.backendFileId}`
|
||||
);
|
||||
const downloadUrl = res.data.url;
|
||||
|
||||
await makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true });
|
||||
const cacheKey = getCacheKey(file.name);
|
||||
const ext = getExtension(file.name);
|
||||
const fileUri = `${DOWNLOAD_DIR}${cacheKey}${ext}`;
|
||||
|
||||
const result = await downloadAsync(downloadUrl, fileUri);
|
||||
|
||||
const entry: LocalFileEntry = {
|
||||
id: `local_${file.backendFileId}`,
|
||||
backendFileId: file.backendFileId,
|
||||
localUri: result.uri,
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
size: file.size,
|
||||
syncStatus: 'synced',
|
||||
createdAt: file.createdAt,
|
||||
tags: file.tags,
|
||||
};
|
||||
localFileRegistry.register(entry);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
|
||||
return result.uri;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFreeLocalSpace() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (fileIds: string[]) => {
|
||||
for (const fid of fileIds) {
|
||||
const entry = localFileRegistry.get(fid);
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.localUri) {
|
||||
try {
|
||||
await deleteAsync(entry.localUri, { idempotent: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
localFileRegistry.markAsCloudOnly(fid);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Generated
+11
@@ -16,6 +16,7 @@
|
||||
"expo-document-picker": "~57.0.0",
|
||||
"expo-file-system": "~57.0.0",
|
||||
"expo-image-picker": "~57.0.2",
|
||||
"expo-media-library": "~57.0.3",
|
||||
"expo-print": "~57.0.0",
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
@@ -3091,6 +3092,16 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-media-library": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-57.0.3.tgz",
|
||||
"integrity": "sha512-VI2NEDaMWIfz9Gxgibzu8BxxxVvgwHRAeZDjV9gci9YH+JkXn5vdvNAnRdmojaPxpp3tbWMfMDB1ympFcrak9A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "57.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.5.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"expo-document-picker": "~57.0.0",
|
||||
"expo-file-system": "~57.0.0",
|
||||
"expo-image-picker": "~57.0.2",
|
||||
"expo-media-library": "~57.0.3",
|
||||
"expo-print": "~57.0.0",
|
||||
"expo-secure-store": "~57.0.0",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createMMKV } from 'react-native-mmkv';
|
||||
import { LocalFileEntry, SyncStatus } from '../types';
|
||||
|
||||
const storage = createMMKV({ id: 'vaultdrop-local-files' });
|
||||
|
||||
const INDEX_KEY = 'local_files_index';
|
||||
|
||||
function getAllIds(): string[] {
|
||||
const raw = storage.getString(INDEX_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as string[];
|
||||
}
|
||||
|
||||
function setAllIds(ids: string[]) {
|
||||
storage.set(INDEX_KEY, JSON.stringify(ids));
|
||||
}
|
||||
|
||||
function entryKey(id: string): string {
|
||||
return `local_file_${id}`;
|
||||
}
|
||||
|
||||
export const localFileRegistry = {
|
||||
register(entry: LocalFileEntry) {
|
||||
storage.set(entryKey(entry.id), JSON.stringify(entry));
|
||||
const ids = getAllIds();
|
||||
if (!ids.includes(entry.id)) {
|
||||
setAllIds([entry.id, ...ids]);
|
||||
}
|
||||
},
|
||||
|
||||
get(id: string): LocalFileEntry | undefined {
|
||||
const raw = storage.getString(entryKey(id));
|
||||
if (!raw) return undefined;
|
||||
return JSON.parse(raw) as LocalFileEntry;
|
||||
},
|
||||
|
||||
getByBackendId(backendId: string): LocalFileEntry | undefined {
|
||||
const ids = getAllIds();
|
||||
for (const id of ids) {
|
||||
const entry = this.get(id);
|
||||
if (entry?.backendFileId === backendId) return entry;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
getAll(): LocalFileEntry[] {
|
||||
const ids = getAllIds();
|
||||
return ids
|
||||
.map((id) => this.get(id))
|
||||
.filter((e): e is LocalFileEntry => e !== undefined);
|
||||
},
|
||||
|
||||
update(id: string, updates: Partial<LocalFileEntry>) {
|
||||
const existing = this.get(id);
|
||||
if (!existing) return;
|
||||
const updated = { ...existing, ...updates };
|
||||
storage.set(entryKey(id), JSON.stringify(updated));
|
||||
},
|
||||
|
||||
updateSyncStatus(id: string, syncStatus: SyncStatus) {
|
||||
this.update(id, { syncStatus });
|
||||
},
|
||||
|
||||
markAsCloudOnly(id: string) {
|
||||
this.update(id, { syncStatus: 'cloud', localUri: '' });
|
||||
},
|
||||
|
||||
remove(id: string) {
|
||||
storage.remove(entryKey(id));
|
||||
const ids = getAllIds().filter((i) => i !== id);
|
||||
setAllIds(ids);
|
||||
},
|
||||
|
||||
removeByBackendId(backendId: string) {
|
||||
const entry = this.getByBackendId(backendId);
|
||||
if (entry) this.remove(entry.id);
|
||||
},
|
||||
|
||||
count(): number {
|
||||
return getAllIds().length;
|
||||
},
|
||||
};
|
||||
+15
-1
@@ -24,7 +24,7 @@ export interface FileItem {
|
||||
thumbnails?: Thumbnail[];
|
||||
}
|
||||
|
||||
export function isFolder(file: FileItem): boolean {
|
||||
export function isFolder(file: FileItem | { isFolder: boolean }): boolean {
|
||||
return file.isFolder;
|
||||
}
|
||||
|
||||
@@ -116,3 +116,17 @@ export interface RefreshResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export type SyncStatus = 'local' | 'synced' | 'cloud';
|
||||
|
||||
export interface LocalFileEntry {
|
||||
id: string;
|
||||
backendFileId?: string;
|
||||
localUri: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
syncStatus: SyncStatus;
|
||||
createdAt: string;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user