diff --git a/backend/internal/db/files.sql.go b/backend/internal/db/files.sql.go
index 54e5bb8..497c1b8 100644
--- a/backend/internal/db/files.sql.go
+++ b/backend/internal/db/files.sql.go
@@ -120,7 +120,7 @@ func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
const listFiles = `-- name: ListFiles :many
SELECT id, name, mime_type, size, storage_key, checksum, ocr_text, created_at, updated_at, parent_file_id, is_folder FROM files
WHERE parent_file_id IS NULL
-ORDER BY created_at DESC
+ORDER BY is_folder DESC, created_at DESC
`
func (q *Queries) ListFiles(ctx context.Context) ([]File, error) {
@@ -281,6 +281,22 @@ func (q *Queries) ListFolders(ctx context.Context) ([]File, error) {
return items, nil
}
+const moveFiles = `-- name: MoveFiles :exec
+UPDATE files
+SET parent_file_id = $1, updated_at = CURRENT_TIMESTAMP
+WHERE id = ANY($2::text[])
+`
+
+type MoveFilesParams struct {
+ ParentFileID sql.NullString `json:"parent_file_id"`
+ Column2 []string `json:"column_2"`
+}
+
+func (q *Queries) MoveFiles(ctx context.Context, arg MoveFilesParams) error {
+ _, err := q.db.ExecContext(ctx, moveFiles, arg.ParentFileID, pq.Array(arg.Column2))
+ return err
+}
+
const updateFile = `-- name: UpdateFile :exec
UPDATE files
SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP
diff --git a/backend/internal/db/queries/files.sql b/backend/internal/db/queries/files.sql
index ca5b9ba..cd462c9 100644
--- a/backend/internal/db/queries/files.sql
+++ b/backend/internal/db/queries/files.sql
@@ -5,7 +5,7 @@ WHERE id = $1 LIMIT 1;
-- name: ListFiles :many
SELECT * FROM files
WHERE parent_file_id IS NULL
-ORDER BY created_at DESC;
+ORDER BY is_folder DESC, created_at DESC;
-- name: ListFolders :many
SELECT * FROM files
@@ -32,6 +32,11 @@ SELECT * FROM files
WHERE parent_file_id = $1
ORDER BY is_folder DESC, created_at DESC;
+-- name: MoveFiles :exec
+UPDATE files
+SET parent_file_id = $1, updated_at = CURRENT_TIMESTAMP
+WHERE id = ANY($2::text[]);
+
-- name: UpdateFile :exec
UPDATE files
SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP
diff --git a/backend/internal/handler/files.go b/backend/internal/handler/files.go
index 67cbe43..52bb74d 100644
--- a/backend/internal/handler/files.go
+++ b/backend/internal/handler/files.go
@@ -197,3 +197,48 @@ func (h *FileHandler) GetTags(c *gin.Context) {
}
api.Success(c, tags)
}
+
+func (h *FileHandler) MoveFiles(c *gin.Context) {
+ var body struct {
+ FileIDs []string `json:"file_ids" binding:"required"`
+ ParentFileID *string `json:"parent_file_id"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ api.Error(c, http.StatusBadRequest, "INVALID_BODY", "Body must contain 'file_ids' array")
+ return
+ }
+
+ if err := h.files.MoveFiles(body.FileIDs, body.ParentFileID); err != nil {
+ api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to move files")
+ return
+ }
+
+ api.Success(c, gin.H{"moved": len(body.FileIDs)})
+}
+
+func (h *FileHandler) ListFolders(c *gin.Context) {
+ folders, err := h.files.ListFolders()
+ if err != nil {
+ api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to list folders")
+ return
+ }
+ api.Success(c, folders)
+}
+
+func (h *FileHandler) CreateFolder(c *gin.Context) {
+ var body struct {
+ Name string `json:"name" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ api.Error(c, http.StatusBadRequest, "INVALID_BODY", "Body must contain 'name'")
+ return
+ }
+
+ folder, err := h.files.CreateFolder(body.Name)
+ if err != nil {
+ api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to create folder")
+ return
+ }
+
+ api.Success(c, folder)
+}
diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go
index 487fe2d..397d705 100644
--- a/backend/internal/handler/router.go
+++ b/backend/internal/handler/router.go
@@ -9,9 +9,12 @@ func SetupRoutes(r *gin.Engine, h *Handler) {
api.GET("/files", h.File.List)
api.POST("/files/upload", h.File.Upload)
+ api.POST("/files/move", h.File.MoveFiles)
+ api.POST("/files/folders", h.File.CreateFolder)
+ api.GET("/files/folders", h.File.ListFolders)
api.GET("/files/download/:id", h.File.Download)
- api.GET("/files/:id", h.File.Get)
api.DELETE("/files/:id", h.File.Delete)
+ api.GET("/files/:id", h.File.Get)
api.POST("/files/:id/tags", h.File.AddTags)
api.GET("/files/:id/tags", h.File.GetTags)
diff --git a/backend/internal/service/file.go b/backend/internal/service/file.go
index 6026b8e..f1bbf07 100644
--- a/backend/internal/service/file.go
+++ b/backend/internal/service/file.go
@@ -162,6 +162,57 @@ func (s *FileService) GetTagsByFileID(fileID string) ([]model.Tag, error) {
return tags, nil
}
+func (s *FileService) MoveFiles(fileIDs []string, parentFileID *string) error {
+ var parentID sql.NullString
+ if parentFileID != nil {
+ parentID = sql.NullString{String: *parentFileID, Valid: true}
+ }
+ return s.queries.MoveFiles(context.Background(), db.MoveFilesParams{
+ ParentFileID: parentID,
+ Column2: fileIDs,
+ })
+}
+
+func (s *FileService) CreateFolder(name string) (*model.File, error) {
+ f, err := s.queries.CreateFolder(context.Background(), db.CreateFolderParams{
+ ID: uuid.New().String(),
+ Name: name,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create folder: %w", err)
+ }
+ m := dbToModel(f, nil)
+ return &m, nil
+}
+
+func (s *FileService) ListFolders() ([]model.File, error) {
+ dbFiles, err := s.queries.ListFolders(context.Background())
+ if err != nil {
+ return nil, fmt.Errorf("list folders: %w", err)
+ }
+ folders := make([]model.File, len(dbFiles))
+ for i, f := range dbFiles {
+ folders[i] = dbToModel(f, nil)
+ }
+ return folders, nil
+}
+
+func (s *FileService) ListFilesByParentID(parentID string) ([]model.File, error) {
+ dbFiles, err := s.queries.ListFilesByParentID(context.Background(), sql.NullString{String: parentID, Valid: true})
+ if err != nil {
+ return nil, fmt.Errorf("list files by parent: %w", err)
+ }
+ files := make([]model.File, len(dbFiles))
+ for i, f := range dbFiles {
+ tags, err := s.queries.GetTagsByFileID(context.Background(), sql.NullString{String: f.ID, Valid: true})
+ if err != nil {
+ return nil, fmt.Errorf("get tags for file %s: %w", f.ID, err)
+ }
+ files[i] = dbToModel(f, tags)
+ }
+ return files, nil
+}
+
func dbToModel(f db.File, dbTags []db.Tag) model.File {
tags := make([]model.Tag, len(dbTags))
for i, t := range dbTags {
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index 54221c3..4087269 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -4,7 +4,7 @@ import { useNavigation } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialIcons } from '@expo/vector-icons';
-import { useFiles, useFileImage, useDeleteFile, useAddTags } from '../hooks/useFiles';
+import { useFiles, useFileImage, useDeleteFile, useAddTags, useMoveFiles, useFolders } from '../hooks/useFiles';
import { FileItem, isFolder } from '../types';
import { SearchBar, SearchFilters } from '../components/SearchBar';
import { FileThumbnail } from '../components/FileThumbnail';
@@ -140,6 +140,9 @@ export function HomeScreen() {
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
const [tagInput, setTagInput] = useState('');
const addTags = useAddTags();
+ const moveFiles = useMoveFiles();
+ const { data: foldersData } = useFolders();
+ const [moveModalVisible, setMoveModalVisible] = useState(false);
useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
@@ -229,6 +232,13 @@ export function HomeScreen() {
setSelectedIds(new Set());
}, [tagInput, selectedIds, tagModalMode, addTags]);
+ const handleMove = useCallback(async (folderId: string | null) => {
+ const ids = Array.from(selectedIds);
+ await moveFiles.mutateAsync({ fileIds: ids, parentFileId: folderId });
+ setMoveModalVisible(false);
+ setSelectedIds(new Set());
+ }, [selectedIds, moveFiles]);
+
const handleItemPress = useCallback((file: FileItem) => {
if (selectionMode) {
toggleSelection(file.id);
@@ -364,6 +374,13 @@ export function HomeScreen() {
Folder
+ setMoveModalVisible(true)}
+ >
+
+ Déplacer
+
) : (
@@ -422,6 +439,31 @@ export function HomeScreen() {
+
+ setMoveModalVisible(false)}>
+ setMoveModalVisible(false)}>
+ {}}>
+ Déplacer vers...
+ handleMove(null)}
+ >
+
+ Racine
+
+ {(foldersData?.data ?? []).map((folder) => (
+ handleMove(folder.id)}
+ >
+
+ {folder.name}
+
+ ))}
+
+
+
);
}
@@ -590,6 +632,23 @@ const styles = StyleSheet.create({
borderColor: '#F57C00',
backgroundColor: 'transparent',
},
+ moveActionBtn: {
+ borderColor: '#00897B',
+ backgroundColor: 'transparent',
+ },
+ folderOption: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 8,
+ gap: 10,
+ borderBottomWidth: 1,
+ borderBottomColor: '#f0f0f0',
+ },
+ folderOptionText: {
+ fontSize: 16,
+ color: '#333',
+ },
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
diff --git a/mobile/constants/api.ts b/mobile/constants/api.ts
index 642f602..4d55977 100644
--- a/mobile/constants/api.ts
+++ b/mobile/constants/api.ts
@@ -5,6 +5,8 @@ export const ENDPOINTS = {
FILE: '/files',
UPLOAD: '/files/upload',
SEARCH: '/files/search',
+ MOVE: '/files/move',
+ FOLDERS: '/files/folders',
OCR_JOBS: '/ocr/jobs',
HEALTH: '/health',
} as const;
diff --git a/mobile/hooks/useFiles.ts b/mobile/hooks/useFiles.ts
index 36801d9..6260bc4 100644
--- a/mobile/hooks/useFiles.ts
+++ b/mobile/hooks/useFiles.ts
@@ -51,3 +51,22 @@ export function useAddTags() {
},
});
}
+
+export function useMoveFiles() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ fileIds, parentFileId }: { fileIds: string[]; parentFileId: string | null }) =>
+ apiClient.post(ENDPOINTS.MOVE, { file_ids: fileIds, parent_file_id: parentFileId }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['files'] });
+ },
+ });
+}
+
+export function useFolders() {
+ return useQuery({
+ queryKey: ['folders'],
+ queryFn: () => apiClient.get<{ data: FileItem[] }>(ENDPOINTS.FOLDERS),
+ });
+}