mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3277,6 +3277,7 @@ export default {
|
||||
link: 'リンク',
|
||||
dragDropFiles: 'ファイルをここにドラッグ&ドロップ',
|
||||
dropFilesHere: 'ここにファイルをドロップ',
|
||||
releaseToUpload: '離してアップロード',
|
||||
orClickToBrowse: 'またはクリックして選択',
|
||||
allFileTypesSupported: 'すべてのファイルタイプに対応。ZIPファイルは展開されます。',
|
||||
zipFilesDetected: 'ZIPファイルを検出',
|
||||
|
||||
@@ -3090,6 +3090,7 @@ export default {
|
||||
link: '연결',
|
||||
dragDropFiles: '파일을 여기에 드래그 앤 드롭',
|
||||
dropFilesHere: '파일을 여기에 드롭',
|
||||
releaseToUpload: '놓아서 업로드',
|
||||
orClickToBrowse: '또는 클릭하여 탐색',
|
||||
allFileTypesSupported: '모든 파일 형식 지원. ZIP 파일은 압축 해제됩니다.',
|
||||
zipFilesDetected: 'ZIP 파일 감지됨',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ı',
|
||||
|
||||
@@ -3265,6 +3265,7 @@ export default {
|
||||
link: '链接',
|
||||
dragDropFiles: '将文件拖放到此处',
|
||||
dropFilesHere: '将文件放在此处',
|
||||
releaseToUpload: '释放以上传',
|
||||
orClickToBrowse: '或点击浏览',
|
||||
allFileTypesSupported: '支持所有文件类型。ZIP 文件将被解压。',
|
||||
zipFilesDetected: '检测到 ZIP 文件',
|
||||
|
||||
@@ -3265,6 +3265,7 @@ export default {
|
||||
link: '連結',
|
||||
dragDropFiles: '將檔案拖放到此處',
|
||||
dropFilesHere: '將檔案放在此處',
|
||||
releaseToUpload: '釋放以上傳',
|
||||
orClickToBrowse: '或點選瀏覽',
|
||||
allFileTypesSupported: '支援所有檔案類型。ZIP 檔案將被解壓。',
|
||||
zipFilesDetected: '偵測到 ZIP 檔案',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user