init project
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# VaultDrop
|
||||
|
||||
## Project Status
|
||||
|
||||
Project initialized — `webui/` (React Native) and `backend/` (Go) have scaffolding in place.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend**: Go, Gin HTTP framework, SQLite + FTS5, Tesseract OCR (system call)
|
||||
- **Frontend**: React Native (Expo SDK 57), React Navigation, TanStack Query, react-native-image-picker
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && go run cmd/server/main.go
|
||||
|
||||
# Frontend
|
||||
cd webui && npx expo start
|
||||
|
||||
# Typecheck frontend
|
||||
cd webui && npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Backend Structure
|
||||
|
||||
- Entry point: `backend/cmd/server/main.go`
|
||||
- Internal packages: `handlers/`, `models/`, `repository/`, `service/`, `ocr/`
|
||||
- Response helpers: `pkg/api/response.go`
|
||||
- File uploads stored in `backend/uploads/`
|
||||
- Standard JSON response envelope: `{ "data": ..., "meta": { "page": ..., "total": ... } }`
|
||||
- Error format: `{ "error": { "code": "...", "message": "..." } }`
|
||||
- OCR language: `fra+eng`
|
||||
- Handlers are stub implementations (return not-implemented errors)
|
||||
|
||||
## Frontend Structure
|
||||
|
||||
- Entry point: `webui/App.tsx` (React Navigation + TanStack Query providers)
|
||||
- Screens: `app/index.tsx` (home), `app/upload.tsx`, `app/scan.tsx`, `app/search.tsx`
|
||||
- Components: `components/FileCard.tsx`, `TagChip.tsx`, `UploadProgress.tsx`
|
||||
- Hooks: `hooks/useFiles.ts`, `hooks/useSearch.ts`, `hooks/useUpload.ts`
|
||||
- API client: `api/client.ts`, types: `types/index.ts`, constants: `constants/api.ts`
|
||||
- No business logic on the client — all heavy processing server-side
|
||||
- API base URL configured via `EXPO_PUBLIC_API_BASE_URL` env var
|
||||
|
||||
## Non-Goals (V1)
|
||||
|
||||
Collaborative/multi-user, plugin system, complex offline sync, public sharing.
|
||||
|
||||
## References
|
||||
|
||||
- `README.md` — full spec, API endpoints, data flow, folder structure, iteration roadmap
|
||||
@@ -0,0 +1,301 @@
|
||||
# VaultDrop — Application de Gestion de Fichiers V1
|
||||
|
||||
## Vision
|
||||
|
||||
Application mobile tout-en-un permettant de centraliser, organiser et retrouver ses documents. Upload depuis l'appareil, scan caméra avec OCR, tagging et recherche rapide. Le back-end Go assure le traitement asynchrone (OCR, indexing) et la persistence. Le front React Native reste léger : il affiche, interagit et met en cache.
|
||||
|
||||
## Scope V1
|
||||
|
||||
On livre un produit fonctionnel utilisable au quotidien, pas un framework. V1 = gestion de fichiers avec scan OCR intégré. Pas de mode collaboratif, pas de plugins, pas de partage avancé.
|
||||
|
||||
## Non-Goals (V1)
|
||||
|
||||
- Mode collaboratif / multi-utilisateur
|
||||
- Système de plugins
|
||||
- Synchronisation offline complexe
|
||||
- Plateforme de partage publique
|
||||
|
||||
---
|
||||
|
||||
## Vue d'Ensemble Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ React Native App │ ────> │ API Go Backend │
|
||||
│ (Expo managed) │ HTTPS │ (REST) │
|
||||
│ - Upload │ │ - Upload fichiers │
|
||||
│ - Caméra / Scan │ │ - OCR async │
|
||||
│ - Tags / Recherche│ │ - Index/search │
|
||||
│ - Affichage │ │ - Persistance │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
Le mobile ne contient pas de logique métier. Tout traitement lourd (OCR, extraction de texte, indexation) est géré côté back-end.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités Attendues
|
||||
|
||||
### Upload de fichiers
|
||||
- Sélection depuis la galerie appareil
|
||||
- Upload par drag-and-drop interne
|
||||
- Support des formats : PDF, images (JPG, PNG)
|
||||
- Feedback visuel pendant l'envoi (progress)
|
||||
- Retry automatique en cas d'échec réseau
|
||||
|
||||
### Scan Documents (Caméra)
|
||||
- Prise de photo depuis l'app
|
||||
- Recadrage et orientation automatique
|
||||
- Envoi direct vers le back-end pour OCR
|
||||
- Retour du texte extrait affiché à l'utilisateur
|
||||
|
||||
### Tagging
|
||||
- Ajout de tags manuels sur chaque fichier
|
||||
- Suggestion de tags basée sur le contenu OCR
|
||||
- Filtrage par tag dans la liste
|
||||
|
||||
### Recherche
|
||||
- Recherche full-text sur le contenu OCR
|
||||
- Recherche par nom de fichier
|
||||
- Filtres combinés (tag + texte)
|
||||
|
||||
### Listing
|
||||
- Liste des fichiers uploadés avec aperçu
|
||||
- Tri par date, nom, tag
|
||||
- Pagination côté serveur
|
||||
|
||||
### Partage (Optionnel, Phase Later)
|
||||
- Génération de lien temporaire
|
||||
- Pas prioritaire en V1
|
||||
|
||||
---
|
||||
|
||||
## Exigences UX
|
||||
|
||||
- UI claire et épurée, minimaliste
|
||||
- Temps de réponse < 2s pour les actions principales
|
||||
- Feedback immédiat sur toutes les interactions
|
||||
- Pas d'écran de chargement > 3s sans spinner
|
||||
- Mode offline minimal : liste des fichiers déjà chargée visible même sans réseau
|
||||
|
||||
---
|
||||
|
||||
## Attentes API REST (Back-end Go)
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Méthode | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | /api/v1/files | Liste des fichiers (pagination) |
|
||||
| POST | /api/v1/files/upload | Upload d'un fichier |
|
||||
| GET | /api/v1/files/:id | Détail d'un fichier |
|
||||
| DELETE | /api/v1/files/:id | Suppression d'un fichier |
|
||||
| GET | /api/v1/files/search?q= | Recherche full-text |
|
||||
| POST | /api/v1/files/:id/tags | Ajout de tags |
|
||||
| GET | /api/v1/files/:id/tags | Tags d'un fichier |
|
||||
| POST | /api/v1/ocr/jobs | Soumettre un job OCR |
|
||||
| GET | /api/v1/ocr/jobs/:id | Statut d'un job OCR |
|
||||
| GET | /api/v1/health | Health check |
|
||||
|
||||
### Format de réponse standard
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"total": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Erreurs
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "FILE_TOO_LARGE",
|
||||
"message": "Fichier dépasse la limite de 50 Mo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flux de Données
|
||||
|
||||
```
|
||||
[Capture photo] ──> [Upload API] ──> [Back-end stocke]
|
||||
│
|
||||
v
|
||||
[Job OCR créé]
|
||||
│
|
||||
v (async)
|
||||
[Traitement OCR]
|
||||
[Extraction texte]
|
||||
[Indexation search]
|
||||
│
|
||||
v
|
||||
[App affiche fichier] <──── [Polling statut] <── [Résultat prêt]
|
||||
```
|
||||
|
||||
1. L'utilisateur prend une photo ou sélectionne un fichier
|
||||
2. Le fichier est uploadé vers POST /api/v1/files/upload
|
||||
3. Le back-end crée un job OCR et retourne immédiatement un ID
|
||||
4. L'app poll GET /api/v1/ocr/jobs/:id ou utilise un webhook
|
||||
5. Une fois l'OCR terminé, le texte est indexé et le fichier apparaît avec son contenu searchable
|
||||
|
||||
---
|
||||
|
||||
## Choix Techniques
|
||||
|
||||
### Front-end (React Native)
|
||||
- Expo managed workflow (développement rapide, build plus simple)
|
||||
- TanStack Query (gestion serveur state, cache, refetch)
|
||||
- React Navigation (navigation entre écrans)
|
||||
- react-native-image-picker (sélection + caméra)
|
||||
- MMKV (stockage clé-valeur pour cache local)
|
||||
|
||||
### Back-end (Go)
|
||||
- Framework HTTP : Gin ou Fiber (au choix implémenteur)
|
||||
- Persistance : SQLite pour V1 (volumétrie faible attendue)
|
||||
- OCR : Tesseract en ligne de commande (appel système)
|
||||
- Search : SQLite FTS5 pour la recherche full-text
|
||||
- Stockage fichiers : disque local avec chemin référencé en base
|
||||
|
||||
---
|
||||
|
||||
## Structure de Dossiers Suggérée
|
||||
|
||||
### Front-end (React Native / Expo)
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── app/ # Expo Router ou navigation
|
||||
│ ├── index.tsx # Ecran principal (liste)
|
||||
│ ├── upload.tsx # Ecran upload
|
||||
│ ├── scan.tsx # Ecran scan caméra
|
||||
│ └── search.tsx # Ecran recherche
|
||||
├── components/
|
||||
│ ├── FileCard.tsx
|
||||
│ ├── TagChip.tsx
|
||||
│ └── UploadProgress.tsx
|
||||
├── hooks/
|
||||
│ ├── useFiles.ts # TanStack Query hooks
|
||||
│ ├── useSearch.ts
|
||||
│ └── useUpload.ts
|
||||
├── api/
|
||||
│ └── client.ts # Client API (axios ou fetch)
|
||||
├── types/
|
||||
│ └── index.ts # Types TypeScript
|
||||
├── constants/
|
||||
│ └── api.ts # URLs, clés API
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### Back-end (Go)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Point d'entrée
|
||||
├── internal/
|
||||
│ ├── handlers/ # Handlers HTTP
|
||||
│ ├── models/ # Modèles de données
|
||||
│ ├── repository/ # Accès données
|
||||
│ ├── service/ # Logique métier
|
||||
│ └── ocr/ # Module OCR
|
||||
├── pkg/
|
||||
│ └── api/
|
||||
│ └── response.go # Helpers réponse
|
||||
├── uploads/ # Fichiers stockés
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prérequis
|
||||
|
||||
- Node.js latest lts
|
||||
- npm ou yarn
|
||||
- Expo CLI (`npm install -g expo-cli`)
|
||||
- Go latest lts
|
||||
- Tesseract OCR installé (`apt install tesseract-ocr` sur Debian/Ubuntu)
|
||||
|
||||
### Installation (Front-end)
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
npm install
|
||||
npx expo start
|
||||
```
|
||||
|
||||
### Installation (Back-end)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go mod download
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
### Build APK Android (Expo)
|
||||
|
||||
```bash
|
||||
npx expo run:android --variant release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables d'Environnement
|
||||
|
||||
### Front-end (.env)
|
||||
|
||||
```
|
||||
API_BASE_URL=http://localhost:8080/api/v1
|
||||
TESSERACT_LANG=fr+eng
|
||||
```
|
||||
|
||||
### Back-end (.env)
|
||||
|
||||
```
|
||||
PORT=8080
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE_MB=50
|
||||
OCR_LANG=fra+eng
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Itérations Futures
|
||||
|
||||
### V2 — Fiabilité et Performance
|
||||
- Remplacement SQLite par PostgreSQL
|
||||
- Upload chunked pour gros fichiers
|
||||
- Compression d'images côté client
|
||||
|
||||
### V3 — Organisation Avancée
|
||||
- Dossiers virtuels / hiérarchie
|
||||
- Tags suggérés par IA
|
||||
- OCR multilingue amélioré
|
||||
|
||||
### V4 — Collaboration
|
||||
- Comptes utilisateurs
|
||||
- Partage avec lien temporaire
|
||||
- Rôle et permissions
|
||||
|
||||
### V5 — Plateforme
|
||||
- Système de plugins (event bus)
|
||||
- API publique
|
||||
- Extensions tierces
|
||||
|
||||
---
|
||||
|
||||
## Statut du Projet
|
||||
|
||||
Phase : Conception et prototypage
|
||||
Backend Go : En cours de structuration
|
||||
Frontend React Native : À initier
|
||||
@@ -0,0 +1,23 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Uploads
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/vaultdrop/backend/internal/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
r.GET("/api/v1/health", handlers.Health)
|
||||
|
||||
r.GET("/api/v1/files", handlers.ListFiles)
|
||||
r.POST("/api/v1/files/upload", handlers.UploadFile)
|
||||
r.GET("/api/v1/files/:id", handlers.GetFile)
|
||||
r.DELETE("/api/v1/files/:id", handlers.DeleteFile)
|
||||
r.GET("/api/v1/files/search", handlers.SearchFiles)
|
||||
|
||||
r.POST("/api/v1/files/:id/tags", handlers.AddTags)
|
||||
r.GET("/api/v1/files/:id/tags", handlers.GetTags)
|
||||
|
||||
r.POST("/api/v1/ocr/jobs", handlers.CreateOcrJob)
|
||||
r.GET("/api/v1/ocr/jobs/:id", handlers.GetOcrJobStatus)
|
||||
|
||||
log.Printf("Server starting on port %s", port)
|
||||
if err := r.Run(":" + port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
module github.com/vaultdrop/backend
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require github.com/gin-gonic/gin v1.12.0
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ListFiles(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []interface{}{},
|
||||
"meta": gin.H{
|
||||
"page": 1,
|
||||
"total": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetFile(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"code": "FILE_NOT_FOUND",
|
||||
"message": "File not found",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func UploadFile(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": gin.H{
|
||||
"code": "NOT_IMPLEMENTED",
|
||||
"message": "Upload not yet implemented",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteFile(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": gin.H{
|
||||
"code": "NOT_IMPLEMENTED",
|
||||
"message": "Delete not yet implemented",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func SearchFiles(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []interface{}{},
|
||||
"meta": gin.H{
|
||||
"page": 1,
|
||||
"total": 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func AddTags(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": gin.H{
|
||||
"code": "NOT_IMPLEMENTED",
|
||||
"message": "Add tags not yet implemented",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetTags(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": []interface{}{},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"status": "healthy",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func CreateOcrJob(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": gin.H{
|
||||
"code": "NOT_IMPLEMENTED",
|
||||
"message": "OCR job creation not yet implemented",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetOcrJobStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"code": "JOB_NOT_FOUND",
|
||||
"message": "OCR job not found",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type File struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"-"`
|
||||
OcrText string `json:"ocrText,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type OcrJob struct {
|
||||
ID string `json:"id"`
|
||||
FileID string `json:"fileId"`
|
||||
Status string `json:"status"`
|
||||
Result string `json:"result,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type PaginatedResponse struct {
|
||||
Data interface{} `json:"data"`
|
||||
Meta struct {
|
||||
Page int `json:"page"`
|
||||
Total int `json:"total"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Success(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func Created(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func Paginated(c *gin.Context, data interface{}, page, total int) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": data,
|
||||
"meta": gin.H{
|
||||
"page": page,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func Error(c *gin.Context, status int, code, message string) {
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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