display files with cache image
This commit is contained in:
@@ -91,7 +91,6 @@ export function useAutoSync() {
|
||||
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
console.log(`[useAutoSync] mode=${globalMode}, upload de ${pendingFiles.length} fichier(s)`);
|
||||
setIsSyncing(true);
|
||||
|
||||
for (const entry of pendingFiles) {
|
||||
@@ -107,15 +106,12 @@ export function useAutoSync() {
|
||||
backendFileId: uploaded.id,
|
||||
syncStatus: 'synced',
|
||||
});
|
||||
|
||||
console.log(`[useAutoSync] "${entry.name}" uploadé → id=${uploaded.id}`);
|
||||
} catch (err) {
|
||||
console.error(`[useAutoSync] échec upload "${entry.name}":`, err);
|
||||
// upload failed, will retry on next cycle
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
console.log(`[useAutoSync] sync terminé`);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
isRunning.current = false;
|
||||
@@ -123,9 +119,11 @@ export function useAutoSync() {
|
||||
}, [queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
checkAndSync();
|
||||
}, 5_000);
|
||||
const interval = setInterval(checkAndSync, 30_000);
|
||||
checkAndSync();
|
||||
return () => clearInterval(interval);
|
||||
return () => { clearTimeout(timeout); clearInterval(interval); };
|
||||
}, [checkAndSync]);
|
||||
|
||||
return { triggerSync: checkAndSync };
|
||||
|
||||
@@ -74,10 +74,9 @@ async function scanSafFolder(folder: StoredFolder): Promise<DeviceFile[]> {
|
||||
});
|
||||
}
|
||||
return files;
|
||||
} catch (err) {
|
||||
console.error(`[useDeviceFiles] SAF scan error for folder "${folder.name}":`, err);
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeviceFiles() {
|
||||
@@ -121,7 +120,6 @@ export function useDeviceFiles() {
|
||||
|
||||
setFiles(deviceFiles);
|
||||
} catch (err) {
|
||||
console.error('[useDeviceFiles] scan error:', err);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -132,11 +130,8 @@ export function useDeviceFiles() {
|
||||
const visibleFolders = safDirectory.getVisibleFolders();
|
||||
if (visibleFolders.length === 0) return;
|
||||
|
||||
const safFiles: DeviceFile[] = [];
|
||||
for (const folder of visibleFolders) {
|
||||
const folderFiles = await scanSafFolder(folder);
|
||||
safFiles.push(...folderFiles);
|
||||
}
|
||||
const results = await Promise.all(visibleFolders.map((folder) => scanSafFolder(folder)));
|
||||
const safFiles = results.flat();
|
||||
|
||||
setFiles((prev) => {
|
||||
const existing = new Set(prev.filter((f) => !f.folderId).map((f) => f.id));
|
||||
@@ -191,7 +186,6 @@ export function useDeviceFiles() {
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[useDeviceFiles] pickDirectory error:', err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -2,14 +2,26 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { FileItem, PaginatedResponse } from '../types';
|
||||
import { metadataCache } from '../services/metadataCache';
|
||||
|
||||
export function useFiles(page: number = 1, limit: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['files', page, limit],
|
||||
queryFn: () =>
|
||||
apiClient.get<PaginatedResponse<FileItem>>(
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<PaginatedResponse<FileItem>>(
|
||||
`${ENDPOINTS.FILES}?page=${page}&limit=${limit}&thumbnail=thumbnail`
|
||||
),
|
||||
);
|
||||
metadataCache.setFiles(res.data, res.meta.page, res.meta.total);
|
||||
return res;
|
||||
},
|
||||
initialData: () => {
|
||||
const cached = metadataCache.getFiles();
|
||||
if (cached && cached.page === page) {
|
||||
return { data: cached.files, meta: { page: cached.page, total: cached.total } };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,6 +48,7 @@ export function useDeleteFile() {
|
||||
mutationFn: (id: string) => apiClient.delete(`${ENDPOINTS.FILES}/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
metadataCache.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -48,6 +61,7 @@ export function useAddTags() {
|
||||
apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags, tag_type: tagType }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
metadataCache.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -60,6 +74,7 @@ export function useMoveFiles() {
|
||||
apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
metadataCache.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ import { LocalFileEntry } from '../types';
|
||||
|
||||
export function useLocalFiles() {
|
||||
const { files: deviceFiles, isLoading: deviceLoading, hasPermission, requestPermission, rescan, pickDirectory, folders, refreshFolders } = useDeviceFiles();
|
||||
|
||||
const registryEntries = useMemo(() => localFileRegistry.getAll(), []);
|
||||
const lastDeviceCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceFiles.length === 0) return;
|
||||
if (deviceFiles.length === lastDeviceCount.current) return;
|
||||
lastDeviceCount.current = deviceFiles.length;
|
||||
|
||||
const newEntries: LocalFileEntry[] = [];
|
||||
for (const df of deviceFiles) {
|
||||
if (localFileRegistry.get(df.id)) continue;
|
||||
const entry: LocalFileEntry = {
|
||||
newEntries.push({
|
||||
id: df.id,
|
||||
localUri: df.uri,
|
||||
name: df.name,
|
||||
@@ -20,12 +24,15 @@ export function useLocalFiles() {
|
||||
syncStatus: 'local',
|
||||
createdAt: df.createdAt,
|
||||
folderId: df.folderId,
|
||||
};
|
||||
localFileRegistry.register(entry);
|
||||
});
|
||||
}
|
||||
if (newEntries.length > 0) {
|
||||
localFileRegistry.registerBatch(newEntries);
|
||||
}
|
||||
}, [deviceFiles]);
|
||||
|
||||
const localFiles = useMemo(() => {
|
||||
const registryEntries = localFileRegistry.getAll();
|
||||
const merged = new Map<string, LocalFileEntry>();
|
||||
|
||||
for (const entry of registryEntries) {
|
||||
@@ -47,10 +54,8 @@ export function useLocalFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
const result = Array.from(merged.values());
|
||||
console.log(`[useLocalFiles] registry=${registryEntries.length} device=${deviceFiles.length} merged=${result.length}`);
|
||||
return result;
|
||||
}, [deviceFiles, registryEntries]);
|
||||
return Array.from(merged.values());
|
||||
}, [deviceFiles]);
|
||||
|
||||
return {
|
||||
localFiles,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { downloadAsync, documentDirectory, makeDirectoryAsync } from 'expo-file-system/legacy';
|
||||
import { useFiles } from './useFiles';
|
||||
import { localFileRegistry } from '../services/localFileRegistry';
|
||||
import { apiClient } from '../api/client';
|
||||
import { LocalFileEntry } from '../types';
|
||||
import { setIsSyncing } from './useSyncQueue';
|
||||
|
||||
const SYNC_DIR = `${documentDirectory}synced-files/`;
|
||||
|
||||
export function usePullSync() {
|
||||
const queryClient = useQueryClient();
|
||||
const isRunning = useRef(false);
|
||||
|
||||
const pullNewFiles = useCallback(async () => {
|
||||
if (isRunning.current) return { pulled: 0 };
|
||||
isRunning.current = true;
|
||||
|
||||
try {
|
||||
setIsSyncing(true);
|
||||
|
||||
const res = await apiClient.get<{ data: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
url?: string;
|
||||
}> }>('/files?page=1&limit=100&thumbnail=thumbnail');
|
||||
|
||||
const backendFiles = res.data ?? [];
|
||||
const registry = localFileRegistry.getAll();
|
||||
const existingBackendIds = new Set(
|
||||
registry.filter((e) => e.backendFileId).map((e) => e.backendFileId)
|
||||
);
|
||||
|
||||
let pulled = 0;
|
||||
|
||||
for (const bf of backendFiles) {
|
||||
if (existingBackendIds.has(bf.id)) continue;
|
||||
if (bf.size === 0) continue;
|
||||
|
||||
try {
|
||||
const detail = await apiClient.get<{ data: { url: string } }>(`/files/${bf.id}`);
|
||||
const downloadUrl = detail.data.url;
|
||||
|
||||
await makeDirectoryAsync(SYNC_DIR, { intermediates: true });
|
||||
const safeName = bf.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileUri = `${SYNC_DIR}${bf.id}_${safeName}`;
|
||||
|
||||
const result = await downloadAsync(downloadUrl, fileUri);
|
||||
|
||||
const entry: LocalFileEntry = {
|
||||
id: `pull_${bf.id}`,
|
||||
backendFileId: bf.id,
|
||||
localUri: result.uri,
|
||||
name: bf.name,
|
||||
mimeType: bf.mimeType,
|
||||
size: bf.size,
|
||||
syncStatus: 'synced',
|
||||
createdAt: bf.createdAt,
|
||||
};
|
||||
localFileRegistry.register(entry);
|
||||
pulled++;
|
||||
} catch {
|
||||
// skip individual file failures
|
||||
}
|
||||
}
|
||||
|
||||
if (pulled > 0) {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
}
|
||||
|
||||
return { pulled };
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
isRunning.current = false;
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
return { pullNewFiles };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { downloadAsync, documentDirectory, makeDirectoryAsync, deleteAsync } fro
|
||||
import { useFiles } from './useFiles';
|
||||
import { useLocalFiles } from './useLocalFiles';
|
||||
import { localFileRegistry } from '../services/localFileRegistry';
|
||||
import { thumbnailCache } from '../services/thumbnailCache';
|
||||
import { apiClient } from '../api/client';
|
||||
import { FileItem, LocalFileEntry, SyncStatus, Tag } from '../types';
|
||||
|
||||
@@ -24,6 +25,7 @@ export interface UnifiedFileItem {
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
isDeviceFile?: boolean;
|
||||
duplicateOf?: string;
|
||||
}
|
||||
|
||||
const DOWNLOAD_DIR = `${documentDirectory}synced-files/`;
|
||||
@@ -41,6 +43,15 @@ function getExtension(name: string): string {
|
||||
return dot >= 0 ? name.slice(dot) : '';
|
||||
}
|
||||
|
||||
function parseExpiresFromUrl(url: string): number {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const expires = u.searchParams.get('expires');
|
||||
if (expires) return Number(expires) * 1000;
|
||||
} catch {}
|
||||
return Date.now() + 50 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
const { data: backendData, isLoading: backendLoading, error: backendError } = useFiles(page, limit);
|
||||
const { localFiles, isLoading: localLoading, hasPermission, requestPermission, pickDirectory, folders, refreshFolders } = useLocalFiles();
|
||||
@@ -57,8 +68,25 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
}
|
||||
}
|
||||
|
||||
const nameSizeIndex = new Map<string, string>();
|
||||
for (const bf of backendFiles) {
|
||||
if (!bf.isFolder && bf.size > 0) {
|
||||
nameSizeIndex.set(`${bf.name}::${bf.size}`, bf.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bf of backendFiles) {
|
||||
const localEntry = backendIdToLocal.get(bf.id);
|
||||
|
||||
let thumbUrl = bf.thumbnailUrl;
|
||||
if (thumbUrl && bf.thumbnailUrl) {
|
||||
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
|
||||
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
|
||||
} else {
|
||||
const cached = thumbnailCache.get(bf.id);
|
||||
if (cached) thumbUrl = cached;
|
||||
}
|
||||
|
||||
merged.set(bf.id, {
|
||||
id: bf.id,
|
||||
backendFileId: bf.id,
|
||||
@@ -74,7 +102,7 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
isFolder: bf.isFolder,
|
||||
parentFileId: bf.parentFileId,
|
||||
url: bf.url,
|
||||
thumbnailUrl: bf.thumbnailUrl,
|
||||
thumbnailUrl: thumbUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +110,18 @@ export function useUnifiedFiles(page: number = 1, limit: number = 50) {
|
||||
if (lf.backendFileId && merged.has(lf.backendFileId)) continue;
|
||||
if (merged.has(lf.id)) continue;
|
||||
|
||||
if (!lf.folderId && lf.size > 0) {
|
||||
const key = `${lf.name}::${lf.size}`;
|
||||
const matchId = nameSizeIndex.get(key);
|
||||
if (matchId) {
|
||||
const existing = merged.get(matchId);
|
||||
if (existing && !existing.localUri && lf.localUri) {
|
||||
merged.set(matchId, { ...existing, localUri: lf.localUri, syncStatus: 'synced' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
merged.set(lf.id, {
|
||||
id: lf.id,
|
||||
backendFileId: lf.backendFileId,
|
||||
@@ -138,6 +178,16 @@ export function useUnifiedFilesByParent(parentId: string) {
|
||||
|
||||
for (const bf of backendFiles) {
|
||||
const localEntry = backendIdToLocal.get(bf.id);
|
||||
|
||||
let thumbUrl = bf.thumbnailUrl;
|
||||
if (thumbUrl && bf.thumbnailUrl) {
|
||||
const expiresAt = parseExpiresFromUrl(bf.thumbnailUrl);
|
||||
thumbnailCache.set(bf.id, bf.thumbnailUrl, expiresAt);
|
||||
} else {
|
||||
const cached = thumbnailCache.get(bf.id);
|
||||
if (cached) thumbUrl = cached;
|
||||
}
|
||||
|
||||
merged.set(bf.id, {
|
||||
id: bf.id,
|
||||
backendFileId: bf.id,
|
||||
@@ -153,7 +203,7 @@ export function useUnifiedFilesByParent(parentId: string) {
|
||||
isFolder: bf.isFolder,
|
||||
parentFileId: bf.parentFileId,
|
||||
url: bf.url,
|
||||
thumbnailUrl: bf.thumbnailUrl,
|
||||
thumbnailUrl: thumbUrl,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user