feat(file-manager,archives): page-wide drag-and-drop upload (#1510)

Adds page-wide drag-and-drop file upload to the File Manager
  tab — drop any file anywhere on the page and the upload modal
  opens pre-populated with the dropped files. The Upload Files
  button still works for click-to-browse.

  Also fixes the Archives drag-cancel bug @maikolscripts reported
  in the same issue: cancelling a drag (drag back outside the
  browser, Escape mid-drag, or release outside the page) used to
  leave the overlay stuck until page refresh.

  Both pages share a new hook usePageFileDrop. The fix:
  - relatedTarget containment check (catches drag-out-of-window)
  - document-level drop / dragend / keydown(Escape) listeners
    that only register while isDraggingOver === true, so the
    three cancel paths all reset uniformly

  FileUploadModal gains an optional initialFiles prop so the
  File Manager page can pre-seed the modal from a page-wide
  drop. seededInitialRef guards against re-adding on re-renders.

  Permission gate: File Manager drop zone disabled when the
  user lacks library:upload, so a viewer-tier user doesn't get
  a misleading overlay.
This commit is contained in:
maziggy
2026-06-21 14:50:29 +02:00
parent c930c0e80d
commit 31ee7a5d9a
19 changed files with 507 additions and 171 deletions
+4
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,173 @@
/**
* Tests for usePageFileDrop. Each "cancel path" gets its own case so a future
* regression on any of the three (drag-out-of-window, Escape, dragend) is
* pinned independently — #1510 reported the Archives overlay sticking after
* cancel, and these cases enforce the document-level reset.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, act, createEvent } from '@testing-library/react';
import { usePageFileDrop } from '../../hooks/usePageFileDrop';
function makeFile(name: string, size = 1024): File {
return new File(['x'.repeat(size)], name, { type: 'application/octet-stream' });
}
function Harness(props: {
onFiles: (f: File[]) => void;
onRejected?: () => void;
extensions?: string[];
disabled?: boolean;
}) {
const { isDraggingOver, dragHandlers } = usePageFileDrop(props);
return (
<div data-testid="wrapper" {...dragHandlers}>
{isDraggingOver && <div data-testid="overlay">overlay</div>}
<div data-testid="child">child</div>
</div>
);
}
describe('usePageFileDrop', () => {
it('shows the overlay on dragenter with files', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.getByTestId('overlay')).toBeInTheDocument();
});
it('ignores dragenter for non-file payloads (text selection, dnd-kit)', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['text/plain'], files: [] } });
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
});
// JSDOM doesn't propagate relatedTarget through fireEvent.dragLeave(elem, {...}),
// so these three cases build the DragEvent manually and defineProperty the
// field before dispatching.
function dispatchDragLeave(wrapper: HTMLElement, related: Node | null) {
const ev = createEvent.dragLeave(wrapper);
Object.defineProperty(ev, 'relatedTarget', { value: related, configurable: true });
fireEvent(wrapper, ev);
}
it('keeps the overlay when dragging over a child (relatedTarget inside wrapper)', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
const child = screen.getByTestId('child');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.getByTestId('overlay')).toBeInTheDocument();
dispatchDragLeave(wrapper, child);
expect(screen.getByTestId('overlay')).toBeInTheDocument();
});
it('hides the overlay when dragLeave targets something outside the wrapper', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.getByTestId('overlay')).toBeInTheDocument();
const outside = document.createElement('div');
document.body.appendChild(outside);
dispatchDragLeave(wrapper, outside);
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
document.body.removeChild(outside);
});
it('hides the overlay when relatedTarget is null (cursor left the window)', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.getByTestId('overlay')).toBeInTheDocument();
dispatchDragLeave(wrapper, null);
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
});
it('resets on document drop (cancel path: release outside any drop target)', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.getByTestId('overlay')).toBeInTheDocument();
act(() => {
document.dispatchEvent(new Event('drop'));
});
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
});
it('resets on document dragend (cancel path: drag aborted)', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.getByTestId('overlay')).toBeInTheDocument();
act(() => {
document.dispatchEvent(new Event('dragend'));
});
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
});
it('resets on Escape (cancel path: user aborts mid-drag)', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.getByTestId('overlay')).toBeInTheDocument();
act(() => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
});
it('passes dropped files to onFiles', () => {
const onFiles = vi.fn();
render(<Harness onFiles={onFiles} />);
const wrapper = screen.getByTestId('wrapper');
const file = makeFile('model.3mf');
fireEvent.drop(wrapper, { dataTransfer: { files: [file] } });
expect(onFiles).toHaveBeenCalledWith([file]);
});
it('filters by extensions and calls onRejected when nothing matches', () => {
const onFiles = vi.fn();
const onRejected = vi.fn();
render(<Harness onFiles={onFiles} onRejected={onRejected} extensions={['.3mf']} />);
const wrapper = screen.getByTestId('wrapper');
const file = makeFile('image.png');
fireEvent.drop(wrapper, { dataTransfer: { files: [file] } });
expect(onFiles).not.toHaveBeenCalled();
expect(onRejected).toHaveBeenCalled();
});
it('only passes matched files through when extensions filter mixed types', () => {
const onFiles = vi.fn();
render(<Harness onFiles={onFiles} extensions={['.3mf']} />);
const wrapper = screen.getByTestId('wrapper');
const a = makeFile('a.3mf');
const b = makeFile('b.txt');
fireEvent.drop(wrapper, { dataTransfer: { files: [a, b] } });
expect(onFiles).toHaveBeenCalledWith([a]);
});
it('clears the overlay on a successful drop', () => {
render(<Harness onFiles={vi.fn()} />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.getByTestId('overlay')).toBeInTheDocument();
fireEvent.drop(wrapper, { dataTransfer: { files: [makeFile('a.3mf')] } });
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
});
it('is a no-op when disabled', () => {
const onFiles = vi.fn();
render(<Harness onFiles={onFiles} disabled />);
const wrapper = screen.getByTestId('wrapper');
fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
fireEvent.drop(wrapper, { dataTransfer: { files: [makeFile('a.3mf')] } });
expect(onFiles).not.toHaveBeenCalled();
});
});
+16 -2
View File
@@ -1,4 +1,4 @@
import { useState, useRef, type DragEvent } from 'react';
import { useState, useRef, useEffect, type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import {
Upload,
@@ -36,9 +36,11 @@ interface FileUploadModalProps {
validateFile?: (file: File) => string | undefined;
/** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
accept?: string;
/** Pre-seed the modal with files (e.g. from a page-wide drop) on first mount. */
initialFiles?: File[];
}
export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) {
export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept, initialFiles }: FileUploadModalProps) {
const { t } = useTranslation();
const [files, setFiles] = useState<UploadFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
@@ -153,6 +155,18 @@ export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUpl
setFiles((prev) => prev.filter((_, i) => i !== index));
};
// Seed once on mount when the parent passed initialFiles (page-wide drop).
// The ref/list shape means a subsequent re-render with the same files won't
// double-add — only the first non-empty initialFiles arg ever flows through.
const seededInitialRef = useRef(false);
useEffect(() => {
if (seededInitialRef.current) return;
if (!initialFiles || initialFiles.length === 0) return;
seededInitialRef.current = true;
addFiles(initialFiles);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
interface UsePageFileDropOptions {
/** Called when files are dropped that pass the extension filter. */
onFiles: (files: File[]) => void;
/** Called when a drop event had files but none matched `extensions`. */
onRejected?: () => void;
/** Lowercase extensions including the dot (e.g. ['.3mf']). Omit to accept all. */
extensions?: string[];
/** Disable the drop zone entirely (e.g. when the user lacks upload permission). */
disabled?: boolean;
}
interface UsePageFileDropResult {
isDraggingOver: boolean;
dragHandlers: {
onDragOver: (e: DragEvent) => void;
onDragEnter: (e: DragEvent) => void;
onDragLeave: (e: DragEvent) => void;
onDrop: (e: DragEvent) => void;
};
}
/**
* Page-wide drag-and-drop file zone. Survives the three cancel paths that
* dragLeave alone misses: drag-out-of-window, Escape during drag, and drag
* release outside any drop target. Each fix is captured by a separate test
* case in usePageFileDrop.test.tsx.
*/
export function usePageFileDrop({
onFiles,
onRejected,
extensions,
disabled = false,
}: UsePageFileDropOptions): UsePageFileDropResult {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const onFilesRef = useRef(onFiles);
const onRejectedRef = useRef(onRejected);
const extensionsRef = useRef(extensions);
useEffect(() => { onFilesRef.current = onFiles; }, [onFiles]);
useEffect(() => { onRejectedRef.current = onRejected; }, [onRejected]);
useEffect(() => { extensionsRef.current = extensions; }, [extensions]);
const handleDragOver = useCallback((e: DragEvent) => {
if (disabled) return;
e.preventDefault();
if (e.dataTransfer.types.includes('Files')) {
setIsDraggingOver(true);
}
}, [disabled]);
const handleDragEnter = useCallback((e: DragEvent) => {
if (disabled) return;
e.preventDefault();
if (e.dataTransfer.types.includes('Files')) {
setIsDraggingOver(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: DragEvent) => {
if (disabled) return;
e.preventDefault();
const wrapper = e.currentTarget as Node;
const next = e.relatedTarget as Node | null;
if (!next || !wrapper.contains(next)) {
setIsDraggingOver(false);
}
}, [disabled]);
const handleDrop = useCallback((e: DragEvent) => {
if (disabled) return;
e.preventDefault();
setIsDraggingOver(false);
const all = Array.from(e.dataTransfer.files);
if (all.length === 0) return;
const exts = extensionsRef.current;
const matched = exts && exts.length > 0
? all.filter(f => exts.some(ext => f.name.toLowerCase().endsWith(ext)))
: all;
if (matched.length > 0) {
onFilesRef.current(matched);
} else {
onRejectedRef.current?.();
}
}, [disabled]);
useEffect(() => {
if (!isDraggingOver) return;
const reset = () => setIsDraggingOver(false);
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') reset();
};
document.addEventListener('drop', reset);
document.addEventListener('dragend', reset);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('drop', reset);
document.removeEventListener('dragend', reset);
document.removeEventListener('keydown', handleKey);
};
}, [isDraggingOver]);
return {
isDraggingOver,
dragHandlers: {
onDragOver: handleDragOver,
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
},
};
}
+1
View File
@@ -3277,6 +3277,7 @@ export default {
link: 'Verknüpfen',
dragDropFiles: 'Dateien hierher ziehen',
dropFilesHere: 'Dateien hier ablegen',
releaseToUpload: 'Loslassen zum Hochladen',
orClickToBrowse: 'oder klicken zum Durchsuchen',
allFileTypesSupported: 'Alle Dateitypen werden unterstützt. ZIP-Dateien werden extrahiert.',
zipFilesDetected: 'ZIP-Dateien erkannt',
+1
View File
@@ -3292,6 +3292,7 @@ export default {
link: 'Link',
dragDropFiles: 'Drag & drop files here',
dropFilesHere: 'Drop files here',
releaseToUpload: 'Release to upload',
orClickToBrowse: 'or click to browse',
allFileTypesSupported: 'All file types supported. ZIP files will be extracted.',
zipFilesDetected: 'ZIP files detected',
+1
View File
@@ -3280,6 +3280,7 @@ export default {
link: 'Vincular',
dragDropFiles: 'Arrastre y suelte archivos aquí',
dropFilesHere: 'Suelte archivos aquí',
releaseToUpload: 'Suelte para subir',
orClickToBrowse: 'o haga clic para examinar',
allFileTypesSupported: 'Se admiten todos los tipos de archivo. Los archivos ZIP se extraerán.',
zipFilesDetected: 'Archivos ZIP detectados',
+1
View File
@@ -3266,6 +3266,7 @@ export default {
link: 'Lier',
dragDropFiles: 'Glissez les fichiers ici',
dropFilesHere: 'Déposez ici',
releaseToUpload: 'Relâcher pour téléverser',
orClickToBrowse: 'ou cliquez pour parcourir',
allFileTypesSupported: 'Tous types supportés. ZIP extraits.',
zipFilesDetected: 'ZIP détectés',
+1
View File
@@ -3265,6 +3265,7 @@ export default {
link: 'Collega',
dragDropFiles: 'Trascina e rilascia file qui',
dropFilesHere: 'Rilascia file qui',
releaseToUpload: 'Rilascia per caricare',
orClickToBrowse: 'oppure clicca per sfogliare',
allFileTypesSupported: 'Tutti i tipi di file supportati. I file ZIP saranno estratti.',
zipFilesDetected: 'File ZIP rilevati',
+1
View File
@@ -3277,6 +3277,7 @@ export default {
link: 'リンク',
dragDropFiles: 'ファイルをここにドラッグ&ドロップ',
dropFilesHere: 'ここにファイルをドロップ',
releaseToUpload: '離してアップロード',
orClickToBrowse: 'またはクリックして選択',
allFileTypesSupported: 'すべてのファイルタイプに対応。ZIPファイルは展開されます。',
zipFilesDetected: 'ZIPファイルを検出',
+1
View File
@@ -3090,6 +3090,7 @@ export default {
link: '연결',
dragDropFiles: '파일을 여기에 드래그 앤 드롭',
dropFilesHere: '파일을 여기에 드롭',
releaseToUpload: '놓아서 업로드',
orClickToBrowse: '또는 클릭하여 탐색',
allFileTypesSupported: '모든 파일 형식 지원. ZIP 파일은 압축 해제됩니다.',
zipFilesDetected: 'ZIP 파일 감지됨',
+1
View File
@@ -3265,6 +3265,7 @@ export default {
link: 'Vincular',
dragDropFiles: 'Arraste e solte os arquivos aqui',
dropFilesHere: 'Solte os arquivos aqui',
releaseToUpload: 'Solte para enviar',
orClickToBrowse: 'ou clique para procurar',
allFileTypesSupported: 'Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.',
zipFilesDetected: 'Arquivos ZIP detectados',
+1
View File
@@ -3272,6 +3272,7 @@ export default {
link: 'Bağla',
dragDropFiles: 'Dosyaları buraya sürükleyip bırakın',
dropFilesHere: 'Dosyaları buraya bırakın',
releaseToUpload: 'Yüklemek için bırakın',
orClickToBrowse: 'veya göz atmak için tıklayın',
allFileTypesSupported: 'Tüm dosya türleri desteklenir. ZIP dosyaları çıkarılacak.',
zipFilesDetected: 'ZIP dosyaları algılandı',
+1
View File
@@ -3265,6 +3265,7 @@ export default {
link: '链接',
dragDropFiles: '将文件拖放到此处',
dropFilesHere: '将文件放在此处',
releaseToUpload: '释放以上传',
orClickToBrowse: '或点击浏览',
allFileTypesSupported: '支持所有文件类型。ZIP 文件将被解压。',
zipFilesDetected: '检测到 ZIP 文件',
+1
View File
@@ -3265,6 +3265,7 @@ export default {
link: '連結',
dragDropFiles: '將檔案拖放到此處',
dropFilesHere: '將檔案放在此處',
releaseToUpload: '釋放以上傳',
orClickToBrowse: '或點選瀏覽',
allFileTypesSupported: '支援所有檔案類型。ZIP 檔案將被解壓。',
zipFilesDetected: '偵測到 ZIP 檔案',
+16 -32
View File
@@ -64,6 +64,7 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
import { getCurrencySymbol } from '../utils/currency';
import { getBedTypeInfo } from '../utils/bedType';
import { useIsMobile } from '../hooks/useIsMobile';
import { usePageFileDrop } from '../hooks/usePageFileDrop';
import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
@@ -2618,7 +2619,6 @@ export function ArchivesPage() {
);
const [showUpload, setShowUpload] = useState(false);
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// Install-step-4 nudge — covers the slicer-side variant of "Store sent files
// on external storage" that the connection diagnostic can't detect (printer
@@ -3122,34 +3122,20 @@ export function ArchivesPage() {
const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || hideDuplicates || filterTag || filterFileType !== 'all';
// Drag & drop handlers for page-wide upload
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (e.dataTransfer.types.includes('Files')) {
setIsDraggingOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
// Only hide if leaving the page (not entering a child)
if (e.currentTarget === e.target) {
setIsDraggingOver(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDraggingOver(false);
const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.3mf'));
if (droppedFiles.length > 0) {
setUploadFiles(droppedFiles);
// Page-wide drag-and-drop upload (#1510). The hook covers the three cancel
// paths the previous inline implementation missed (drag-out-of-window, Escape,
// dragend outside any drop target). Disabled while the upload modal is open
// so drags into the modal's own drop zone don't bubble up and flash the page
// overlay behind it.
const { isDraggingOver, dragHandlers } = usePageFileDrop({
disabled: showUpload,
extensions: ['.3mf'],
onFiles: (files) => {
setUploadFiles(files);
setShowUpload(true);
} else if (e.dataTransfer.files.length > 0) {
showToast(t('archives.page.only3mfSupported'), 'warning');
}
}, [showToast, t]);
},
onRejected: () => showToast(t('archives.page.only3mfSupported'), 'warning'),
});
// Keyboard shortcuts
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -3190,16 +3176,14 @@ export function ArchivesPage() {
return (
<div
className="p-4 md:p-8 relative"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
{...dragHandlers}
>
{/* Drag & Drop Overlay */}
{isDraggingOver && (
<div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
<div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
<Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
<p className="text-2xl font-semibold text-white mb-2">Drop .3mf files here</p>
<p className="text-2xl font-semibold text-white mb-2">{t('archives.page.dropFilesHere')}</p>
<p className="text-bambu-gray">{t('archives.releaseToUpload')}</p>
</div>
</div>
+36 -2
View File
@@ -62,6 +62,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
import { useToast } from '../contexts/ToastContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { usePageFileDrop } from '../hooks/usePageFileDrop';
import { useAuth } from '../contexts/AuthContext';
import { formatDuration, parseUTCDate } from '../utils/date';
import { formatFileSize } from '../utils/file';
@@ -959,6 +960,7 @@ export function FileManagerPage() {
const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
const [showMoveModal, setShowMoveModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
const [showPurgeModal, setShowPurgeModal] = useState(false);
const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
@@ -1450,6 +1452,20 @@ export function FileManagerPage() {
queryClient.invalidateQueries({ queryKey: ['library-stats'] });
};
// Page-wide drag-and-drop upload (#1510). Disabled when the user lacks
// library:upload so a non-uploader can't accidentally show the overlay,
// and also disabled while the upload modal itself is open so drags into
// the modal's own drop zone don't bubble up and flash the page overlay
// behind it.
const canUpload = hasPermission('library:upload');
const { isDraggingOver, dragHandlers } = usePageFileDrop({
disabled: !canUpload || showUploadModal,
onFiles: (files) => {
setDroppedFiles(files);
setShowUploadModal(true);
},
});
const handleDownload = (id: number) => {
api.downloadLibraryFile(id).catch((err) => {
console.error('Library file download failed:', err);
@@ -1491,7 +1507,21 @@ export function FileManagerPage() {
}, [selectedFolderId, folders]);
return (
<div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
<div
className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col relative"
{...dragHandlers}
>
{/* Drag & Drop Overlay — page-wide file upload (#1510) */}
{isDraggingOver && (
<div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
<div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
<Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
<p className="text-2xl font-semibold text-white mb-2">{t('fileManager.dropFilesHere')}</p>
<p className="text-bambu-gray">{t('fileManager.releaseToUpload')}</p>
</div>
</div>
)}
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
@@ -2417,8 +2447,12 @@ export function FileManagerPage() {
{showUploadModal && (
<FileUploadModal
folderId={selectedFolderId}
onClose={() => setShowUploadModal(false)}
onClose={() => {
setShowUploadModal(false);
setDroppedFiles([]);
}}
onUploadComplete={handleUploadComplete}
initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined}
/>
)}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DSkiM9pi.js"></script>
<script type="module" crossorigin src="/assets/index-ef6eQr6-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9kvaB_m.css">
</head>
<body>