init project
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"expo@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
@@ -0,0 +1,3 @@
|
||||
# Expo HAS CHANGED
|
||||
|
||||
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { HomeScreen } from './app/index';
|
||||
import { UploadScreen } from './app/upload';
|
||||
import { ScanScreen } from './app/scan';
|
||||
import { SearchScreen } from './app/search';
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator initialRouteName="Home">
|
||||
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'VaultDrop' }} />
|
||||
<Stack.Screen name="Upload" component={UploadScreen} options={{ title: 'Upload' }} />
|
||||
<Stack.Screen name="Scan" component={ScanScreen} options={{ title: 'Scan' }} />
|
||||
<Stack.Screen name="Search" component={SearchScreen} options={{ title: 'Recherche' }} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
<StatusBar style="auto" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { API_BASE_URL } from '../constants/api';
|
||||
import { ApiError } from '../types';
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.error?.message || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint);
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async uploadFile<T>(endpoint: string, formData: FormData): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json();
|
||||
throw new Error(error.error?.message || 'Upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient(API_BASE_URL);
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "webui",
|
||||
"slug": "webui",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { View, FlatList, StyleSheet, TouchableOpacity, Text } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useFiles } from '../hooks/useFiles';
|
||||
import { FileCard } from '../components/FileCard';
|
||||
import { FileItem } from '../types';
|
||||
|
||||
type RootStackParamList = {
|
||||
Home: undefined;
|
||||
Upload: undefined;
|
||||
Scan: undefined;
|
||||
Search: undefined;
|
||||
};
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export function HomeScreen() {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const { data, isLoading, error } = useFiles();
|
||||
|
||||
const renderItem = ({ item }: { item: FileItem }) => (
|
||||
<FileCard
|
||||
file={item}
|
||||
onPress={(file) => console.log('File pressed:', file.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text>Chargement...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.center}>
|
||||
<Text>Erreur de chargement</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={data?.data || []}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
/>
|
||||
|
||||
<View style={styles.bottomNav}>
|
||||
<TouchableOpacity
|
||||
style={styles.navButton}
|
||||
onPress={() => navigation.navigate('Upload')}
|
||||
>
|
||||
<Text style={styles.navText}>Upload</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.navButton}
|
||||
onPress={() => navigation.navigate('Scan')}
|
||||
>
|
||||
<Text style={styles.navText}>Scan</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.navButton}
|
||||
onPress={() => navigation.navigate('Search')}
|
||||
>
|
||||
<Text style={styles.navText}>Recherche</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
bottomNav: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
},
|
||||
navButton: {
|
||||
padding: 8,
|
||||
},
|
||||
navText: {
|
||||
fontSize: 16,
|
||||
color: '#1976D2',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import * as ImagePicker from 'react-native-image-picker';
|
||||
|
||||
export function ScanScreen() {
|
||||
const takePhoto = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchCamera({
|
||||
mediaType: 'photo',
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
if (result.didCancel) return;
|
||||
if (result.errorCode) {
|
||||
Alert.alert('Erreur', result.errorMessage || "Impossible d'accéder à la caméra");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.assets && result.assets[0]) {
|
||||
console.log('Photo taken:', result.assets[0]);
|
||||
// TODO: Send to backend for OCR
|
||||
}
|
||||
} catch (err) {
|
||||
Alert.alert('Erreur', "Impossible d'accéder à la caméra");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Scanner un document</Text>
|
||||
<Text style={styles.description}>
|
||||
Prenez une photo de votre document pour extraire le texte via OCR
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={styles.scanButton} onPress={takePhoto}>
|
||||
<Text style={styles.scanText}>Prendre une photo</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
scanButton: {
|
||||
backgroundColor: '#4CAF50',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
},
|
||||
scanText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, TextInput, FlatList, StyleSheet, Text } from 'react-native';
|
||||
import { useSearch } from '../hooks/useSearch';
|
||||
import { FileCard } from '../components/FileCard';
|
||||
import { FileItem } from '../types';
|
||||
|
||||
export function SearchScreen() {
|
||||
const [query, setQuery] = useState('');
|
||||
const { data, isLoading } = useSearch(query);
|
||||
|
||||
const renderItem = ({ item }: { item: FileItem }) => (
|
||||
<FileCard
|
||||
file={item}
|
||||
onPress={(file) => console.log('File pressed:', file.id)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Rechercher un fichier..."
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
{isLoading && <Text style={styles.loading}>Recherche en cours...</Text>}
|
||||
|
||||
<FlatList
|
||||
data={data?.data || []}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 12,
|
||||
margin: 16,
|
||||
borderRadius: 8,
|
||||
fontSize: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
loading: {
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import * as ImagePicker from 'react-native-image-picker';
|
||||
import { useUpload } from '../hooks/useUpload';
|
||||
import { UploadProgress } from '../components/UploadProgress';
|
||||
|
||||
export function UploadScreen() {
|
||||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'uploading' | 'processing' | 'success' | 'error'>('idle');
|
||||
const [error, setError] = useState<string>();
|
||||
const upload = useUpload();
|
||||
|
||||
const pickImage = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibrary({
|
||||
mediaType: 'photo',
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
if (result.didCancel) return;
|
||||
if (result.errorCode) {
|
||||
Alert.alert('Erreur', result.errorMessage || "Impossible d'accéder à la galerie");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.assets && result.assets[0]) {
|
||||
setUploadStatus('uploading');
|
||||
const asset = result.assets[0];
|
||||
const file = {
|
||||
uri: asset.uri,
|
||||
type: asset.type || 'image/jpeg',
|
||||
name: asset.fileName || 'photo.jpg',
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await upload.mutateAsync(file as any);
|
||||
setUploadStatus('processing');
|
||||
console.log('Upload success:', response);
|
||||
setUploadStatus('success');
|
||||
} catch (err) {
|
||||
setUploadStatus('error');
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Alert.alert('Erreur', "Impossible d'accéder à la galerie");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<UploadProgress status={uploadStatus} error={error} />
|
||||
|
||||
<TouchableOpacity style={styles.uploadButton} onPress={pickImage}>
|
||||
<Text style={styles.uploadText}>Sélectionner une photo</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
uploadButton: {
|
||||
backgroundColor: '#1976D2',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
uploadText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { FileItem } from '../types';
|
||||
import { TagChip } from './TagChip';
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileItem;
|
||||
onPress?: (file: FileItem) => void;
|
||||
}
|
||||
|
||||
export function FileCard({ file, onPress }: FileCardProps) {
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.container} onPress={() => onPress?.(file)}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text style={styles.size}>{formatSize(file.size)}</Text>
|
||||
</View>
|
||||
|
||||
{file.ocrText && (
|
||||
<Text style={styles.preview} numberOfLines={2}>
|
||||
{file.ocrText}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.tags}>
|
||||
{file.tags.map((tag) => (
|
||||
<TagChip key={tag.id} name={tag.name} />
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
name: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
size: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
preview: {
|
||||
fontSize: 14,
|
||||
color: '#444',
|
||||
marginBottom: 8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
tags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
interface TagChipProps {
|
||||
name: string;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function TagChip({ name, onRemove }: TagChipProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.text}>{name}</Text>
|
||||
{onRemove && (
|
||||
<TouchableOpacity onPress={onRemove} style={styles.removeButton}>
|
||||
<Text style={styles.removeText}>×</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#E3F2FD',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
fontSize: 12,
|
||||
color: '#1976D2',
|
||||
},
|
||||
removeButton: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
removeText: {
|
||||
fontSize: 14,
|
||||
color: '#1976D2',
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
|
||||
interface UploadProgressProps {
|
||||
progress?: number;
|
||||
status: 'idle' | 'uploading' | 'processing' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function UploadProgress({ progress, status, error }: UploadProgressProps) {
|
||||
if (status === 'idle') return null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{status === 'uploading' && (
|
||||
<>
|
||||
<ActivityIndicator size="small" color="#1976D2" />
|
||||
<Text style={styles.text}>
|
||||
Upload en cours... {progress !== undefined && `${progress}%`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'processing' && (
|
||||
<>
|
||||
<ActivityIndicator size="small" color="#1976D2" />
|
||||
<Text style={styles.text}>Traitement OCR en cours...</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Text style={[styles.text, styles.success]}>Upload terminé !</Text>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Text style={[styles.text, styles.error]}>
|
||||
{error || "Erreur lors de l'upload"}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
text: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
},
|
||||
success: {
|
||||
color: '#4CAF50',
|
||||
},
|
||||
error: {
|
||||
color: '#F44336',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
export const ENDPOINTS = {
|
||||
FILES: '/files',
|
||||
UPLOAD: '/files/upload',
|
||||
SEARCH: '/files/search',
|
||||
OCR_JOBS: '/ocr/jobs',
|
||||
HEALTH: '/health',
|
||||
} as const;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { FileItem, PaginatedResponse } from '../types';
|
||||
|
||||
export function useFiles(page: number = 1, limit: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['files', page, limit],
|
||||
queryFn: () =>
|
||||
apiClient.get<PaginatedResponse<FileItem>>(
|
||||
`${ENDPOINTS.FILES}?page=${page}&limit=${limit}`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFile(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['files', id],
|
||||
queryFn: () => apiClient.get<FileItem>(`${ENDPOINTS.FILES}/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`${ENDPOINTS.FILES}/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTags() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ fileId, tags }: { fileId: string; tags: string[] }) =>
|
||||
apiClient.post(`${ENDPOINTS.FILES}/${fileId}/tags`, { tags }),
|
||||
onSuccess: (_, { fileId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files', fileId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { FileItem, PaginatedResponse } from '../types';
|
||||
|
||||
export function useSearch(query: string, page: number = 1, limit: number = 20) {
|
||||
return useQuery({
|
||||
queryKey: ['search', query, page, limit],
|
||||
queryFn: () =>
|
||||
apiClient.get<PaginatedResponse<FileItem>>(
|
||||
`${ENDPOINTS.FILES}/search?q=${encodeURIComponent(query)}&page=${page}&limit=${limit}`
|
||||
),
|
||||
enabled: query.length > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../api/client';
|
||||
import { ENDPOINTS } from '../constants/api';
|
||||
import { OcrJob } from '../types';
|
||||
|
||||
export function useUpload() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return apiClient.uploadFile<{ id: string; ocrJobId: string }>(
|
||||
ENDPOINTS.UPLOAD,
|
||||
formData
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useOcrJobStatus(jobId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['ocr', jobId],
|
||||
queryFn: () => apiClient.get<OcrJob>(`${ENDPOINTS.OCR_JOBS}/${jobId}`),
|
||||
enabled: !!jobId,
|
||||
refetchInterval: (query: any) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === 'completed' || status === 'failed') return false;
|
||||
return 1000;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
Generated
+6316
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "webui",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@react-navigation/native": "^7.3.8",
|
||||
"@react-navigation/native-stack": "^7.17.10",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"expo": "~57.0.4",
|
||||
"expo-status-bar": "~57.0.0",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-image-picker": "^8.2.1",
|
||||
"react-native-mmkv": "^4.3.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
ocrText?: string;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface OcrJob {
|
||||
id: string;
|
||||
fileId: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
result?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
page: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user