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
+78
View File
@@ -502,6 +502,84 @@ async def get_file(
} }
@router.get("/files")
async def list_all_files(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all non-deleted files for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
result = await db.execute(
select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
files = result.scalars().all()
return [
{
"id": str(f.id),
"name": f.name,
"folder_id": str(f.folder_id) if f.folder_id else None,
"uploaded_by": str(f.uploaded_by),
"mime_type": f.mime_type,
"size_bytes": f.size_bytes,
"deleted_at": None,
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in files
]
@router.get("/folders/{folder_id}/files")
async def list_files_in_folder(
folder_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""List all non-deleted files in a specific folder (non-recursive)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
fid = _parse_uuid(folder_id, "folder_id")
# Validate folder exists
folder_result = await db.execute(
select(Folder).where(
Folder.id == fid,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
if folder_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
result = await db.execute(
select(DmsFile).where(
DmsFile.tenant_id == tenant_id,
DmsFile.folder_id == fid,
DmsFile.deleted_at.is_(None),
)
)
files = result.scalars().all()
return [
{
"id": str(f.id),
"name": f.name,
"folder_id": str(f.folder_id) if f.folder_id else None,
"uploaded_by": str(f.uploaded_by),
"mime_type": f.mime_type,
"size_bytes": f.size_bytes,
"deleted_at": None,
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in files
]
@router.patch("/files/{file_id}") @router.patch("/files/{file_id}")
async def update_file( async def update_file(
file_id: str, file_id: str,
+19 -2
View File
@@ -25,15 +25,20 @@ export interface DmsFile {
folder_id: string | null; folder_id: string | null;
name: string; name: string;
mime_type: string; mime_type: string;
size: number; size_bytes?: number;
storage_path: string; storage_path: string;
checksum?: string | null; checksum?: string | null;
deleted_at: string | null; deleted_at: string | null;
created_by: string; uploaded_by?: string;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
shared_with?: string[]; shared_with?: string[];
permissions?: FilePermission[]; permissions?: FilePermission[];
access_level?: string;
/** @deprecated Use size_bytes instead */
size?: number;
/** @deprecated Use uploaded_by instead */
created_by?: string;
} }
export interface FilePermission { export interface FilePermission {
@@ -189,6 +194,18 @@ export function getSharedWithMe(): Promise<DmsFile[]> {
return apiGet<DmsFile[]>('/dms/shared-with-me'); return apiGet<DmsFile[]>('/dms/shared-with-me');
} }
export function fetchSharedWithMe(): Promise<DmsFile[]> {
return getSharedWithMe();
}
export function fetchFilesByFolder(folderId: string): Promise<DmsFile[]> {
return apiGet<DmsFile[]>(`/dms/folders/${folderId}/files`);
}
export function fetchAllFiles(): Promise<DmsFile[]> {
return apiGet<DmsFile[]>('/dms/files');
}
// ─── Bulk Operations ─────────────────────────────────────────────────────── // ─── Bulk Operations ───────────────────────────────────────────────────────
export function bulkMoveFiles(payload: BulkMovePayload): Promise<void> { export function bulkMoveFiles(payload: BulkMovePayload): Promise<void> {
+188
View File
@@ -0,0 +1,188 @@
/**
* FileDetails - right column file details panel for DMS.
* Shows file metadata, action buttons, and permissions.
*/
import React from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { DmsFile } from '@/api/dms';
import { getFileIcon, formatFileSize } from './FileExplorer';
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch {
return dateStr;
}
}
function getFileSize(file: DmsFile): number {
return file.size_bytes ?? file.size ?? 0;
}
export interface FileDetailsProps {
file: DmsFile | null;
onPreview: (file: DmsFile) => void;
onShare: (file: DmsFile) => void;
onDelete: (file: DmsFile) => void;
onClose: () => void;
}
export function FileDetails({ file, onPreview, onShare, onDelete, onClose }: FileDetailsProps) {
const { t } = useTranslation();
if (!file) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center" data-testid="file-details-empty">
<svg className="w-12 h-12 text-secondary-300 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<p className="text-sm font-medium text-secondary-600">{t('dms.noFileSelected')}</p>
<p className="mt-1 text-xs text-secondary-400">{t('dms.noFileSelectedHint')}</p>
</div>
);
}
const icon = getFileIcon(file.mime_type);
const uploader = file.uploaded_by || file.created_by || '-';
const hasPermissions = file.permissions && file.permissions.length > 0;
return (
<div className="flex flex-col h-full" data-testid="file-details">
{/* Header with close button */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200">
<h3 className="text-sm font-semibold text-secondary-900 truncate" title={file.name}>{file.name}</h3>
<button
onClick={onClose}
className="p-1 rounded text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch"
aria-label={t('common.close')}
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4">
{/* File icon (large) */}
<div className="flex justify-center mb-4">
{file.mime_type.startsWith('image/') ? (
<div className="w-24 h-24 bg-secondary-100 rounded-lg flex items-center justify-center overflow-hidden">
<img
src={`/api/v1/dms/files/${file.id}/preview`}
alt={file.name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
) : (
<svg className={clsx('w-24 h-24', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d={icon.path} />
</svg>
)}
</div>
{/* File name */}
<p className="text-center text-sm font-medium text-secondary-900 mb-4 break-words" title={file.name}>{file.name}</p>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-2 mb-6">
<button
onClick={() => onPreview(file)}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch"
>
<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 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{t('dms.preview')}
</button>
<button
onClick={() => onShare(file)}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-secondary-50 text-secondary-700 hover:bg-secondary-100 min-h-touch"
>
<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="M8.7 10.7l6.6-3.4M8.7 13.3l6.6 3.4M18 12a3 3 0 11-6 0 3 3 0 016 0zM9 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{t('dms.share')}
</button>
<a
href={`/api/v1/dms/files/${file.id}/preview`}
download={file.name}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-secondary-50 text-secondary-700 hover:bg-secondary-100 min-h-touch"
>
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{t('dms.download')}
</a>
<button
onClick={() => onDelete(file)}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-danger-50 text-danger-700 hover:bg-danger-100 min-h-touch"
>
<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="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>
{t('dms.delete')}
</button>
</div>
{/* Metadata section */}
<div className="space-y-3">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">{t('dms.details')}</h4>
<dl className="space-y-2">
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('dms.fileSize')}</dt>
<dd className="text-secondary-900 font-medium">{formatFileSize(getFileSize(file))}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('dms.fileType')}</dt>
<dd className="text-secondary-900 font-medium">{file.mime_type || t('dms.icon.other')}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('dms.fileCreated')}</dt>
<dd className="text-secondary-900">{formatDate(file.created_at)}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('dms.fileModified')}</dt>
<dd className="text-secondary-900">{formatDate(file.updated_at)}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('dms.uploadedBy')}</dt>
<dd className="text-secondary-900 font-mono text-xs">{uploader}</dd>
</div>
{file.access_level ? (
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('dms.permissionLevel')}</dt>
<dd className="text-secondary-900 font-medium">{file.access_level}</dd>
</div>
) : null}
</dl>
</div>
{/* Permissions section */}
{hasPermissions ? (
<div className="mt-6 space-y-2">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">{t('dms.shareWith')}</h4>
<ul className="space-y-1">
{file.permissions!.map((perm) => (
<li key={perm.id} className="flex items-center justify-between text-sm">
<span className="text-secondary-700 font-mono text-xs">
{perm.user_id || perm.group_id || '-'}
</span>
<span className="text-xs text-secondary-500">{perm.permission}</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,423 @@
/**
* FileExplorer - middle column file browser with multiple view modes.
* Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg.
*/
import React, { useMemo } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { DmsFile } from '@/api/dms';
export function getFileIcon(mimeType: string): { path: string; color: string } {
if (mimeType === 'application/pdf') {
return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-danger-500' };
}
if (mimeType.startsWith('image/')) {
return { path: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-accent-500' };
}
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
return { path: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-success-500' };
}
if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) {
return { path: 'M7 8h10M7 16h10M7 12h6m-7 8h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', color: 'text-warning-500' };
}
if (mimeType.startsWith('text/') || mimeType.includes('document') || mimeType.includes('word')) {
return { path: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', color: 'text-primary-500' };
}
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) {
return { path: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4', color: 'text-secondary-500' };
}
return { path: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z', color: 'text-secondary-400' };
}
export function formatFileSize(bytes: number): string {
if (!bytes || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function getFileSize(file: DmsFile): number {
return file.size_bytes ?? file.size ?? 0;
}
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return dateStr;
}
}
export type ViewMode = 'list' | 'table' | 'icons-sm' | 'icons-md' | 'icons-lg';
export type SortBy = 'name' | 'size' | 'date' | 'type';
export type SortOrder = 'asc' | 'desc';
export interface FileExplorerProps {
files: DmsFile[];
selectedFileIds: Set<string>;
selectedFile: DmsFile | null;
viewMode: ViewMode;
onToggleSelect: (fileId: string) => void;
onFileClick: (file: DmsFile) => void;
onFileDoubleClick: (file: DmsFile) => void;
onFileShare: (file: DmsFile) => void;
onFileDelete: (file: DmsFile) => void;
onFilePreview: (file: DmsFile) => void;
loading?: boolean;
sortBy: SortBy;
sortOrder: SortOrder;
onSortChange: (by: string, order: string) => void;
}
function SortHeader({
label,
field,
sortBy,
sortOrder,
onSortChange,
className,
}: {
label: string;
field: SortBy;
sortBy: SortBy;
sortOrder: SortOrder;
onSortChange: (by: string, order: string) => void;
className?: string;
}) {
const isActive = sortBy === field;
return (
<button
type="button"
onClick={() => onSortChange(field, isActive && sortOrder === 'asc' ? 'desc' : 'asc')}
className={clsx(
'inline-flex items-center gap-1 text-xs font-medium hover:text-secondary-700',
isActive ? 'text-primary-600' : 'text-secondary-500',
className,
)}
>
{label}
{isActive && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'asc' ? 'M5 15l7-7 7 7' : 'M19 9l-7 7-7-7'} />
</svg>
)}
</button>
);
}
function ActionButtons({
file,
onFilePreview,
onFileShare,
onFileDelete,
t,
}: {
file: DmsFile;
onFilePreview: (file: DmsFile) => void;
onFileShare: (file: DmsFile) => void;
onFileDelete: (file: DmsFile) => void;
t: (key: string) => string;
}) {
return (
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); onFilePreview(file); }}
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
aria-label={t('dms.preview')}
title={t('dms.preview')}
>
<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 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onFileShare(file); }}
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50 min-h-touch min-w-touch"
aria-label={t('dms.share')}
title={t('dms.share')}
>
<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="M8.7 10.7l6.6-3.4M8.7 13.3l6.6 3.4M18 12a3 3 0 11-6 0 3 3 0 016 0zM9 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onFileDelete(file); }}
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch"
aria-label={t('dms.delete')}
title={t('dms.delete')}
>
<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="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>
</button>
</div>
);
}
export function FileExplorer({
files,
selectedFileIds,
selectedFile,
viewMode,
onToggleSelect,
onFileClick,
onFileDoubleClick,
onFileShare,
onFileDelete,
onFilePreview,
loading = false,
sortBy,
sortOrder,
onSortChange,
}: FileExplorerProps) {
const { t } = useTranslation();
const sortedFiles = useMemo(() => {
const sorted = [...files];
sorted.sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') {
cmp = a.name.localeCompare(b.name);
} else if (sortBy === 'size') {
cmp = getFileSize(a) - getFileSize(b);
} else if (sortBy === 'date') {
cmp = (a.updated_at || a.created_at || '').localeCompare(b.updated_at || b.created_at || '');
} else if (sortBy === 'type') {
cmp = a.mime_type.localeCompare(b.mime_type);
}
return sortOrder === 'asc' ? cmp : -cmp;
});
return sorted;
}, [files, sortBy, sortOrder]);
if (loading) {
return (
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
<svg className="animate-spin h-6 w-6 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-2 text-secondary-500 text-sm">{t('dms.loading')}</span>
</div>
);
}
if (files.length === 0) {
return (
<div className="text-center py-12" data-testid="file-explorer-empty">
<svg className="mx-auto h-12 w-12 text-secondary-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<p className="mt-2 text-sm text-secondary-500">{t('dms.noFiles')}</p>
</div>
);
}
// ─── List View ───
if (viewMode === 'list') {
return (
<div className="overflow-y-auto h-full" data-testid="file-explorer-list">
{/* Sort header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-100 sticky top-0 bg-white z-10">
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
<span className="text-secondary-300">|</span>
<SortHeader label={t('dms.sortSize')} field="size" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
<span className="text-secondary-300">|</span>
<SortHeader label={t('dms.sortDate')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
</div>
<ul className="divide-y divide-secondary-50">
{sortedFiles.map((file) => {
const icon = getFileIcon(file.mime_type);
const isSelected = selectedFileIds.has(file.id);
const isActive = selectedFile?.id === file.id;
return (
<li key={file.id}>
<div
className={clsx(
'flex items-center gap-2 px-3 py-2 cursor-pointer motion-safe:transition-colors',
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
)}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
data-testid={`file-row-${file.id}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(file.id)}
onClick={(e) => e.stopPropagation()}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0"
aria-label={t('dms.bulkSelect')}
/>
<svg className={clsx('w-5 h-5 flex-shrink-0', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
</svg>
<span className="text-sm font-medium text-secondary-900 truncate flex-1" title={file.name}>{file.name}</span>
<span className="text-xs text-secondary-500 flex-shrink-0">{formatFileSize(getFileSize(file))}</span>
<span className="text-xs text-secondary-400 flex-shrink-0 hidden sm:inline">{formatDate(file.updated_at || file.created_at)}</span>
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
</div>
</li>
);
})}
</ul>
</div>
);
}
// ─── Table View ───
if (viewMode === 'table') {
return (
<div className="overflow-auto h-full" data-testid="file-explorer-table">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white border-b border-secondary-200 z-10">
<tr>
<th className="px-3 py-2 w-8">
<input
type="checkbox"
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('dms.bulkSelect')}
onChange={() => {}}
/>
</th>
<th className="px-3 py-2 text-left">
<SortHeader label={t('dms.sortName')} field="name" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
</th>
<th className="px-3 py-2 text-left">
<SortHeader label={t('dms.fileSize')} field="size" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
</th>
<th className="px-3 py-2 text-left">
<SortHeader label={t('dms.fileType')} field="type" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
</th>
<th className="px-3 py-2 text-left">
<SortHeader label={t('dms.fileModified')} field="date" sortBy={sortBy} sortOrder={sortOrder} onSortChange={onSortChange} />
</th>
<th className="px-3 py-2 text-right">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-50">
{sortedFiles.map((file) => {
const icon = getFileIcon(file.mime_type);
const isSelected = selectedFileIds.has(file.id);
const isActive = selectedFile?.id === file.id;
return (
<tr
key={file.id}
className={clsx(
'cursor-pointer motion-safe:transition-colors',
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
)}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
data-testid={`file-row-${file.id}`}
>
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(file.id)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('dms.bulkSelect')}
/>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<svg className={clsx('w-5 h-5 flex-shrink-0', icon.color)} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
</svg>
<span className="font-medium text-secondary-900 truncate" title={file.name}>{file.name}</span>
</div>
</td>
<td className="px-3 py-2 text-secondary-600">{formatFileSize(getFileSize(file))}</td>
<td className="px-3 py-2 text-secondary-600">{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}</td>
<td className="px-3 py-2 text-secondary-600">{formatDate(file.updated_at || file.created_at)}</td>
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── Icon Views ───
const gridClass =
viewMode === 'icons-sm'
? 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8'
: viewMode === 'icons-md'
? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3';
const iconSize =
viewMode === 'icons-sm' ? 'w-8 h-8'
: viewMode === 'icons-md' ? 'w-10 h-10'
: 'w-16 h-16';
return (
<div className={clsx('grid gap-3 p-3 overflow-y-auto h-full', gridClass)} data-testid={`file-explorer-${viewMode}`}>
{sortedFiles.map((file) => {
const icon = getFileIcon(file.mime_type);
const isSelected = selectedFileIds.has(file.id);
const isActive = selectedFile?.id === file.id;
return (
<div
key={file.id}
className={clsx(
'bg-white rounded-lg border p-3 motion-safe:transition-all cursor-pointer flex flex-col items-center text-center',
isActive
? 'border-primary-500 ring-2 ring-primary-200'
: isSelected
? 'border-primary-400'
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
)}
onClick={() => onFileClick(file)}
onDoubleClick={() => onFileDoubleClick(file)}
data-testid={`file-card-${file.id}`}
>
<div className="flex items-center justify-between w-full mb-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => onToggleSelect(file.id)}
onClick={(e) => e.stopPropagation()}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={t('dms.bulkSelect')}
/>
</div>
{viewMode === 'icons-lg' && file.mime_type.startsWith('image/') ? (
<div className="w-16 h-16 bg-secondary-100 rounded-lg flex items-center justify-center mb-2 overflow-hidden">
<img
src={`/api/v1/dms/files/${file.id}/preview`}
alt={file.name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
) : (
<svg className={clsx(iconSize, icon.color, 'mb-2')} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={icon.path} />
</svg>
)}
<p className="text-xs font-medium text-secondary-900 truncate w-full" title={file.name}>{file.name}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-secondary-500">
<span>{formatFileSize(getFileSize(file))}</span>
</div>
<div className="mt-2">
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
</div>
</div>
);
})}
</div>
);
}
+1 -1
View File
@@ -121,7 +121,7 @@ export function FileGrid({
</div> </div>
<p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p> <p className="text-sm font-medium text-secondary-900 truncate" title={file.name}>{file.name}</p>
<div className="mt-1 flex items-center gap-3 text-xs text-secondary-500"> <div className="mt-1 flex items-center gap-3 text-xs text-secondary-500">
<span>{formatFileSize(file.size)}</span> <span>{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
{file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>} {file.mime_type && <span className="truncate">{file.mime_type.split('/')[1]?.toUpperCase()}</span>}
</div> </div>
<div className="mt-3 flex items-center gap-1"> <div className="mt-3 flex items-center gap-1">
@@ -52,7 +52,7 @@ export function FilePreviewModal({ open, file, onClose }: FilePreviewModalProps)
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<Badge variant="info">{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}</Badge> <Badge variant="info">{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}</Badge>
<span className="text-sm text-secondary-500">{formatFileSize(file.size)}</span> <span className="text-sm text-secondary-500">{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
{file.created_at && ( {file.created_at && (
<span className="text-sm text-secondary-500"> <span className="text-sm text-secondary-500">
{t('dms.fileModified')}: {new Date(file.created_at).toLocaleDateString()} {t('dms.fileModified')}: {new Date(file.created_at).toLocaleDateString()}
+313
View File
@@ -0,0 +1,313 @@
/**
* SourceTree - left sidebar directory tree for DMS.
* Replaces FolderTree with a "sources" concept:
* ▼ Meine Dateien (local folders)
* ▼ Geteilte Ordner (shared files from other users)
* ▼ Externe Speicher (placeholder for future plugins)
*/
import React, { useState } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { DmsFolder, DmsFile } from '@/api/dms';
interface SourceTreeFolderItemProps {
folder: DmsFolder;
level: number;
selectedFolderId: string | null;
onSelect: (folderId: string) => void;
}
function SourceTreeFolderItem({
folder,
level,
selectedFolderId,
onSelect,
}: SourceTreeFolderItemProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(true);
const hasChildren = folder.children && folder.children.length > 0;
const isSelected = selectedFolderId === folder.id;
return (
<li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} aria-selected={isSelected}>
<div
className={clsx(
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch',
'hover:bg-secondary-100 motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
isSelected && 'bg-primary-50 text-primary-700 font-medium',
)}
style={{ paddingLeft: `${level * 12 + 8}px` }}
onClick={() => onSelect(folder.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(folder.id);
}
}}
tabIndex={0}
role="button"
aria-label={folder.name}
>
{hasChildren && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpanded((prev) => !prev);
}}
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
aria-label={expanded ? t('dms.folders') : t('dms.folders')}
>
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
</svg>
</button>
)}
{!hasChildren && <span className="w-4 flex-shrink-0" aria-hidden="true" />}
<svg className="w-4 h-4 text-warning-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{folder.name}</span>
{typeof folder.file_count === 'number' && folder.file_count > 0 && (
<span className="ml-auto text-xs text-secondary-400">{folder.file_count}</span>
)}
</div>
{hasChildren && expanded && (
<ul role="group" className="space-y-0.5">
{folder.children!.map((child) => (
<SourceTreeFolderItem
key={child.id}
folder={child}
level={level + 1}
selectedFolderId={selectedFolderId}
onSelect={onSelect}
/>
))}
</ul>
)}
</li>
);
}
interface SourceSectionProps {
title: string;
icon: React.ReactNode;
selected: boolean;
onClick: () => void;
children?: React.ReactNode;
defaultExpanded?: boolean;
}
function SourceSection({ title, icon, selected, onClick, children, defaultExpanded = true }: SourceSectionProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
return (
<div className="mb-1">
<div
className={clsx(
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-semibold cursor-pointer min-h-touch',
'hover:bg-secondary-100 motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
selected && 'bg-primary-50 text-primary-700',
)}
onClick={() => {
onClick();
setExpanded(true);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
setExpanded(true);
}
}}
tabIndex={0}
role="button"
aria-label={title}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpanded((prev) => !prev);
}}
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
aria-label={expanded ? 'Collapse' : 'Expand'}
>
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
</svg>
</button>
{icon}
<span className="truncate">{title}</span>
</div>
{expanded && children && (
<ul role="group" className="space-y-0.5 mt-0.5">
{children}
</ul>
)}
</div>
);
}
export interface SourceTreeProps {
folders: DmsFolder[];
sharedFiles: DmsFile[];
selectedFolderId: string | null;
selectedSource: string | null;
onSelectFolder: (folderId: string | null) => void;
onSelectSource: (source: string) => void;
loading?: boolean;
}
export function SourceTree({
folders,
sharedFiles,
selectedFolderId,
selectedSource,
onSelectFolder,
onSelectSource,
loading = false,
}: SourceTreeProps) {
const { t } = useTranslation();
if (loading) {
return (
<div className="space-y-2 p-2" data-testid="source-tree-loading">
{[1, 2, 3].map((i) => (
<div key={i} className="h-8 bg-secondary-100 rounded animate-pulse" />
))}
</div>
);
}
const localSelected = selectedSource === 'local' || (selectedSource === null && selectedFolderId !== null);
const sharedSelected = selectedSource === 'shared';
const externalSelected = selectedSource === 'external';
return (
<nav aria-label={t('dms.sources')} data-testid="source-tree" className="p-2">
{/* All Files entry */}
<div
className={clsx(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch mb-1',
'hover:bg-secondary-100 motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
selectedSource === null && selectedFolderId === null && 'bg-primary-50 text-primary-700 font-medium',
)}
onClick={() => {
onSelectFolder(null);
onSelectSource('local');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectFolder(null);
onSelectSource('local');
}
}}
tabIndex={0}
role="button"
aria-label={t('dms.allFiles')}
>
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span>{t('dms.allFiles')}</span>
</div>
{/* Meine Dateien section */}
<SourceSection
title={t('dms.myFiles')}
selected={localSelected}
onClick={() => {
onSelectSource('local');
if (!selectedFolderId) {
onSelectFolder(null);
}
}}
icon={
<svg className="w-4 h-4 text-primary-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
}
>
{folders.length === 0 ? (
<li className="px-2 py-1 text-xs text-secondary-400 italic">{t('dms.noFiles')}</li>
) : (
folders.map((folder) => (
<SourceTreeFolderItem
key={folder.id}
folder={folder}
level={0}
selectedFolderId={selectedFolderId}
onSelect={onSelectFolder}
/>
))
)}
</SourceSection>
{/* Geteilte Ordner section */}
<SourceSection
title={t('dms.sharedFolders')}
selected={sharedSelected}
onClick={() => {
onSelectSource('shared');
onSelectFolder(null);
}}
icon={
<svg className="w-4 h-4 text-accent-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
}
>
{sharedFiles.length === 0 ? (
<li className="px-2 py-1 text-xs text-secondary-400 italic">{t('dms.noFiles')}</li>
) : (
sharedFiles.map((file) => (
<li key={file.id} role="treeitem" aria-selected={false}>
<div
className={clsx(
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm cursor-pointer min-h-touch',
'hover:bg-secondary-100 motion-safe:transition-colors',
)}
style={{ paddingLeft: '20px' }}
onClick={() => onSelectSource('shared')}
tabIndex={0}
role="button"
aria-label={file.name}
>
<svg className="w-4 h-4 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="truncate">{file.name}</span>
</div>
</li>
))
)}
</SourceSection>
{/* Externe Speicher section */}
<SourceSection
title={t('dms.externalStorage')}
selected={externalSelected}
onClick={() => {
onSelectSource('external');
onSelectFolder(null);
}}
icon={
<svg className="w-4 h-4 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12H3l9-9 9 9h-2M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7M9 21v-6h6v6" />
</svg>
}
defaultExpanded={false}
>
<li className="px-2 py-1 text-xs text-secondary-400 italic" style={{ paddingLeft: '20px' }}>
{t('dms.externalStorageHint')}
</li>
</SourceSection>
</nav>
);
}
+55 -6
View File
@@ -655,25 +655,74 @@
"trash": "Papierkorb", "trash": "Papierkorb",
"newFolder": "Neuer Ordner", "newFolder": "Neuer Ordner",
"upload": "Hochladen", "upload": "Hochladen",
"search": "Dateien durchsuchen", "search": "Suchen",
"searchPlaceholder": "Suchen...", "searchPlaceholder": "Suchen...",
"folders": "Ordner", "folders": "Ordner",
"files": "Dateien", "files": "Dateien",
"uploadDropHere": "Dateien hierher ziehen oder klicken zum Auswaehlen", "uploadDropHere": "Dateien hierher ziehen oder klicken zum Auswählen",
"shareWith": "Freigaben", "shareWith": "Freigaben",
"addShare": "Freigabe hinzufuegen", "addShare": "Freigabe hinzufügen",
"userOrGroup": "Benutzer oder Gruppe", "userOrGroup": "Benutzer oder Gruppe",
"userId": "Benutzer-ID", "userId": "Benutzer-ID",
"groupId": "Gruppen-ID", "groupId": "Gruppen-ID",
"permissionLevel": "Berechtigungsstufe", "permissionLevel": "Berechtigungsstufe",
"shareLink": "Oeffentlicher Link", "shareLink": "Öffentlicher Link",
"password": "Passwort", "password": "Passwort",
"expiryDate": "Ablaufdatum", "expiryDate": "Ablaufdatum",
"createShareLink": "Link erstellen", "createShareLink": "Link erstellen",
"loading": "Wird geladen", "loading": "Wird geladen",
"shared": "Freigabe erstellt", "shared": "Geteilt",
"linkCopied": "Link kopiert", "linkCopied": "Link kopiert",
"uploadError": "Upload fehlgeschlagen" "uploadError": "Upload fehlgeschlagen",
"sources": "Quellen",
"myFiles": "Meine Dateien",
"sharedFolders": "Geteilte Ordner",
"externalStorage": "Externe Speicher",
"externalStorageHint": "Externe Speicher können in den Einstellungen hinzugefügt werden",
"viewList": "Listenansicht",
"viewTable": "Tabellenansicht",
"viewIconsSm": "Kleine Symbole",
"viewIconsMd": "Mittlere Symbole",
"viewIconsLg": "Große Symbole",
"details": "Details",
"toggleDetails": "Details ein/aus",
"fileSize": "Größe",
"fileType": "Typ",
"fileModified": "Geändert",
"fileCreated": "Erstellt",
"uploadedBy": "Hochgeladen von",
"noFileSelected": "Keine Datei ausgewählt",
"noFileSelectedHint": "Wählen Sie eine Datei aus, um Details zu sehen",
"download": "Herunterladen",
"rename": "Umbenennen",
"allFiles": "Alle Dateien",
"sortBy": "Sortieren nach",
"sortName": "Name",
"sortSize": "Größe",
"sortDate": "Datum",
"sortType": "Typ",
"bulkMove": "Verschieben",
"bulkDelete": "Löschen",
"selected": "ausgewählt",
"confirmDelete": "Möchten Sie diese Datei wirklich löschen?",
"confirmBulkDelete": "Möchten Sie die ausgewählten Dateien wirklich löschen?",
"createFolder": "Ordner erstellen",
"folderName": "Ordnername",
"cancel": "Abbrechen",
"delete": "Löschen",
"preview": "Vorschau",
"share": "Teilen",
"noFiles": "Keine Dateien",
"uploadSuccess": "Upload erfolgreich",
"uploading": "Wird hochgeladen",
"deleted": "Gelöscht",
"bulkSelect": "Auswählen",
"bulkMoveTitle": "Dateien verschieben",
"bulkDeleteTitle": "Dateien löschen",
"selectTargetFolder": "Zielordner auswählen",
"icon": {
"other": "Datei"
}
}, },
"permissions": { "permissions": {
"user": "Benutzer", "user": "Benutzer",
+55 -6
View File
@@ -653,16 +653,16 @@
"dms": { "dms": {
"title": "Files", "title": "Files",
"trash": "Trash", "trash": "Trash",
"newFolder": "New Folder", "newFolder": "New folder",
"upload": "Upload", "upload": "Upload",
"search": "Search files", "search": "Search",
"searchPlaceholder": "Search...", "searchPlaceholder": "Search...",
"folders": "Folders", "folders": "Folders",
"files": "Files", "files": "Files",
"uploadDropHere": "Drag files here or click to select", "uploadDropHere": "Drag files here or click to select",
"shareWith": "Shares", "shareWith": "Share",
"addShare": "Add share", "addShare": "Add share",
"userOrGroup": "User or Group", "userOrGroup": "User or group",
"userId": "User ID", "userId": "User ID",
"groupId": "Group ID", "groupId": "Group ID",
"permissionLevel": "Permission level", "permissionLevel": "Permission level",
@@ -671,9 +671,58 @@
"expiryDate": "Expiry date", "expiryDate": "Expiry date",
"createShareLink": "Create link", "createShareLink": "Create link",
"loading": "Loading", "loading": "Loading",
"shared": "Share created", "shared": "Shared",
"linkCopied": "Link copied", "linkCopied": "Link copied",
"uploadError": "Upload failed" "uploadError": "Upload failed",
"sources": "Sources",
"myFiles": "My Files",
"sharedFolders": "Shared Folders",
"externalStorage": "External Storage",
"externalStorageHint": "External storage can be added in settings",
"viewList": "List view",
"viewTable": "Table view",
"viewIconsSm": "Small icons",
"viewIconsMd": "Medium icons",
"viewIconsLg": "Large icons",
"details": "Details",
"toggleDetails": "Toggle details",
"fileSize": "Size",
"fileType": "Type",
"fileModified": "Modified",
"fileCreated": "Created",
"uploadedBy": "Uploaded by",
"noFileSelected": "No file selected",
"noFileSelectedHint": "Select a file to see details",
"download": "Download",
"rename": "Rename",
"allFiles": "All Files",
"sortBy": "Sort by",
"sortName": "Name",
"sortSize": "Size",
"sortDate": "Date",
"sortType": "Type",
"bulkMove": "Move",
"bulkDelete": "Delete",
"selected": "selected",
"confirmDelete": "Are you sure you want to delete this file?",
"confirmBulkDelete": "Are you sure you want to delete the selected files?",
"createFolder": "Create folder",
"folderName": "Folder name",
"cancel": "Cancel",
"delete": "Delete",
"preview": "Preview",
"share": "Share",
"noFiles": "No files",
"uploadSuccess": "Upload successful",
"uploading": "Uploading",
"deleted": "Deleted",
"bulkSelect": "Select",
"bulkMoveTitle": "Move files",
"bulkDeleteTitle": "Delete files",
"selectTargetFolder": "Select target folder",
"icon": {
"other": "File"
}
}, },
"permissions": { "permissions": {
"user": "User", "user": "User",
+503 -123
View File
@@ -1,27 +1,35 @@
/** /**
* DMS file browser page. * DMS file browser page — complete redesign.
* Folder tree sidebar + file grid + upload + search + preview + share + bulk actions. * 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 { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Card } from '@/components/ui/Card';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { FolderTree } from '@/components/dms/FolderTree'; import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { FileGrid } from '@/components/dms/FileGrid'; 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 { UploadDropzone } from '@/components/dms/UploadDropzone';
import { FilePreviewModal } from '@/components/dms/FilePreviewModal'; import { FilePreviewModal } from '@/components/dms/FilePreviewModal';
import { ShareDialog } from '@/components/dms/ShareDialog'; import { ShareDialog } from '@/components/dms/ShareDialog';
import { BulkActions } from '@/components/dms/BulkActions'; import { BulkActions } from '@/components/dms/BulkActions';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { import {
fetchFolders, fetchFolders,
createFolder, createFolder,
deleteFile, deleteFile,
searchFiles, searchFiles,
fetchFilesByFolder,
fetchAllFiles,
getSharedWithMe,
bulkMoveFiles,
bulkDeleteFiles,
type DmsFolder, type DmsFolder,
type DmsFile, type DmsFile,
} from '@/api/dms'; } from '@/api/dms';
@@ -29,14 +37,33 @@ import {
export function DmsPage() { export function DmsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast(); const toast = useToast();
// Data state
const [folders, setFolders] = useState<DmsFolder[]>([]); const [folders, setFolders] = useState<DmsFolder[]>([]);
const [files, setFiles] = useState<DmsFile[]>([]); const [files, setFiles] = useState<DmsFile[]>([]);
const [sharedFiles, setSharedFiles] = useState<DmsFile[]>([]);
const [loadingFolders, setLoadingFolders] = useState(true); const [loadingFolders, setLoadingFolders] = useState(true);
const [loadingFiles, setLoadingFiles] = useState(true); const [loadingFiles, setLoadingFiles] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Navigation state
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null); 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 [selectedFileIds, setSelectedFileIds] = useState<Set<string>>(new Set());
const [selectedFile, setSelectedFile] = useState<DmsFile | null>(null);
// Search state
const [searchQuery, setSearchQuery] = useState(''); 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 [showUpload, setShowUpload] = useState(false);
const [showNewFolder, setShowNewFolder] = useState(false); const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState(''); const [newFolderName, setNewFolderName] = useState('');
@@ -44,6 +71,14 @@ export function DmsPage() {
const [shareFile, setShareFile] = useState<DmsFile | null>(null); const [shareFile, setShareFile] = useState<DmsFile | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DmsFile | null>(null); const [deleteTarget, setDeleteTarget] = useState<DmsFile | null>(null);
const [submittingFolder, setSubmittingFolder] = useState(false); 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 // Load folders
const loadFolders = useCallback(async () => { const loadFolders = useCallback(async () => {
@@ -58,37 +93,111 @@ export function DmsPage() {
setLoadingFolders(false); setLoadingFolders(false);
}, []); }, []);
// Load shared files
const loadSharedFiles = useCallback(async () => {
try {
const shared = await getSharedWithMe();
setSharedFiles(shared);
} catch {
setSharedFiles([]);
}
}, []);
useEffect(() => { useEffect(() => {
loadFolders(); 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 loadFiles = useCallback(async () => {
const loadId = `${selectedSource}-${selectedFolderId}-${searchQuery}`;
currentLoadId.current = loadId;
setLoadingFiles(true); setLoadingFiles(true);
try { try {
let result: DmsFile[] = [];
if (searchQuery.trim()) { if (searchQuery.trim()) {
const result = await searchFiles(searchQuery); const searchResult = await searchFiles(searchQuery);
setFiles(result.files); result = searchResult.files;
} else if (selectedSource === 'shared') {
result = await getSharedWithMe();
} else if (selectedFolderId) {
result = await fetchFilesByFolder(selectedFolderId);
} else { } else {
// For folder view, we would normally call a folder files endpoint result = await fetchAllFiles();
// Since the API only has search and shared-with-me, we show empty or use search }
setFiles([]); if (currentLoadId.current === loadId) {
setFiles(result);
} }
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
setError(msg); if (currentLoadId.current === loadId) {
setFiles([]); setError(msg);
setFiles([]);
}
} }
setLoadingFiles(false); if (currentLoadId.current === loadId) {
}, [searchQuery]); setLoadingFiles(false);
}
}, [selectedSource, selectedFolderId, searchQuery]);
useEffect(() => { useEffect(() => {
const debounce = setTimeout(() => { const debounce = setTimeout(() => {
loadFiles(); loadFiles();
}, 300); }, 300);
return () => clearTimeout(debounce); 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) => { const handleToggleSelect = useCallback((fileId: string) => {
setSelectedFileIds((prev) => { setSelectedFileIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -101,35 +210,24 @@ export function DmsPage() {
}); });
}, []); }, []);
const handleFileClick = useCallback((file: DmsFile) => { // Handle confirm delete
setPreviewFile(file);
}, []);
const handleFileShare = useCallback((file: DmsFile) => {
setShareFile(file);
}, []);
const handleFileDelete = useCallback((file: DmsFile) => {
setDeleteTarget(file);
}, []);
const handleFilePreview = useCallback((file: DmsFile) => {
setPreviewFile(file);
}, []);
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
await deleteFile(deleteTarget.id); await deleteFile(deleteTarget.id);
toast.success(t('dms.deleted')); toast.success(t('dms.deleted'));
setFiles((prev) => prev.filter((f) => f.id !== deleteTarget.id)); setFiles((prev) => prev.filter((f) => f.id !== deleteTarget.id));
if (selectedFile?.id === deleteTarget.id) {
setSelectedFile(null);
}
setDeleteTarget(null); setDeleteTarget(null);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
toast.error(msg); toast.error(msg);
} }
}, [deleteTarget, toast, t]); }, [deleteTarget, selectedFile, toast, t]);
// Handle create folder
const handleCreateFolder = useCallback(async () => { const handleCreateFolder = useCallback(async () => {
if (!newFolderName.trim()) return; if (!newFolderName.trim()) return;
setSubmittingFolder(true); setSubmittingFolder(true);
@@ -149,134 +247,401 @@ export function DmsPage() {
setSubmittingFolder(false); setSubmittingFolder(false);
}, [newFolderName, selectedFolderId, toast, t]); }, [newFolderName, selectedFolderId, toast, t]);
// Handle upload complete
const handleUploadComplete = useCallback(() => { const handleUploadComplete = useCallback(() => {
loadFiles(); loadFiles();
}, [loadFiles]); }, [loadFiles]);
// Handle clear selection
const handleClearSelection = useCallback(() => { const handleClearSelection = useCallback(() => {
setSelectedFileIds(new Set()); setSelectedFileIds(new Set());
}, []); }, []);
return ( // Handle sort change
<div className="p-6 max-w-7xl mx-auto" data-testid="dms-page"> const handleSortChange = useCallback((by: string, order: string) => {
<div className="flex items-center justify-between mb-6"> setSortBy(by as SortBy);
<div className="flex items-center gap-4"> setSortOrder(order as SortOrder);
<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 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 && ( {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> <p className="text-sm text-danger-700">{error}</p>
</div> </div>
)} )}
{/* New folder form */}
{showNewFolder && ( {showNewFolder && (
<div className="mb-4 flex items-center gap-3 p-4 bg-secondary-50 rounded-lg" data-testid="new-folder-form"> <div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="new-folder-form">
<Input <div className="flex items-center gap-3 max-w-md">
label={t('dms.folderName')} <Input
value={newFolderName} label={t('dms.folderName')}
onChange={(e) => setNewFolderName(e.target.value)} value={newFolderName}
placeholder={t('dms.folderName')} onChange={(e) => setNewFolderName(e.target.value)}
/> placeholder={t('dms.folderName')}
<Button onClick={handleCreateFolder} isLoading={submittingFolder} size="sm"> onKeyDown={(e) => {
{t('dms.createFolder')} if (e.key === 'Enter') handleCreateFolder();
</Button> }}
<Button variant="secondary" size="sm" onClick={() => setShowNewFolder(false)}> />
{t('dms.cancel')} <Button size="sm" onClick={handleCreateFolder} isLoading={submittingFolder}>
</Button> {t('dms.createFolder')}
</Button>
<Button variant="secondary" size="sm" onClick={() => { setShowNewFolder(false); setNewFolderName(''); }}>
{t('dms.cancel')}
</Button>
</div>
</div> </div>
)} )}
{/* Upload dropzone */}
{showUpload && ( {showUpload && (
<div className="mb-6"> <div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="upload-section">
<UploadDropzone <UploadDropzone folderId={selectedFolderId} onUploaded={handleUploadComplete} />
folderId={selectedFolderId}
onUploaded={handleUploadComplete}
/>
</div> </div>
)} )}
<div className="mb-4"> {/* Desktop: three-pane layout with resizable panels */}
<Input <div className="hidden md:flex flex-1 overflow-hidden">
label={t('dms.search')} {/* Left: SourceTree */}
value={searchQuery} <ResizablePanel
onChange={(e) => setSearchQuery(e.target.value)} initialWidth={240}
placeholder={t('dms.searchPlaceholder')} 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> </div>
<BulkActions {/* Mobile: single-pane view switching */}
selectedFileIds={selectedFileIds} <div className="flex md:hidden flex-1 overflow-hidden flex-col">
folders={folders} {/* View 1: SourceTree */}
onClear={handleClearSelection} {activeView === 'tree' && (
onActionComplete={() => { <div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-source-pane">
handleClearSelection(); <div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
loadFiles(); <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 className="grid grid-cols-1 md:grid-cols-4 gap-6 mt-4"> </div>
<div className="md:col-span-1"> <SourceTree
<Card title={t('dms.folders')} data-testid="folder-tree-container">
<FolderTree
folders={folders} folders={folders}
sharedFiles={sharedFiles}
selectedFolderId={selectedFolderId} selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId} selectedSource={selectedSource}
onSelectFolder={handleSelectFolder}
onSelectSource={handleSelectSource}
loading={loadingFolders} loading={loadingFolders}
/> />
</Card> </div>
</div> )}
<div className="md:col-span-3">
<Card title={t('dms.files')} data-testid="file-grid-container"> {/* View 2: FileExplorer */}
<FileGrid {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} files={files}
selectedFileIds={selectedFileIds} selectedFileIds={selectedFileIds}
selectedFile={selectedFile}
viewMode={viewMode}
onToggleSelect={handleToggleSelect} onToggleSelect={handleToggleSelect}
onFileClick={handleFileClick} onFileClick={handleFileClick}
onFileDoubleClick={handleFileDoubleClick}
onFileShare={handleFileShare} onFileShare={handleFileShare}
onFileDelete={handleFileDelete} onFileDelete={handleFileDelete}
onFilePreview={handleFilePreview} onFilePreview={handleFilePreview}
loading={loadingFiles} 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> </div>
<FilePreviewModal {/* Floating bulk action bar */}
open={!!previewFile} {hasBulkSelection && (
file={previewFile} <BulkActions
onClose={() => setPreviewFile(null)} selectedFileIds={selectedFileIds}
/> folders={folders}
onClear={handleClearSelection}
onActionComplete={loadFiles}
/>
)}
<ShareDialog {/* Bulk move dialog */}
open={!!shareFile} {showBulkMove && (
file={shareFile} <ConfirmDialog
onClose={() => setShareFile(null)} open={showBulkMove}
onShared={handleUploadComplete} title={t('dms.bulkMoveTitle')}
/> message={t('dms.selectTargetFolder')}
confirmLabel={t('dms.bulkMove')}
onConfirm={handleBulkMove}
onCancel={() => { setShowBulkMove(false); setBulkMoveTarget(''); }}
/>
)}
{/* Delete confirmation */}
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
title={t('dms.delete')} title={t('dms.delete')}
@@ -286,6 +651,21 @@ export function DmsPage() {
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
onCancel={() => setDeleteTarget(null)} 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> </div>
); );
} }
+5 -4
View File
@@ -59,11 +59,12 @@ export function DmsTrashPage() {
key: 'size', key: 'size',
header: t('dms.fileSize'), header: t('dms.fileSize'),
sortable: true, sortable: true,
accessor: (file) => file.size, accessor: (file) => file.size_bytes ?? file.size ?? 0,
render: (file) => { render: (file) => {
if (file.size < 1024) return `${file.size} B`; const sz = file.size_bytes ?? file.size ?? 0;
if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`; if (sz < 1024) return `${sz} B`;
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`; if (sz < 1024 * 1024) return `${(sz / 1024).toFixed(1)} KB`;
return `${(sz / (1024 * 1024)).toFixed(1)} MB`;
}, },
}, },
{ {