diff --git a/mobile/App.tsx b/mobile/App.tsx
index bcc143a..281df21 100644
--- a/mobile/App.tsx
+++ b/mobile/App.tsx
@@ -11,6 +11,7 @@ import { BatchReviewScreen } from './app/batch-review';
import { PendingReviewScreen } from './app/pending-review';
import { FileDetailScreen } from './app/file-detail';
import { FileEditScreen } from './app/file-edit';
+import { FolderScreen } from './app/folder';
const Stack = createNativeStackNavigator();
const queryClient = new QueryClient();
@@ -37,6 +38,7 @@ export default function App() {
options={{ title: 'Détails', headerTintColor: '#fff', headerStyle: { backgroundColor: '#000' } }}
/>
+
diff --git a/mobile/app/folder.tsx b/mobile/app/folder.tsx
new file mode 100644
index 0000000..fd0ea22
--- /dev/null
+++ b/mobile/app/folder.tsx
@@ -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;
+
+type FolderRouteProp = RouteProp;
+
+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 (
+
+
+ {selected && (
+
+
+
+
+
+ )}
+ {file.name}
+
+ );
+}
+
+export function FolderScreen() {
+ const route = useRoute();
+ const navigation = useNavigation();
+ const { folderId, folderName } = route.params;
+ const { data, isLoading, error } = useFiles();
+ const deleteFile = useDeleteFile();
+ const [selectedIds, setSelectedIds] = useState>(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();
+ 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 (
+
+ Chargement...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Erreur de chargement
+
+ );
+ }
+
+ return (
+
+ item.id}
+ contentContainerStyle={styles.list}
+ ListEmptyComponent={
+
+
+ Dossier vide
+
+ }
+ renderItem={({ item: file }) => (
+ handleItemPress(file)}
+ onLongPress={() => handleItemLongPress(file)}
+ onFolderPress={() => navigation.push('Folder', { folderId: file.id, folderName: file.name })}
+ />
+ )}
+ />
+
+ {selectionMode && (
+
+
+
+
+
+
+ {selectedIds.size} sélectionnée{selectedIds.size > 1 ? 's' : ''}
+
+
+
+
+ Supprimer
+
+
+ )}
+
+ );
+}
+
+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',
+ },
+});
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 52a8dfe..54221c3 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -5,7 +5,7 @@ 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 } from '../hooks/useFiles';
-import { FileItem } from '../types';
+import { FileItem, isFolder } from '../types';
import { SearchBar, SearchFilters } from '../components/SearchBar';
import { FileThumbnail } from '../components/FileThumbnail';
@@ -19,6 +19,7 @@ type RootStackParamList = {
Scan: undefined;
FileDetail: { fileIds: string[]; initialIndex: number };
FileEdit: { fileIds: string[] };
+ Folder: { folderId: string; folderName: string };
};
type NavigationProp = NativeStackNavigationProp;
@@ -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.ocrText && file.ocrText?.toLowerCase().includes(q)) return true;
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 true;
if (!filters.name && !filters.ocrText && !filters.tags) {
if (file.name.toLowerCase().includes(q)) return true;
if (file.ocrText?.toLowerCase().includes(q)) return true;
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 true;
}
@@ -231,6 +232,8 @@ export function HomeScreen() {
const handleItemPress = useCallback((file: FileItem) => {
if (selectionMode) {
toggleSelection(file.id);
+ } else if (isFolder(file)) {
+ navigation.navigate('Folder', { folderId: file.id, folderName: file.name });
} else {
navigation.navigate('FileDetail', {
fileIds: filteredFiles.map((f) => f.id),
diff --git a/mobile/components/FileCard.tsx b/mobile/components/FileCard.tsx
index aad32f9..2ed5d11 100644
--- a/mobile/components/FileCard.tsx
+++ b/mobile/components/FileCard.tsx
@@ -43,7 +43,7 @@ export function FileCard({ file, onPress }: FileCardProps) {
{file.tags.map((tag) => (
-
+
))}
diff --git a/mobile/types/index.ts b/mobile/types/index.ts
index 450db5c..f3e1619 100644
--- a/mobile/types/index.ts
+++ b/mobile/types/index.ts
@@ -7,9 +7,14 @@ export interface FileItem {
updatedAt: string;
ocrText?: string;
tags: Tag[];
+ parentFileId?: string;
url?: string;
}
+export function isFolder(file: FileItem): boolean {
+ return file.parentFileId != null;
+}
+
export interface Tag {
id: string;
tag_name: string;