Kaynağa Gözat

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.
maziggy 2 ay önce
ebeveyn
işleme
31ee7a5d9a

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
CHANGELOG.md


+ 173 - 0
frontend/src/__tests__/hooks/usePageFileDrop.test.tsx

@@ -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
frontend/src/components/FileUploadModal.tsx

@@ -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 { useTranslation } from 'react-i18next';
 import {
 import {
   Upload,
   Upload,
@@ -36,9 +36,11 @@ interface FileUploadModalProps {
   validateFile?: (file: File) => string | undefined;
   validateFile?: (file: File) => string | undefined;
   /** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
   /** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
   accept?: string;
   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 { t } = useTranslation();
   const [files, setFiles] = useState<UploadFile[]>([]);
   const [files, setFiles] = useState<UploadFile[]>([]);
   const [isDragging, setIsDragging] = useState(false);
   const [isDragging, setIsDragging] = useState(false);
@@ -153,6 +155,18 @@ export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUpl
     setFiles((prev) => prev.filter((_, i) => i !== index));
     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 hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
   const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && 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');
   const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');

+ 116 - 0
frontend/src/hooks/usePageFileDrop.ts

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -3277,6 +3277,7 @@ export default {
     link: 'Verknüpfen',
     link: 'Verknüpfen',
     dragDropFiles: 'Dateien hierher ziehen',
     dragDropFiles: 'Dateien hierher ziehen',
     dropFilesHere: 'Dateien hier ablegen',
     dropFilesHere: 'Dateien hier ablegen',
+    releaseToUpload: 'Loslassen zum Hochladen',
     orClickToBrowse: 'oder klicken zum Durchsuchen',
     orClickToBrowse: 'oder klicken zum Durchsuchen',
     allFileTypesSupported: 'Alle Dateitypen werden unterstützt. ZIP-Dateien werden extrahiert.',
     allFileTypesSupported: 'Alle Dateitypen werden unterstützt. ZIP-Dateien werden extrahiert.',
     zipFilesDetected: 'ZIP-Dateien erkannt',
     zipFilesDetected: 'ZIP-Dateien erkannt',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -3292,6 +3292,7 @@ export default {
     link: 'Link',
     link: 'Link',
     dragDropFiles: 'Drag & drop files here',
     dragDropFiles: 'Drag & drop files here',
     dropFilesHere: 'Drop files here',
     dropFilesHere: 'Drop files here',
+    releaseToUpload: 'Release to upload',
     orClickToBrowse: 'or click to browse',
     orClickToBrowse: 'or click to browse',
     allFileTypesSupported: 'All file types supported. ZIP files will be extracted.',
     allFileTypesSupported: 'All file types supported. ZIP files will be extracted.',
     zipFilesDetected: 'ZIP files detected',
     zipFilesDetected: 'ZIP files detected',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -3280,6 +3280,7 @@ export default {
     link: 'Vincular',
     link: 'Vincular',
     dragDropFiles: 'Arrastre y suelte archivos aquí',
     dragDropFiles: 'Arrastre y suelte archivos aquí',
     dropFilesHere: 'Suelte archivos aquí',
     dropFilesHere: 'Suelte archivos aquí',
+    releaseToUpload: 'Suelte para subir',
     orClickToBrowse: 'o haga clic para examinar',
     orClickToBrowse: 'o haga clic para examinar',
     allFileTypesSupported: 'Se admiten todos los tipos de archivo. Los archivos ZIP se extraerán.',
     allFileTypesSupported: 'Se admiten todos los tipos de archivo. Los archivos ZIP se extraerán.',
     zipFilesDetected: 'Archivos ZIP detectados',
     zipFilesDetected: 'Archivos ZIP detectados',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -3266,6 +3266,7 @@ export default {
     link: 'Lier',
     link: 'Lier',
     dragDropFiles: 'Glissez les fichiers ici',
     dragDropFiles: 'Glissez les fichiers ici',
     dropFilesHere: 'Déposez ici',
     dropFilesHere: 'Déposez ici',
+    releaseToUpload: 'Relâcher pour téléverser',
     orClickToBrowse: 'ou cliquez pour parcourir',
     orClickToBrowse: 'ou cliquez pour parcourir',
     allFileTypesSupported: 'Tous types supportés. ZIP extraits.',
     allFileTypesSupported: 'Tous types supportés. ZIP extraits.',
     zipFilesDetected: 'ZIP détectés',
     zipFilesDetected: 'ZIP détectés',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -3265,6 +3265,7 @@ export default {
     link: 'Collega',
     link: 'Collega',
     dragDropFiles: 'Trascina e rilascia file qui',
     dragDropFiles: 'Trascina e rilascia file qui',
     dropFilesHere: 'Rilascia file qui',
     dropFilesHere: 'Rilascia file qui',
+    releaseToUpload: 'Rilascia per caricare',
     orClickToBrowse: 'oppure clicca per sfogliare',
     orClickToBrowse: 'oppure clicca per sfogliare',
     allFileTypesSupported: 'Tutti i tipi di file supportati. I file ZIP saranno estratti.',
     allFileTypesSupported: 'Tutti i tipi di file supportati. I file ZIP saranno estratti.',
     zipFilesDetected: 'File ZIP rilevati',
     zipFilesDetected: 'File ZIP rilevati',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -3277,6 +3277,7 @@ export default {
     link: 'リンク',
     link: 'リンク',
     dragDropFiles: 'ファイルをここにドラッグ&ドロップ',
     dragDropFiles: 'ファイルをここにドラッグ&ドロップ',
     dropFilesHere: 'ここにファイルをドロップ',
     dropFilesHere: 'ここにファイルをドロップ',
+    releaseToUpload: '離してアップロード',
     orClickToBrowse: 'またはクリックして選択',
     orClickToBrowse: 'またはクリックして選択',
     allFileTypesSupported: 'すべてのファイルタイプに対応。ZIPファイルは展開されます。',
     allFileTypesSupported: 'すべてのファイルタイプに対応。ZIPファイルは展開されます。',
     zipFilesDetected: 'ZIPファイルを検出',
     zipFilesDetected: 'ZIPファイルを検出',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -3090,6 +3090,7 @@ export default {
     link: '연결',
     link: '연결',
     dragDropFiles: '파일을 여기에 드래그 앤 드롭',
     dragDropFiles: '파일을 여기에 드래그 앤 드롭',
     dropFilesHere: '파일을 여기에 드롭',
     dropFilesHere: '파일을 여기에 드롭',
+    releaseToUpload: '놓아서 업로드',
     orClickToBrowse: '또는 클릭하여 탐색',
     orClickToBrowse: '또는 클릭하여 탐색',
     allFileTypesSupported: '모든 파일 형식 지원. ZIP 파일은 압축 해제됩니다.',
     allFileTypesSupported: '모든 파일 형식 지원. ZIP 파일은 압축 해제됩니다.',
     zipFilesDetected: 'ZIP 파일 감지됨',
     zipFilesDetected: 'ZIP 파일 감지됨',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3265,6 +3265,7 @@ export default {
     link: 'Vincular',
     link: 'Vincular',
     dragDropFiles: 'Arraste e solte os arquivos aqui',
     dragDropFiles: 'Arraste e solte os arquivos aqui',
     dropFilesHere: 'Solte os arquivos aqui',
     dropFilesHere: 'Solte os arquivos aqui',
+    releaseToUpload: 'Solte para enviar',
     orClickToBrowse: 'ou clique para procurar',
     orClickToBrowse: 'ou clique para procurar',
     allFileTypesSupported: 'Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.',
     allFileTypesSupported: 'Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.',
     zipFilesDetected: 'Arquivos ZIP detectados',
     zipFilesDetected: 'Arquivos ZIP detectados',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -3272,6 +3272,7 @@ export default {
     link: 'Bağla',
     link: 'Bağla',
     dragDropFiles: 'Dosyaları buraya sürükleyip bırakın',
     dragDropFiles: 'Dosyaları buraya sürükleyip bırakın',
     dropFilesHere: 'Dosyaları buraya 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',
     orClickToBrowse: 'veya göz atmak için tıklayın',
     allFileTypesSupported: 'Tüm dosya türleri desteklenir. ZIP dosyaları çıkarılacak.',
     allFileTypesSupported: 'Tüm dosya türleri desteklenir. ZIP dosyaları çıkarılacak.',
     zipFilesDetected: 'ZIP dosyaları algılandı',
     zipFilesDetected: 'ZIP dosyaları algılandı',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3265,6 +3265,7 @@ export default {
     link: '链接',
     link: '链接',
     dragDropFiles: '将文件拖放到此处',
     dragDropFiles: '将文件拖放到此处',
     dropFilesHere: '将文件放在此处',
     dropFilesHere: '将文件放在此处',
+    releaseToUpload: '释放以上传',
     orClickToBrowse: '或点击浏览',
     orClickToBrowse: '或点击浏览',
     allFileTypesSupported: '支持所有文件类型。ZIP 文件将被解压。',
     allFileTypesSupported: '支持所有文件类型。ZIP 文件将被解压。',
     zipFilesDetected: '检测到 ZIP 文件',
     zipFilesDetected: '检测到 ZIP 文件',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3265,6 +3265,7 @@ export default {
     link: '連結',
     link: '連結',
     dragDropFiles: '將檔案拖放到此處',
     dragDropFiles: '將檔案拖放到此處',
     dropFilesHere: '將檔案放在此處',
     dropFilesHere: '將檔案放在此處',
+    releaseToUpload: '釋放以上傳',
     orClickToBrowse: '或點選瀏覽',
     orClickToBrowse: '或點選瀏覽',
     allFileTypesSupported: '支援所有檔案類型。ZIP 檔案將被解壓。',
     allFileTypesSupported: '支援所有檔案類型。ZIP 檔案將被解壓。',
     zipFilesDetected: '偵測到 ZIP 檔案',
     zipFilesDetected: '偵測到 ZIP 檔案',

+ 16 - 32
frontend/src/pages/ArchivesPage.tsx

@@ -64,6 +64,7 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
 import { getBedTypeInfo } from '../utils/bedType';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
+import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -2618,7 +2619,6 @@ export function ArchivesPage() {
   );
   );
   const [showUpload, setShowUpload] = useState(false);
   const [showUpload, setShowUpload] = useState(false);
   const [uploadFiles, setUploadFiles] = useState<File[]>([]);
   const [uploadFiles, setUploadFiles] = useState<File[]>([]);
-  const [isDraggingOver, setIsDraggingOver] = useState(false);
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
   // Install-step-4 nudge — covers the slicer-side variant of "Store sent files
   // Install-step-4 nudge — covers the slicer-side variant of "Store sent files
   // on external storage" that the connection diagnostic can't detect (printer
   // 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';
   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);
       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
   // Keyboard shortcuts
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -3190,16 +3176,14 @@ export function ArchivesPage() {
   return (
   return (
     <div
     <div
       className="p-4 md:p-8 relative"
       className="p-4 md:p-8 relative"
-      onDragOver={handleDragOver}
-      onDragLeave={handleDragLeave}
-      onDrop={handleDrop}
+      {...dragHandlers}
     >
     >
       {/* Drag & Drop Overlay */}
       {/* Drag & Drop Overlay */}
       {isDraggingOver && (
       {isDraggingOver && (
         <div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
         <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">
           <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" />
             <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>
             <p className="text-bambu-gray">{t('archives.releaseToUpload')}</p>
           </div>
           </div>
         </div>
         </div>

+ 36 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -62,6 +62,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
+import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate } from '../utils/date';
 import { formatDuration, parseUTCDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
 import { formatFileSize } from '../utils/file';
@@ -959,6 +960,7 @@ export function FileManagerPage() {
   const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
   const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
+  const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
   const [showPurgeModal, setShowPurgeModal] = useState(false);
   const [showPurgeModal, setShowPurgeModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | 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'] });
     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) => {
   const handleDownload = (id: number) => {
     api.downloadLibraryFile(id).catch((err) => {
     api.downloadLibraryFile(id).catch((err) => {
       console.error('Library file download failed:', err);
       console.error('Library file download failed:', err);
@@ -1491,7 +1507,21 @@ export function FileManagerPage() {
   }, [selectedFolderId, folders]);
   }, [selectedFolderId, folders]);
 
 
   return (
   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 */}
       {/* Header */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
         <div>
         <div>
@@ -2417,8 +2447,12 @@ export function FileManagerPage() {
       {showUploadModal && (
       {showUploadModal && (
         <FileUploadModal
         <FileUploadModal
           folderId={selectedFolderId}
           folderId={selectedFolderId}
-          onClose={() => setShowUploadModal(false)}
+          onClose={() => {
+            setShowUploadModal(false);
+            setDroppedFiles([]);
+          }}
           onUploadComplete={handleUploadComplete}
           onUploadComplete={handleUploadComplete}
+          initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined}
         />
         />
       )}
       )}
 
 

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-ef6eQr6-.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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">
     <link rel="stylesheet" crossorigin href="/assets/index-D9kvaB_m.css">
   </head>
   </head>
   <body>
   <body>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor