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)
|
||||
- `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
|
||||
- `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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
- 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.
|
||||
|
||||
# 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 { AuthProvider } from '../context/AuthContext';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Stack />
|
||||
</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 { useEffect, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { pickDirectory } from '../services/safDirectory';
|
||||
import { saveDirectory, getFolders } from '../services/localStorage';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
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() {
|
||||
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const router = useRouter();
|
||||
const [roots, setRoots] = useState<StoredFolder[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const load = useCallback(async () => {
|
||||
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 folder = await pickDirectory();
|
||||
if ( !folder ) return;
|
||||
const saved = await saveDirectory(folder);
|
||||
setFolders((prev) => [...prev, folder.name]);
|
||||
try {
|
||||
const result = await syncRoot(saved.resource_id);
|
||||
console.info('walk', JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.warn('walk failed', error);
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -47,28 +68,37 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: '600',
|
||||
padding: 16,
|
||||
},
|
||||
button: {
|
||||
marginTop: 24,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: '#1a73e8',
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#0057ff',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonLabel: {
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
folder: {
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
row: {
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
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 StoredFolder,
|
||||
} from '../services/db';
|
||||
|
||||
export type SyncResult = {
|
||||
rootUri: string;
|
||||
folders: number;
|
||||
files: number;
|
||||
missing: number;
|
||||
};
|
||||
import type { SyncResult } from './syncDevice.types';
|
||||
|
||||
function uriDepth(uri: string): number {
|
||||
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",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"expo": "~57.0.8",
|
||||
"expo-constants": "~57.0.17",
|
||||
"expo-file-system": "~57.0.6",
|
||||
@@ -2831,6 +2832,32 @@
|
||||
"integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==",
|
||||
"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": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "2.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"expo": "~57.0.8",
|
||||
"expo-constants": "~57.0.17",
|
||||
"expo-file-system": "~57.0.6",
|
||||
|
||||
Reference in New Issue
Block a user