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
+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>
<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">
<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>}
</div>
<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="flex items-center gap-4 flex-wrap">
<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 && (
<span className="text-sm text-secondary-500">
{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>
);
}