From 523454fe91661e994d9154b06637a428a3f5973e Mon Sep 17 00:00:00 2001 From: m Date: Fri, 28 Aug 2026 23:03:35 +0200 Subject: [PATCH] add list view --- backend/internal/handler/resource.go | 6 +- mobile/app/folder.tsx | 83 +--------- mobile/app/index.tsx | 227 ++++++++------------------- mobile/components/FileCard.tsx | 212 ++++++++++++++++++++----- mobile/components/FileThumbnail.tsx | 7 +- mobile/components/SearchBar.tsx | 127 ++++++++------- mobile/hooks/useFiles.ts | 1 + mobile/hooks/useLocalFiles.ts | 53 +++++-- mobile/hooks/useSearch.ts | 1 + mobile/package-lock.json | 13 ++ mobile/package.json | 1 + mobile/services/fileStore/index.ts | 17 +- mobile/services/fileStore/schema.ts | 1 + mobile/services/thumbnail.ts | 25 +++ mobile/types/index.ts | 1 + 15 files changed, 422 insertions(+), 353 deletions(-) create mode 100644 mobile/services/thumbnail.ts diff --git a/backend/internal/handler/resource.go b/backend/internal/handler/resource.go index 44caa89..eab08a3 100644 --- a/backend/internal/handler/resource.go +++ b/backend/internal/handler/resource.go @@ -129,7 +129,7 @@ func (h *ResourceHandler) List(c *gin.Context) { } downloadURL := h.urls.GenerateDownloadURL(r.ID) - thumbURL := downloadURL + var thumbURL string if thumbnailQuality != "" { if best := h.resources.GetBestVariant(r.ID, thumbnailQuality); best != nil { thumbURL = h.urls.GenerateVariantURL(best.ID) @@ -209,7 +209,7 @@ func (h *ResourceHandler) Get(c *gin.Context) { } downloadURL := h.urls.GenerateDownloadURL(resource.ID) - thumbURL := downloadURL + var thumbURL string if thumbnailQuality != "" { if best := h.resources.GetBestVariant(resource.ID, thumbnailQuality); best != nil { thumbURL = h.urls.GenerateVariantURL(best.ID) @@ -399,7 +399,7 @@ func (h *ResourceHandler) ListByParent(c *gin.Context) { } downloadURL := h.urls.GenerateDownloadURL(r.ID) - thumbURL := downloadURL + var thumbURL string if thumbnailQuality != "" { if best := h.resources.GetBestVariant(r.ID, thumbnailQuality); best != nil { thumbURL = h.urls.GenerateVariantURL(best.ID) diff --git a/mobile/app/folder.tsx b/mobile/app/folder.tsx index d42d44f..50761e7 100644 --- a/mobile/app/folder.tsx +++ b/mobile/app/folder.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState, useCallback, useEffect } from 'react'; -import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, Modal, TextInput } from 'react-native'; +import { View, FlatList, StyleSheet, TouchableOpacity, Text, Modal, TextInput } from 'react-native'; import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { MaterialIcons } from '@expo/vector-icons'; @@ -12,7 +12,7 @@ import { SelectionPanel } from '../components/SelectionPanel'; import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal'; import { UnifiedFileItem } from '../types'; import { isFolder } from '../types'; -import { FileThumbnail } from '../components/FileThumbnail'; +import { FileCard } from '../components/FileCard'; import { fileStore } from '../services/fileStore'; import { downloadRegistry } from '../services/downloadRegistry'; import { apiClient } from '../api/client'; @@ -20,9 +20,6 @@ import { ENDPOINTS } from '../constants/api'; import { useQueryClient } from '@tanstack/react-query'; import { deleteAsync } from 'expo-file-system/legacy'; -const NUM_COLUMNS = 3; -const SCREEN_WIDTH = Dimensions.get('window').width; -const ITEM_SIZE = (SCREEN_WIDTH - 16 * 2 - (NUM_COLUMNS - 1) * 6) / NUM_COLUMNS; const PAGE_SIZE = 100; type RootStackParamList = { @@ -35,44 +32,6 @@ type NavigationProp = NativeStackNavigationProp; type FolderRouteProp = RouteProp; -function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }: { - file: UnifiedFileItem; - onPress?: () => void; - onLongPress?: () => void; - selected?: boolean; - onFolderPress?: () => void; -}) { - const folder = isFolder(file); - - return ( - - - {selected && ( - - - - - - )} - {file.name} - - ); -} - export function FolderScreen() { const route = useRoute(); const navigation = useNavigation(); @@ -280,12 +239,11 @@ export function FolderScreen() { ) : null } renderItem={({ item: file }) => ( - handleItemPress(file)} - onLongPress={() => handleItemLongPress(file)} - onFolderPress={() => navigation.push('Folder', { folderId: file.id, folderName: file.name })} + onPress={handleItemPress} + onLongPress={handleItemLongPress} /> )} /> @@ -387,6 +345,7 @@ const styles = StyleSheet.create({ }, list: { padding: 15, + paddingBottom: 80, }, empty: { paddingVertical: 60, @@ -397,36 +356,6 @@ const styles = StyleSheet.create({ 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, diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index b5e4641..8b18027 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -1,16 +1,14 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; -import { View, FlatList, StyleSheet, TouchableOpacity, Text, Dimensions, KeyboardAvoidingView, Platform, Keyboard, Alert, Modal, TextInput, ActivityIndicator } from 'react-native'; +import { View, FlatList, StyleSheet, TouchableOpacity, Text, KeyboardAvoidingView, Platform, Keyboard, Modal, TextInput, ActivityIndicator } from 'react-native'; 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 { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import Animated, { runOnJS, useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; import { useAddTags, useMoveResources, useFolders, useFiles, useFreeLocalSpace, useCreateFolder } from '../hooks/useFiles'; import { SelectionPanel } from '../components/SelectionPanel'; import { UnifiedFileItem, isFolder } from '../types'; -import { SearchBar, SearchFilters, MediaFilter } from '../components/SearchBar'; -import { FileThumbnail } from '../components/FileThumbnail'; +import { SearchBar, SearchFilters, SortState } from '../components/SearchBar'; +import { FileCard } from '../components/FileCard'; import { SettingsModal } from '../components/SettingsModal'; import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal'; import { UploadModal } from '../components/UploadModal'; @@ -29,9 +27,7 @@ import { useLocalFiles } from '../hooks/useLocalFiles'; import { deleteAsync } from 'expo-file-system/legacy'; import { useDebounce } from '../hooks/useDebounce'; -const SCREEN_WIDTH = Dimensions.get('window').width; -const PADDING_H = 15; -const ITEM_GAP = 6; +const PAGE_SIZE = 100; type RootStackParamList = { Home: undefined; @@ -52,37 +48,6 @@ 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])); } -const FileGridItem = React.memo(function FileGridItem({ file, size, onPress, onLongPress, selected }: { file: UnifiedFileItem; size: number; onPress?: (f: UnifiedFileItem) => void; onLongPress?: (f: UnifiedFileItem) => void; selected?: boolean }) { - return ( - onPress?.(file)} - onLongPress={() => onLongPress?.(file)} - delayLongPress={400} - activeOpacity={0.7} - > - - {selected && ( - - - - - - )} - - ); -}); - function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean { if (!query) return true; const q = query.toLowerCase(); @@ -95,7 +60,19 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte return false; } -const PAGE_SIZE = 100; +function compareBySort(a: UnifiedFileItem, b: UnifiedFileItem, sort: SortState): number { + let cmp = 0; + if (sort.key === 'name') { + cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + } else if (sort.key === 'size') { + cmp = (a.size ?? 0) - (b.size ?? 0); + } else { + const da = parseBackendDate(a.createdAt); + const db = parseBackendDate(b.createdAt); + cmp = (da?.getTime() ?? 0) - (db?.getTime() ?? 0); + } + return sort.direction === 'asc' ? cmp : -cmp; +} export function HomeScreen() { const navigation = useNavigation(); @@ -108,10 +85,9 @@ export function HomeScreen() { const [searchQuery, setSearchQuery] = useState(''); const debouncedSearch = useDebounce(searchQuery, 250); const [filters, setFilters] = useState({ name: true, ocrText: true }); - const [mediaFilter, setMediaFilter] = useState('documents'); + const [sort, setSort] = useState({ key: 'date', direction: 'desc' }); const [keyboardOpen, setKeyboardOpen] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [numColumns, setNumColumns] = useState(3); const [tagModalVisible, setTagModalVisible] = useState(false); const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag'); const [tagInput, setTagInput] = useState(''); @@ -130,14 +106,6 @@ export function HomeScreen() { const { tasks: uploadTasks } = useUploadQueue(); useAutoSync(); - const pinchScale = useSharedValue(1); - - const gridAnimatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: pinchScale.value }], - })); - - const itemSize = (SCREEN_WIDTH - PADDING_H * 2 - (numColumns - 1) * ITEM_GAP) / numColumns; - const loadMore = useCallback(() => { if (isFetching) return; const total = data?.meta?.total ?? 0; @@ -150,7 +118,7 @@ export function HomeScreen() { const totalFiles = data?.meta?.total ?? 0; const loadedFiles = data?.data?.length ?? 0; const hasMore = loadedFiles > 0 && loadedFiles < totalFiles; - const isFiltering = mediaFilter !== 'all' || !!debouncedSearch.trim(); + const isFiltering = !!debouncedSearch.trim(); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)); @@ -201,20 +169,6 @@ export function HomeScreen() { [files, debouncedSearch, filters] ); - const mediaFilteredFiles = useMemo(() => { - if (mediaFilter === 'all') return filteredFiles; - - const PHOTOS_VIDEOS = ['image/','video/']; - const DOCUMENTS = ['application/','text/']; - - return filteredFiles.filter((f) => { - const mt = (f.mimeType ?? '').toLowerCase(); - if (mediaFilter === 'documents') return DOCUMENTS.some(docType => mt.startsWith(docType)) || PHOTOS_VIDEOS.every(docType => !mt.startsWith(docType)); - if (mediaFilter === 'photos-videos') return PHOTOS_VIDEOS.some(docType => mt.startsWith(docType)); - return true; - }); - }, [filteredFiles, mediaFilter]); - const uploadGhostItems = useMemo(() => { return uploadTasks .filter((t) => t.status === 'pending' || t.status === 'uploading') @@ -238,17 +192,14 @@ export function HomeScreen() { const sortedFiles = useMemo(() => { const uploadedExistingIds = new Set( - mediaFilteredFiles.map((f) => f.localUri).filter(Boolean) + filteredFiles.map((f) => f.localUri).filter(Boolean) ); const ghosts = uploadGhostItems.filter( (g) => g.localUri && !uploadedExistingIds.has(g.localUri) ); - return [...ghosts, ...mediaFilteredFiles].sort((a, b) => { - const da = parseBackendDate(a.createdAt); - const db = parseBackendDate(b.createdAt); - return (db?.getTime() ?? 0) - (da?.getTime() ?? 0); - }); - }, [mediaFilteredFiles, uploadGhostItems]); + const sorted = [...filteredFiles].sort((a, b) => compareBySort(a, b, sort)); + return [...ghosts, ...sorted]; + }, [filteredFiles, uploadGhostItems, sort]); const displayedCount = sortedFiles.length; @@ -258,29 +209,6 @@ export function HomeScreen() { return map; }, [sortedFiles]); - const handlePinchEnd = useCallback((scale: number) => { - if (scale > 1.2) { - setNumColumns(prev => Math.max(2, prev - 1)); - } else if (scale < 0.8) { - setNumColumns(prev => Math.min(6, prev + 1)); - } - }, []); - - const pinchGesture = useMemo(() => - Gesture.Pinch() - .onBegin(() => { - pinchScale.value = 1; - }) - .onChange((event) => { - pinchScale.value = event.scale; - }) - .onEnd((event) => { - pinchScale.value = withSpring(1); - runOnJS(handlePinchEnd)(event.scale); - }), - [] - ); - const toggleSelection = useCallback((id: string) => { setSelectedIds((prev) => { const next = new Set(prev); @@ -456,14 +384,13 @@ export function HomeScreen() { }, [selectionMode, toggleSelection]); const renderItem = useCallback(({ item }: { item: UnifiedFileItem }) => ( - - ), [itemSize, selectedIds, handleItemPress, handleItemLongPress]); + ), [selectedIds, handleItemPress, handleItemLongPress]); if (isLoading) { return ( @@ -535,45 +462,40 @@ export function HomeScreen() { )} )} - - - item.id} - numColumns={numColumns} - columnWrapperStyle={{ gap: ITEM_GAP }} - contentContainerStyle={styles.list} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - onEndReached={loadMore} - onEndReachedThreshold={0.5} - ListFooterComponent={ - hasMore ? ( - - {isFetching ? ( - - ) : ( - - Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`}) - - )} - - ) : displayedCount > 0 ? ( - {displayedCount} fichier{displayedCount > 1 ? 's' : ''} - ) : null - } - ListEmptyComponent={ - - - {debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'} - - - } - renderItem={renderItem} - /> - - + + item.id} + contentContainerStyle={styles.list} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" + onEndReached={loadMore} + onEndReachedThreshold={0.5} + ListFooterComponent={ + hasMore ? ( + + {isFetching ? ( + + ) : ( + + Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`}) + + )} + + ) : displayedCount > 0 ? ( + {displayedCount} fichier{displayedCount > 1 ? 's' : ''} + ) : null + } + ListEmptyComponent={ + + + {debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'} + + + } + renderItem={renderItem} + /> + {!selectionMode && ( setSearchQuery('')} filters={filters} onFiltersChange={setFilters} - mediaFilter={mediaFilter} - onMediaFilterChange={setMediaFilter} + sort={sort} + onSortChange={setSort} bottomPadding={keyboardOpen ? insets.bottom+8 : 0} /> )} @@ -785,7 +707,8 @@ const styles = StyleSheet.create({ flex: 1, }, list: { - padding: PADDING_H, + paddingHorizontal: 15, + paddingTop: 10, paddingBottom: 80, }, empty: { @@ -796,30 +719,6 @@ const styles = StyleSheet.create({ fontSize: 16, color: '#999', }, - gridItem: { - marginBottom: ITEM_GAP, - }, - gridItemSelected: { - opacity: 0.85, - }, - selectedOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - justifyContent: 'flex-start', - alignItems: 'flex-end', - padding: 4, - }, - checkCircle: { - width: 24, - height: 24, - borderRadius: 12, - backgroundColor: '#1976D2', - justifyContent: 'center', - alignItems: 'center', - }, bottomNav: { flexDirection: 'row', justifyContent: 'space-around', diff --git a/mobile/components/FileCard.tsx b/mobile/components/FileCard.tsx index 1642593..55c370b 100644 --- a/mobile/components/FileCard.tsx +++ b/mobile/components/FileCard.tsx @@ -1,12 +1,19 @@ import React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; import { Image } from 'expo-image'; +import { MaterialIcons } from '@expo/vector-icons'; import { FileItem } from '../types'; import { TagChip } from './TagChip'; +import { SyncStatusBadge } from './SyncStatusBadge'; +import type { ComponentProps } from 'react'; + +type IconName = ComponentProps['name']; interface FileCardProps { file: FileItem; onPress?: (file: FileItem) => void; + onLongPress?: (file: FileItem) => void; + selected?: boolean; } function formatSize(bytes: number) { @@ -15,34 +22,112 @@ function formatSize(bytes: number) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -export function FileCard({ file, onPress }: FileCardProps) { - const imageUri = file.thumbnailUrl || (file.url && file.mimeType?.startsWith('image/') ? file.url : undefined); +function getFileInfo(mimeType: string, fileName: string): { icon: IconName; color: string; bg: string } { + if (mimeType.startsWith('image/')) return { icon: 'image', color: '#4CAF50', bg: '#E8F5E9' }; + if (mimeType === 'application/pdf') return { icon: 'picture-as-pdf', color: '#E53935', bg: '#FFEBEE' }; + if (mimeType.includes('word') || mimeType.includes('document')) return { icon: 'description', color: '#1565C0', bg: '#E3F2FD' }; + if (mimeType.includes('spreadsheet') || mimeType.includes('excel') || mimeType.includes('csv')) return { icon: 'table-chart', color: '#2E7D32', bg: '#E8F5E9' }; + if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return { icon: 'slideshow', color: '#E65100', bg: '#FFF3E0' }; + if (mimeType.startsWith('text/')) return { icon: 'article', color: '#546E7A', bg: '#ECEFF1' }; + if (mimeType.startsWith('video/')) return { icon: 'movie', color: '#6A1B9A', bg: '#F3E5F5' }; + if (mimeType.startsWith('audio/')) return { icon: 'audiotrack', color: '#AD1457', bg: '#FCE4EC' }; + if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('rar') || mimeType.includes('tar')) return { icon: 'folder-zip', color: '#6D4C41', bg: '#EFEBE9' }; + if (mimeType.includes('json') || mimeType.includes('xml')) return { icon: 'code', color: '#37474F', bg: '#ECEFF1' }; + return { icon: 'insert-drive-file', color: '#757575', bg: '#F5F5F5' }; +} + +function getExtension(fileName: string): string { + const ext = fileName.split('.').pop(); + return ext ? ext.toUpperCase() : ''; +} + +export function FileCard({ file, onPress, onLongPress, selected }: FileCardProps) { + const info = getFileInfo(file.mimeType, file.name); + const ext = getExtension(file.name); + + const imageUri = file.thumbnailUrl || file.thumbnailLocal || (file.url && file.mimeType?.startsWith('image/') ? file.url : undefined) || (file.localUri && file.mimeType?.startsWith('image/') ? file.localUri : undefined); + const isFolder = file.isFolder; + const isUploading = file.isUploading; return ( - onPress?.(file)}> - {imageUri && ( - - - - )} + onPress?.(file)} + onLongPress={() => onLongPress?.(file)} + delayLongPress={400} + activeOpacity={0.7} + > + + {isFolder ? ( + + + + ) : imageUri ? ( + + ) : ( + + {isUploading ? ( + + ) : ( + <> + + {ext.length <= 4 && {ext}} + + )} + + )} - - - {file.name} - - {formatSize(file.size)} + {selected && ( + + + + + + )} + + {file.syncStatus && !isUploading && ( + + + + )} - {file.ocrText && ( - - {file.ocrText} - - )} + + + + {file.name} + + {!isFolder && file.size > 0 && ( + {formatSize(file.size)} + )} + - - {file.tags.map((tag) => ( - - ))} + {isUploading ? ( + + + + Upload {(file.uploadProgress ?? 0)}% + + + ) : file.ocrText ? ( + + {file.ocrText} + + ) : null} + + {file.tags && file.tags.length > 0 && ( + + {file.tags.slice(0, 3).map((tag) => ( + + ))} + + )} ); @@ -50,49 +135,104 @@ export function FileCard({ file, onPress }: FileCardProps) { const styles = StyleSheet.create({ container: { + flexDirection: 'row', backgroundColor: '#fff', - borderRadius: 8, - padding: 12, + borderRadius: 10, + padding: 10, marginBottom: 8, + gap: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 4, - elevation: 2, + elevation: 1, }, - imageContainer: { - marginBottom: 8, - borderRadius: 4, + containerSelected: { + opacity: 0.85, + borderWidth: 1.5, + borderColor: '#1976D2', + }, + thumbnailWrap: { + width: 64, + height: 64, + borderRadius: 8, overflow: 'hidden', }, - image: { - width: '100%', - height: 120, - borderRadius: 4, + thumbnail: { + width: 64, + height: 64, + borderRadius: 8, + }, + placeholder: { + justifyContent: 'center', + alignItems: 'center', + gap: 1, + }, + ext: { + fontSize: 9, + fontWeight: '700', + }, + selectedOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'flex-start', + alignItems: 'flex-end', + padding: 4, + }, + checkCircle: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: '#1976D2', + justifyContent: 'center', + alignItems: 'center', + }, + badge: { + position: 'absolute', + bottom: 4, + left: 4, + }, + body: { + flex: 1, + justifyContent: 'center', }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4, + gap: 8, }, name: { - fontSize: 16, + fontSize: 15, fontWeight: '600', color: '#333', flex: 1, - marginRight: 8, }, size: { - fontSize: 13, + fontSize: 12, color: '#999', }, preview: { fontSize: 13, color: '#666', - marginBottom: 8, + marginBottom: 6, lineHeight: 18, }, + uploadRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 6, + }, + uploadText: { + fontSize: 13, + color: '#1976D2', + fontWeight: '500', + }, tags: { flexDirection: 'row', flexWrap: 'wrap', diff --git a/mobile/components/FileThumbnail.tsx b/mobile/components/FileThumbnail.tsx index 919cbda..3f72cd8 100644 --- a/mobile/components/FileThumbnail.tsx +++ b/mobile/components/FileThumbnail.tsx @@ -23,6 +23,8 @@ function getFileInfo(mimeType: string, fileName: string): FileTypeInfo { if (mimeType.startsWith('text/')) return { icon: 'article', color: '#546E7A', bg: '#ECEFF1' }; if (mimeType.startsWith('video/')) return { icon: 'movie', color: '#6A1B9A', bg: '#F3E5F5' }; if (mimeType.startsWith('audio/')) return { icon: 'audiotrack', color: '#AD1457', bg: '#FCE4EC' }; + if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('rar') || mimeType.includes('tar')) return { icon: 'folder-zip', color: '#6D4C41', bg: '#EFEBE9' }; + if (mimeType.includes('json') || mimeType.includes('xml')) return { icon: 'code', color: '#37474F', bg: '#ECEFF1' }; return { icon: 'insert-drive-file', color: '#757575', bg: '#F5F5F5' }; } @@ -34,6 +36,7 @@ function getExtension(fileName: string): string { interface FileThumbnailProps { uri?: string; thumbnailUrl?: string; + thumbnailLocal?: string; mimeType: string; fileName: string; size: number; @@ -44,7 +47,7 @@ interface FileThumbnailProps { uploadProgress?: number; } -export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailUrl, mimeType, fileName, size, isLoading, syncStatus, isFolder, isUploading, uploadProgress }: FileThumbnailProps) { +export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailUrl, thumbnailLocal, mimeType, fileName, size, isLoading, syncStatus, isFolder, isUploading, uploadProgress }: FileThumbnailProps) { const info = getFileInfo(mimeType, fileName); const ext = getExtension(fileName); @@ -64,7 +67,7 @@ export const FileThumbnail = React.memo(function FileThumbnail({ uri, thumbnailU ); } - const imageUri = thumbnailUrl || (uri && mimeType.startsWith('image/') ? uri : undefined); + const imageUri = thumbnailUrl || thumbnailLocal || (uri && mimeType.startsWith('image/') ? uri : undefined); if (imageUri) { return ( diff --git a/mobile/components/SearchBar.tsx b/mobile/components/SearchBar.tsx index be874eb..0a7bd5a 100644 --- a/mobile/components/SearchBar.tsx +++ b/mobile/components/SearchBar.tsx @@ -10,7 +10,13 @@ export interface SearchFilters { ocrText: boolean; } -export type MediaFilter = 'all' | 'documents' | 'photos-videos'; +export type SortKey = 'date' | 'name' | 'size'; +export type SortDirection = 'asc' | 'desc'; + +export interface SortState { + key: SortKey; + direction: SortDirection; +} interface SearchBarProps { query: string; @@ -18,8 +24,8 @@ interface SearchBarProps { onClear: () => void; filters: SearchFilters; onFiltersChange: (f: SearchFilters) => void; - mediaFilter: MediaFilter; - onMediaFilterChange: (f: MediaFilter) => void; + sort: SortState; + onSortChange: (s: SortState) => void; bottomPadding?: number; } @@ -28,31 +34,19 @@ const FILTER_OPTIONS: { key: keyof SearchFilters; label: string; icon: IconName { key: 'ocrText', label: 'Texte OCR', icon: 'document-scanner' }, ]; -const MEDIA_OPTIONS: { key: MediaFilter; label: string; icon: IconName }[] = [ - { key: 'all', label: 'Tous', icon: 'filter-none' }, - { key: 'documents', label: 'Documents', icon: 'description' }, - { key: 'photos-videos', label: 'Photos', icon: 'photo-library' }, +const SORT_OPTIONS: { key: SortKey; label: string; icon: IconName }[] = [ + { key: 'date', label: 'Date', icon: 'schedule' }, + { key: 'name', label: 'Nom', icon: 'sort-by-alpha' }, + { key: 'size', label: 'Taille', icon: 'data-usage' }, ]; -const MEDIA_ICONS: Record = { - all: 'filter-none', - documents: 'description', - 'photos-videos': 'photo-library', +const SORT_LABELS: Record = { + date: 'Date', + name: 'Nom', + size: 'Taille', }; -const MEDIA_LABELS: Record = { - all: 'Tous', - documents: 'Documents', - 'photos-videos': 'Photos', -}; - -function cycleMediaFilter(current: MediaFilter): MediaFilter { - if (current === 'all') return 'documents'; - if (current === 'documents') return 'photos-videos'; - return 'all'; -} - -export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersChange, mediaFilter, onMediaFilterChange, bottomPadding = 0 }: SearchBarProps) { +export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersChange, sort, onSortChange, bottomPadding = 0 }: SearchBarProps) { const animatedHeight = useRef(new Animated.Value(0)).current; const [panelOpen, setPanelOpen] = React.useState(false); @@ -67,14 +61,22 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha const panelMaxHeight = animatedHeight.interpolate({ inputRange: [0, 1], - outputRange: [0, 120], + outputRange: [0, 260], }); const toggleFilter = (key: keyof SearchFilters) => { onFiltersChange({ ...filters, [key]: !filters[key] }); }; - const hasActiveFilter = filters.name || filters.ocrText; + const selectSort = (key: SortKey) => { + if (sort.key === key) { + onSortChange({ key, direction: sort.direction === 'asc' ? 'desc' : 'asc' }); + } else { + onSortChange({ key, direction: 'desc' }); + } + }; + + const hasActiveFilter = filters.name || filters.ocrText || sort.key !== 'date'; return ( @@ -107,20 +109,10 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha color={hasActiveFilter ? '#fff' : '#1976D2'} /> - - onMediaFilterChange(cycleMediaFilter(mediaFilter))} - > - - + Rechercher dans {FILTER_OPTIONS.map((opt) => ( ))} - - {MEDIA_OPTIONS.map((opt) => ( - onMediaFilterChange(opt.key)} - > - - - {opt.label} - - - ))} + + Trier par + {SORT_OPTIONS.map((opt) => { + const active = sort.key === opt.key; + return ( + selectSort(opt.key)} + > + + + {opt.label} + + + ); + })} + + {sort.key !== 'date' && ( + + Tri : {SORT_LABELS[sort.key]} ({sort.direction === 'asc' ? 'A → Z' : 'Z → A'}) + + )} ); @@ -208,16 +210,20 @@ const styles = StyleSheet.create({ filterPanel: { flexDirection: 'row', flexWrap: 'wrap', + alignItems: 'center', paddingHorizontal: 12, paddingBottom: 10, gap: 8, overflow: 'hidden', }, - mediaDivider: { + sectionLabel: { width: '100%', - height: 1, - backgroundColor: '#e0e0e0', - marginVertical: 4, + fontSize: 12, + fontWeight: '700', + color: '#666', + marginTop: 4, + textTransform: 'uppercase', + letterSpacing: 0.5, }, filterChip: { flexDirection: 'row', @@ -239,4 +245,9 @@ const styles = StyleSheet.create({ filterChipTextActive: { color: '#fff', }, + sortHint: { + width: '100%', + fontSize: 12, + color: '#999', + }, }); diff --git a/mobile/hooks/useFiles.ts b/mobile/hooks/useFiles.ts index 7bdc6e8..ff39636 100644 --- a/mobile/hooks/useFiles.ts +++ b/mobile/hooks/useFiles.ts @@ -25,6 +25,7 @@ function recordToUnifiedItem(record: ReturnType): Unif parentResourceId: record.parentResourceId ?? undefined, ownerId: record.ownerId ?? undefined, thumbnailUrl: record.thumbnailUrl ?? undefined, + thumbnailLocal: record.thumbnailLocal ?? undefined, isDeviceFile: record.source === 'local' && !record.backendId, }; } diff --git a/mobile/hooks/useLocalFiles.ts b/mobile/hooks/useLocalFiles.ts index 151a78c..67581b1 100644 --- a/mobile/hooks/useLocalFiles.ts +++ b/mobile/hooks/useLocalFiles.ts @@ -1,29 +1,56 @@ import { useMemo, useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { useDeviceFiles } from './useDeviceFiles'; import { fileStore } from '../services/fileStore'; +import { generateLocalThumbnail } from '../services/thumbnail'; import { UnifiedFileItem } from '../types'; export function useLocalFiles() { const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickAndScanRecursive, folders, refreshFolders, discovered, discoverMediaAlbums } = useDeviceFiles(); + const queryClient = useQueryClient(); const lastDeviceCount = useRef(0); + const thumbnailQueue = useRef>(new Set()); useEffect(() => { if (deviceFiles.length === 0) return; if (deviceFiles.length === lastDeviceCount.current) return; lastDeviceCount.current = deviceFiles.length; - fileStore.mergeFromDevice( - deviceFiles.map((df) => ({ - id: df.id, - uri: df.uri, - name: df.name, - mimeType: df.mimeType, - size: df.size, - createdAt: df.createdAt, - folderId: df.folderId, - })), - ); - }, [deviceFiles]); + const toMerge = deviceFiles.map((df) => ({ + id: df.id, + uri: df.uri, + name: df.name, + mimeType: df.mimeType, + size: df.size, + createdAt: df.createdAt, + folderId: df.folderId, + })); + + fileStore.mergeFromDevice(toMerge); + + const jobs: Promise[] = []; + for (const df of toMerge) { + if (thumbnailQueue.current.has(df.id)) continue; + const mime = (df.mimeType ?? '').toLowerCase(); + if (!mime.startsWith('image/')) continue; + if (fileStore.getById(df.id)?.thumbnailLocal) continue; + thumbnailQueue.current.add(df.id); + jobs.push( + generateLocalThumbnail(df.uri, df.mimeType).then((thumb) => { + try { + if (thumb) fileStore.setThumbnailLocal(df.id, thumb); + } catch {} + }).finally(() => { + thumbnailQueue.current.delete(df.id); + }), + ); + } + if (jobs.length > 0) { + Promise.all(jobs).finally(() => { + queryClient.invalidateQueries({ queryKey: ['resources'] }); + }); + } + }, [deviceFiles, queryClient]); const localFiles = useMemo(() => { const registryEntries = fileStore.getAllLocal(); @@ -40,6 +67,8 @@ export function useLocalFiles() { source: entry.source as UnifiedFileItem['source'], syncStatus: entry.syncStatus as UnifiedFileItem['syncStatus'], localUri: entry.localUri ?? undefined, + thumbnailUrl: entry.thumbnailUrl ?? undefined, + thumbnailLocal: entry.thumbnailLocal ?? undefined, tags: entry.tags ?? [], isFolder: entry.isFolder === 1, parentResourceId: entry.parentResourceId ?? undefined, diff --git a/mobile/hooks/useSearch.ts b/mobile/hooks/useSearch.ts index 4e0ac3d..4080f68 100644 --- a/mobile/hooks/useSearch.ts +++ b/mobile/hooks/useSearch.ts @@ -21,6 +21,7 @@ function recordToUnifiedItem(record: ReturnType): Unif parentResourceId: record.parentResourceId ?? undefined, ownerId: record.ownerId ?? undefined, thumbnailUrl: record.thumbnailUrl ?? undefined, + thumbnailLocal: record.thumbnailLocal ?? undefined, isDeviceFile: record.source === 'local' && !record.backendId, }; } diff --git a/mobile/package-lock.json b/mobile/package-lock.json index d05c24b..b5e21c9 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -22,6 +22,7 @@ "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.1", "expo-image": "~57.0.1", + "expo-image-manipulator": "~57.0.14", "expo-image-picker": "~57.0.6", "expo-media-library": "~57.0.3", "expo-print": "~57.0.1", @@ -4385,6 +4386,18 @@ "expo": "*" } }, + "node_modules/expo-image-manipulator": { + "version": "57.0.14", + "resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-57.0.14.tgz", + "integrity": "sha512-ObL2DZG53M26xvtbMa5kc9VQtIx7FngvqCgPsdX2Ws/TejHQTxG53z3yPpuT07PNiDMXhzZwwCvw9+fQojarag==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-picker": { "version": "57.0.6", "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.6.tgz", diff --git a/mobile/package.json b/mobile/package.json index dfc9e05..97ace66 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -17,6 +17,7 @@ "expo-document-picker": "~57.0.1", "expo-file-system": "~57.0.1", "expo-image": "~57.0.1", + "expo-image-manipulator": "~57.0.14", "expo-image-picker": "~57.0.6", "expo-media-library": "~57.0.3", "expo-print": "~57.0.1", diff --git a/mobile/services/fileStore/index.ts b/mobile/services/fileStore/index.ts index de41a9d..ae43be5 100644 --- a/mobile/services/fileStore/index.ts +++ b/mobile/services/fileStore/index.ts @@ -6,7 +6,7 @@ import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from const DB_NAME = 'vaultdrop-v3.db'; const SCHEMA_VERSION_KEY = 'schema_version'; -const SCHEMA_VERSION = 4; +const SCHEMA_VERSION = 5; let _db: ReturnType | null = null; let _sqliteDb: SQLite.SQLiteDatabase | null = null; @@ -65,6 +65,7 @@ function createSchema(db: SQLite.SQLiteDatabase) { is_folder INTEGER NOT NULL DEFAULT 0, ocr_text TEXT, thumbnail_url TEXT, + thumbnail_local TEXT, owner_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -165,6 +166,7 @@ export type FileRecord = { isFolder: number; ocrText: string | null; thumbnailUrl: string | null; + thumbnailLocal?: string | null; ownerId: string | null; createdAt: string; updatedAt: string; @@ -185,6 +187,7 @@ type FileRow = { isFolder: number; ocrText: string | null; thumbnailUrl: string | null; + thumbnailLocal: string | null; ownerId: string | null; createdAt: string; updatedAt: string; @@ -205,6 +208,7 @@ function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord { isFolder: row.isFolder, ocrText: row.ocrText, thumbnailUrl: row.thumbnailUrl, + thumbnailLocal: row.thumbnailLocal, ownerId: row.ownerId, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -247,6 +251,7 @@ function upsertRow(file: FileRecord) { isFolder: file.isFolder, ocrText: file.ocrText, thumbnailUrl: file.thumbnailUrl, + thumbnailLocal: file.thumbnailLocal ?? null, ownerId: file.ownerId, createdAt: file.createdAt, updatedAt: file.updatedAt, @@ -265,6 +270,7 @@ function upsertRow(file: FileRecord) { isFolder: file.isFolder, ocrText: file.ocrText, thumbnailUrl: file.thumbnailUrl, + thumbnailLocal: file.thumbnailLocal ?? null, ownerId: file.ownerId, updatedAt: file.updatedAt, lastSyncedAt: file.lastSyncedAt, @@ -450,6 +456,7 @@ export const fileStore = { isFolder: bf.isFolder ? 1 : 0, ocrText: bf.ocrText ?? null, thumbnailUrl: bf.thumbnailUrl ?? null, + thumbnailLocal: existing?.thumbnailLocal ?? null, ownerId: bf.ownerId ?? null, createdAt: bf.createdAt, updatedAt: bf.updatedAt ?? now, @@ -492,6 +499,7 @@ export const fileStore = { isFolder: 0, ocrText: null, thumbnailUrl: null, + thumbnailLocal: null, ownerId: null, createdAt: df.createdAt, updatedAt: now, @@ -509,6 +517,7 @@ export const fileStore = { if (updates.localUri !== undefined) setFields.localUri = updates.localUri; if (updates.source !== undefined) setFields.source = updates.source; if (updates.thumbnailUrl !== undefined) setFields.thumbnailUrl = updates.thumbnailUrl; + if (updates.thumbnailLocal !== undefined) setFields.thumbnailLocal = updates.thumbnailLocal; if (updates.ocrText !== undefined) setFields.ocrText = updates.ocrText; if (updates.parentResourceId !== undefined) setFields.parentResourceId = updates.parentResourceId; if (updates.name !== undefined) setFields.name = updates.name; @@ -532,6 +541,12 @@ export const fileStore = { .where(eq(files.backendId, backendId)).run(); }, + setThumbnailLocal(id: string, thumbnailLocal: string) { + const d = getDb(); + d.update(files).set({ thumbnailLocal, updatedAt: new Date().toISOString() }) + .where(eq(files.id, id)).run(); + }, + markDeleted(id: string) { const d = getDb(); d.insert(deletedFiles).values({ id, deletedAt: new Date().toISOString() }) diff --git a/mobile/services/fileStore/schema.ts b/mobile/services/fileStore/schema.ts index cc1da2f..c442725 100644 --- a/mobile/services/fileStore/schema.ts +++ b/mobile/services/fileStore/schema.ts @@ -15,6 +15,7 @@ export const files = sqliteTable( isFolder: integer('is_folder').notNull().default(0), ocrText: text('ocr_text'), thumbnailUrl: text('thumbnail_url'), + thumbnailLocal: text('thumbnail_local'), ownerId: text('owner_id'), createdAt: text('created_at').notNull(), updatedAt: text('updated_at').notNull(), diff --git a/mobile/services/thumbnail.ts b/mobile/services/thumbnail.ts new file mode 100644 index 0000000..c53e252 --- /dev/null +++ b/mobile/services/thumbnail.ts @@ -0,0 +1,25 @@ +import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; + +const THUMB_SIZE = 128; + +function isGeneratableImage(mimeType: string): boolean { + return (mimeType ?? '').toLowerCase().startsWith('image/'); +} + +export async function generateLocalThumbnail( + uri: string | undefined, + mimeType: string, +): Promise { + if (!uri || !isGeneratableImage(mimeType)) return null; + try { + const result = await manipulateAsync( + uri, + [{ resize: { width: THUMB_SIZE } }], + { format: SaveFormat.JPEG, compress: 0.7, base64: true }, + ); + if (!result.base64) return null; + return `data:image/jpeg;base64,${result.base64}`; + } catch { + return null; + } +} diff --git a/mobile/types/index.ts b/mobile/types/index.ts index 899b84c..2b4f2d9 100644 --- a/mobile/types/index.ts +++ b/mobile/types/index.ts @@ -26,6 +26,7 @@ export interface UnifiedFileItem { ownerId?: string; url?: string; thumbnailUrl?: string; + thumbnailLocal?: string; variants?: Variant[]; isDeviceFile?: boolean; isUploading?: boolean;