diff --git a/app/plugins/builtins/dms/routes.py b/app/plugins/builtins/dms/routes.py index 13c3dba..6156633 100644 --- a/app/plugins/builtins/dms/routes.py +++ b/app/plugins/builtins/dms/routes.py @@ -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}") async def update_file( file_id: str, diff --git a/frontend/src/api/dms.ts b/frontend/src/api/dms.ts index 1f03585..ecbd90f 100644 --- a/frontend/src/api/dms.ts +++ b/frontend/src/api/dms.ts @@ -25,15 +25,20 @@ export interface DmsFile { folder_id: string | null; name: string; mime_type: string; - size: number; + size_bytes?: number; storage_path: string; checksum?: string | null; deleted_at: string | null; - created_by: string; + uploaded_by?: string; created_at?: string | null; updated_at?: string | null; shared_with?: string[]; permissions?: FilePermission[]; + access_level?: string; + /** @deprecated Use size_bytes instead */ + size?: number; + /** @deprecated Use uploaded_by instead */ + created_by?: string; } export interface FilePermission { @@ -189,6 +194,18 @@ export function getSharedWithMe(): Promise { return apiGet('/dms/shared-with-me'); } +export function fetchSharedWithMe(): Promise { + return getSharedWithMe(); +} + +export function fetchFilesByFolder(folderId: string): Promise { + return apiGet(`/dms/folders/${folderId}/files`); +} + +export function fetchAllFiles(): Promise { + return apiGet('/dms/files'); +} + // ─── Bulk Operations ─────────────────────────────────────────────────────── export function bulkMoveFiles(payload: BulkMovePayload): Promise { diff --git a/frontend/src/components/dms/FileDetails.tsx b/frontend/src/components/dms/FileDetails.tsx new file mode 100644 index 0000000..29ae2ad --- /dev/null +++ b/frontend/src/components/dms/FileDetails.tsx @@ -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 ( +
+ +

{t('dms.noFileSelected')}

+

{t('dms.noFileSelectedHint')}

+
+ ); + } + + const icon = getFileIcon(file.mime_type); + const uploader = file.uploaded_by || file.created_by || '-'; + const hasPermissions = file.permissions && file.permissions.length > 0; + + return ( +
+ {/* Header with close button */} +
+

{file.name}

+ +
+ + {/* Scrollable content */} +
+ {/* File icon (large) */} +
+ {file.mime_type.startsWith('image/') ? ( +
+ {file.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ ) : ( + + )} +
+ + {/* File name */} +

{file.name}

+ + {/* Action buttons */} +
+ + + + + {t('dms.download')} + + +
+ + {/* Metadata section */} +
+

{t('dms.details')}

+
+
+
{t('dms.fileSize')}
+
{formatFileSize(getFileSize(file))}
+
+
+
{t('dms.fileType')}
+
{file.mime_type || t('dms.icon.other')}
+
+
+
{t('dms.fileCreated')}
+
{formatDate(file.created_at)}
+
+
+
{t('dms.fileModified')}
+
{formatDate(file.updated_at)}
+
+
+
{t('dms.uploadedBy')}
+
{uploader}
+
+ {file.access_level ? ( +
+
{t('dms.permissionLevel')}
+
{file.access_level}
+
+ ) : null} +
+
+ + {/* Permissions section */} + {hasPermissions ? ( +
+

{t('dms.shareWith')}

+
    + {file.permissions!.map((perm) => ( +
  • + + {perm.user_id || perm.group_id || '-'} + + {perm.permission} +
  • + ))} +
+
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/dms/FileExplorer.tsx b/frontend/src/components/dms/FileExplorer.tsx new file mode 100644 index 0000000..ca3f787 --- /dev/null +++ b/frontend/src/components/dms/FileExplorer.tsx @@ -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; + 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 ( + + ); +} + +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 ( +
+ + + +
+ ); +} + +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 ( +
+ + {t('dms.loading')} +
+ ); + } + + if (files.length === 0) { + return ( +
+ +

{t('dms.noFiles')}

+
+ ); + } + + // ─── List View ─── + if (viewMode === 'list') { + return ( +
+ {/* Sort header */} +
+ + | + + | + +
+
    + {sortedFiles.map((file) => { + const icon = getFileIcon(file.mime_type); + const isSelected = selectedFileIds.has(file.id); + const isActive = selectedFile?.id === file.id; + return ( +
  • +
    onFileClick(file)} + onDoubleClick={() => onFileDoubleClick(file)} + data-testid={`file-row-${file.id}`} + > + 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')} + /> + + {file.name} + {formatFileSize(getFileSize(file))} + {formatDate(file.updated_at || file.created_at)} + +
    +
  • + ); + })} +
