DMS: drag-and-drop for files + styling fixes
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* FileExplorer - middle column file browser with multiple view modes.
|
||||
* Replaces FileGrid. Supports: list, table, icons-sm, icons-md, icons-lg.
|
||||
* Files are draggable for drag-and-drop into folders (SourceTree).
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { DmsFile } from '@/api/dms';
|
||||
@@ -71,6 +72,7 @@ export interface FileExplorerProps {
|
||||
sortBy: SortBy;
|
||||
sortOrder: SortOrder;
|
||||
onSortChange: (by: string, order: string) => void;
|
||||
onFileDragStart?: (fileIds: string[]) => void;
|
||||
}
|
||||
|
||||
function SortHeader({
|
||||
@@ -126,7 +128,7 @@ function ActionButtons({
|
||||
<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"
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50"
|
||||
aria-label={t('dms.preview')}
|
||||
title={t('dms.preview')}
|
||||
>
|
||||
@@ -137,7 +139,7 @@ function ActionButtons({
|
||||
</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"
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-primary-600 hover:bg-primary-50"
|
||||
aria-label={t('dms.share')}
|
||||
title={t('dms.share')}
|
||||
>
|
||||
@@ -147,7 +149,7 @@ function ActionButtons({
|
||||
</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"
|
||||
className="p-1.5 rounded text-secondary-400 hover:text-danger-600 hover:bg-danger-50"
|
||||
aria-label={t('dms.delete')}
|
||||
title={t('dms.delete')}
|
||||
>
|
||||
@@ -174,8 +176,10 @@ export function FileExplorer({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onFileDragStart,
|
||||
}: FileExplorerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
|
||||
const sortedFiles = useMemo(() => {
|
||||
const sorted = [...files];
|
||||
@@ -195,6 +199,24 @@ export function FileExplorer({
|
||||
return sorted;
|
||||
}, [files, sortBy, sortOrder]);
|
||||
|
||||
// ─── Drag handler ───
|
||||
function handleDragStart(e: React.DragEvent, file: DmsFile) {
|
||||
let ids: string[];
|
||||
if (selectedFileIds.has(file.id) && selectedFileIds.size > 1) {
|
||||
ids = Array.from(selectedFileIds);
|
||||
} else {
|
||||
ids = [file.id];
|
||||
}
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({ type: 'files', ids }));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setDraggingId(file.id);
|
||||
onFileDragStart?.(ids);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
setDraggingId(null);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="file-explorer-loading">
|
||||
@@ -222,7 +244,6 @@ export function FileExplorer({
|
||||
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>
|
||||
@@ -235,15 +256,20 @@ export function FileExplorer({
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
const isActive = selectedFile?.id === file.id;
|
||||
const isDragging = draggingId === 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',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, file)}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-testid={`file-row-${file.id}`}
|
||||
>
|
||||
<input
|
||||
@@ -305,15 +331,20 @@ export function FileExplorer({
|
||||
const icon = getFileIcon(file.mime_type);
|
||||
const isSelected = selectedFileIds.has(file.id);
|
||||
const isActive = selectedFile?.id === file.id;
|
||||
const isDragging = draggingId === file.id;
|
||||
return (
|
||||
<tr
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'cursor-pointer motion-safe:transition-colors',
|
||||
isActive ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, file)}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-testid={`file-row-${file.id}`}
|
||||
>
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -349,37 +380,111 @@ export function FileExplorer({
|
||||
}
|
||||
|
||||
// ─── 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';
|
||||
// auto-fill grid with minmax to prevent overflow
|
||||
const gridMinWidth =
|
||||
viewMode === 'icons-sm' ? '80px'
|
||||
: viewMode === 'icons-md' ? '120px'
|
||||
: '180px';
|
||||
|
||||
const iconSize =
|
||||
viewMode === 'icons-sm' ? 'w-8 h-8'
|
||||
: viewMode === 'icons-md' ? 'w-10 h-10'
|
||||
: 'w-16 h-16';
|
||||
const gridClass = `grid gap-3 p-3 overflow-y-auto h-full items-start content-start`;
|
||||
const gridStyle = { gridTemplateColumns: `repeat(auto-fill, minmax(${gridMinWidth}, 1fr))` };
|
||||
|
||||
return (
|
||||
<div className={clsx('grid gap-3 p-3 overflow-y-auto h-full', gridClass)} data-testid={`file-explorer-${viewMode}`}>
|
||||
<div className={gridClass} style={gridStyle} 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;
|
||||
const isDragging = draggingId === file.id;
|
||||
|
||||
// icons-sm: compact, no border, no action buttons
|
||||
if (viewMode === 'icons-sm') {
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'rounded-lg p-1.5 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
||||
isActive ? 'bg-primary-50' : isSelected ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, file)}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<svg className={clsx('w-8 h-8 mb-1', 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>
|
||||
<p className="text-[10px] font-medium text-secondary-900 truncate w-full" title={file.name}>{file.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// icons-md: medium cards with border, reduced padding
|
||||
if (viewMode === 'icons-md') {
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
className={clsx(
|
||||
'bg-white rounded-lg border p-2 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
||||
isActive
|
||||
? 'border-primary-500 ring-1 ring-primary-200'
|
||||
: isSelected
|
||||
? 'border-primary-400'
|
||||
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, file)}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full mb-1.5">
|
||||
<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>
|
||||
<svg className={clsx('w-8 h-8 mb-1.5', 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>
|
||||
<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-1.5 text-xs text-secondary-500">
|
||||
<span>{formatFileSize(getFileSize(file))}</span>
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<ActionButtons file={file} onFilePreview={onFilePreview} onFileShare={onFileShare} onFileDelete={onFileDelete} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// icons-lg: large cards with image preview
|
||||
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',
|
||||
'bg-white rounded-lg border p-3 cursor-pointer flex flex-col items-center text-center overflow-hidden',
|
||||
isActive
|
||||
? 'border-primary-500 ring-2 ring-primary-200'
|
||||
: isSelected
|
||||
? 'border-primary-400'
|
||||
: 'border-secondary-200 hover:border-secondary-300 hover:shadow-sm',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
onClick={() => onFileClick(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, file)}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-testid={`file-card-${file.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full mb-2">
|
||||
@@ -392,7 +497,7 @@ export function FileExplorer({
|
||||
aria-label={t('dms.bulkSelect')}
|
||||
/>
|
||||
</div>
|
||||
{viewMode === 'icons-lg' && file.mime_type.startsWith('image/') ? (
|
||||
{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`}
|
||||
@@ -404,7 +509,7 @@ export function FileExplorer({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<svg className={clsx(iconSize, icon.color, 'mb-2')} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<svg className={clsx('w-16 h-16 mb-2', 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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user