2026-07-01 16:54:32 +02:00
|
|
|
/**
|
2026-07-20 17:31:04 +02:00
|
|
|
* DMS file browser page — complete redesign.
|
|
|
|
|
* 3-column explorer-style layout: SourceTree | FileExplorer | FileDetails.
|
|
|
|
|
* Uses PluginToolbar for actions, ResizablePanel for desktop layout.
|
2026-07-20 22:33:52 +00:00
|
|
|
* Supports drag-and-drop: files→folders, folders→folders (reparent), files→root.
|
2026-07-01 16:54:32 +02:00
|
|
|
*/
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
2026-07-01 16:54:32 +02:00
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
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';
|
2026-07-20 17:31:04 +02:00
|
|
|
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';
|
2026-07-01 16:54:32 +02:00
|
|
|
import { UploadDropzone } from '@/components/dms/UploadDropzone';
|
|
|
|
|
import { FilePreviewModal } from '@/components/dms/FilePreviewModal';
|
|
|
|
|
import { ShareDialog } from '@/components/dms/ShareDialog';
|
|
|
|
|
import { BulkActions } from '@/components/dms/BulkActions';
|
2026-07-20 17:31:04 +02:00
|
|
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
2026-07-01 16:54:32 +02:00
|
|
|
import {
|
|
|
|
|
fetchFolders,
|
|
|
|
|
createFolder,
|
|
|
|
|
deleteFile,
|
|
|
|
|
searchFiles,
|
2026-07-20 17:31:04 +02:00
|
|
|
fetchFilesByFolder,
|
|
|
|
|
fetchAllFiles,
|
|
|
|
|
getSharedWithMe,
|
|
|
|
|
bulkMoveFiles,
|
|
|
|
|
bulkDeleteFiles,
|
2026-07-20 22:33:52 +00:00
|
|
|
updateFolder,
|
2026-07-01 16:54:32 +02:00
|
|
|
type DmsFolder,
|
|
|
|
|
type DmsFile,
|
|
|
|
|
} from '@/api/dms';
|
|
|
|
|
|
|
|
|
|
export function DmsPage() {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const toast = useToast();
|
2026-07-20 17:31:04 +02:00
|
|
|
|
|
|
|
|
// Data state
|
2026-07-01 16:54:32 +02:00
|
|
|
const [folders, setFolders] = useState<DmsFolder[]>([]);
|
|
|
|
|
const [files, setFiles] = useState<DmsFile[]>([]);
|
2026-07-20 17:31:04 +02:00
|
|
|
const [sharedFiles, setSharedFiles] = useState<DmsFile[]>([]);
|
2026-07-01 16:54:32 +02:00
|
|
|
const [loadingFolders, setLoadingFolders] = useState(true);
|
2026-07-20 17:31:04 +02:00
|
|
|
const [loadingFiles, setLoadingFiles] = useState(false);
|
2026-07-01 16:54:32 +02:00
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-07-20 17:31:04 +02:00
|
|
|
|
|
|
|
|
// Navigation state
|
2026-07-01 16:54:32 +02:00
|
|
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
2026-07-20 17:31:04 +02:00
|
|
|
const [selectedSource, setSelectedSource] = useState<string | null>('local');
|
|
|
|
|
|
|
|
|
|
// Selection state
|
2026-07-01 16:54:32 +02:00
|
|
|
const [selectedFileIds, setSelectedFileIds] = useState<Set<string>>(new Set());
|
2026-07-20 17:31:04 +02:00
|
|
|
const [selectedFile, setSelectedFile] = useState<DmsFile | null>(null);
|
|
|
|
|
|
|
|
|
|
// Search state
|
2026-07-01 16:54:32 +02:00
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
2026-07-20 17:31:04 +02:00
|
|
|
|
|
|
|
|
// View state
|
|
|
|
|
const [viewMode, setViewMode] = useState<ViewMode>('icons-md');
|
|
|
|
|
const [showDetails, setShowDetails] = useState(false);
|
|
|
|
|
const [sortBy, setSortBy] = useState<SortBy>('name');
|
|
|
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
|
|
|
|
|
|
|
|
|
// Modal state
|
2026-07-01 16:54:32 +02:00
|
|
|
const [showUpload, setShowUpload] = useState(false);
|
|
|
|
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
|
|
|
|
const [newFolderName, setNewFolderName] = useState('');
|
|
|
|
|
const [previewFile, setPreviewFile] = useState<DmsFile | null>(null);
|
|
|
|
|
const [shareFile, setShareFile] = useState<DmsFile | null>(null);
|
|
|
|
|
const [deleteTarget, setDeleteTarget] = useState<DmsFile | null>(null);
|
|
|
|
|
const [submittingFolder, setSubmittingFolder] = useState(false);
|
2026-07-20 17:31:04 +02:00
|
|
|
const [showBulkMove, setShowBulkMove] = useState(false);
|
|
|
|
|
const [bulkMoveTarget, setBulkMoveTarget] = useState<string>('');
|
|
|
|
|
|
|
|
|
|
// Mobile view state
|
|
|
|
|
const [activeView, setActiveView] = useState<'tree' | 'files' | 'details'>('tree');
|
|
|
|
|
|
|
|
|
|
// Ref for race condition prevention
|
|
|
|
|
const currentLoadId = useRef<string>('');
|
2026-07-01 16:54:32 +02:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Load shared files
|
|
|
|
|
const loadSharedFiles = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const shared = await getSharedWithMe();
|
|
|
|
|
setSharedFiles(shared);
|
|
|
|
|
} catch {
|
|
|
|
|
setSharedFiles([]);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-01 16:54:32 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
loadFolders();
|
2026-07-20 17:31:04 +02:00
|
|
|
loadSharedFiles();
|
|
|
|
|
}, [loadFolders, loadSharedFiles]);
|
2026-07-01 16:54:32 +02:00
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Load files based on current selection
|
2026-07-01 16:54:32 +02:00
|
|
|
const loadFiles = useCallback(async () => {
|
2026-07-20 17:31:04 +02:00
|
|
|
const loadId = `${selectedSource}-${selectedFolderId}-${searchQuery}`;
|
|
|
|
|
currentLoadId.current = loadId;
|
2026-07-01 16:54:32 +02:00
|
|
|
setLoadingFiles(true);
|
|
|
|
|
try {
|
2026-07-20 17:31:04 +02:00
|
|
|
let result: DmsFile[] = [];
|
2026-07-01 16:54:32 +02:00
|
|
|
if (searchQuery.trim()) {
|
2026-07-20 17:31:04 +02:00
|
|
|
const searchResult = await searchFiles(searchQuery);
|
|
|
|
|
result = searchResult.files;
|
|
|
|
|
} else if (selectedSource === 'shared') {
|
|
|
|
|
result = await getSharedWithMe();
|
|
|
|
|
} else if (selectedFolderId) {
|
|
|
|
|
result = await fetchFilesByFolder(selectedFolderId);
|
2026-07-01 16:54:32 +02:00
|
|
|
} else {
|
2026-07-20 17:31:04 +02:00
|
|
|
result = await fetchAllFiles();
|
|
|
|
|
}
|
|
|
|
|
if (currentLoadId.current === loadId) {
|
|
|
|
|
setFiles(result);
|
2026-07-01 16:54:32 +02:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
2026-07-20 17:31:04 +02:00
|
|
|
if (currentLoadId.current === loadId) {
|
|
|
|
|
setError(msg);
|
|
|
|
|
setFiles([]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (currentLoadId.current === loadId) {
|
|
|
|
|
setLoadingFiles(false);
|
2026-07-01 16:54:32 +02:00
|
|
|
}
|
2026-07-20 17:31:04 +02:00
|
|
|
}, [selectedSource, selectedFolderId, searchQuery]);
|
2026-07-01 16:54:32 +02:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const debounce = setTimeout(() => {
|
|
|
|
|
loadFiles();
|
|
|
|
|
}, 300);
|
|
|
|
|
return () => clearTimeout(debounce);
|
2026-07-20 17:31:04 +02:00
|
|
|
}, [loadFiles]);
|
2026-07-01 16:54:32 +02:00
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// 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');
|
2026-07-01 16:54:32 +02:00
|
|
|
}, []);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Handle file click (select for details)
|
2026-07-01 16:54:32 +02:00
|
|
|
const handleFileClick = useCallback((file: DmsFile) => {
|
2026-07-20 17:31:04 +02:00
|
|
|
setSelectedFile(file);
|
|
|
|
|
if (showDetails) {
|
|
|
|
|
setActiveView('details');
|
|
|
|
|
}
|
|
|
|
|
}, [showDetails]);
|
|
|
|
|
|
|
|
|
|
// Handle file double click (open preview)
|
|
|
|
|
const handleFileDoubleClick = useCallback((file: DmsFile) => {
|
2026-07-01 16:54:32 +02:00
|
|
|
setPreviewFile(file);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Handle file share
|
2026-07-01 16:54:32 +02:00
|
|
|
const handleFileShare = useCallback((file: DmsFile) => {
|
|
|
|
|
setShareFile(file);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Handle file delete
|
2026-07-01 16:54:32 +02:00
|
|
|
const handleFileDelete = useCallback((file: DmsFile) => {
|
|
|
|
|
setDeleteTarget(file);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Handle file preview
|
2026-07-01 16:54:32 +02:00
|
|
|
const handleFilePreview = useCallback((file: DmsFile) => {
|
|
|
|
|
setPreviewFile(file);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// 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 confirm delete
|
2026-07-01 16:54:32 +02:00
|
|
|
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));
|
2026-07-20 17:31:04 +02:00
|
|
|
if (selectedFile?.id === deleteTarget.id) {
|
|
|
|
|
setSelectedFile(null);
|
|
|
|
|
}
|
2026-07-01 16:54:32 +02:00
|
|
|
setDeleteTarget(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
|
|
|
toast.error(msg);
|
|
|
|
|
}
|
2026-07-20 17:31:04 +02:00
|
|
|
}, [deleteTarget, selectedFile, toast, t]);
|
2026-07-01 16:54:32 +02:00
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Handle create folder
|
2026-07-01 16:54:32 +02:00
|
|
|
const handleCreateFolder = useCallback(async () => {
|
|
|
|
|
if (!newFolderName.trim()) return;
|
|
|
|
|
setSubmittingFolder(true);
|
|
|
|
|
try {
|
|
|
|
|
const folder = await createFolder({
|
|
|
|
|
name: newFolderName.trim(),
|
|
|
|
|
parent_id: selectedFolderId,
|
|
|
|
|
});
|
|
|
|
|
setFolders((prev) => [...prev, folder]);
|
|
|
|
|
setNewFolderName('');
|
|
|
|
|
setShowNewFolder(false);
|
|
|
|
|
toast.success(t('dms.createFolder'));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
|
|
|
toast.error(msg);
|
|
|
|
|
}
|
|
|
|
|
setSubmittingFolder(false);
|
|
|
|
|
}, [newFolderName, selectedFolderId, toast, t]);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Handle upload complete
|
2026-07-01 16:54:32 +02:00
|
|
|
const handleUploadComplete = useCallback(() => {
|
|
|
|
|
loadFiles();
|
|
|
|
|
}, [loadFiles]);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Handle clear selection
|
2026-07-01 16:54:32 +02:00
|
|
|
const handleClearSelection = useCallback(() => {
|
|
|
|
|
setSelectedFileIds(new Set());
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// Handle sort change
|
|
|
|
|
const handleSortChange = useCallback((by: string, order: string) => {
|
|
|
|
|
setSortBy(by as SortBy);
|
|
|
|
|
setSortOrder(order as SortOrder);
|
|
|
|
|
}, []);
|
2026-07-01 16:54:32 +02:00
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// 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');
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-20 22:33:52 +00:00
|
|
|
// ─── 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]);
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
// 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: (
|
|
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
onClick: () => setShowNewFolder((v) => !v),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'upload',
|
|
|
|
|
plugin: 'dms',
|
|
|
|
|
label: t('dms.upload'),
|
|
|
|
|
group: 'file-actions',
|
|
|
|
|
icon: (
|
|
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
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: (
|
|
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
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: (
|
|
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
onClick: () => setShowBulkMove(true),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'bulk-delete',
|
|
|
|
|
plugin: 'dms',
|
|
|
|
|
label: t('dms.bulkDelete'),
|
|
|
|
|
group: 'bulk-actions',
|
|
|
|
|
icon: (
|
|
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
onClick: handleBulkDelete,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerItems('dms', items);
|
|
|
|
|
return () => unregisterPlugin('dms');
|
|
|
|
|
}, [viewMode, showDetails, hasBulkSelection, handleSearch, handleBulkDelete, t]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col h-full" data-testid="dms-page">
|
2026-07-01 16:54:32 +02:00
|
|
|
{error && (
|
2026-07-20 17:31:04 +02:00
|
|
|
<div className="mb-2 p-3 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="dms-error">
|
2026-07-01 16:54:32 +02:00
|
|
|
<p className="text-sm text-danger-700">{error}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
{/* New folder form */}
|
2026-07-01 16:54:32 +02:00
|
|
|
{showNewFolder && (
|
2026-07-20 17:31:04 +02:00
|
|
|
<div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="new-folder-form">
|
|
|
|
|
<div className="flex items-center gap-3 max-w-md">
|
|
|
|
|
<Input
|
|
|
|
|
label={t('dms.folderName')}
|
|
|
|
|
value={newFolderName}
|
|
|
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
|
|
|
placeholder={t('dms.folderName')}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter') handleCreateFolder();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<Button size="sm" onClick={handleCreateFolder} isLoading={submittingFolder}>
|
|
|
|
|
{t('dms.createFolder')}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" size="sm" onClick={() => { setShowNewFolder(false); setNewFolderName(''); }}>
|
|
|
|
|
{t('dms.cancel')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-07-01 16:54:32 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
{/* Upload dropzone */}
|
2026-07-01 16:54:32 +02:00
|
|
|
{showUpload && (
|
2026-07-20 17:31:04 +02:00
|
|
|
<div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="upload-section">
|
|
|
|
|
<UploadDropzone folderId={selectedFolderId} onUploaded={handleUploadComplete} />
|
2026-07-01 16:54:32 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
{/* Desktop: three-pane layout with resizable panels */}
|
|
|
|
|
<div className="hidden md:flex flex-1 overflow-hidden">
|
|
|
|
|
{/* Left: SourceTree */}
|
|
|
|
|
<ResizablePanel
|
|
|
|
|
initialWidth={240}
|
|
|
|
|
minWidth={180}
|
|
|
|
|
maxWidth={400}
|
|
|
|
|
className="border-r border-secondary-200 bg-white"
|
|
|
|
|
data-testid="dms-source-pane"
|
|
|
|
|
>
|
|
|
|
|
<SourceTree
|
|
|
|
|
folders={folders}
|
|
|
|
|
sharedFiles={sharedFiles}
|
|
|
|
|
selectedFolderId={selectedFolderId}
|
|
|
|
|
selectedSource={selectedSource}
|
|
|
|
|
onSelectFolder={handleSelectFolder}
|
|
|
|
|
onSelectSource={handleSelectSource}
|
|
|
|
|
loading={loadingFolders}
|
2026-07-20 22:33:52 +00:00
|
|
|
onDropFiles={handleDropFiles}
|
|
|
|
|
onDropFolder={handleDropFolder}
|
2026-07-20 17:31:04 +02:00
|
|
|
/>
|
|
|
|
|
</ResizablePanel>
|
2026-07-01 16:54:32 +02:00
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
{/* Middle: FileExplorer */}
|
|
|
|
|
<ResizablePanel
|
|
|
|
|
resizable={false}
|
|
|
|
|
className="bg-white"
|
|
|
|
|
data-testid="dms-explorer-pane"
|
|
|
|
|
>
|
|
|
|
|
<FileExplorer
|
|
|
|
|
files={files}
|
|
|
|
|
selectedFileIds={selectedFileIds}
|
|
|
|
|
selectedFile={selectedFile}
|
|
|
|
|
viewMode={viewMode}
|
|
|
|
|
onToggleSelect={handleToggleSelect}
|
|
|
|
|
onFileClick={handleFileClick}
|
|
|
|
|
onFileDoubleClick={handleFileDoubleClick}
|
|
|
|
|
onFileShare={handleFileShare}
|
|
|
|
|
onFileDelete={handleFileDelete}
|
|
|
|
|
onFilePreview={handleFilePreview}
|
|
|
|
|
loading={loadingFiles}
|
|
|
|
|
sortBy={sortBy}
|
|
|
|
|
sortOrder={sortOrder}
|
|
|
|
|
onSortChange={handleSortChange}
|
|
|
|
|
/>
|
|
|
|
|
</ResizablePanel>
|
2026-07-01 16:54:32 +02:00
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
{/* Right: FileDetails (only when toggled on) */}
|
|
|
|
|
{showDetails && (
|
|
|
|
|
<ResizablePanel
|
|
|
|
|
initialWidth={280}
|
|
|
|
|
minWidth={200}
|
|
|
|
|
maxWidth={400}
|
|
|
|
|
handleSide="left"
|
|
|
|
|
className="border-l border-secondary-200 bg-white"
|
|
|
|
|
data-testid="dms-details-pane"
|
|
|
|
|
>
|
|
|
|
|
<FileDetails
|
|
|
|
|
file={selectedFile}
|
|
|
|
|
onPreview={handleFilePreview}
|
|
|
|
|
onShare={handleFileShare}
|
|
|
|
|
onDelete={handleFileDelete}
|
|
|
|
|
onClose={() => setShowDetails(false)}
|
|
|
|
|
/>
|
|
|
|
|
</ResizablePanel>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Mobile: single-pane view switching */}
|
|
|
|
|
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
|
|
|
|
|
{/* View 1: SourceTree */}
|
|
|
|
|
{activeView === 'tree' && (
|
|
|
|
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-source-pane">
|
|
|
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
|
|
|
|
<h2 className="text-sm font-semibold text-secondary-900">{t('dms.title')}</h2>
|
|
|
|
|
<Link to="/dms/trash" className="ml-auto text-xs text-primary-600 hover:text-primary-700">
|
|
|
|
|
{t('dms.trash')}
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
<SourceTree
|
2026-07-01 16:54:32 +02:00
|
|
|
folders={folders}
|
2026-07-20 17:31:04 +02:00
|
|
|
sharedFiles={sharedFiles}
|
2026-07-01 16:54:32 +02:00
|
|
|
selectedFolderId={selectedFolderId}
|
2026-07-20 17:31:04 +02:00
|
|
|
selectedSource={selectedSource}
|
|
|
|
|
onSelectFolder={handleSelectFolder}
|
|
|
|
|
onSelectSource={handleSelectSource}
|
2026-07-01 16:54:32 +02:00
|
|
|
loading={loadingFolders}
|
2026-07-20 22:33:52 +00:00
|
|
|
onDropFiles={handleDropFiles}
|
|
|
|
|
onDropFolder={handleDropFolder}
|
2026-07-01 16:54:32 +02:00
|
|
|
/>
|
2026-07-20 17:31:04 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* View 2: FileExplorer */}
|
|
|
|
|
{activeView === 'files' && (
|
|
|
|
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-explorer-pane">
|
|
|
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveView('tree')}
|
|
|
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
|
|
|
|
aria-label={t('common.back')}
|
|
|
|
|
data-testid="mobile-back-to-tree"
|
|
|
|
|
>
|
|
|
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span className="text-sm font-medium">{t('dms.sources')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
{showDetails && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveView('details')}
|
|
|
|
|
className="ml-auto inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
|
|
|
|
aria-label={t('dms.details')}
|
|
|
|
|
data-testid="mobile-go-to-details"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-sm font-medium">{t('dms.details')}</span>
|
|
|
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<FileExplorer
|
2026-07-01 16:54:32 +02:00
|
|
|
files={files}
|
|
|
|
|
selectedFileIds={selectedFileIds}
|
2026-07-20 17:31:04 +02:00
|
|
|
selectedFile={selectedFile}
|
|
|
|
|
viewMode={viewMode}
|
2026-07-01 16:54:32 +02:00
|
|
|
onToggleSelect={handleToggleSelect}
|
|
|
|
|
onFileClick={handleFileClick}
|
2026-07-20 17:31:04 +02:00
|
|
|
onFileDoubleClick={handleFileDoubleClick}
|
2026-07-01 16:54:32 +02:00
|
|
|
onFileShare={handleFileShare}
|
|
|
|
|
onFileDelete={handleFileDelete}
|
|
|
|
|
onFilePreview={handleFilePreview}
|
|
|
|
|
loading={loadingFiles}
|
2026-07-20 17:31:04 +02:00
|
|
|
sortBy={sortBy}
|
|
|
|
|
sortOrder={sortOrder}
|
|
|
|
|
onSortChange={handleSortChange}
|
2026-07-01 16:54:32 +02:00
|
|
|
/>
|
2026-07-20 17:31:04 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* View 3: FileDetails */}
|
|
|
|
|
{activeView === 'details' && showDetails && (
|
|
|
|
|
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-details-pane">
|
|
|
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setActiveView('files')}
|
|
|
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
|
|
|
|
aria-label={t('common.back')}
|
|
|
|
|
data-testid="mobile-back-to-files"
|
|
|
|
|
>
|
|
|
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span className="text-sm font-medium">{t('dms.files')}</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<FileDetails
|
|
|
|
|
file={selectedFile}
|
|
|
|
|
onPreview={handleFilePreview}
|
|
|
|
|
onShare={handleFileShare}
|
|
|
|
|
onDelete={handleFileDelete}
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setShowDetails(false);
|
|
|
|
|
setActiveView('files');
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-07-01 16:54:32 +02:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
{/* Floating bulk action bar */}
|
|
|
|
|
{hasBulkSelection && (
|
|
|
|
|
<BulkActions
|
|
|
|
|
selectedFileIds={selectedFileIds}
|
|
|
|
|
folders={folders}
|
|
|
|
|
onClear={handleClearSelection}
|
|
|
|
|
onActionComplete={loadFiles}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-01 16:54:32 +02:00
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
{/* Bulk move dialog */}
|
|
|
|
|
{showBulkMove && (
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
open={showBulkMove}
|
|
|
|
|
title={t('dms.bulkMoveTitle')}
|
|
|
|
|
message={t('dms.selectTargetFolder')}
|
|
|
|
|
confirmLabel={t('dms.bulkMove')}
|
|
|
|
|
onConfirm={handleBulkMove}
|
|
|
|
|
onCancel={() => { setShowBulkMove(false); setBulkMoveTarget(''); }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-07-01 16:54:32 +02:00
|
|
|
|
2026-07-20 17:31:04 +02:00
|
|
|
{/* Delete confirmation */}
|
2026-07-01 16:54:32 +02:00
|
|
|
<ConfirmDialog
|
|
|
|
|
open={!!deleteTarget}
|
|
|
|
|
title={t('dms.delete')}
|
|
|
|
|
message={t('dms.confirmDelete')}
|
|
|
|
|
confirmLabel={t('dms.delete')}
|
|
|
|
|
variant="danger"
|
|
|
|
|
onConfirm={handleConfirmDelete}
|
|
|
|
|
onCancel={() => setDeleteTarget(null)}
|
|
|
|
|
/>
|
2026-07-20 17:31:04 +02:00
|
|
|
|
|
|
|
|
{/* File preview modal */}
|
|
|
|
|
<FilePreviewModal
|
|
|
|
|
open={!!previewFile}
|
|
|
|
|
file={previewFile}
|
|
|
|
|
onClose={() => setPreviewFile(null)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Share dialog */}
|
|
|
|
|
<ShareDialog
|
|
|
|
|
open={!!shareFile}
|
|
|
|
|
file={shareFile}
|
|
|
|
|
onClose={() => setShareFile(null)}
|
|
|
|
|
onShared={() => loadFiles()}
|
|
|
|
|
/>
|
2026-07-01 16:54:32 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|