0962f3a961
- DMS file browser: folder tree + file grid + upload dropzone + search + preview modal - DMS share dialog: user/group share + public share links with password+expiry - DMS bulk actions: bulk move + bulk delete with confirm dialogs - DMS trash view: deleted files list with restore button - Tags: TagPicker on company/contact detail pages (new tabs tab) - Tags: TagCloud + BulkTagDialog for bulk tag assignment - Permissions: share link creation, permission display, copy-link button - API clients: dms.ts, tags.ts, permissions.ts - Routes: /dms, /dms/trash added to router - Sidebar: DMS nav link updated - i18n: de.json + en.json translations for DMS/Tags/Permissions - 33 new tests (5 test files), full regression 276/276 pass - tsc --noEmit: 0 errors, vite build: 252 modules
166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
/**
|
|
* Upload dropzone for DMS file browser.
|
|
* Supports drag-and-drop and click-to-select file uploads with progress.
|
|
*/
|
|
|
|
import React, { useCallback, useRef, useState } from 'react';
|
|
import clsx from 'clsx';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { uploadFile } from '@/api/dms';
|
|
import { useToast } from '@/components/ui/Toast';
|
|
|
|
export interface UploadDropzoneProps {
|
|
folderId: string | null;
|
|
onUploaded: () => void;
|
|
}
|
|
|
|
interface UploadProgress {
|
|
fileName: string;
|
|
progress: number;
|
|
error: string | null;
|
|
}
|
|
|
|
export function UploadDropzone({ folderId, onUploaded }: UploadDropzoneProps) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [uploads, setUploads] = useState<UploadProgress[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
const handleFiles = useCallback(async (fileList: FileList) => {
|
|
const files = Array.from(fileList);
|
|
if (files.length === 0) return;
|
|
|
|
setIsUploading(true);
|
|
const progressEntries = files.map((f) => ({ fileName: f.name, progress: 0, error: null as string | null }));
|
|
setUploads(progressEntries);
|
|
|
|
let allSuccess = true;
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
try {
|
|
setUploads((prev) => prev.map((u, idx) => idx === i ? { ...u, progress: 50 } : u));
|
|
await uploadFile(file, folderId);
|
|
setUploads((prev) => prev.map((u, idx) => idx === i ? { ...u, progress: 100 } : u));
|
|
} catch (err) {
|
|
allSuccess = false;
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
setUploads((prev) => prev.map((u, idx) => idx === i ? { ...u, error: errorMsg } : u));
|
|
}
|
|
}
|
|
|
|
setIsUploading(false);
|
|
if (allSuccess) {
|
|
toast.success(t('dms.uploadSuccess'));
|
|
} else {
|
|
toast.error(t('dms.uploadError'));
|
|
}
|
|
onUploaded();
|
|
setTimeout(() => setUploads([]), 3000);
|
|
}, [folderId, onUploaded, toast, t]);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(true);
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
if (e.dataTransfer.files.length > 0) {
|
|
handleFiles(e.dataTransfer.files);
|
|
}
|
|
}, [handleFiles]);
|
|
|
|
const handleClick = useCallback(() => {
|
|
inputRef.current?.click();
|
|
}, []);
|
|
|
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
handleFiles(e.target.files);
|
|
e.target.value = '';
|
|
}
|
|
}, [handleFiles]);
|
|
|
|
return (
|
|
<div data-testid="upload-dropzone">
|
|
<div
|
|
className={clsx(
|
|
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer motion-safe:transition-all',
|
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
isDragging
|
|
? 'border-primary-500 bg-primary-50'
|
|
: 'border-secondary-300 hover:border-secondary-400 bg-secondary-50'
|
|
)}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={handleClick}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleClick();
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={t('dms.uploadDropHere')}
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple
|
|
onChange={handleInputChange}
|
|
className="hidden"
|
|
aria-hidden="true"
|
|
/>
|
|
<svg className="mx-auto h-10 w-10 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
</svg>
|
|
<p className="mt-2 text-sm text-secondary-600">{t('dms.uploadDropHere')}</p>
|
|
</div>
|
|
{uploads.length > 0 && (
|
|
<div className="mt-4 space-y-2" data-testid="upload-progress-list">
|
|
{uploads.map((u, i) => (
|
|
<div key={i} className="bg-white rounded-md border border-secondary-200 p-3">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm text-secondary-700 truncate">{u.fileName}</span>
|
|
{u.error ? (
|
|
<span className="text-xs text-danger-600">{u.error}</span>
|
|
) : (
|
|
<span className="text-xs text-secondary-500">{u.progress}%</span>
|
|
)}
|
|
</div>
|
|
<div className="h-2 bg-secondary-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={clsx('h-full rounded-full transition-all', u.error ? 'bg-danger-500' : 'bg-primary-500')}
|
|
style={{ width: `${u.error ? 100 : u.progress}%` }}
|
|
role="progressbar"
|
|
aria-valuenow={u.error ? 100 : u.progress}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{isUploading && (
|
|
<div className="mt-2 text-sm text-secondary-500" data-testid="upload-indicator">
|
|
{t('dms.uploading')}...
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|