move files into folders
This commit is contained in:
@@ -120,7 +120,7 @@ func (q *Queries) GetFile(ctx context.Context, id string) (File, error) {
|
|||||||
const listFiles = `-- name: ListFiles :many
|
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
|
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
|
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) {
|
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
|
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
|
const updateFile = `-- name: UpdateFile :exec
|
||||||
UPDATE files
|
UPDATE files
|
||||||
SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP
|
SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ WHERE id = $1 LIMIT 1;
|
|||||||
-- name: ListFiles :many
|
-- name: ListFiles :many
|
||||||
SELECT * FROM files
|
SELECT * FROM files
|
||||||
WHERE parent_file_id IS NULL
|
WHERE parent_file_id IS NULL
|
||||||
ORDER BY created_at DESC;
|
ORDER BY is_folder DESC, created_at DESC;
|
||||||
|
|
||||||
-- name: ListFolders :many
|
-- name: ListFolders :many
|
||||||
SELECT * FROM files
|
SELECT * FROM files
|
||||||
@@ -32,6 +32,11 @@ SELECT * FROM files
|
|||||||
WHERE parent_file_id = $1
|
WHERE parent_file_id = $1
|
||||||
ORDER BY is_folder DESC, created_at DESC;
|
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
|
-- name: UpdateFile :exec
|
||||||
UPDATE files
|
UPDATE files
|
||||||
SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP
|
SET name = $1, mime_type = $2, ocr_text = $3, updated_at = CURRENT_TIMESTAMP
|
||||||
|
|||||||
@@ -197,3 +197,48 @@ func (h *FileHandler) GetTags(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
api.Success(c, tags)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ func SetupRoutes(r *gin.Engine, h *Handler) {
|
|||||||
|
|
||||||
api.GET("/files", h.File.List)
|
api.GET("/files", h.File.List)
|
||||||
api.POST("/files/upload", h.File.Upload)
|
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/download/:id", h.File.Download)
|
||||||
api.GET("/files/:id", h.File.Get)
|
|
||||||
api.DELETE("/files/:id", h.File.Delete)
|
api.DELETE("/files/:id", h.File.Delete)
|
||||||
|
api.GET("/files/:id", h.File.Get)
|
||||||
|
|
||||||
api.POST("/files/:id/tags", h.File.AddTags)
|
api.POST("/files/:id/tags", h.File.AddTags)
|
||||||
api.GET("/files/:id/tags", h.File.GetTags)
|
api.GET("/files/:id/tags", h.File.GetTags)
|
||||||
|
|||||||
@@ -162,6 +162,57 @@ func (s *FileService) GetTagsByFileID(fileID string) ([]model.Tag, error) {
|
|||||||
return tags, nil
|
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 {
|
func dbToModel(f db.File, dbTags []db.Tag) model.File {
|
||||||
tags := make([]model.Tag, len(dbTags))
|
tags := make([]model.Tag, len(dbTags))
|
||||||
for i, t := range dbTags {
|
for i, t := range dbTags {
|
||||||
|
|||||||
+60
-1
@@ -4,7 +4,7 @@ import { useNavigation } from '@react-navigation/native';
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
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 { FileItem, isFolder } from '../types';
|
||||||
import { SearchBar, SearchFilters } from '../components/SearchBar';
|
import { SearchBar, SearchFilters } from '../components/SearchBar';
|
||||||
import { FileThumbnail } from '../components/FileThumbnail';
|
import { FileThumbnail } from '../components/FileThumbnail';
|
||||||
@@ -140,6 +140,9 @@ export function HomeScreen() {
|
|||||||
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
|
const [tagModalMode, setTagModalMode] = useState<'tag' | 'folder'>('tag');
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
const addTags = useAddTags();
|
const addTags = useAddTags();
|
||||||
|
const moveFiles = useMoveFiles();
|
||||||
|
const { data: foldersData } = useFolders();
|
||||||
|
const [moveModalVisible, setMoveModalVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
|
||||||
@@ -229,6 +232,13 @@ export function HomeScreen() {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
}, [tagInput, selectedIds, tagModalMode, addTags]);
|
}, [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) => {
|
const handleItemPress = useCallback((file: FileItem) => {
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
toggleSelection(file.id);
|
toggleSelection(file.id);
|
||||||
@@ -364,6 +374,13 @@ export function HomeScreen() {
|
|||||||
<MaterialIcons name="create-new-folder" size={20} color="#F57C00" />
|
<MaterialIcons name="create-new-folder" size={20} color="#F57C00" />
|
||||||
<Text style={[styles.selectionActionText, { color: '#F57C00' }]}>Folder</Text>
|
<Text style={[styles.selectionActionText, { color: '#F57C00' }]}>Folder</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.selectionActionBtn, styles.moveActionBtn]}
|
||||||
|
onPress={() => setMoveModalVisible(true)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="drive-file-move" size={20} color="#00897B" />
|
||||||
|
<Text style={[styles.selectionActionText, { color: '#00897B' }]}>Déplacer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -422,6 +439,31 @@ export function HomeScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal visible={moveModalVisible} transparent animationType="fade" onRequestClose={() => setMoveModalVisible(false)}>
|
||||||
|
<TouchableOpacity style={styles.modalOverlay} activeOpacity={1} onPress={() => setMoveModalVisible(false)}>
|
||||||
|
<TouchableOpacity activeOpacity={1} style={styles.modalContent} onPress={() => {}}>
|
||||||
|
<Text style={styles.modalTitle}>Déplacer vers...</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.folderOption}
|
||||||
|
onPress={() => handleMove(null)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="home" size={20} color="#666" />
|
||||||
|
<Text style={styles.folderOptionText}>Racine</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{(foldersData?.data ?? []).map((folder) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={folder.id}
|
||||||
|
style={styles.folderOption}
|
||||||
|
onPress={() => handleMove(folder.id)}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="folder" size={20} color="#F57C00" />
|
||||||
|
<Text style={styles.folderOptionText}>{folder.name}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -590,6 +632,23 @@ const styles = StyleSheet.create({
|
|||||||
borderColor: '#F57C00',
|
borderColor: '#F57C00',
|
||||||
backgroundColor: 'transparent',
|
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: {
|
modalOverlay: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const ENDPOINTS = {
|
|||||||
FILE: '/files',
|
FILE: '/files',
|
||||||
UPLOAD: '/files/upload',
|
UPLOAD: '/files/upload',
|
||||||
SEARCH: '/files/search',
|
SEARCH: '/files/search',
|
||||||
|
MOVE: '/files/move',
|
||||||
|
FOLDERS: '/files/folders',
|
||||||
OCR_JOBS: '/ocr/jobs',
|
OCR_JOBS: '/ocr/jobs',
|
||||||
HEALTH: '/health',
|
HEALTH: '/health',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user