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:
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user