From 04b6dfc6d4fb3cf1e27a6df5a3e5b7aede8746d9 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 13 Jul 2026 17:07:00 +0200 Subject: [PATCH] move files into folders --- backend/internal/db/files.sql.go | 18 +++++++- backend/internal/db/queries/files.sql | 7 ++- backend/internal/handler/files.go | 45 ++++++++++++++++++++ backend/internal/handler/router.go | 5 ++- backend/internal/service/file.go | 51 ++++++++++++++++++++++ mobile/app/index.tsx | 61 ++++++++++++++++++++++++++- mobile/constants/api.ts | 2 + mobile/hooks/useFiles.ts | 19 +++++++++ 8 files changed, 204 insertions(+), 4 deletions(-) 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), + }); +}