+
+ ); + } + + // ─── Table View ─── + if (viewMode === 'table') { + return ( +
+ + + + + + + + + + + + + {sortedFiles.map((file) => { + const icon = getFileIcon(file.mime_type); + const isSelected = selectedFileIds.has(file.id); + const isActive = selectedFile?.id === file.id; + return ( + onFileClick(file)} + onDoubleClick={() => onFileDoubleClick(file)} + data-testid={`file-row-${file.id}`} + > + + + + + + + + ); + })} + +
+ {}} + /> + + + + + + + + + {t('common.actions')}
e.stopPropagation()}> + onToggleSelect(file.id)} + className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" + aria-label={t('dms.bulkSelect')} + /> + +
+ + {file.name} +
+
{formatFileSize(getFileSize(file))}{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}{formatDate(file.updated_at || file.created_at)} e.stopPropagation()}> + +
+
+ ); + } + + // ─── 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 ( +
+ {sortedFiles.map((file) => { + const icon = getFileIcon(file.mime_type); + const isSelected = selectedFileIds.has(file.id); + const isActive = selectedFile?.id === file.id; + return ( +
onFileClick(file)} + onDoubleClick={() => onFileDoubleClick(file)} + data-testid={`file-card-${file.id}`} + > +
+ onToggleSelect(file.id)} + onClick={(e) => e.stopPropagation()} + className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500" + aria-label={t('dms.bulkSelect')} + /> +
+ {viewMode === 'icons-lg' && file.mime_type.startsWith('image/') ? ( +
+ {file.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ ) : ( + + )} +

{file.name}

+
+ {formatFileSize(getFileSize(file))} +
+
+ +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/dms/FileGrid.tsx b/frontend/src/components/dms/FileGrid.tsx index fc83a4d..cd76cca 100644 --- a/frontend/src/components/dms/FileGrid.tsx +++ b/frontend/src/components/dms/FileGrid.tsx @@ -121,7 +121,7 @@ export function FileGrid({

{file.name}

- {formatFileSize(file.size)} + {formatFileSize(file.size_bytes ?? file.size ?? 0)} {file.mime_type && {file.mime_type.split('/')[1]?.toUpperCase()}}
diff --git a/frontend/src/components/dms/FilePreviewModal.tsx b/frontend/src/components/dms/FilePreviewModal.tsx index 1ec18e9..437ea87 100644 --- a/frontend/src/components/dms/FilePreviewModal.tsx +++ b/frontend/src/components/dms/FilePreviewModal.tsx @@ -52,7 +52,7 @@ export function FilePreviewModal({ open, file, onClose }: FilePreviewModalProps)
{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')} - {formatFileSize(file.size)} + {formatFileSize(file.size_bytes ?? file.size ?? 0)} {file.created_at && ( {t('dms.fileModified')}: {new Date(file.created_at).toLocaleDateString()} diff --git a/frontend/src/components/dms/SourceTree.tsx b/frontend/src/components/dms/SourceTree.tsx new file mode 100644 index 0000000..228c4d2 --- /dev/null +++ b/frontend/src/components/dms/SourceTree.tsx @@ -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 ( +
  • +
    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 && ( + + )} + {!hasChildren &&
    + {hasChildren && expanded && ( +
      + {folder.children!.map((child) => ( + + ))} +
    + )} +
  • + ); +} + +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 ( +
    +
    { + onClick(); + setExpanded(true); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + setExpanded(true); + } + }} + tabIndex={0} + role="button" + aria-label={title} + > + + {icon} + {title} +
    + {expanded && children && ( +
      + {children} +
    + )} +
    + ); +} + +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 ( +
    + {[1, 2, 3].map((i) => ( +
    + ))} +
    + ); + } + + const localSelected = selectedSource === 'local' || (selectedSource === null && selectedFolderId !== null); + const sharedSelected = selectedSource === 'shared'; + const externalSelected = selectedSource === 'external'; + + return ( + + ); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 974923c..1ba43b6 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -655,25 +655,74 @@ "trash": "Papierkorb", "newFolder": "Neuer Ordner", "upload": "Hochladen", - "search": "Dateien durchsuchen", + "search": "Suchen", "searchPlaceholder": "Suchen...", "folders": "Ordner", "files": "Dateien", - "uploadDropHere": "Dateien hierher ziehen oder klicken zum Auswaehlen", + "uploadDropHere": "Dateien hierher ziehen oder klicken zum Auswählen", "shareWith": "Freigaben", - "addShare": "Freigabe hinzufuegen", + "addShare": "Freigabe hinzufügen", "userOrGroup": "Benutzer oder Gruppe", "userId": "Benutzer-ID", "groupId": "Gruppen-ID", "permissionLevel": "Berechtigungsstufe", - "shareLink": "Oeffentlicher Link", + "shareLink": "Öffentlicher Link", "password": "Passwort", "expiryDate": "Ablaufdatum", "createShareLink": "Link erstellen", "loading": "Wird geladen", - "shared": "Freigabe erstellt", + "shared": "Geteilt", "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": { "user": "Benutzer", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c8de973..c9fe566 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -653,16 +653,16 @@ "dms": { "title": "Files", "trash": "Trash", - "newFolder": "New Folder", + "newFolder": "New folder", "upload": "Upload", - "search": "Search files", + "search": "Search", "searchPlaceholder": "Search...", "folders": "Folders", "files": "Files", "uploadDropHere": "Drag files here or click to select", - "shareWith": "Shares", + "shareWith": "Share", "addShare": "Add share", - "userOrGroup": "User or Group", + "userOrGroup": "User or group", "userId": "User ID", "groupId": "Group ID", "permissionLevel": "Permission level", @@ -671,9 +671,58 @@ "expiryDate": "Expiry date", "createShareLink": "Create link", "loading": "Loading", - "shared": "Share created", + "shared": "Shared", "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": { "user": "User", diff --git a/frontend/src/pages/Dms.tsx b/frontend/src/pages/Dms.tsx index 23c55f1..41d220b 100644 --- a/frontend/src/pages/Dms.tsx +++ b/frontend/src/pages/Dms.tsx @@ -1,27 +1,35 @@ /** - * DMS file browser page. - * Folder tree sidebar + file grid + upload + search + preview + share + bulk actions. + * DMS file browser page — complete redesign. + * 3-column explorer-style layout: SourceTree | FileExplorer | FileDetails. + * Uses PluginToolbar for actions, ResizablePanel for desktop layout. */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; -import { Card } from '@/components/ui/Card'; import { ConfirmDialog } from '@/components/ui/ConfirmDialog'; import { useToast } from '@/components/ui/Toast'; -import { FolderTree } from '@/components/dms/FolderTree'; -import { FileGrid } from '@/components/dms/FileGrid'; +import { ResizablePanel } from '@/components/ui/ResizablePanel'; +import { SourceTree } from '@/components/dms/SourceTree'; +import { FileExplorer, type ViewMode, type SortBy, type SortOrder } from '@/components/dms/FileExplorer'; +import { FileDetails } from '@/components/dms/FileDetails'; import { UploadDropzone } from '@/components/dms/UploadDropzone'; import { FilePreviewModal } from '@/components/dms/FilePreviewModal'; import { ShareDialog } from '@/components/dms/ShareDialog'; import { BulkActions } from '@/components/dms/BulkActions'; +import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { fetchFolders, createFolder, deleteFile, searchFiles, + fetchFilesByFolder, + fetchAllFiles, + getSharedWithMe, + bulkMoveFiles, + bulkDeleteFiles, type DmsFolder, type DmsFile, } from '@/api/dms'; @@ -29,14 +37,33 @@ import { export function DmsPage() { const { t } = useTranslation(); const toast = useToast(); + + // Data state const [folders, setFolders] = useState([]); const [files, setFiles] = useState([]); + const [sharedFiles, setSharedFiles] = useState([]); const [loadingFolders, setLoadingFolders] = useState(true); - const [loadingFiles, setLoadingFiles] = useState(true); + const [loadingFiles, setLoadingFiles] = useState(false); const [error, setError] = useState(null); + + // Navigation state const [selectedFolderId, setSelectedFolderId] = useState(null); + const [selectedSource, setSelectedSource] = useState('local'); + + // Selection state const [selectedFileIds, setSelectedFileIds] = useState>(new Set()); + const [selectedFile, setSelectedFile] = useState(null); + + // Search state const [searchQuery, setSearchQuery] = useState(''); + + // View state + const [viewMode, setViewMode] = useState('icons-md'); + const [showDetails, setShowDetails] = useState(false); + const [sortBy, setSortBy] = useState('name'); + const [sortOrder, setSortOrder] = useState('asc'); + + // Modal state const [showUpload, setShowUpload] = useState(false); const [showNewFolder, setShowNewFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(''); @@ -44,6 +71,14 @@ export function DmsPage() { const [shareFile, setShareFile] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [submittingFolder, setSubmittingFolder] = useState(false); + const [showBulkMove, setShowBulkMove] = useState(false); + const [bulkMoveTarget, setBulkMoveTarget] = useState(''); + + // Mobile view state + const [activeView, setActiveView] = useState<'tree' | 'files' | 'details'>('tree'); + + // Ref for race condition prevention + const currentLoadId = useRef(''); // Load folders const loadFolders = useCallback(async () => { @@ -58,37 +93,111 @@ export function DmsPage() { setLoadingFolders(false); }, []); + // Load shared files + const loadSharedFiles = useCallback(async () => { + try { + const shared = await getSharedWithMe(); + setSharedFiles(shared); + } catch { + setSharedFiles([]); + } + }, []); + useEffect(() => { loadFolders(); - }, [loadFolders]); + loadSharedFiles(); + }, [loadFolders, loadSharedFiles]); - // Load files (mock: in real app would fetch by folder or search) + // Load files based on current selection const loadFiles = useCallback(async () => { + const loadId = `${selectedSource}-${selectedFolderId}-${searchQuery}`; + currentLoadId.current = loadId; setLoadingFiles(true); try { + let result: DmsFile[] = []; if (searchQuery.trim()) { - const result = await searchFiles(searchQuery); - setFiles(result.files); + const searchResult = await searchFiles(searchQuery); + result = searchResult.files; + } else if (selectedSource === 'shared') { + result = await getSharedWithMe(); + } else if (selectedFolderId) { + result = await fetchFilesByFolder(selectedFolderId); } else { - // For folder view, we would normally call a folder files endpoint - // Since the API only has search and shared-with-me, we show empty or use search - setFiles([]); + result = await fetchAllFiles(); + } + if (currentLoadId.current === loadId) { + setFiles(result); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); - setError(msg); - setFiles([]); + if (currentLoadId.current === loadId) { + setError(msg); + setFiles([]); + } } - setLoadingFiles(false); - }, [searchQuery]); + if (currentLoadId.current === loadId) { + setLoadingFiles(false); + } + }, [selectedSource, selectedFolderId, searchQuery]); useEffect(() => { const debounce = setTimeout(() => { loadFiles(); }, 300); return () => clearTimeout(debounce); - }, [loadFiles, selectedFolderId]); + }, [loadFiles]); + // Handle folder selection + const handleSelectFolder = useCallback((folderId: string | null) => { + setSelectedFolderId(folderId); + setSelectedSource('local'); + setSearchQuery(''); + setSelectedFile(null); + setSelectedFileIds(new Set()); + setActiveView('files'); + }, []); + + // Handle source selection + const handleSelectSource = useCallback((source: string) => { + setSelectedSource(source); + if (source !== 'local') { + setSelectedFolderId(null); + } + setSearchQuery(''); + setSelectedFile(null); + setSelectedFileIds(new Set()); + setActiveView('files'); + }, []); + + // Handle file click (select for details) + const handleFileClick = useCallback((file: DmsFile) => { + setSelectedFile(file); + if (showDetails) { + setActiveView('details'); + } + }, [showDetails]); + + // Handle file double click (open preview) + const handleFileDoubleClick = useCallback((file: DmsFile) => { + setPreviewFile(file); + }, []); + + // Handle file share + const handleFileShare = useCallback((file: DmsFile) => { + setShareFile(file); + }, []); + + // Handle file delete + const handleFileDelete = useCallback((file: DmsFile) => { + setDeleteTarget(file); + }, []); + + // Handle file preview + const handleFilePreview = useCallback((file: DmsFile) => { + setPreviewFile(file); + }, []); + + // Handle toggle select const handleToggleSelect = useCallback((fileId: string) => { setSelectedFileIds((prev) => { const next = new Set(prev); @@ -101,35 +210,24 @@ export function DmsPage() { }); }, []); - const handleFileClick = useCallback((file: DmsFile) => { - setPreviewFile(file); - }, []); - - const handleFileShare = useCallback((file: DmsFile) => { - setShareFile(file); - }, []); - - const handleFileDelete = useCallback((file: DmsFile) => { - setDeleteTarget(file); - }, []); - - const handleFilePreview = useCallback((file: DmsFile) => { - setPreviewFile(file); - }, []); - + // Handle confirm delete const handleConfirmDelete = useCallback(async () => { if (!deleteTarget) return; try { await deleteFile(deleteTarget.id); toast.success(t('dms.deleted')); setFiles((prev) => prev.filter((f) => f.id !== deleteTarget.id)); + if (selectedFile?.id === deleteTarget.id) { + setSelectedFile(null); + } setDeleteTarget(null); } catch (err) { const msg = err instanceof Error ? err.message : String(err); toast.error(msg); } - }, [deleteTarget, toast, t]); + }, [deleteTarget, selectedFile, toast, t]); + // Handle create folder const handleCreateFolder = useCallback(async () => { if (!newFolderName.trim()) return; setSubmittingFolder(true); @@ -149,134 +247,401 @@ export function DmsPage() { setSubmittingFolder(false); }, [newFolderName, selectedFolderId, toast, t]); + // Handle upload complete const handleUploadComplete = useCallback(() => { loadFiles(); }, [loadFiles]); + // Handle clear selection const handleClearSelection = useCallback(() => { setSelectedFileIds(new Set()); }, []); - return ( -
    -
    -
    -

    {t('dms.title')}

    - - {t('dms.trash')} - -
    -
    - - -
    -
    + // Handle sort change + const handleSortChange = useCallback((by: string, order: string) => { + setSortBy(by as SortBy); + setSortOrder(order as SortOrder); + }, []); + // Handle bulk delete + const handleBulkDelete = useCallback(async () => { + const fileIds = Array.from(selectedFileIds); + try { + await bulkDeleteFiles({ file_ids: fileIds }); + toast.success(t('dms.deleted')); + setFiles((prev) => prev.filter((f) => !selectedFileIds.has(f.id))); + setSelectedFileIds(new Set()); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast.error(msg); + } + }, [selectedFileIds, toast, t]); + + // Handle bulk move + const handleBulkMove = useCallback(async () => { + const fileIds = Array.from(selectedFileIds); + try { + await bulkMoveFiles({ + file_ids: fileIds, + target_folder_id: bulkMoveTarget || null, + }); + toast.success(t('dms.bulkMove')); + setFiles((prev) => prev.filter((f) => !selectedFileIds.has(f.id))); + setSelectedFileIds(new Set()); + setShowBulkMove(false); + setBulkMoveTarget(''); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + toast.error(msg); + } + }, [selectedFileIds, bulkMoveTarget, toast, t]); + + // Handle search + const handleSearch = useCallback((query: string) => { + setSearchQuery(query); + setSelectedFolderId(null); + setSelectedSource('local'); + }, []); + + // Register toolbar items + const registerItems = usePluginToolbarStore((s) => s.registerItems); + const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin); + const hasBulkSelection = selectedFileIds.size > 0; + + useEffect(() => { + const items = [ + // File actions group + { + id: 'new-folder', + plugin: 'dms', + label: t('dms.newFolder'), + group: 'file-actions', + icon: ( + + + + ), + onClick: () => setShowNewFolder((v) => !v), + }, + { + id: 'upload', + plugin: 'dms', + label: t('dms.upload'), + group: 'file-actions', + icon: ( + + + + ), + 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: ( + + + + ), + 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: ( + + + + ), + onClick: () => setShowBulkMove(true), + }, + { + id: 'bulk-delete', + plugin: 'dms', + label: t('dms.bulkDelete'), + group: 'bulk-actions', + icon: ( + + + + ), + onClick: handleBulkDelete, + }, + ); + } + + registerItems('dms', items); + return () => unregisterPlugin('dms'); + }, [viewMode, showDetails, hasBulkSelection, handleSearch, handleBulkDelete, t]); + + return ( +
    {error && ( -
    +

    {error}

    )} + {/* New folder form */} {showNewFolder && ( -
    - setNewFolderName(e.target.value)} - placeholder={t('dms.folderName')} - /> - - +
    +
    + setNewFolderName(e.target.value)} + placeholder={t('dms.folderName')} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreateFolder(); + }} + /> + + +
    )} + {/* Upload dropzone */} {showUpload && ( -
    - +
    +
    )} -
    - setSearchQuery(e.target.value)} - placeholder={t('dms.searchPlaceholder')} - /> + {/* Desktop: three-pane layout with resizable panels */} +
    + {/* Left: SourceTree */} + + + + + {/* Middle: FileExplorer */} + + + + + {/* Right: FileDetails (only when toggled on) */} + {showDetails && ( + + setShowDetails(false)} + /> + + )}
    - { - handleClearSelection(); - loadFiles(); - }} - /> - -
    -
    - - + {/* View 1: SourceTree */} + {activeView === 'tree' && ( +
    +
    +

    {t('dms.title')}

    + + {t('dms.trash')} + +
    + - -
    -
    - - + )} + + {/* View 2: FileExplorer */} + {activeView === 'files' && ( +
    +
    + + {showDetails && ( + + )} +
    + - -
    +
    + )} + + {/* View 3: FileDetails */} + {activeView === 'details' && showDetails && ( +
    +
    + +
    + { + setShowDetails(false); + setActiveView('files'); + }} + /> +
    + )}
    - setPreviewFile(null)} - /> + {/* Floating bulk action bar */} + {hasBulkSelection && ( + + )} - setShareFile(null)} - onShared={handleUploadComplete} - /> + {/* Bulk move dialog */} + {showBulkMove && ( + { setShowBulkMove(false); setBulkMoveTarget(''); }} + /> + )} + {/* Delete confirmation */} setDeleteTarget(null)} /> + + {/* File preview modal */} + setPreviewFile(null)} + /> + + {/* Share dialog */} + setShareFile(null)} + onShared={() => loadFiles()} + />
    ); } diff --git a/frontend/src/pages/DmsTrash.tsx b/frontend/src/pages/DmsTrash.tsx index cba3093..5bca9f2 100644 --- a/frontend/src/pages/DmsTrash.tsx +++ b/frontend/src/pages/DmsTrash.tsx @@ -59,11 +59,12 @@ export function DmsTrashPage() { key: 'size', header: t('dms.fileSize'), sortable: true, - accessor: (file) => file.size, + accessor: (file) => file.size_bytes ?? file.size ?? 0, render: (file) => { - if (file.size < 1024) return `${file.size} B`; - if (file.size < 1024 * 1024) return `${(file.size / 1024).toFixed(1)} KB`; - return `${(file.size / (1024 * 1024)).toFixed(1)} MB`; + const sz = file.size_bytes ?? file.size ?? 0; + if (sz < 1024) return `${sz} B`; + if (sz < 1024 * 1024) return `${(sz / 1024).toFixed(1)} KB`; + return `${(sz / (1024 * 1024)).toFixed(1)} MB`; }, }, {