add list view
This commit is contained in:
@@ -129,7 +129,7 @@ func (h *ResourceHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := h.urls.GenerateDownloadURL(r.ID)
|
downloadURL := h.urls.GenerateDownloadURL(r.ID)
|
||||||
thumbURL := downloadURL
|
var thumbURL string
|
||||||
if thumbnailQuality != "" {
|
if thumbnailQuality != "" {
|
||||||
if best := h.resources.GetBestVariant(r.ID, thumbnailQuality); best != nil {
|
if best := h.resources.GetBestVariant(r.ID, thumbnailQuality); best != nil {
|
||||||
thumbURL = h.urls.GenerateVariantURL(best.ID)
|
thumbURL = h.urls.GenerateVariantURL(best.ID)
|
||||||
@@ -209,7 +209,7 @@ func (h *ResourceHandler) Get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := h.urls.GenerateDownloadURL(resource.ID)
|
downloadURL := h.urls.GenerateDownloadURL(resource.ID)
|
||||||
thumbURL := downloadURL
|
var thumbURL string
|
||||||
if thumbnailQuality != "" {
|
if thumbnailQuality != "" {
|
||||||
if best := h.resources.GetBestVariant(resource.ID, thumbnailQuality); best != nil {
|
if best := h.resources.GetBestVariant(resource.ID, thumbnailQuality); best != nil {
|
||||||
thumbURL = h.urls.GenerateVariantURL(best.ID)
|
thumbURL = h.urls.GenerateVariantURL(best.ID)
|
||||||
@@ -399,7 +399,7 @@ func (h *ResourceHandler) ListByParent(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := h.urls.GenerateDownloadURL(r.ID)
|
downloadURL := h.urls.GenerateDownloadURL(r.ID)
|
||||||
thumbURL := downloadURL
|
var thumbURL string
|
||||||
if thumbnailQuality != "" {
|
if thumbnailQuality != "" {
|
||||||
if best := h.resources.GetBestVariant(r.ID, thumbnailQuality); best != nil {
|
if best := h.resources.GetBestVariant(r.ID, thumbnailQuality); best != nil {
|
||||||
thumbURL = h.urls.GenerateVariantURL(best.ID)
|
thumbURL = h.urls.GenerateVariantURL(best.ID)
|
||||||
|
|||||||
+6
-77
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
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 { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
||||||
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';
|
||||||
@@ -12,7 +12,7 @@ import { SelectionPanel } from '../components/SelectionPanel';
|
|||||||
import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
|
import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
|
||||||
import { UnifiedFileItem } from '../types';
|
import { UnifiedFileItem } from '../types';
|
||||||
import { isFolder } from '../types';
|
import { isFolder } from '../types';
|
||||||
import { FileThumbnail } from '../components/FileThumbnail';
|
import { FileCard } from '../components/FileCard';
|
||||||
import { fileStore } from '../services/fileStore';
|
import { fileStore } from '../services/fileStore';
|
||||||
import { downloadRegistry } from '../services/downloadRegistry';
|
import { downloadRegistry } from '../services/downloadRegistry';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
@@ -20,9 +20,6 @@ import { ENDPOINTS } from '../constants/api';
|
|||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { deleteAsync } from 'expo-file-system/legacy';
|
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;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
type RootStackParamList = {
|
type RootStackParamList = {
|
||||||
@@ -35,44 +32,6 @@ type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
|||||||
|
|
||||||
type FolderRouteProp = RouteProp<RootStackParamList, 'Folder'>;
|
type FolderRouteProp = RouteProp<RootStackParamList, 'Folder'>;
|
||||||
|
|
||||||
function FolderGridItem({ file, onPress, onLongPress, selected, onFolderPress }: {
|
|
||||||
file: UnifiedFileItem;
|
|
||||||
onPress?: () => void;
|
|
||||||
onLongPress?: () => void;
|
|
||||||
selected?: boolean;
|
|
||||||
onFolderPress?: () => void;
|
|
||||||
}) {
|
|
||||||
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={file.url ?? file.localUri}
|
|
||||||
thumbnailUrl={file.thumbnailUrl}
|
|
||||||
mimeType={file.mimeType}
|
|
||||||
fileName={file.name}
|
|
||||||
size={ITEM_SIZE}
|
|
||||||
syncStatus={file.syncStatus}
|
|
||||||
isFolder={file.isFolder}
|
|
||||||
/>
|
|
||||||
{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() {
|
export function FolderScreen() {
|
||||||
const route = useRoute<FolderRouteProp>();
|
const route = useRoute<FolderRouteProp>();
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
@@ -280,12 +239,11 @@ export function FolderScreen() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
renderItem={({ item: file }) => (
|
renderItem={({ item: file }) => (
|
||||||
<FolderGridItem
|
<FileCard
|
||||||
file={file}
|
file={file}
|
||||||
selected={selectedIds.has(file.id)}
|
selected={selectedIds.has(file.id)}
|
||||||
onPress={() => handleItemPress(file)}
|
onPress={handleItemPress}
|
||||||
onLongPress={() => handleItemLongPress(file)}
|
onLongPress={handleItemLongPress}
|
||||||
onFolderPress={() => navigation.push('Folder', { folderId: file.id, folderName: file.name })}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -387,6 +345,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
padding: 15,
|
padding: 15,
|
||||||
|
paddingBottom: 80,
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
paddingVertical: 60,
|
paddingVertical: 60,
|
||||||
@@ -397,36 +356,6 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#999',
|
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: {
|
selectionBar: {
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
|
|||||||
+63
-164
@@ -1,16 +1,14 @@
|
|||||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
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 { useNavigation } from '@react-navigation/native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 { 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 { useAddTags, useMoveResources, useFolders, useFiles, useFreeLocalSpace, useCreateFolder } from '../hooks/useFiles';
|
||||||
import { SelectionPanel } from '../components/SelectionPanel';
|
import { SelectionPanel } from '../components/SelectionPanel';
|
||||||
import { UnifiedFileItem, isFolder } from '../types';
|
import { UnifiedFileItem, isFolder } from '../types';
|
||||||
import { SearchBar, SearchFilters, MediaFilter } from '../components/SearchBar';
|
import { SearchBar, SearchFilters, SortState } from '../components/SearchBar';
|
||||||
import { FileThumbnail } from '../components/FileThumbnail';
|
import { FileCard } from '../components/FileCard';
|
||||||
import { SettingsModal } from '../components/SettingsModal';
|
import { SettingsModal } from '../components/SettingsModal';
|
||||||
import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
|
import { ConfirmModal, type ConfirmOption } from '../components/ConfirmModal';
|
||||||
import { UploadModal } from '../components/UploadModal';
|
import { UploadModal } from '../components/UploadModal';
|
||||||
@@ -29,9 +27,7 @@ import { useLocalFiles } from '../hooks/useLocalFiles';
|
|||||||
import { deleteAsync } from 'expo-file-system/legacy';
|
import { deleteAsync } from 'expo-file-system/legacy';
|
||||||
import { useDebounce } from '../hooks/useDebounce';
|
import { useDebounce } from '../hooks/useDebounce';
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
const PAGE_SIZE = 100;
|
||||||
const PADDING_H = 15;
|
|
||||||
const ITEM_GAP = 6;
|
|
||||||
|
|
||||||
type RootStackParamList = {
|
type RootStackParamList = {
|
||||||
Home: undefined;
|
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]));
|
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 (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.gridItem, { width: size }, selected && styles.gridItemSelected]}
|
|
||||||
onPress={() => onPress?.(file)}
|
|
||||||
onLongPress={() => onLongPress?.(file)}
|
|
||||||
delayLongPress={400}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<FileThumbnail
|
|
||||||
uri={file.url ?? file.localUri}
|
|
||||||
thumbnailUrl={file.thumbnailUrl}
|
|
||||||
mimeType={file.mimeType}
|
|
||||||
fileName={file.name}
|
|
||||||
size={size}
|
|
||||||
syncStatus={file.syncStatus}
|
|
||||||
isFolder={file.isFolder}
|
|
||||||
isUploading={file.isUploading}
|
|
||||||
uploadProgress={file.uploadProgress}
|
|
||||||
/>
|
|
||||||
{selected && (
|
|
||||||
<View style={styles.selectedOverlay}>
|
|
||||||
<View style={styles.checkCircle}>
|
|
||||||
<MaterialIcons name="check" size={18} color="#fff" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean {
|
function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilters): boolean {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
@@ -95,7 +60,19 @@ function matchesQuery(file: UnifiedFileItem, query: string, filters: SearchFilte
|
|||||||
return false;
|
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() {
|
export function HomeScreen() {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
@@ -108,10 +85,9 @@ export function HomeScreen() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const debouncedSearch = useDebounce(searchQuery, 250);
|
const debouncedSearch = useDebounce(searchQuery, 250);
|
||||||
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true });
|
const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true });
|
||||||
const [mediaFilter, setMediaFilter] = useState<MediaFilter>('documents');
|
const [sort, setSort] = useState<SortState>({ key: 'date', direction: 'desc' });
|
||||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [numColumns, setNumColumns] = useState(3);
|
|
||||||
const [tagModalVisible, setTagModalVisible] = useState(false);
|
const [tagModalVisible, setTagModalVisible] = useState(false);
|
||||||
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
|
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
@@ -130,14 +106,6 @@ export function HomeScreen() {
|
|||||||
const { tasks: uploadTasks } = useUploadQueue();
|
const { tasks: uploadTasks } = useUploadQueue();
|
||||||
useAutoSync();
|
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(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (isFetching) return;
|
if (isFetching) return;
|
||||||
const total = data?.meta?.total ?? 0;
|
const total = data?.meta?.total ?? 0;
|
||||||
@@ -150,7 +118,7 @@ export function HomeScreen() {
|
|||||||
const totalFiles = data?.meta?.total ?? 0;
|
const totalFiles = data?.meta?.total ?? 0;
|
||||||
const loadedFiles = data?.data?.length ?? 0;
|
const loadedFiles = data?.data?.length ?? 0;
|
||||||
const hasMore = loadedFiles > 0 && loadedFiles < totalFiles;
|
const hasMore = loadedFiles > 0 && loadedFiles < totalFiles;
|
||||||
const isFiltering = mediaFilter !== 'all' || !!debouncedSearch.trim();
|
const isFiltering = !!debouncedSearch.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
||||||
@@ -201,20 +169,6 @@ export function HomeScreen() {
|
|||||||
[files, debouncedSearch, filters]
|
[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(() => {
|
const uploadGhostItems = useMemo(() => {
|
||||||
return uploadTasks
|
return uploadTasks
|
||||||
.filter((t) => t.status === 'pending' || t.status === 'uploading')
|
.filter((t) => t.status === 'pending' || t.status === 'uploading')
|
||||||
@@ -238,17 +192,14 @@ export function HomeScreen() {
|
|||||||
|
|
||||||
const sortedFiles = useMemo(() => {
|
const sortedFiles = useMemo(() => {
|
||||||
const uploadedExistingIds = new Set(
|
const uploadedExistingIds = new Set(
|
||||||
mediaFilteredFiles.map((f) => f.localUri).filter(Boolean)
|
filteredFiles.map((f) => f.localUri).filter(Boolean)
|
||||||
);
|
);
|
||||||
const ghosts = uploadGhostItems.filter(
|
const ghosts = uploadGhostItems.filter(
|
||||||
(g) => g.localUri && !uploadedExistingIds.has(g.localUri)
|
(g) => g.localUri && !uploadedExistingIds.has(g.localUri)
|
||||||
);
|
);
|
||||||
return [...ghosts, ...mediaFilteredFiles].sort((a, b) => {
|
const sorted = [...filteredFiles].sort((a, b) => compareBySort(a, b, sort));
|
||||||
const da = parseBackendDate(a.createdAt);
|
return [...ghosts, ...sorted];
|
||||||
const db = parseBackendDate(b.createdAt);
|
}, [filteredFiles, uploadGhostItems, sort]);
|
||||||
return (db?.getTime() ?? 0) - (da?.getTime() ?? 0);
|
|
||||||
});
|
|
||||||
}, [mediaFilteredFiles, uploadGhostItems]);
|
|
||||||
|
|
||||||
const displayedCount = sortedFiles.length;
|
const displayedCount = sortedFiles.length;
|
||||||
|
|
||||||
@@ -258,29 +209,6 @@ export function HomeScreen() {
|
|||||||
return map;
|
return map;
|
||||||
}, [sortedFiles]);
|
}, [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) => {
|
const toggleSelection = useCallback((id: string) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -456,14 +384,13 @@ export function HomeScreen() {
|
|||||||
}, [selectionMode, toggleSelection]);
|
}, [selectionMode, toggleSelection]);
|
||||||
|
|
||||||
const renderItem = useCallback(({ item }: { item: UnifiedFileItem }) => (
|
const renderItem = useCallback(({ item }: { item: UnifiedFileItem }) => (
|
||||||
<FileGridItem
|
<FileCard
|
||||||
file={item}
|
file={item}
|
||||||
size={itemSize}
|
|
||||||
selected={selectedIds.has(item.id)}
|
selected={selectedIds.has(item.id)}
|
||||||
onPress={handleItemPress}
|
onPress={handleItemPress}
|
||||||
onLongPress={handleItemLongPress}
|
onLongPress={handleItemLongPress}
|
||||||
/>
|
/>
|
||||||
), [itemSize, selectedIds, handleItemPress, handleItemLongPress]);
|
), [selectedIds, handleItemPress, handleItemLongPress]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -535,45 +462,40 @@ export function HomeScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<GestureDetector gesture={pinchGesture}>
|
<View style={styles.listWrapper}>
|
||||||
<Animated.View style={[styles.listWrapper, gridAnimatedStyle]}>
|
<FlatList
|
||||||
<FlatList
|
data={sortedFiles}
|
||||||
key={numColumns}
|
keyExtractor={(item) => item.id}
|
||||||
data={sortedFiles}
|
contentContainerStyle={styles.list}
|
||||||
keyExtractor={(item) => item.id}
|
keyboardShouldPersistTaps="handled"
|
||||||
numColumns={numColumns}
|
keyboardDismissMode="on-drag"
|
||||||
columnWrapperStyle={{ gap: ITEM_GAP }}
|
onEndReached={loadMore}
|
||||||
contentContainerStyle={styles.list}
|
onEndReachedThreshold={0.5}
|
||||||
keyboardShouldPersistTaps="handled"
|
ListFooterComponent={
|
||||||
keyboardDismissMode="on-drag"
|
hasMore ? (
|
||||||
onEndReached={loadMore}
|
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore} disabled={isFetching}>
|
||||||
onEndReachedThreshold={0.5}
|
{isFetching ? (
|
||||||
ListFooterComponent={
|
<ActivityIndicator size="small" color="#1976D2" />
|
||||||
hasMore ? (
|
) : (
|
||||||
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore} disabled={isFetching}>
|
<Text style={styles.loadMoreText}>
|
||||||
{isFetching ? (
|
Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`})
|
||||||
<ActivityIndicator size="small" color="#1976D2" />
|
</Text>
|
||||||
) : (
|
)}
|
||||||
<Text style={styles.loadMoreText}>
|
</TouchableOpacity>
|
||||||
Charger plus ({displayedCount}{!isFiltering && `/${totalFiles}`})
|
) : displayedCount > 0 ? (
|
||||||
</Text>
|
<Text style={styles.loadedAllText}>{displayedCount} fichier{displayedCount > 1 ? 's' : ''}</Text>
|
||||||
)}
|
) : null
|
||||||
</TouchableOpacity>
|
}
|
||||||
) : displayedCount > 0 ? (
|
ListEmptyComponent={
|
||||||
<Text style={styles.loadedAllText}>{displayedCount} fichier{displayedCount > 1 ? 's' : ''}</Text>
|
<View style={styles.empty}>
|
||||||
) : null
|
<Text style={styles.emptyText}>
|
||||||
}
|
{debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'}
|
||||||
ListEmptyComponent={
|
</Text>
|
||||||
<View style={styles.empty}>
|
</View>
|
||||||
<Text style={styles.emptyText}>
|
}
|
||||||
{debouncedSearch ? 'Aucun résultat' : 'Aucun fichier'}
|
renderItem={renderItem}
|
||||||
</Text>
|
/>
|
||||||
</View>
|
</View>
|
||||||
}
|
|
||||||
renderItem={renderItem}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
</GestureDetector>
|
|
||||||
|
|
||||||
{!selectionMode && (
|
{!selectionMode && (
|
||||||
<SearchBar
|
<SearchBar
|
||||||
@@ -582,8 +504,8 @@ export function HomeScreen() {
|
|||||||
onClear={() => setSearchQuery('')}
|
onClear={() => setSearchQuery('')}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onFiltersChange={setFilters}
|
onFiltersChange={setFilters}
|
||||||
mediaFilter={mediaFilter}
|
sort={sort}
|
||||||
onMediaFilterChange={setMediaFilter}
|
onSortChange={setSort}
|
||||||
bottomPadding={keyboardOpen ? insets.bottom+8 : 0}
|
bottomPadding={keyboardOpen ? insets.bottom+8 : 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -785,7 +707,8 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
padding: PADDING_H,
|
paddingHorizontal: 15,
|
||||||
|
paddingTop: 10,
|
||||||
paddingBottom: 80,
|
paddingBottom: 80,
|
||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
@@ -796,30 +719,6 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#999',
|
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: {
|
bottomNav: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-around',
|
justifyContent: 'space-around',
|
||||||
|
|||||||
+176
-36
@@ -1,12 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { FileItem } from '../types';
|
import { FileItem } from '../types';
|
||||||
import { TagChip } from './TagChip';
|
import { TagChip } from './TagChip';
|
||||||
|
import { SyncStatusBadge } from './SyncStatusBadge';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||||
|
|
||||||
interface FileCardProps {
|
interface FileCardProps {
|
||||||
file: FileItem;
|
file: FileItem;
|
||||||
onPress?: (file: FileItem) => void;
|
onPress?: (file: FileItem) => void;
|
||||||
|
onLongPress?: (file: FileItem) => void;
|
||||||
|
selected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(bytes: number) {
|
function formatSize(bytes: number) {
|
||||||
@@ -15,34 +22,112 @@ function formatSize(bytes: number) {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileCard({ file, onPress }: FileCardProps) {
|
function getFileInfo(mimeType: string, fileName: string): { icon: IconName; color: string; bg: string } {
|
||||||
const imageUri = file.thumbnailUrl || (file.url && file.mimeType?.startsWith('image/') ? file.url : undefined);
|
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 (
|
return (
|
||||||
<TouchableOpacity style={styles.container} onPress={() => onPress?.(file)}>
|
<TouchableOpacity
|
||||||
{imageUri && (
|
style={[styles.container, selected && styles.containerSelected]}
|
||||||
<View style={styles.imageContainer}>
|
onPress={() => onPress?.(file)}
|
||||||
<Image source={{ uri: imageUri }} style={styles.image} contentFit="cover" cachePolicy="memory-disk" transition={200} />
|
onLongPress={() => onLongPress?.(file)}
|
||||||
</View>
|
delayLongPress={400}
|
||||||
)}
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={styles.thumbnailWrap}>
|
||||||
|
{isFolder ? (
|
||||||
|
<View style={[styles.thumbnail, styles.placeholder, { backgroundColor: '#FFF3E0' }]}>
|
||||||
|
<MaterialIcons name="folder" size={30} color="#F57C00" />
|
||||||
|
</View>
|
||||||
|
) : imageUri ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUri }}
|
||||||
|
style={styles.thumbnail}
|
||||||
|
contentFit="cover"
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.thumbnail, styles.placeholder, { backgroundColor: info.bg }]}>
|
||||||
|
{isUploading ? (
|
||||||
|
<ActivityIndicator size="small" color="#1976D2" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MaterialIcons name={info.icon} size={26} color={info.color} />
|
||||||
|
{ext.length <= 4 && <Text style={[styles.ext, { color: info.color }]}>{ext}</Text>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.header}>
|
{selected && (
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
<View style={styles.selectedOverlay}>
|
||||||
{file.name}
|
<View style={styles.checkCircle}>
|
||||||
</Text>
|
<MaterialIcons name="check" size={16} color="#fff" />
|
||||||
<Text style={styles.size}>{formatSize(file.size)}</Text>
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file.syncStatus && !isUploading && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<SyncStatusBadge status={file.syncStatus} size={16} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{file.ocrText && (
|
<View style={styles.body}>
|
||||||
<Text style={styles.preview} numberOfLines={2}>
|
<View style={styles.header}>
|
||||||
{file.ocrText}
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
</Text>
|
{file.name}
|
||||||
)}
|
</Text>
|
||||||
|
{!isFolder && file.size > 0 && (
|
||||||
|
<Text style={styles.size}>{formatSize(file.size)}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.tags}>
|
{isUploading ? (
|
||||||
{file.tags.map((tag) => (
|
<View style={styles.uploadRow}>
|
||||||
<TagChip key={tag.id} name={tag.tag_name} />
|
<MaterialIcons name="cloud-upload" size={14} color="#1976D2" />
|
||||||
))}
|
<Text style={styles.uploadText}>
|
||||||
|
Upload {(file.uploadProgress ?? 0)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : file.ocrText ? (
|
||||||
|
<Text style={styles.preview} numberOfLines={2}>
|
||||||
|
{file.ocrText}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{file.tags && file.tags.length > 0 && (
|
||||||
|
<View style={styles.tags}>
|
||||||
|
{file.tags.slice(0, 3).map((tag) => (
|
||||||
|
<TagChip key={tag.id} name={tag.tag_name} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@@ -50,49 +135,104 @@ export function FileCard({ file, onPress }: FileCardProps) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderRadius: 8,
|
borderRadius: 10,
|
||||||
padding: 12,
|
padding: 10,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
|
gap: 12,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 1 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 2,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
imageContainer: {
|
containerSelected: {
|
||||||
marginBottom: 8,
|
opacity: 0.85,
|
||||||
borderRadius: 4,
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#1976D2',
|
||||||
|
},
|
||||||
|
thumbnailWrap: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
image: {
|
thumbnail: {
|
||||||
width: '100%',
|
width: 64,
|
||||||
height: 120,
|
height: 64,
|
||||||
borderRadius: 4,
|
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: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
fontSize: 16,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#333',
|
color: '#333',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 8,
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
color: '#999',
|
color: '#999',
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: '#666',
|
color: '#666',
|
||||||
marginBottom: 8,
|
marginBottom: 6,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
|
uploadRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
uploadText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#1976D2',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
tags: {
|
tags: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
|
|||||||
@@ -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('text/')) return { icon: 'article', color: '#546E7A', bg: '#ECEFF1' };
|
||||||
if (mimeType.startsWith('video/')) return { icon: 'movie', color: '#6A1B9A', bg: '#F3E5F5' };
|
if (mimeType.startsWith('video/')) return { icon: 'movie', color: '#6A1B9A', bg: '#F3E5F5' };
|
||||||
if (mimeType.startsWith('audio/')) return { icon: 'audiotrack', color: '#AD1457', bg: '#FCE4EC' };
|
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' };
|
return { icon: 'insert-drive-file', color: '#757575', bg: '#F5F5F5' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ function getExtension(fileName: string): string {
|
|||||||
interface FileThumbnailProps {
|
interface FileThumbnailProps {
|
||||||
uri?: string;
|
uri?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
thumbnailLocal?: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -44,7 +47,7 @@ interface FileThumbnailProps {
|
|||||||
uploadProgress?: number;
|
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 info = getFileInfo(mimeType, fileName);
|
||||||
const ext = getExtension(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) {
|
if (imageUri) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,13 @@ export interface SearchFilters {
|
|||||||
ocrText: boolean;
|
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 {
|
interface SearchBarProps {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -18,8 +24,8 @@ interface SearchBarProps {
|
|||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
filters: SearchFilters;
|
filters: SearchFilters;
|
||||||
onFiltersChange: (f: SearchFilters) => void;
|
onFiltersChange: (f: SearchFilters) => void;
|
||||||
mediaFilter: MediaFilter;
|
sort: SortState;
|
||||||
onMediaFilterChange: (f: MediaFilter) => void;
|
onSortChange: (s: SortState) => void;
|
||||||
bottomPadding?: number;
|
bottomPadding?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,31 +34,19 @@ const FILTER_OPTIONS: { key: keyof SearchFilters; label: string; icon: IconName
|
|||||||
{ key: 'ocrText', label: 'Texte OCR', icon: 'document-scanner' },
|
{ key: 'ocrText', label: 'Texte OCR', icon: 'document-scanner' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MEDIA_OPTIONS: { key: MediaFilter; label: string; icon: IconName }[] = [
|
const SORT_OPTIONS: { key: SortKey; label: string; icon: IconName }[] = [
|
||||||
{ key: 'all', label: 'Tous', icon: 'filter-none' },
|
{ key: 'date', label: 'Date', icon: 'schedule' },
|
||||||
{ key: 'documents', label: 'Documents', icon: 'description' },
|
{ key: 'name', label: 'Nom', icon: 'sort-by-alpha' },
|
||||||
{ key: 'photos-videos', label: 'Photos', icon: 'photo-library' },
|
{ key: 'size', label: 'Taille', icon: 'data-usage' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MEDIA_ICONS: Record<MediaFilter, IconName> = {
|
const SORT_LABELS: Record<SortKey, string> = {
|
||||||
all: 'filter-none',
|
date: 'Date',
|
||||||
documents: 'description',
|
name: 'Nom',
|
||||||
'photos-videos': 'photo-library',
|
size: 'Taille',
|
||||||
};
|
};
|
||||||
|
|
||||||
const MEDIA_LABELS: Record<MediaFilter, string> = {
|
export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersChange, sort, onSortChange, bottomPadding = 0 }: SearchBarProps) {
|
||||||
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) {
|
|
||||||
const animatedHeight = useRef(new Animated.Value(0)).current;
|
const animatedHeight = useRef(new Animated.Value(0)).current;
|
||||||
const [panelOpen, setPanelOpen] = React.useState(false);
|
const [panelOpen, setPanelOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -67,14 +61,22 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha
|
|||||||
|
|
||||||
const panelMaxHeight = animatedHeight.interpolate({
|
const panelMaxHeight = animatedHeight.interpolate({
|
||||||
inputRange: [0, 1],
|
inputRange: [0, 1],
|
||||||
outputRange: [0, 120],
|
outputRange: [0, 260],
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleFilter = (key: keyof SearchFilters) => {
|
const toggleFilter = (key: keyof SearchFilters) => {
|
||||||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
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 (
|
return (
|
||||||
<View style={[styles.wrapper, { paddingBottom: bottomPadding }]}>
|
<View style={[styles.wrapper, { paddingBottom: bottomPadding }]}>
|
||||||
@@ -107,20 +109,10 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha
|
|||||||
color={hasActiveFilter ? '#fff' : '#1976D2'}
|
color={hasActiveFilter ? '#fff' : '#1976D2'}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.iconBtn, mediaFilter !== 'all' && styles.iconBtnActive]}
|
|
||||||
onPress={() => onMediaFilterChange(cycleMediaFilter(mediaFilter))}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={MEDIA_ICONS[mediaFilter]}
|
|
||||||
size={22}
|
|
||||||
color={mediaFilter !== 'all' ? '#fff' : '#1976D2'}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View style={[styles.filterPanel, { maxHeight: panelMaxHeight, opacity: animatedHeight }]}>
|
<Animated.View style={[styles.filterPanel, { maxHeight: panelMaxHeight, opacity: animatedHeight }]}>
|
||||||
|
<Text style={styles.sectionLabel}>Rechercher dans</Text>
|
||||||
{FILTER_OPTIONS.map((opt) => (
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
@@ -137,23 +129,33 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
<View style={styles.mediaDivider} />
|
|
||||||
{MEDIA_OPTIONS.map((opt) => (
|
<Text style={styles.sectionLabel}>Trier par</Text>
|
||||||
<TouchableOpacity
|
{SORT_OPTIONS.map((opt) => {
|
||||||
key={opt.key}
|
const active = sort.key === opt.key;
|
||||||
style={[styles.filterChip, mediaFilter === opt.key && styles.filterChipActive]}
|
return (
|
||||||
onPress={() => onMediaFilterChange(opt.key)}
|
<TouchableOpacity
|
||||||
>
|
key={opt.key}
|
||||||
<MaterialIcons
|
style={[styles.filterChip, active && styles.filterChipActive]}
|
||||||
name={opt.icon}
|
onPress={() => selectSort(opt.key)}
|
||||||
size={16}
|
>
|
||||||
color={mediaFilter === opt.key ? '#fff' : '#1976D2'}
|
<MaterialIcons
|
||||||
/>
|
name={active && sort.direction === 'desc' ? 'arrow-downward' : active && sort.direction === 'asc' ? 'arrow-upward' : opt.icon}
|
||||||
<Text style={[styles.filterChipText, mediaFilter === opt.key && styles.filterChipTextActive]}>
|
size={16}
|
||||||
{opt.label}
|
color={active ? '#fff' : '#1976D2'}
|
||||||
</Text>
|
/>
|
||||||
</TouchableOpacity>
|
<Text style={[styles.filterChipText, active && styles.filterChipTextActive]}>
|
||||||
))}
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{sort.key !== 'date' && (
|
||||||
|
<Text style={styles.sortHint}>
|
||||||
|
Tri : {SORT_LABELS[sort.key]} ({sort.direction === 'asc' ? 'A → Z' : 'Z → A'})
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -208,16 +210,20 @@ const styles = StyleSheet.create({
|
|||||||
filterPanel: {
|
filterPanel: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
mediaDivider: {
|
sectionLabel: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: 1,
|
fontSize: 12,
|
||||||
backgroundColor: '#e0e0e0',
|
fontWeight: '700',
|
||||||
marginVertical: 4,
|
color: '#666',
|
||||||
|
marginTop: 4,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
filterChip: {
|
filterChip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -239,4 +245,9 @@ const styles = StyleSheet.create({
|
|||||||
filterChipTextActive: {
|
filterChipTextActive: {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
},
|
},
|
||||||
|
sortHint: {
|
||||||
|
width: '100%',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): Unif
|
|||||||
parentResourceId: record.parentResourceId ?? undefined,
|
parentResourceId: record.parentResourceId ?? undefined,
|
||||||
ownerId: record.ownerId ?? undefined,
|
ownerId: record.ownerId ?? undefined,
|
||||||
thumbnailUrl: record.thumbnailUrl ?? undefined,
|
thumbnailUrl: record.thumbnailUrl ?? undefined,
|
||||||
|
thumbnailLocal: record.thumbnailLocal ?? undefined,
|
||||||
isDeviceFile: record.source === 'local' && !record.backendId,
|
isDeviceFile: record.source === 'local' && !record.backendId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,56 @@
|
|||||||
import { useMemo, useEffect, useRef } from 'react';
|
import { useMemo, useEffect, useRef } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useDeviceFiles } from './useDeviceFiles';
|
import { useDeviceFiles } from './useDeviceFiles';
|
||||||
import { fileStore } from '../services/fileStore';
|
import { fileStore } from '../services/fileStore';
|
||||||
|
import { generateLocalThumbnail } from '../services/thumbnail';
|
||||||
import { UnifiedFileItem } from '../types';
|
import { UnifiedFileItem } from '../types';
|
||||||
|
|
||||||
export function useLocalFiles() {
|
export function useLocalFiles() {
|
||||||
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickAndScanRecursive, folders, refreshFolders, discovered, discoverMediaAlbums } = useDeviceFiles();
|
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickAndScanRecursive, folders, refreshFolders, discovered, discoverMediaAlbums } = useDeviceFiles();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const lastDeviceCount = useRef(0);
|
const lastDeviceCount = useRef(0);
|
||||||
|
const thumbnailQueue = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deviceFiles.length === 0) return;
|
if (deviceFiles.length === 0) return;
|
||||||
if (deviceFiles.length === lastDeviceCount.current) return;
|
if (deviceFiles.length === lastDeviceCount.current) return;
|
||||||
lastDeviceCount.current = deviceFiles.length;
|
lastDeviceCount.current = deviceFiles.length;
|
||||||
|
|
||||||
fileStore.mergeFromDevice(
|
const toMerge = deviceFiles.map((df) => ({
|
||||||
deviceFiles.map((df) => ({
|
id: df.id,
|
||||||
id: df.id,
|
uri: df.uri,
|
||||||
uri: df.uri,
|
name: df.name,
|
||||||
name: df.name,
|
mimeType: df.mimeType,
|
||||||
mimeType: df.mimeType,
|
size: df.size,
|
||||||
size: df.size,
|
createdAt: df.createdAt,
|
||||||
createdAt: df.createdAt,
|
folderId: df.folderId,
|
||||||
folderId: df.folderId,
|
}));
|
||||||
})),
|
|
||||||
);
|
fileStore.mergeFromDevice(toMerge);
|
||||||
}, [deviceFiles]);
|
|
||||||
|
const jobs: Promise<void>[] = [];
|
||||||
|
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 localFiles = useMemo(() => {
|
||||||
const registryEntries = fileStore.getAllLocal();
|
const registryEntries = fileStore.getAllLocal();
|
||||||
@@ -40,6 +67,8 @@ export function useLocalFiles() {
|
|||||||
source: entry.source as UnifiedFileItem['source'],
|
source: entry.source as UnifiedFileItem['source'],
|
||||||
syncStatus: entry.syncStatus as UnifiedFileItem['syncStatus'],
|
syncStatus: entry.syncStatus as UnifiedFileItem['syncStatus'],
|
||||||
localUri: entry.localUri ?? undefined,
|
localUri: entry.localUri ?? undefined,
|
||||||
|
thumbnailUrl: entry.thumbnailUrl ?? undefined,
|
||||||
|
thumbnailLocal: entry.thumbnailLocal ?? undefined,
|
||||||
tags: entry.tags ?? [],
|
tags: entry.tags ?? [],
|
||||||
isFolder: entry.isFolder === 1,
|
isFolder: entry.isFolder === 1,
|
||||||
parentResourceId: entry.parentResourceId ?? undefined,
|
parentResourceId: entry.parentResourceId ?? undefined,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function recordToUnifiedItem(record: ReturnType<typeof fileStore.getById>): Unif
|
|||||||
parentResourceId: record.parentResourceId ?? undefined,
|
parentResourceId: record.parentResourceId ?? undefined,
|
||||||
ownerId: record.ownerId ?? undefined,
|
ownerId: record.ownerId ?? undefined,
|
||||||
thumbnailUrl: record.thumbnailUrl ?? undefined,
|
thumbnailUrl: record.thumbnailUrl ?? undefined,
|
||||||
|
thumbnailLocal: record.thumbnailLocal ?? undefined,
|
||||||
isDeviceFile: record.source === 'local' && !record.backendId,
|
isDeviceFile: record.source === 'local' && !record.backendId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+13
@@ -22,6 +22,7 @@
|
|||||||
"expo-document-picker": "~57.0.1",
|
"expo-document-picker": "~57.0.1",
|
||||||
"expo-file-system": "~57.0.1",
|
"expo-file-system": "~57.0.1",
|
||||||
"expo-image": "~57.0.1",
|
"expo-image": "~57.0.1",
|
||||||
|
"expo-image-manipulator": "~57.0.14",
|
||||||
"expo-image-picker": "~57.0.6",
|
"expo-image-picker": "~57.0.6",
|
||||||
"expo-media-library": "~57.0.3",
|
"expo-media-library": "~57.0.3",
|
||||||
"expo-print": "~57.0.1",
|
"expo-print": "~57.0.1",
|
||||||
@@ -4385,6 +4386,18 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo-image-picker": {
|
||||||
"version": "57.0.6",
|
"version": "57.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.6.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"expo-document-picker": "~57.0.1",
|
"expo-document-picker": "~57.0.1",
|
||||||
"expo-file-system": "~57.0.1",
|
"expo-file-system": "~57.0.1",
|
||||||
"expo-image": "~57.0.1",
|
"expo-image": "~57.0.1",
|
||||||
|
"expo-image-manipulator": "~57.0.14",
|
||||||
"expo-image-picker": "~57.0.6",
|
"expo-image-picker": "~57.0.6",
|
||||||
"expo-media-library": "~57.0.3",
|
"expo-media-library": "~57.0.3",
|
||||||
"expo-print": "~57.0.1",
|
"expo-print": "~57.0.1",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from
|
|||||||
|
|
||||||
const DB_NAME = 'vaultdrop-v3.db';
|
const DB_NAME = 'vaultdrop-v3.db';
|
||||||
const SCHEMA_VERSION_KEY = 'schema_version';
|
const SCHEMA_VERSION_KEY = 'schema_version';
|
||||||
const SCHEMA_VERSION = 4;
|
const SCHEMA_VERSION = 5;
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
let _sqliteDb: SQLite.SQLiteDatabase | null = null;
|
let _sqliteDb: SQLite.SQLiteDatabase | null = null;
|
||||||
@@ -65,6 +65,7 @@ function createSchema(db: SQLite.SQLiteDatabase) {
|
|||||||
is_folder INTEGER NOT NULL DEFAULT 0,
|
is_folder INTEGER NOT NULL DEFAULT 0,
|
||||||
ocr_text TEXT,
|
ocr_text TEXT,
|
||||||
thumbnail_url TEXT,
|
thumbnail_url TEXT,
|
||||||
|
thumbnail_local TEXT,
|
||||||
owner_id TEXT,
|
owner_id TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
@@ -165,6 +166,7 @@ export type FileRecord = {
|
|||||||
isFolder: number;
|
isFolder: number;
|
||||||
ocrText: string | null;
|
ocrText: string | null;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
|
thumbnailLocal?: string | null;
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -185,6 +187,7 @@ type FileRow = {
|
|||||||
isFolder: number;
|
isFolder: number;
|
||||||
ocrText: string | null;
|
ocrText: string | null;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
|
thumbnailLocal: string | null;
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -205,6 +208,7 @@ function rowToRecord(row: FileRow, tags?: Tag[]): FileRecord {
|
|||||||
isFolder: row.isFolder,
|
isFolder: row.isFolder,
|
||||||
ocrText: row.ocrText,
|
ocrText: row.ocrText,
|
||||||
thumbnailUrl: row.thumbnailUrl,
|
thumbnailUrl: row.thumbnailUrl,
|
||||||
|
thumbnailLocal: row.thumbnailLocal,
|
||||||
ownerId: row.ownerId,
|
ownerId: row.ownerId,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
@@ -247,6 +251,7 @@ function upsertRow(file: FileRecord) {
|
|||||||
isFolder: file.isFolder,
|
isFolder: file.isFolder,
|
||||||
ocrText: file.ocrText,
|
ocrText: file.ocrText,
|
||||||
thumbnailUrl: file.thumbnailUrl,
|
thumbnailUrl: file.thumbnailUrl,
|
||||||
|
thumbnailLocal: file.thumbnailLocal ?? null,
|
||||||
ownerId: file.ownerId,
|
ownerId: file.ownerId,
|
||||||
createdAt: file.createdAt,
|
createdAt: file.createdAt,
|
||||||
updatedAt: file.updatedAt,
|
updatedAt: file.updatedAt,
|
||||||
@@ -265,6 +270,7 @@ function upsertRow(file: FileRecord) {
|
|||||||
isFolder: file.isFolder,
|
isFolder: file.isFolder,
|
||||||
ocrText: file.ocrText,
|
ocrText: file.ocrText,
|
||||||
thumbnailUrl: file.thumbnailUrl,
|
thumbnailUrl: file.thumbnailUrl,
|
||||||
|
thumbnailLocal: file.thumbnailLocal ?? null,
|
||||||
ownerId: file.ownerId,
|
ownerId: file.ownerId,
|
||||||
updatedAt: file.updatedAt,
|
updatedAt: file.updatedAt,
|
||||||
lastSyncedAt: file.lastSyncedAt,
|
lastSyncedAt: file.lastSyncedAt,
|
||||||
@@ -450,6 +456,7 @@ export const fileStore = {
|
|||||||
isFolder: bf.isFolder ? 1 : 0,
|
isFolder: bf.isFolder ? 1 : 0,
|
||||||
ocrText: bf.ocrText ?? null,
|
ocrText: bf.ocrText ?? null,
|
||||||
thumbnailUrl: bf.thumbnailUrl ?? null,
|
thumbnailUrl: bf.thumbnailUrl ?? null,
|
||||||
|
thumbnailLocal: existing?.thumbnailLocal ?? null,
|
||||||
ownerId: bf.ownerId ?? null,
|
ownerId: bf.ownerId ?? null,
|
||||||
createdAt: bf.createdAt,
|
createdAt: bf.createdAt,
|
||||||
updatedAt: bf.updatedAt ?? now,
|
updatedAt: bf.updatedAt ?? now,
|
||||||
@@ -492,6 +499,7 @@ export const fileStore = {
|
|||||||
isFolder: 0,
|
isFolder: 0,
|
||||||
ocrText: null,
|
ocrText: null,
|
||||||
thumbnailUrl: null,
|
thumbnailUrl: null,
|
||||||
|
thumbnailLocal: null,
|
||||||
ownerId: null,
|
ownerId: null,
|
||||||
createdAt: df.createdAt,
|
createdAt: df.createdAt,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -509,6 +517,7 @@ export const fileStore = {
|
|||||||
if (updates.localUri !== undefined) setFields.localUri = updates.localUri;
|
if (updates.localUri !== undefined) setFields.localUri = updates.localUri;
|
||||||
if (updates.source !== undefined) setFields.source = updates.source;
|
if (updates.source !== undefined) setFields.source = updates.source;
|
||||||
if (updates.thumbnailUrl !== undefined) setFields.thumbnailUrl = updates.thumbnailUrl;
|
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.ocrText !== undefined) setFields.ocrText = updates.ocrText;
|
||||||
if (updates.parentResourceId !== undefined) setFields.parentResourceId = updates.parentResourceId;
|
if (updates.parentResourceId !== undefined) setFields.parentResourceId = updates.parentResourceId;
|
||||||
if (updates.name !== undefined) setFields.name = updates.name;
|
if (updates.name !== undefined) setFields.name = updates.name;
|
||||||
@@ -532,6 +541,12 @@ export const fileStore = {
|
|||||||
.where(eq(files.backendId, backendId)).run();
|
.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) {
|
markDeleted(id: string) {
|
||||||
const d = getDb();
|
const d = getDb();
|
||||||
d.insert(deletedFiles).values({ id, deletedAt: new Date().toISOString() })
|
d.insert(deletedFiles).values({ id, deletedAt: new Date().toISOString() })
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const files = sqliteTable(
|
|||||||
isFolder: integer('is_folder').notNull().default(0),
|
isFolder: integer('is_folder').notNull().default(0),
|
||||||
ocrText: text('ocr_text'),
|
ocrText: text('ocr_text'),
|
||||||
thumbnailUrl: text('thumbnail_url'),
|
thumbnailUrl: text('thumbnail_url'),
|
||||||
|
thumbnailLocal: text('thumbnail_local'),
|
||||||
ownerId: text('owner_id'),
|
ownerId: text('owner_id'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export interface UnifiedFileItem {
|
|||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
|
thumbnailLocal?: string;
|
||||||
variants?: Variant[];
|
variants?: Variant[];
|
||||||
isDeviceFile?: boolean;
|
isDeviceFile?: boolean;
|
||||||
isUploading?: boolean;
|
isUploading?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user