folder new interface

This commit is contained in:
m
2026-07-13 16:47:55 +02:00
parent 545a17e9df
commit c8f5935469
5 changed files with 302 additions and 4 deletions
+2
View File
@@ -11,6 +11,7 @@ import { BatchReviewScreen } from './app/batch-review';
import { PendingReviewScreen } from './app/pending-review'; import { PendingReviewScreen } from './app/pending-review';
import { FileDetailScreen } from './app/file-detail'; import { FileDetailScreen } from './app/file-detail';
import { FileEditScreen } from './app/file-edit'; import { FileEditScreen } from './app/file-edit';
import { FolderScreen } from './app/folder';
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -37,6 +38,7 @@ export default function App() {
options={{ title: 'Détails', headerTintColor: '#fff', headerStyle: { backgroundColor: '#000' } }} options={{ title: 'Détails', headerTintColor: '#fff', headerStyle: { backgroundColor: '#000' } }}
/> />
<Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} /> <Stack.Screen name="FileEdit" component={FileEditScreen} options={{ title: 'Édition' }} />
<Stack.Screen name="Folder" component={FolderScreen} options={{ title: 'Dossier' }} />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
<StatusBar style="auto" /> <StatusBar style="auto" />
+288
View File
@@ -0,0 +1,288 @@
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, Alert } from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons';
import { useFiles, useFileImage, useDeleteFile } from '../hooks/useFiles';
import { FileItem, isFolder } from '../types';
import { FileThumbnail } from '../components/FileThumbnail';
const NUM_COLUMNS = 3;
const SCREEN_WIDTH = Dimensions.get('window').width;
const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS;
type RootStackParamList = {
Folder: { folderId: string; folderName: string };
FileDetail: { fileIds: string[]; initialIndex: number };
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type FolderRouteProp = RouteProp<RootStackParamList, 'Folder'>;
function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }: {
file: FileItem;
onPress?: () => void;
onLongPress?: () => void;
selected?: boolean;
onFolderPress?: () => void;
}) {
const { data, isLoading } = useFileImage(file.id);
const folder = isFolder(file);
return (
<TouchableOpacity
style={[styles.gridItem, selected && styles.gridItemSelected]}
onPress={folder ? onFolderPress : onPress}
onLongPress={onLongPress}
delayLongPress={400}
activeOpacity={0.7}
>
<FileThumbnail
uri={data?.data?.url}
mimeType={file.mimeType}
fileName={file.name}
size={ITEM_SIZE}
isLoading={isLoading}
/>
{selected && (
<View style={styles.selectedOverlay}>
<View style={styles.checkCircle}>
<MaterialIcons name="check" size={18} color="#fff" />
</View>
</View>
)}
<Text style={styles.fileName} numberOfLines={1}>{file.name}</Text>
</TouchableOpacity>
);
}
export function FolderScreen() {
const route = useRoute<FolderRouteProp>();
const navigation = useNavigation<NavigationProp>();
const { folderId, folderName } = route.params;
const { data, isLoading, error } = useFiles();
const deleteFile = useDeleteFile();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
useEffect(() => {
navigation.setOptions({ title: folderName });
}, [navigation, folderName]);
const files = useMemo(() => {
const all = data?.data ?? [];
return all.filter((f) => f.parentFileId === folderId);
}, [data, folderId]);
const fileIdToIndex = useMemo(() => {
const map = new Map<string, number>();
files.forEach((f, i) => map.set(f.id, i));
return map;
}, [files]);
const toggleSelection = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
}, []);
const handleDelete = useCallback(() => {
const count = selectedIds.size;
if (count === 0) return;
Alert.alert(
'Supprimer',
`Supprimer ${count === 1 ? 'ce fichier' : `ces ${count} fichiers`} ?`,
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: async () => {
for (const id of selectedIds) {
await deleteFile.mutateAsync(id);
}
setSelectedIds(new Set());
},
},
]
);
}, [selectedIds, deleteFile]);
const handleItemPress = useCallback((file: FileItem) => {
if (selectionMode) {
toggleSelection(file.id);
} else if (isFolder(file)) {
navigation.push('Folder', { folderId: file.id, folderName: file.name });
} else {
navigation.navigate('FileDetail', {
fileIds: files.map((f) => f.id),
initialIndex: fileIdToIndex.get(file.id) ?? 0,
});
}
}, [selectionMode, toggleSelection, navigation, files, fileIdToIndex]);
const handleItemLongPress = useCallback((file: FileItem) => {
if (!selectionMode) toggleSelection(file.id);
}, [selectionMode, toggleSelection]);
if (isLoading) {
return (
<View style={styles.center}>
<Text style={styles.infoText}>Chargement...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.infoText}>Erreur de chargement</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={files}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
ListEmptyComponent={
<View style={styles.empty}>
<MaterialIcons name="folder-open" size={48} color="#ccc" />
<Text style={styles.emptyText}>Dossier vide</Text>
</View>
}
renderItem={({ item: file }) => (
<FolderGridItem
file={file}
selected={selectedIds.has(file.id)}
onPress={() => handleItemPress(file)}
onLongPress={() => handleItemLongPress(file)}
onFolderPress={() => navigation.push('Folder', { folderId: file.id, folderName: file.name })}
/>
)}
/>
{selectionMode && (
<View style={styles.selectionBar}>
<View style={styles.selectionHeader}>
<TouchableOpacity onPress={clearSelection} style={styles.cancelBtn}>
<MaterialIcons name="close" size={22} color="#333" />
</TouchableOpacity>
<Text style={styles.selectionCount}>
{selectedIds.size} sélectionnée{selectedIds.size > 1 ? 's' : ''}
</Text>
</View>
<TouchableOpacity style={styles.deleteBtn} onPress={handleDelete}>
<MaterialIcons name="delete" size={20} color="#F44336" />
<Text style={styles.deleteText}>Supprimer</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
infoText: {
fontSize: 16,
color: '#666',
},
list: {
padding: 15,
},
empty: {
paddingVertical: 60,
alignItems: 'center',
gap: 12,
},
emptyText: {
fontSize: 16,
color: '#999',
},
gridItem: {
width: ITEM_SIZE,
},
gridItemSelected: {
opacity: 0.85,
},
selectedOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 18,
justifyContent: 'flex-start',
alignItems: 'flex-end',
padding: 4,
},
checkCircle: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
},
fileName: {
fontSize: 11,
color: '#666',
marginTop: 4,
textAlign: 'center',
},
selectionBar: {
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingHorizontal: 16,
paddingVertical: 12,
},
selectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
cancelBtn: {
padding: 4,
},
selectionCount: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
deleteBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 10,
borderRadius: 8,
borderWidth: 1.5,
borderColor: '#F44336',
gap: 6,
},
deleteText: {
fontSize: 15,
fontWeight: '600',
color: '#F44336',
},
});
+6 -3
View File
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { useFiles, useFileImage, useDeleteFile, useAddTags } from '../hooks/useFiles'; import { useFiles, useFileImage, useDeleteFile, useAddTags } from '../hooks/useFiles';
import { FileItem } from '../types'; import { FileItem, isFolder } from '../types';
import { SearchBar, SearchFilters } from '../components/SearchBar'; import { SearchBar, SearchFilters } from '../components/SearchBar';
import { FileThumbnail } from '../components/FileThumbnail'; import { FileThumbnail } from '../components/FileThumbnail';
@@ -19,6 +19,7 @@ type RootStackParamList = {
Scan: undefined; Scan: undefined;
FileDetail: { fileIds: string[]; initialIndex: number }; FileDetail: { fileIds: string[]; initialIndex: number };
FileEdit: { fileIds: string[] }; FileEdit: { fileIds: string[] };
Folder: { folderId: string; folderName: string };
}; };
type NavigationProp = NativeStackNavigationProp<RootStackParamList>; type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
@@ -111,14 +112,14 @@ function matchesQuery(file: FileItem, query: string, filters: SearchFilters): bo
if (filters.name && file.name.toLowerCase().includes(q)) return true; if (filters.name && file.name.toLowerCase().includes(q)) return true;
if (filters.ocrText && file.ocrText?.toLowerCase().includes(q)) return true; if (filters.ocrText && file.ocrText?.toLowerCase().includes(q)) return true;
if (filters.tags && file.tags?.some((t) => { if (filters.tags && file.tags?.some((t) => {
const tagName = typeof t === 'string' ? t : t.name; const tagName = typeof t === 'string' ? t : t.tag_name;
return tagName?.toLowerCase().includes(q); return tagName?.toLowerCase().includes(q);
})) return true; })) return true;
if (!filters.name && !filters.ocrText && !filters.tags) { if (!filters.name && !filters.ocrText && !filters.tags) {
if (file.name.toLowerCase().includes(q)) return true; if (file.name.toLowerCase().includes(q)) return true;
if (file.ocrText?.toLowerCase().includes(q)) return true; if (file.ocrText?.toLowerCase().includes(q)) return true;
if (file.tags?.some((t) => { if (file.tags?.some((t) => {
const tagName = typeof t === 'string' ? t : t.name; const tagName = typeof t === 'string' ? t : t.tag_name;
return tagName?.toLowerCase().includes(q); return tagName?.toLowerCase().includes(q);
})) return true; })) return true;
} }
@@ -231,6 +232,8 @@ export function HomeScreen() {
const handleItemPress = useCallback((file: FileItem) => { const handleItemPress = useCallback((file: FileItem) => {
if (selectionMode) { if (selectionMode) {
toggleSelection(file.id); toggleSelection(file.id);
} else if (isFolder(file)) {
navigation.navigate('Folder', { folderId: file.id, folderName: file.name });
} else { } else {
navigation.navigate('FileDetail', { navigation.navigate('FileDetail', {
fileIds: filteredFiles.map((f) => f.id), fileIds: filteredFiles.map((f) => f.id),
+1 -1
View File
@@ -43,7 +43,7 @@ export function FileCard({ file, onPress }: FileCardProps) {
<View style={styles.tags}> <View style={styles.tags}>
{file.tags.map((tag) => ( {file.tags.map((tag) => (
<TagChip key={tag.id} name={tag.name} /> <TagChip key={tag.id} name={tag.tag_name} />
))} ))}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
+5
View File
@@ -7,9 +7,14 @@ export interface FileItem {
updatedAt: string; updatedAt: string;
ocrText?: string; ocrText?: string;
tags: Tag[]; tags: Tag[];
parentFileId?: string;
url?: string; url?: string;
} }
export function isFolder(file: FileItem): boolean {
return file.parentFileId != null;
}
export interface Tag { export interface Tag {
id: string; id: string;
tag_name: string; tag_name: string;