better iterface
This commit is contained in:
@@ -48,8 +48,11 @@ cd mobile && npm run test:db
|
|||||||
- `localStorage.ts` — thin re-export of `services/db` (legacy alias)
|
- `localStorage.ts` — thin re-export of `services/db` (legacy alias)
|
||||||
- `features/syncDevice.ts` — device sync orchestration (two-pass SAF walk, single transaction per root, `exists = 0` reconciliation)
|
- `features/syncDevice.ts` — device sync orchestration (two-pass SAF walk, single transaction per root, `exists = 0` reconciliation)
|
||||||
- `context/AuthContext.tsx` — session context: exposes `deviceUserId` (bootstrapped from `getDeviceUserId()`) and starts the background `syncDevice` loop
|
- `context/AuthContext.tsx` — session context: exposes `deviceUserId` (bootstrapped from `getDeviceUserId()`) and starts the background `syncDevice` loop
|
||||||
|
- `app/` — expo-router screens: `index.tsx` (dossiers racines + ajout SAF), `folder/[id].tsx` (sous-dossiers + fichiers)
|
||||||
|
- `api/` — REST client (`client.ts` fetch wrapper + `types.ts` = contrat d'API : enveloppe `{ data, meta }`, erreurs `{ error: { code, message } }`)
|
||||||
|
- `hooks/` — TanStack Query hooks: `useFiles`, `useSearch`, `useUpload` (+ OCR jobs)
|
||||||
- No business logic on the client — heavy processing stays server-side
|
- No business logic on the client — heavy processing stays server-side
|
||||||
- API base URL configured via `EXPO_PUBLIC_API_BASE_URL` env var (client + hooks not yet built)
|
- API base URL via `EXPO_PUBLIC_API_BASE_URL` (défaut `http://localhost:8080/api/v1`)
|
||||||
|
|
||||||
## Data Conventions
|
## Data Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,7 @@ Persistence is SQLite-backed via `services/db/` (`expo-sqlite`, database `dot.db
|
|||||||
- Query usage: `getFiles(folderResourceId?)`, `getFolders()`, `getFolderFolders(parentResourceId)`, `getFolder`/`getFile(resourceId)`, `saveFolder`/`saveDirectory`, `saveFile(file, folderResourceId)`, `removeFolder`/`removeFile(resourceId)`, `saveUserPreferences`/`getUserPreferences`/`getDeviceUserId`, `saveResourcePermission`/`getResourcePermission`, `canAccess(resourceId, type, level)`, `saveShare`/`getShares`/`removeShare`, `createShareLink`/`getShareLinks`/`incrementLinkDownloads`/`revokeShareLink`, `saveRecipient`/`getRecipients`, `enqueuePendingOperation`/`getNextQueuedOperation`/`markPendingOperation`.
|
- Query usage: `getFiles(folderResourceId?)`, `getFolders()`, `getFolderFolders(parentResourceId)`, `getFolder`/`getFile(resourceId)`, `saveFolder`/`saveDirectory`, `saveFile(file, folderResourceId)`, `removeFolder`/`removeFile(resourceId)`, `saveUserPreferences`/`getUserPreferences`/`getDeviceUserId`, `saveResourcePermission`/`getResourcePermission`, `canAccess(resourceId, type, level)`, `saveShare`/`getShares`/`removeShare`, `createShareLink`/`getShareLinks`/`incrementLinkDownloads`/`revokeShareLink`, `saveRecipient`/`getRecipients`, `enqueuePendingOperation`/`getNextQueuedOperation`/`markPendingOperation`.
|
||||||
- SAF walk (`features/syncDevice.ts`, `syncDevice()`/`syncRoot()`): **two passes** (all folders sorted by uri depth, then all files) inside a **single transaction** (`withTransaction`), receives sync: an interruption rolls back entirely. Reconciles by physical `uri`; rows under the root with a `uri` no longer seen are **marked `exists = 0`** (never deleted). Root folders are the rows with `parent_resource_id IS NULL` + non-null `uri`.
|
- SAF walk (`features/syncDevice.ts`, `syncDevice()`/`syncRoot()`): **two passes** (all folders sorted by uri depth, then all files) inside a **single transaction** (`withTransaction`), receives sync: an interruption rolls back entirely. Reconciles by physical `uri`; rows under the root with a `uri` no longer seen are **marked `exists = 0`** (never deleted). Root folders are the rows with `parent_resource_id IS NULL` + non-null `uri`.
|
||||||
- Heavy processing stays server-side; SQLite only persists local metadata/state.
|
- Heavy processing stays server-side; SQLite only persists local metadata/state.
|
||||||
|
|
||||||
|
# REST API client
|
||||||
|
|
||||||
|
`api/client.ts` + `api/types.ts` = the client-side **API contract** (server must implement it; backend Go is the source of truth once built). Base URL = `EXPO_PUBLIC_API_BASE_URL` (défaut `http://localhost:8080/api/v1`). Envelope: success `{ data, meta?: { page, pageSize, total } }`, errors normalized to `ApiError` (`code` from `{ error: { code, message } }`, or `NETWORK_ERROR` / `HTTP_<status>`). Multipart upload needs the platform FormData (uri/name/type) — never set `Content-Type` manually. TanStack Query v5 providers live in `app/_layout.tsx`; hooks in `hooks/` (`useFiles`, `useSearch`, `useUpload`, OCR jobs).
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type {
|
||||||
|
ApiData,
|
||||||
|
ApiErrorBody,
|
||||||
|
FileDto,
|
||||||
|
FolderDto,
|
||||||
|
ListFilesParams,
|
||||||
|
OcrJob,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
|
||||||
|
|
||||||
|
export const DEFAULT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||||
|
|
||||||
|
function toQuery(params?: QueryParams): string {
|
||||||
|
if ( !params ) return '';
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for ( const [key, value] of Object.entries(params) ) {
|
||||||
|
if ( value === undefined || value === null ) continue;
|
||||||
|
search.set(key, String(value));
|
||||||
|
}
|
||||||
|
const query = search.toString();
|
||||||
|
return query ? `?${query}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
||||||
|
): Promise<ApiData<T>> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('NETWORK_ERROR', 'Serveur injoignable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| ApiData<T>
|
||||||
|
| ApiErrorBody
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if ( !response.ok ) {
|
||||||
|
const error = body != null && 'error' in body ? body.error : null;
|
||||||
|
throw new ApiError(
|
||||||
|
error?.code ?? `HTTP_${response.status}`,
|
||||||
|
error?.message ?? response.statusText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (body ?? { data: undefined as T }) as ApiData<T>;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getApiBaseUrl: () => API_BASE_URL,
|
||||||
|
|
||||||
|
health: () => request<{ status: string }>('/health'),
|
||||||
|
|
||||||
|
listFiles: (params?: ListFilesParams) =>
|
||||||
|
request<FileDto[]>(`/files${toQuery(params)}`),
|
||||||
|
|
||||||
|
getFile: (id: string) =>
|
||||||
|
request<FileDto>(`/files/${encodeURIComponent(id)}`),
|
||||||
|
|
||||||
|
deleteFile: (id: string) =>
|
||||||
|
request<{ id: string }>(`/files/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
|
||||||
|
searchFiles: (q: string, params?: QueryParams) =>
|
||||||
|
request<FileDto[]>(`/files/search${toQuery({ q, ...params })}`),
|
||||||
|
|
||||||
|
listFolders: () => request<FolderDto[]>('/files/folders'),
|
||||||
|
|
||||||
|
uploadFile: (
|
||||||
|
file: { uri: string; name: string; mimeType: string },
|
||||||
|
folderId?: string | null
|
||||||
|
) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', {
|
||||||
|
uri: file.uri,
|
||||||
|
name: file.name,
|
||||||
|
type: file.mimeType,
|
||||||
|
} as unknown as Blob);
|
||||||
|
if ( folderId ) form.append('folderId', folderId);
|
||||||
|
return request<FileDto>('/files/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
createOcrJob: (fileId: string) =>
|
||||||
|
request<OcrJob>('/ocr/jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fileId }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
getOcrJob: (id: string) =>
|
||||||
|
request<OcrJob>(`/ocr/jobs/${encodeURIComponent(id)}`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export type ApiMeta = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiData<T> = {
|
||||||
|
data: T;
|
||||||
|
meta?: ApiMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiErrorBody = {
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mimeType?: string | null;
|
||||||
|
folderId?: string | null;
|
||||||
|
tags?: string[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FolderDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OcrJobStatus = 'queued' | 'processing' | 'done' | 'failed';
|
||||||
|
|
||||||
|
export type OcrJob = {
|
||||||
|
id: string;
|
||||||
|
status: OcrJobStatus;
|
||||||
|
text?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListParams = {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListFilesParams = ListParams & {
|
||||||
|
folderId?: string | null;
|
||||||
|
};
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { AuthProvider } from '../context/AuthContext';
|
import { AuthProvider } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Stack />
|
<Stack />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { getFolder, getFolderFolders, getFiles } from '../../services/localStorage';
|
||||||
|
import type { StoredFile, StoredFolder } from '../../services/db/types';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
key: string;
|
||||||
|
kind: 'folder' | 'file';
|
||||||
|
label: string;
|
||||||
|
meta: string;
|
||||||
|
resourceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if ( bytes < 1024 ) return `${bytes} o`;
|
||||||
|
if ( bytes < 1024 * 1024 ) return `${(bytes / 1024).toFixed(1)} Ko`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FolderScreen() {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const [folder, setFolder] = useState<StoredFolder | null>(null);
|
||||||
|
const [subfolders, setSubfolders] = useState<StoredFolder[]>([]);
|
||||||
|
const [files, setFiles] = useState<StoredFile[]>([]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const current = await getFolder(id);
|
||||||
|
if ( !current ) {
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFolder(current);
|
||||||
|
setSubfolders(await getFolderFolders(id));
|
||||||
|
setFiles(await getFiles(id));
|
||||||
|
}, [id, router]);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
load();
|
||||||
|
}, [load])
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows: Row[] = [
|
||||||
|
...subfolders.map((f) => ({
|
||||||
|
key: f.resource_id,
|
||||||
|
kind: 'folder' as const,
|
||||||
|
label: f.name,
|
||||||
|
meta: 'Dossier',
|
||||||
|
resourceId: f.resource_id,
|
||||||
|
})),
|
||||||
|
...files.map((f) => ({
|
||||||
|
key: f.resource_id,
|
||||||
|
kind: 'file' as const,
|
||||||
|
label: f.name,
|
||||||
|
meta: formatSize(f.size),
|
||||||
|
resourceId: f.resource_id,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Stack.Screen options={{ title: folder?.name ?? 'Dossier' }} />
|
||||||
|
<FlatList
|
||||||
|
data={rows}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable
|
||||||
|
style={styles.row}
|
||||||
|
onPress={() => item.kind === 'folder' && router.push(`/folder/${item.resourceId}`)}
|
||||||
|
>
|
||||||
|
<Text style={item.kind === 'folder' ? styles.folderTitle : styles.fileTitle}>
|
||||||
|
{item.kind === 'folder' ? `${item.label}/` : item.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.rowMeta}>{item.meta}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={styles.empty}>
|
||||||
|
Dossier vide — le prochain syncDevice l'actualisera.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderBottomColor: '#ddd',
|
||||||
|
},
|
||||||
|
folderTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1a73e8',
|
||||||
|
},
|
||||||
|
fileTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
rowMeta: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#888',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: '#888',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
+61
-31
@@ -1,44 +1,65 @@
|
|||||||
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
import { pickDirectory } from '../services/safDirectory';
|
|
||||||
import { saveDirectory, getFolders } from '../services/localStorage';
|
|
||||||
import { syncRoot } from '../features/syncDevice';
|
import { syncRoot } from '../features/syncDevice';
|
||||||
|
import { pickDirectory } from '../services/safDirectory';
|
||||||
|
import { getFolders, saveDirectory } from '../services/localStorage';
|
||||||
|
import type { StoredFolder } from '../services/db/types';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
|
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const router = useRouter();
|
||||||
|
const [roots, setRoots] = useState<StoredFolder[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = useCallback(async () => {
|
||||||
(async () => {
|
|
||||||
const saved = await getFolders();
|
const saved = await getFolders();
|
||||||
setFolders(saved.map((folder) => folder.name));
|
setRoots(saved.filter((folder) => folder.parent_resource_id === null));
|
||||||
})();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
load();
|
||||||
|
}, [load])
|
||||||
|
);
|
||||||
|
|
||||||
const handlePickDirectory = async () => {
|
const handlePickDirectory = async () => {
|
||||||
const folder = await pickDirectory();
|
const folder = await pickDirectory();
|
||||||
if ( !folder ) return;
|
if ( !folder ) return;
|
||||||
const saved = await saveDirectory(folder);
|
const saved = await saveDirectory(folder);
|
||||||
setFolders((prev) => [...prev, folder.name]);
|
|
||||||
try {
|
try {
|
||||||
const result = await syncRoot(saved.resource_id);
|
const result = await syncRoot(saved.resource_id);
|
||||||
console.info('walk', JSON.stringify(result));
|
console.info('walk', JSON.stringify(result));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('walk failed', error);
|
console.warn('walk failed', error);
|
||||||
}
|
}
|
||||||
|
await load();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text style={styles.title}>Dot.</Text>
|
|
||||||
<Pressable style={styles.button} onPress={handlePickDirectory}>
|
|
||||||
<Text style={styles.buttonLabel}>Ajouter un dossier</Text>
|
|
||||||
</Pressable>
|
|
||||||
{folders.map((name) => (
|
|
||||||
<Text key={name} style={styles.folder}>{name}</Text>
|
|
||||||
))}
|
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
|
<Pressable style={styles.button} onPress={handlePickDirectory}>
|
||||||
|
<Text style={styles.buttonText}>Ajouter un dossier</Text>
|
||||||
|
</Pressable>
|
||||||
|
<FlatList
|
||||||
|
data={roots}
|
||||||
|
keyExtractor={(item) => item.resource_id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<Pressable
|
||||||
|
style={styles.row}
|
||||||
|
onPress={() => router.push(`/folder/${item.resource_id}`)}
|
||||||
|
>
|
||||||
|
<Text style={styles.rowTitle}>{item.name}</Text>
|
||||||
|
<Text style={styles.rowMeta}>{item.syncStatus}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Text style={styles.empty}>
|
||||||
|
Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,28 +68,37 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
alignItems: 'center',
|
padding: 16,
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
marginTop: 24,
|
backgroundColor: '#1a73e8',
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
backgroundColor: '#0057ff',
|
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
buttonLabel: {
|
buttonText: {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
folder: {
|
row: {
|
||||||
marginTop: 8,
|
paddingVertical: 14,
|
||||||
fontSize: 14,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
color: '#333',
|
borderBottomColor: '#ddd',
|
||||||
|
},
|
||||||
|
rowTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
rowMeta: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#888',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
color: '#888',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 24,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -11,13 +11,7 @@ import {
|
|||||||
type StoredFile,
|
type StoredFile,
|
||||||
type StoredFolder,
|
type StoredFolder,
|
||||||
} from '../services/db';
|
} from '../services/db';
|
||||||
|
import type { SyncResult } from './syncDevice.types';
|
||||||
export type SyncResult = {
|
|
||||||
rootUri: string;
|
|
||||||
folders: number;
|
|
||||||
files: number;
|
|
||||||
missing: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function uriDepth(uri: string): number {
|
function uriDepth(uri: string): number {
|
||||||
return uri.split('/').length;
|
return uri.split('/').length;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type SyncResult = {
|
||||||
|
rootUri: string;
|
||||||
|
folders: number;
|
||||||
|
files: number;
|
||||||
|
missing: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { FileDto, ListFilesParams } from '../api/types';
|
||||||
|
|
||||||
|
export function useFiles(params?: ListFilesParams) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['files', params],
|
||||||
|
queryFn: () => api.listFiles(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFile(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['files', id],
|
||||||
|
queryFn: () => api.getFile(id),
|
||||||
|
enabled: id.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileTags(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['files', id, 'tags'],
|
||||||
|
queryFn: async (): Promise<string[]> => {
|
||||||
|
const { data } = await api.getFile(id);
|
||||||
|
return data.tags ?? [];
|
||||||
|
},
|
||||||
|
enabled: id.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FileDto };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { FileDto } from '../api/types';
|
||||||
|
|
||||||
|
export function useSearch(q: string, page?: number, pageSize?: number) {
|
||||||
|
const trimmed = q.trim();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['search', trimmed, page, pageSize],
|
||||||
|
queryFn: () => api.searchFiles(trimmed, { page, pageSize }),
|
||||||
|
enabled: trimmed.length > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FileDto };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { FileDto, OcrJob } from '../api/types';
|
||||||
|
|
||||||
|
export function useUpload() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: {
|
||||||
|
file: { uri: string; name: string; mimeType: string };
|
||||||
|
folderId?: string | null;
|
||||||
|
}) => api.uploadFile(input.file, input.folderId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['search'] });
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOcrJob(jobId?: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['ocr', jobId],
|
||||||
|
queryFn: () => api.getOcrJob(jobId!),
|
||||||
|
enabled: Boolean(jobId),
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.data && query.state.data.data.status === 'done' ? false : 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateOcrJob() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (fileId: string) => api.createOcrJob(fileId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.setQueryData(['ocr', result.data.id], result.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FileDto, OcrJob };
|
||||||
Generated
+27
@@ -8,6 +8,7 @@
|
|||||||
"name": "webui",
|
"name": "webui",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"expo": "~57.0.8",
|
"expo": "~57.0.8",
|
||||||
"expo-constants": "~57.0.17",
|
"expo-constants": "~57.0.17",
|
||||||
"expo-file-system": "~57.0.6",
|
"expo-file-system": "~57.0.6",
|
||||||
@@ -2831,6 +2832,32 @@
|
|||||||
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.102.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz",
|
||||||
|
"integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.102.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz",
|
||||||
|
"integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.102.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/better-sqlite3": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "9.6.0",
|
"version": "9.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"expo": "~57.0.8",
|
"expo": "~57.0.8",
|
||||||
"expo-constants": "~57.0.17",
|
"expo-constants": "~57.0.17",
|
||||||
"expo-file-system": "~57.0.6",
|
"expo-file-system": "~57.0.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user