/** * DMS file browser page — complete redesign. * 3-column explorer-style layout: SourceTree | FileExplorer | FileDetails. * Uses PluginToolbar for actions, ResizablePanel for desktop layout. * Supports drag-and-drop: files→folders, folders→folders (reparent), files→root. */ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { useToast } from '@/components/ui/Toast'; import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { SourceTree } from '@/components/dms/SourceTree'; import { FileExplorer, type ViewMode, type SortBy, type SortOrder } from '@/components/dms/FileExplorer'; import { FileDetails } from '@/components/dms/FileDetails'; import { UploadDropzone } from '@/components/dms/UploadDropzone'; import { FilePreviewModal } from '@/components/dms/FilePreviewModal'; import { ShareDialog } from '@/components/dms/ShareDialog'; import { BulkActions } from '@/components/dms/BulkActions'; import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { ArrowRight, ChevronLeft, ChevronRight, Info, Plus, Trash2, Upload } from 'lucide-react'; import { fetchFolders, createFolder, deleteFile, searchFiles, fetchFilesByFolder, fetchAllFiles, getSharedWithMe, bulkMoveFiles, bulkDeleteFiles, updateFolder, type DmsFolder, type DmsFile, } from '@/api/dms'; export function DmsPage() { const { t } = useTranslation(); const toast = useToast(); // Data state const [folders, setFolders] = useState([]); const [files, setFiles] = useState([]); const [sharedFiles, setSharedFiles] = useState([]); const [loadingFolders, setLoadingFolders] = useState(true); const [loadingFiles, setLoadingFiles] = useState(false); const [error, setError] = useState(null); // Navigation state const [selectedFolderId, setSelectedFolderId] = useState(null); const [selectedSource, setSelectedSource] = useState('local'); // Selection state const [selectedFileIds, setSelectedFileIds] = useState>(new Set()); const [selectedFile, setSelectedFile] = useState(null); // Search state const [searchQuery, setSearchQuery] = useState(''); // View state const [viewMode, setViewMode] = useState('icons-md'); const [showDetails, setShowDetails] = useState(false); const [sortBy, setSortBy] = useState('name'); const [sortOrder, setSortOrder] = useState('asc'); // Modal state const [showUpload, setShowUpload] = useState(false); const [showNewFolder, setShowNewFolder] = useState(false); const [previewFile, setPreviewFile] = useState(null); const [shareFile, setShareFile] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [submittingFolder, setSubmittingFolder] = useState(false); const [showBulkMove, setShowBulkMove] = useState(false); const [bulkMoveTarget, setBulkMoveTarget] = useState(''); // ── Folder create form (RHF + Zod) ── const folderSchema = z.object({ name: z.string().min(1, 'required'), }); type FolderFormData = z.infer; const { register: registerFolder, handleSubmit: handleSubmitFolder, reset: resetFolder, formState: { errors: folderErrors } } = useForm({ resolver: zodResolver(folderSchema), defaultValues: { name: '' }, }); // Mobile view state const [activeView, setActiveView] = useState<'tree' | 'files' | 'details'>('tree'); // Ref for race condition prevention const currentLoadId = useRef(''); // Load folders const loadFolders = useCallback(async () => { setLoadingFolders(true); try { const folderList = await fetchFolders(); setFolders(folderList); } catch (err) { const msg = err instanceof Error ? err.message : String(err); setError(msg); } setLoadingFolders(false); }, []); // Load shared files const loadSharedFiles = useCallback(async () => { try { const shared = await getSharedWithMe(); setSharedFiles(shared); } catch { setSharedFiles([]); } }, []); useEffect(() => { loadFolders(); loadSharedFiles(); }, [loadFolders, loadSharedFiles]); // Load files based on current selection const loadFiles = useCallback(async () => { const loadId = `${selectedSource}-${selectedFolderId}-${searchQuery}`; currentLoadId.current = loadId; setLoadingFiles(true); try { let result: DmsFile[] = []; if (searchQuery.trim()) { const searchResult = await searchFiles(searchQuery); result = searchResult.files; } else if (selectedSource === 'shared') { result = await getSharedWithMe(); } else if (selectedFolderId) { result = await fetchFilesByFolder(selectedFolderId); } else { result = await fetchAllFiles(); } if (currentLoadId.current === loadId) { setFiles(result); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (currentLoadId.current === loadId) { setError(msg); setFiles([]); } } if (currentLoadId.current === loadId) { setLoadingFiles(false); } }, [selectedSource, selectedFolderId, searchQuery]); useEffect(() => { const debounce = setTimeout(() => { loadFiles(); }, 300); return () => clearTimeout(debounce); }, [loadFiles]); // Handle folder selection const handleSelectFolder = useCallback((folderId: string | null) => { setSelectedFolderId(folderId); setSelectedSource('local'); setSearchQuery(''); setSelectedFile(null); setSelectedFileIds(new Set()); setActiveView('files'); }, []); // Handle source selection const handleSelectSource = useCallback((source: string) => { setSelectedSource(source); if (source !== 'local') { setSelectedFolderId(null); } setSearchQuery(''); setSelectedFile(null); setSelectedFileIds(new Set()); setActiveView('files'); }, []); // Handle file click (select for details) const handleFileClick = useCallback((file: DmsFile) => { setSelectedFile(file); if (showDetails) { setActiveView('details'); } }, [showDetails]); // Handle file double click (open preview) const handleFileDoubleClick = useCallback((file: DmsFile) => { setPreviewFile(file); }, []); // Handle file share const handleFileShare = useCallback((file: DmsFile) => { setShareFile(file); }, []); // Handle file delete const handleFileDelete = useCallback((file: DmsFile) => { setDeleteTarget(file); }, []); // Handle file preview const handleFilePreview = useCallback((file: DmsFile) => { setPreviewFile(file); }, []); // Handle toggle select const handleToggleSelect = useCallback((fileId: string) => { setSelectedFileIds((prev) => { const next = new Set(prev); if (next.has(fileId)) { next.delete(fileId); } else { next.add(fileId); } return next; }); }, []); // Handle range select (Shift+Click or rubber-band) const handleRangeSelect = useCallback((fileIds: string[]) => { setSelectedFileIds(new Set(fileIds)); }, []); // Handle confirm delete const handleConfirmDelete = useCallback(async () => { if (!deleteTarget) return; try { await deleteFile(deleteTarget.id); toast.success(t('dms.deleted')); setFiles((prev) => prev.filter((f) => f.id !== deleteTarget.id)); if (selectedFile?.id === deleteTarget.id) { setSelectedFile(null); } setDeleteTarget(null); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } }, [deleteTarget, selectedFile, toast, t]); // Handle create folder const handleCreateFolder = useCallback(async (data: FolderFormData) => { setSubmittingFolder(true); try { const folder = await createFolder({ name: data.name.trim(), parent_id: selectedFolderId, }); setFolders((prev) => [...prev, folder]); resetFolder({ name: '' }); setShowNewFolder(false); toast.success(t('dms.createFolder')); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } setSubmittingFolder(false); }, [selectedFolderId, toast, t, resetFolder]); // Handle upload complete const handleUploadComplete = useCallback(() => { loadFiles(); }, [loadFiles]); // Handle clear selection const handleClearSelection = useCallback(() => { setSelectedFileIds(new Set()); }, []); // Handle sort change const handleSortChange = useCallback((by: string, order: string) => { setSortBy(by as SortBy); setSortOrder(order as SortOrder); }, []); // Handle bulk delete const handleBulkDelete = useCallback(async () => { const fileIds = Array.from(selectedFileIds); try { await bulkDeleteFiles({ file_ids: fileIds }); toast.success(t('dms.deleted')); setFiles((prev) => prev.filter((f) => !selectedFileIds.has(f.id))); setSelectedFileIds(new Set()); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } }, [selectedFileIds, toast, t]); // Handle bulk move const handleBulkMove = useCallback(async () => { const fileIds = Array.from(selectedFileIds); try { await bulkMoveFiles({ file_ids: fileIds, target_folder_id: bulkMoveTarget || null, }); toast.success(t('dms.bulkMove')); setFiles((prev) => prev.filter((f) => !selectedFileIds.has(f.id))); setSelectedFileIds(new Set()); setShowBulkMove(false); setBulkMoveTarget(''); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } }, [selectedFileIds, bulkMoveTarget, toast, t]); // Handle search const handleSearch = useCallback((query: string) => { setSearchQuery(query); setSelectedFolderId(null); setSelectedSource('local'); }, []); // ─── Drag-and-drop handlers ─── // Handle files dropped onto a folder (or root when targetFolderId is null) const handleDropFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { if (fileIds.length === 0) return; try { await bulkMoveFiles({ file_ids: fileIds, target_folder_id: targetFolderId, }); toast.success( targetFolderId ? t('dms.filesMoved') : t('dms.filesMovedRoot') ); // Remove moved files from current view (unless we're in the target folder) if (selectedFolderId !== targetFolderId) { setFiles((prev) => prev.filter((f) => !fileIds.includes(f.id))); } // Reload folders to update file counts loadFolders(); // If dropping into a folder, navigate to it if (targetFolderId) { setSelectedFolderId(targetFolderId); setSelectedSource('local'); setSearchQuery(''); setSelectedFileIds(new Set()); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } }, [selectedFolderId, toast, t, loadFolders]); // Handle folder dropped onto another folder (reparent) or root const handleDropFolder = useCallback(async (sourceFolderId: string, targetFolderId: string | null) => { if (sourceFolderId === targetFolderId) return; try { await updateFolder(sourceFolderId, { parent_id: targetFolderId }); toast.success(t('dms.folderMoved')); loadFolders(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } }, [toast, t, loadFolders]); // Register toolbar items const registerItems = usePluginToolbarStore((s) => s.registerItems); const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin); const hasBulkSelection = selectedFileIds.size > 0; useEffect(() => { const items = [ // File actions group { id: 'new-folder', plugin: 'dms', label: t('dms.newFolder'), group: 'file-actions', icon: ( ), onClick: () => setShowNewFolder((v) => !v), }, { id: 'upload', plugin: 'dms', label: t('dms.upload'), group: 'file-actions', icon: ( ), onClick: () => setShowUpload((v) => !v), }, // View modes group { id: 'view-mode', plugin: 'dms', label: t('dms.viewList'), group: 'view-modes', type: 'select' as const, selectValue: viewMode, selectOptions: [ { value: 'list', label: t('dms.viewList') }, { value: 'table', label: t('dms.viewTable') }, { value: 'icons-sm', label: t('dms.viewIconsSm') }, { value: 'icons-md', label: t('dms.viewIconsMd') }, { value: 'icons-lg', label: t('dms.viewIconsLg') }, ], onSelect: (value: string) => setViewMode(value as ViewMode), onClick: () => {}, }, // Search group { id: 'search', plugin: 'dms', label: t('dms.search'), type: 'search' as const, group: 'search', searchPlaceholder: t('dms.searchPlaceholder'), onSearch: handleSearch, onClick: () => {}, }, // Details toggle group { id: 'toggle-details', plugin: 'dms', label: t('dms.toggleDetails'), group: 'details', active: showDetails, icon: ( ), onClick: () => setShowDetails((v) => !v), }, ]; // Add bulk actions when files are selected if (hasBulkSelection) { items.push( { id: 'bulk-move', plugin: 'dms', label: t('dms.bulkMove'), group: 'bulk-actions', icon: ( ), onClick: () => setShowBulkMove(true), }, { id: 'bulk-delete', plugin: 'dms', label: t('dms.bulkDelete'), group: 'bulk-actions', icon: ( ), onClick: handleBulkDelete, }, ); } registerItems('dms', items); return () => unregisterPlugin('dms'); }, [viewMode, showDetails, hasBulkSelection, handleSearch, handleBulkDelete, t]); return (
{error && (

{error}

)} {/* New folder form */} {showNewFolder && (
)} {/* Upload dropzone */} {showUpload && (
)} {/* Desktop: three-pane layout with resizable panels */}
{/* Left: SourceTree */} {/* Middle: FileExplorer */} {/* Right: FileDetails (only when toggled on) */} {showDetails && ( setShowDetails(false)} /> )}
{/* Mobile: single-pane view switching */}
{/* View 1: SourceTree */} {activeView === 'tree' && (

{t('dms.title')}

{t('dms.trash')}
)} {/* View 2: FileExplorer */} {activeView === 'files' && (
{showDetails && ( )}
)} {/* View 3: FileDetails */} {activeView === 'details' && showDetails && (
{ setShowDetails(false); setActiveView('files'); }} />
)}
{/* Floating bulk action bar */} {hasBulkSelection && ( )} {/* Bulk move dialog */} {showBulkMove && ( { setShowBulkMove(false); setBulkMoveTarget(''); }} /> )} {/* Delete confirmation */} setDeleteTarget(null)} /> {/* File preview modal */} setPreviewFile(null)} /> {/* Share dialog */} setShareFile(null)} onShared={() => loadFiles()} />
); }