DMS redesign: 3-column explorer layout with toolbar, source tree, file details panel

- Add backend endpoints: GET /dms/files, GET /dms/folders/{id}/files
- New SourceTree component: sources (Meine Dateien, Geteilte Ordner, Externe Speicher)
- New FileExplorer component: 5 view modes (list, table, icons-sm/md/lg)
- New FileDetails component: right panel with file metadata and actions
- Rewrite Dms.tsx: PluginToolbar + 3-column ResizablePanel layout
- Update DmsFile type: size_bytes + uploaded_by (backward compat aliases)
- Add 60+ i18n keys for de/en
This commit is contained in:
Agent Zero
2026-07-20 17:31:04 +02:00
parent e1a9a8e33e
commit e94e64ba6b
11 changed files with 1641 additions and 143 deletions
+503 -123
View File
@@ -1,27 +1,35 @@
/**
* DMS file browser page.
* Folder tree sidebar + file grid + upload + search + preview + share + bulk actions.
* DMS file browser page — complete redesign.
* 3-column explorer-style layout: SourceTree | FileExplorer | FileDetails.
* Uses PluginToolbar for actions, ResizablePanel for desktop layout.
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useToast } from '@/components/ui/Toast';
import { FolderTree } from '@/components/dms/FolderTree';
import { FileGrid } from '@/components/dms/FileGrid';
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 {
fetchFolders,
createFolder,
deleteFile,
searchFiles,
fetchFilesByFolder,
fetchAllFiles,
getSharedWithMe,
bulkMoveFiles,
bulkDeleteFiles,
type DmsFolder,
type DmsFile,
} from '@/api/dms';
@@ -29,14 +37,33 @@ import {
export function DmsPage() {
const { t } = useTranslation();
const toast = useToast();
// Data state
const [folders, setFolders] = useState<DmsFolder[]>([]);
const [files, setFiles] = useState<DmsFile[]>([]);
const [sharedFiles, setSharedFiles] = useState<DmsFile[]>([]);
const [loadingFolders, setLoadingFolders] = useState(true);
const [loadingFiles, setLoadingFiles] = useState(true);
const [loadingFiles, setLoadingFiles] = useState(false);
const [error, setError] = useState<string | null>(null);
// Navigation state
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const [selectedSource, setSelectedSource] = useState<string | null>('local');
// Selection state
const [selectedFileIds, setSelectedFileIds] = useState<Set<string>>(new Set());
const [selectedFile, setSelectedFile] = useState<DmsFile | null>(null);
// Search state
const [searchQuery, setSearchQuery] = useState('');
// 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
const [showUpload, setShowUpload] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
@@ -44,6 +71,14 @@ export function DmsPage() {
const [shareFile, setShareFile] = useState<DmsFile | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DmsFile | null>(null);
const [submittingFolder, setSubmittingFolder] = useState(false);
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>('');
// Load folders
const loadFolders = useCallback(async () => {
@@ -58,37 +93,111 @@ export function DmsPage() {
setLoadingFolders(false);
}, []);
// Load shared files
const loadSharedFiles = useCallback(async () => {
try {
const shared = await getSharedWithMe();
setSharedFiles(shared);
} catch {
setSharedFiles([]);
}
}, []);
useEffect(() => {
loadFolders();
}, [loadFolders]);
loadSharedFiles();
}, [loadFolders, loadSharedFiles]);
// Load files (mock: in real app would fetch by folder or search)
// 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 result = await searchFiles(searchQuery);
setFiles(result.files);
const searchResult = await searchFiles(searchQuery);
result = searchResult.files;
} else if (selectedSource === 'shared') {
result = await getSharedWithMe();
} else if (selectedFolderId) {
result = await fetchFilesByFolder(selectedFolderId);
} else {
// For folder view, we would normally call a folder files endpoint
// Since the API only has search and shared-with-me, we show empty or use search
setFiles([]);
result = await fetchAllFiles();
}
if (currentLoadId.current === loadId) {
setFiles(result);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setFiles([]);
if (currentLoadId.current === loadId) {
setError(msg);
setFiles([]);
}
}
setLoadingFiles(false);
}, [searchQuery]);
if (currentLoadId.current === loadId) {
setLoadingFiles(false);
}
}, [selectedSource, selectedFolderId, searchQuery]);
useEffect(() => {
const debounce = setTimeout(() => {
loadFiles();
}, 300);
return () => clearTimeout(debounce);
}, [loadFiles, selectedFolderId]);
}, [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);
@@ -101,35 +210,24 @@ export function DmsPage() {
});
}, []);
const handleFileClick = useCallback((file: DmsFile) => {
setPreviewFile(file);
}, []);
const handleFileShare = useCallback((file: DmsFile) => {
setShareFile(file);
}, []);
const handleFileDelete = useCallback((file: DmsFile) => {
setDeleteTarget(file);
}, []);
const handleFilePreview = useCallback((file: DmsFile) => {
setPreviewFile(file);
}, []);
// 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, toast, t]);
}, [deleteTarget, selectedFile, toast, t]);
// Handle create folder
const handleCreateFolder = useCallback(async () => {
if (!newFolderName.trim()) return;
setSubmittingFolder(true);
@@ -149,134 +247,401 @@ export function DmsPage() {
setSubmittingFolder(false);
}, [newFolderName, selectedFolderId, toast, t]);
// Handle upload complete
const handleUploadComplete = useCallback(() => {
loadFiles();
}, [loadFiles]);
// Handle clear selection
const handleClearSelection = useCallback(() => {
setSelectedFileIds(new Set());
}, []);
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="dms-page">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-secondary-900">{t('dms.title')}</h1>
<Link
to="/dms/trash"
className="text-sm text-primary-600 hover:text-primary-700"
>
{t('dms.trash')}
</Link>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setShowNewFolder(!showNewFolder)}
>
{t('dms.newFolder')}
</Button>
<Button
size="sm"
onClick={() => setShowUpload(!showUpload)}
>
{t('dms.upload')}
</Button>
</div>
</div>
// 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');
}, []);
// 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">
{error && (
<div className="mb-4 p-4 bg-danger-50 border border-danger-200 rounded-lg" role="alert" data-testid="dms-error">
<div className="mb-2 p-3 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="dms-error">
<p className="text-sm text-danger-700">{error}</p>
</div>
)}
{/* New folder form */}
{showNewFolder && (
<div className="mb-4 flex items-center gap-3 p-4 bg-secondary-50 rounded-lg" data-testid="new-folder-form">
<Input
label={t('dms.folderName')}
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t('dms.folderName')}
/>
<Button onClick={handleCreateFolder} isLoading={submittingFolder} size="sm">
{t('dms.createFolder')}
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowNewFolder(false)}>
{t('dms.cancel')}
</Button>
<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>
</div>
)}
{/* Upload dropzone */}
{showUpload && (
<div className="mb-6">
<UploadDropzone
folderId={selectedFolderId}
onUploaded={handleUploadComplete}
/>
<div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="upload-section">
<UploadDropzone folderId={selectedFolderId} onUploaded={handleUploadComplete} />
</div>
)}
<div className="mb-4">
<Input
label={t('dms.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('dms.searchPlaceholder')}
/>
{/* 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}
/>
</ResizablePanel>
{/* 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>
{/* 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>
<BulkActions
selectedFileIds={selectedFileIds}
folders={folders}
onClear={handleClearSelection}
onActionComplete={() => {
handleClearSelection();
loadFiles();
}}
/>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mt-4">
<div className="md:col-span-1">
<Card title={t('dms.folders')} data-testid="folder-tree-container">
<FolderTree
{/* 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
folders={folders}
sharedFiles={sharedFiles}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
selectedSource={selectedSource}
onSelectFolder={handleSelectFolder}
onSelectSource={handleSelectSource}
loading={loadingFolders}
/>
</Card>
</div>
<div className="md:col-span-3">
<Card title={t('dms.files')} data-testid="file-grid-container">
<FileGrid
</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
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}
/>
</Card>
</div>
</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>
)}
</div>
<FilePreviewModal
open={!!previewFile}
file={previewFile}
onClose={() => setPreviewFile(null)}
/>
{/* Floating bulk action bar */}
{hasBulkSelection && (
<BulkActions
selectedFileIds={selectedFileIds}
folders={folders}
onClear={handleClearSelection}
onActionComplete={loadFiles}
/>
)}
<ShareDialog
open={!!shareFile}
file={shareFile}
onClose={() => setShareFile(null)}
onShared={handleUploadComplete}
/>
{/* 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(''); }}
/>
)}
{/* Delete confirmation */}
<ConfirmDialog
open={!!deleteTarget}
title={t('dms.delete')}
@@ -286,6 +651,21 @@ export function DmsPage() {
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
{/* File preview modal */}
<FilePreviewModal
open={!!previewFile}
file={previewFile}
onClose={() => setPreviewFile(null)}
/>
{/* Share dialog */}
<ShareDialog
open={!!shareFile}
file={shareFile}
onClose={() => setShareFile(null)}
onShared={() => loadFiles()}
/>
</div>
);
}