Przeglądaj źródła

Multiple fixes and ZIP file support in File Manager

Features:
  - ZIP file support in File Manager (Issue #121): Upload ZIP files to
    automatically extract contents with option to preserve folder structure
  - Delete operation loading indicator: Shows progress during folder/file
    deletion operations

Fixed:
  - Print time stats using actual elapsed time instead of slicer estimates
    (Issue #137)
  - Skip objects modal overflow with scrollable list for many objects
    (Issue #134)

Removed:
  - Duplicate badge feature from File Manager (per user request)

Tests:
  - Added ZIP extraction backend tests (5 tests)
  - Added ConfirmModal loading state frontend tests (5 tests)
maziggy 4 miesięcy temu
rodzic
commit
6225fac398

+ 5 - 0
CHANGELOG.md

@@ -28,6 +28,11 @@ All notable changes to Bambuddy will be documented in this file.
   - Photo is captured before notification is sent (ensures image is available)
   - New "External URL" setting in Settings → Network (auto-detects from browser)
   - Full URL constructed for external notification services (Telegram, Email, Discord, etc.)
+- **ZIP File Support in File Manager** - Upload and extract ZIP files directly in the library (Issue #121):
+  - Drop or select ZIP files to automatically extract contents
+  - Option to preserve folder structure from ZIP or extract flat
+  - Extracts thumbnails and metadata from 3MF/gcode files inside ZIP
+  - Progress indicator shows number of files extracted
 
 ### Fixed
 - **Print time stats using slicer estimates** - Quick Stats "Print Time" now uses actual elapsed time (`completed_at - started_at`) instead of slicer estimates; cancelled prints only count time actually printed (Issue #137)

+ 223 - 0
backend/app/api/routes/library.py

@@ -38,6 +38,9 @@ from backend.app.schemas.library import (
     FolderResponse,
     FolderTreeItem,
     FolderUpdate,
+    ZipExtractError,
+    ZipExtractResponse,
+    ZipExtractResult,
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
 
@@ -740,6 +743,226 @@ async def upload_file(
         raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
 
 
+@router.post("/files/extract-zip", response_model=ZipExtractResponse)
+async def extract_zip_file(
+    file: UploadFile = File(...),
+    folder_id: int | None = None,
+    preserve_structure: bool = True,
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload and extract a ZIP file to the library.
+
+    Args:
+        file: The ZIP file to extract
+        folder_id: Target folder ID (None = root)
+        preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
+    """
+    import tempfile
+    import zipfile
+
+    if not file.filename or not file.filename.lower().endswith(".zip"):
+        raise HTTPException(status_code=400, detail="Only ZIP files are supported")
+
+    # Verify target folder exists if specified
+    if folder_id is not None:
+        folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
+        if not folder_result.scalar_one_or_none():
+            raise HTTPException(status_code=404, detail="Target folder not found")
+
+    # Save ZIP to temp file
+    try:
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
+            content = await file.read()
+            tmp.write(content)
+            tmp_path = tmp.name
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}")
+
+    extracted_files: list[ZipExtractResult] = []
+    errors: list[ZipExtractError] = []
+    folders_created = 0
+    folder_cache: dict[str, int] = {}  # path -> folder_id
+
+    try:
+        with zipfile.ZipFile(tmp_path, "r") as zf:
+            # Filter out directories and hidden/system files
+            file_list = [
+                name
+                for name in zf.namelist()
+                if not name.endswith("/")
+                and not name.startswith("__MACOSX")
+                and not os.path.basename(name).startswith(".")
+            ]
+
+            for zip_path in file_list:
+                try:
+                    # Determine target folder
+                    target_folder_id = folder_id
+
+                    if preserve_structure:
+                        # Get directory path from ZIP
+                        dir_path = os.path.dirname(zip_path)
+                        if dir_path:
+                            # Create folder structure
+                            parts = dir_path.split("/")
+                            current_parent = folder_id
+                            current_path = ""
+
+                            for part in parts:
+                                if not part:
+                                    continue
+                                current_path = f"{current_path}/{part}" if current_path else part
+
+                                if current_path in folder_cache:
+                                    current_parent = folder_cache[current_path]
+                                else:
+                                    # Check if folder exists
+                                    existing = await db.execute(
+                                        select(LibraryFolder).where(
+                                            LibraryFolder.name == part,
+                                            LibraryFolder.parent_id == current_parent
+                                            if current_parent
+                                            else LibraryFolder.parent_id.is_(None),
+                                        )
+                                    )
+                                    existing_folder = existing.scalar_one_or_none()
+
+                                    if existing_folder:
+                                        current_parent = existing_folder.id
+                                    else:
+                                        # Create folder
+                                        new_folder = LibraryFolder(name=part, parent_id=current_parent)
+                                        db.add(new_folder)
+                                        await db.flush()
+                                        current_parent = new_folder.id
+                                        folders_created += 1
+
+                                    folder_cache[current_path] = current_parent
+
+                            target_folder_id = current_parent
+
+                    # Extract file
+                    filename = os.path.basename(zip_path)
+                    ext = os.path.splitext(filename)[1].lower()
+                    file_type = ext[1:] if ext else "unknown"
+
+                    # Generate unique filename for storage
+                    unique_filename = f"{uuid.uuid4().hex}{ext}"
+                    file_path = get_library_files_dir() / unique_filename
+
+                    # Extract and save file
+                    file_content = zf.read(zip_path)
+                    with open(file_path, "wb") as f:
+                        f.write(file_content)
+
+                    # Calculate hash
+                    file_hash = calculate_file_hash(file_path)
+
+                    # Extract metadata and thumbnail for 3MF files
+                    metadata = {}
+                    thumbnail_path = None
+                    thumbnails_dir = get_library_thumbnails_dir()
+
+                    if ext == ".3mf":
+                        try:
+                            parser = ThreeMFParser(str(file_path))
+                            raw_metadata = parser.parse()
+
+                            thumbnail_data = raw_metadata.get("_thumbnail_data")
+                            thumbnail_ext = raw_metadata.get("_thumbnail_ext", ".png")
+
+                            if thumbnail_data:
+                                thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}"
+                                thumb_path = thumbnails_dir / thumb_filename
+                                with open(thumb_path, "wb") as f:
+                                    f.write(thumbnail_data)
+                                thumbnail_path = str(thumb_path)
+
+                            def clean_metadata(obj):
+                                if isinstance(obj, dict):
+                                    return {
+                                        k: clean_metadata(v)
+                                        for k, v in obj.items()
+                                        if not isinstance(v, bytes) and k not in ("_thumbnail_data", "_thumbnail_ext")
+                                    }
+                                elif isinstance(obj, list):
+                                    return [clean_metadata(i) for i in obj if not isinstance(i, bytes)]
+                                elif isinstance(obj, bytes):
+                                    return None
+                                return obj
+
+                            metadata = clean_metadata(raw_metadata)
+                        except Exception as e:
+                            logger.warning(f"Failed to parse 3MF from ZIP: {e}")
+
+                    elif ext == ".gcode":
+                        try:
+                            thumbnail_data = extract_gcode_thumbnail(file_path)
+                            if thumbnail_data:
+                                thumb_filename = f"{uuid.uuid4().hex}.png"
+                                thumb_path = thumbnails_dir / thumb_filename
+                                with open(thumb_path, "wb") as f:
+                                    f.write(thumbnail_data)
+                                thumbnail_path = str(thumb_path)
+                        except Exception as e:
+                            logger.warning(f"Failed to extract gcode thumbnail from ZIP: {e}")
+
+                    elif ext.lower() in IMAGE_EXTENSIONS:
+                        thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
+
+                    # Create database entry
+                    library_file = LibraryFile(
+                        folder_id=target_folder_id,
+                        filename=filename,
+                        file_path=str(file_path),
+                        file_type=file_type,
+                        file_size=len(file_content),
+                        file_hash=file_hash,
+                        thumbnail_path=thumbnail_path,
+                        file_metadata=metadata if metadata else None,
+                    )
+                    db.add(library_file)
+                    await db.flush()
+                    await db.refresh(library_file)
+
+                    extracted_files.append(
+                        ZipExtractResult(
+                            filename=filename,
+                            file_id=library_file.id,
+                            folder_id=target_folder_id,
+                        )
+                    )
+
+                    # Commit after each file to release database lock
+                    # This prevents long-running transactions from blocking other requests
+                    await db.commit()
+
+                except Exception as e:
+                    logger.error(f"Failed to extract {zip_path}: {e}")
+                    errors.append(ZipExtractError(filename=os.path.basename(zip_path), error=str(e)))
+                    # Rollback the failed file but continue with others
+                    await db.rollback()
+
+        return ZipExtractResponse(
+            extracted=len(extracted_files),
+            folders_created=folders_created,
+            files=extracted_files,
+            errors=errors,
+        )
+
+    except zipfile.BadZipFile:
+        raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
+    except Exception as e:
+        logger.error(f"ZIP extraction failed: {e}", exc_info=True)
+        raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
+    finally:
+        # Clean up temp file
+        try:
+            os.unlink(tmp_path)
+        except Exception:
+            pass
+
+
 # ============ Queue Operations ============
 # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts
 

+ 27 - 0
backend/app/schemas/library.py

@@ -235,3 +235,30 @@ class AddToQueueResponse(BaseModel):
 
     added: list[AddToQueueResult]
     errors: list[AddToQueueError]
+
+
+# ============ ZIP Extraction ============
+
+
+class ZipExtractResult(BaseModel):
+    """Result for a single file extracted from ZIP."""
+
+    filename: str
+    file_id: int
+    folder_id: int | None = None
+
+
+class ZipExtractError(BaseModel):
+    """Error for a file that couldn't be extracted."""
+
+    filename: str
+    error: str
+
+
+class ZipExtractResponse(BaseModel):
+    """Schema for ZIP extraction response."""
+
+    extracted: int
+    folders_created: int
+    files: list[ZipExtractResult]
+    errors: list[ZipExtractError]

+ 103 - 0
backend/tests/integration/test_library_api.py

@@ -342,3 +342,106 @@ class TestLibraryAddToQueueAPI:
         assert len(result["added"]) == 0
         assert len(result["errors"]) == 1
         assert "sliced" in result["errors"][0]["error"].lower()
+
+
+class TestLibraryZipExtractAPI:
+    """Integration tests for ZIP extraction endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
+        """Verify non-ZIP files are rejected."""
+        # Create a fake file that's not a ZIP
+        files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
+        assert response.status_code == 400
+        assert "ZIP" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
+        """Verify basic ZIP extraction works."""
+        import io
+        import zipfile
+
+        # Create a simple ZIP file in memory
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("test1.txt", "Content of file 1")
+            zf.writestr("test2.txt", "Content of file 2")
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["extracted"] == 2
+        assert len(result["files"]) == 2
+        assert len(result["errors"]) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction preserves folder structure."""
+        import io
+        import zipfile
+
+        # Create a ZIP file with folder structure
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("folder1/file1.txt", "Content 1")
+            zf.writestr("folder1/subfolder/file2.txt", "Content 2")
+            zf.writestr("folder2/file3.txt", "Content 3")
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        params = {"preserve_structure": "true"}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["extracted"] == 3
+        assert result["folders_created"] >= 3  # folder1, folder1/subfolder, folder2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction can extract flat (no folders)."""
+        import io
+        import zipfile
+
+        # Create a ZIP file with folder structure
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("folder/file1.txt", "Content 1")
+            zf.writestr("folder/file2.txt", "Content 2")
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        params = {"preserve_structure": "false"}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["extracted"] == 2
+        assert result["folders_created"] == 0  # No folders created when flat
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction skips __MACOSX and hidden files."""
+        import io
+        import zipfile
+
+        # Create a ZIP file with macOS junk files
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("real_file.txt", "Real content")
+            zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
+            zf.writestr(".hidden_file", "Hidden content")
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["extracted"] == 1  # Only real_file.txt
+        assert result["files"][0]["filename"] == "real_file.txt"

+ 42 - 0
frontend/src/__tests__/components/ConfirmModal.test.tsx

@@ -122,4 +122,46 @@ describe('ConfirmModal', () => {
       expect(screen.getByText('Confirm Action')).toBeInTheDocument();
     });
   });
+
+  describe('loading state', () => {
+    it('shows loading text when isLoading is true', () => {
+      render(<ConfirmModal {...defaultProps} isLoading={true} loadingText="Deleting..." />);
+      expect(screen.getByText('Deleting...')).toBeInTheDocument();
+    });
+
+    it('shows default loading text when loadingText not provided', () => {
+      render(<ConfirmModal {...defaultProps} isLoading={true} />);
+      expect(screen.getByText('Processing...')).toBeInTheDocument();
+    });
+
+    it('disables buttons when loading', () => {
+      render(<ConfirmModal {...defaultProps} isLoading={true} />);
+      const buttons = screen.getAllByRole('button');
+      buttons.forEach(button => {
+        expect(button).toBeDisabled();
+      });
+    });
+
+    it('does not call onCancel when clicking backdrop while loading', async () => {
+      const user = userEvent.setup();
+      const onCancel = vi.fn();
+      const { container } = render(
+        <ConfirmModal {...defaultProps} onCancel={onCancel} isLoading={true} />
+      );
+
+      const backdrop = container.querySelector('.fixed');
+      if (backdrop) {
+        await user.click(backdrop);
+        expect(onCancel).not.toHaveBeenCalled();
+      }
+    });
+
+    it('does not call onCancel on Escape key while loading', () => {
+      const onCancel = vi.fn();
+      render(<ConfirmModal {...defaultProps} onCancel={onCancel} isLoading={true} />);
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(onCancel).not.toHaveBeenCalled();
+    });
+  });
 });

+ 38 - 0
frontend/src/api/client.ts

@@ -2720,6 +2720,26 @@ export const api = {
     }
     return response.json();
   },
+  extractZipFile: async (
+    file: File,
+    folderId?: number | null,
+    preserveStructure: boolean = true
+  ): Promise<ZipExtractResponse> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const params = new URLSearchParams();
+    if (folderId) params.set('folder_id', String(folderId));
+    params.set('preserve_structure', String(preserveStructure));
+    const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
   updateLibraryFile: (id: number, data: LibraryFileUpdate) =>
     request<LibraryFile>(`/library/files/${id}`, {
       method: 'PUT',
@@ -3007,6 +3027,24 @@ export interface LibraryStats {
   disk_used_bytes: number;
 }
 
+export interface ZipExtractResult {
+  filename: string;
+  file_id: number;
+  folder_id: number | null;
+}
+
+export interface ZipExtractError {
+  filename: string;
+  error: string;
+}
+
+export interface ZipExtractResponse {
+  extracted: number;
+  folders_created: number;
+  files: ZipExtractResult[];
+  errors: ZipExtractError[];
+}
+
 // Library Queue types
 export interface AddToQueueResult {
   file_id: number;

+ 19 - 7
frontend/src/components/ConfirmModal.tsx

@@ -1,5 +1,5 @@
 import { useEffect } from 'react';
-import { AlertTriangle } from 'lucide-react';
+import { AlertTriangle, Loader2 } from 'lucide-react';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 
@@ -9,6 +9,8 @@ interface ConfirmModalProps {
   confirmText?: string;
   cancelText?: string;
   variant?: 'danger' | 'warning' | 'default';
+  isLoading?: boolean;
+  loadingText?: string;
   onConfirm: () => void;
   onCancel: () => void;
 }
@@ -19,17 +21,19 @@ export function ConfirmModal({
   confirmText = 'Confirm',
   cancelText = 'Cancel',
   variant = 'default',
+  isLoading = false,
+  loadingText,
   onConfirm,
   onCancel,
 }: ConfirmModalProps) {
-  // Close on Escape key
+  // Close on Escape key (but not while loading)
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onCancel();
+      if (e.key === 'Escape' && !isLoading) onCancel();
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onCancel]);
+  }, [onCancel, isLoading]);
 
   const variantStyles = {
     danger: {
@@ -51,7 +55,7 @@ export function ConfirmModal({
   return (
     <div
       className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={onCancel}
+      onClick={isLoading ? undefined : onCancel}
     >
       <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
         <CardContent className="p-6">
@@ -65,14 +69,22 @@ export function ConfirmModal({
             </div>
           </div>
           <div className="flex gap-3 mt-6">
-            <Button variant="secondary" onClick={onCancel} className="flex-1">
+            <Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
               {cancelText}
             </Button>
             <Button
               onClick={onConfirm}
               className={`flex-1 ${styles.button}`}
+              disabled={isLoading}
             >
-              {confirmText}
+              {isLoading ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  {loadingText || 'Processing...'}
+                </>
+              ) : (
+                confirmText
+              )}
             </Button>
           </div>
         </CardContent>

+ 86 - 31
frontend/src/pages/FileManagerPage.tsx

@@ -14,7 +14,6 @@ import {
   FileBox,
   Clock,
   HardDrive,
-  Copy,
   File,
   MoveRight,
   CheckSquare,
@@ -418,12 +417,15 @@ interface UploadFile {
   file: File;
   status: 'pending' | 'uploading' | 'success' | 'error';
   error?: string;
+  isZip?: boolean;
+  extractedCount?: number;
 }
 
 function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) {
   const [files, setFiles] = useState<UploadFile[]>([]);
   const [isDragging, setIsDragging] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
+  const [preserveZipStructure, setPreserveZipStructure] = useState(true);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -453,6 +455,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
     const uploadFiles: UploadFile[] = newFiles.map((file) => ({
       file,
       status: 'pending',
+      isZip: file.name.toLowerCase().endsWith('.zip'),
     }));
     setFiles((prev) => [...prev, ...uploadFiles]);
   };
@@ -461,6 +464,8 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
     setFiles((prev) => prev.filter((_, i) => i !== index));
   };
 
+  const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
+
   const handleUpload = async () => {
     if (files.length === 0) return;
 
@@ -474,10 +479,28 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
       );
 
       try {
-        await api.uploadLibraryFile(files[i].file, folderId);
-        setFiles((prev) =>
-          prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
-        );
+        if (files[i].isZip) {
+          // Extract ZIP file
+          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure);
+          setFiles((prev) =>
+            prev.map((f, idx) =>
+              idx === i
+                ? {
+                    ...f,
+                    status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
+                    extractedCount: result.extracted,
+                    error: result.errors.length > 0 ? `${result.errors.length} files failed` : undefined,
+                  }
+                : f
+            )
+          );
+        } else {
+          // Regular file upload
+          await api.uploadLibraryFile(files[i].file, folderId);
+          setFiles((prev) =>
+            prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
+          );
+        }
       } catch (err) {
         setFiles((prev) =>
           prev.map((f, idx) =>
@@ -528,16 +551,42 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
               {isDragging ? 'Drop files here' : 'Drag & drop files here'}
             </p>
             <p className="text-sm text-bambu-gray mt-1">or click to browse</p>
+            <p className="text-xs text-bambu-gray/70 mt-2">ZIP files will be automatically extracted</p>
           </div>
 
           <input
             ref={fileInputRef}
             type="file"
             multiple
+            accept="*/*,.zip"
             className="hidden"
             onChange={handleFileSelect}
           />
 
+          {/* ZIP Options */}
+          {hasZipFiles && (
+            <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-blue-300 font-medium">ZIP files detected</p>
+                  <p className="text-xs text-blue-300/70 mt-1">
+                    ZIP files will be extracted. Choose how to handle folder structure:
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={preserveZipStructure}
+                      onChange={(e) => setPreserveZipStructure(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">Preserve folder structure from ZIP</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
           {/* File List */}
           {files.length > 0 && (
             <div className="max-h-48 overflow-y-auto space-y-2">
@@ -546,11 +595,21 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
                   key={index}
                   className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
                 >
-                  <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                  {uploadFile.isZip ? (
+                    <ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
+                  ) : (
+                    <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                  )}
                   <div className="flex-1 min-w-0">
                     <p className="text-sm text-white truncate">{uploadFile.file.name}</p>
                     <p className="text-xs text-bambu-gray">
                       {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
+                      {uploadFile.isZip && uploadFile.status === 'pending' && (
+                        <span className="text-blue-400 ml-2">• Will be extracted</span>
+                      )}
+                      {uploadFile.extractedCount !== undefined && (
+                        <span className="text-green-400 ml-2">• {uploadFile.extractedCount} files extracted</span>
+                      )}
                     </p>
                   </div>
                   {uploadFile.status === 'pending' && (
@@ -787,13 +846,6 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
         ) : (
           <FileBox className="w-12 h-12 text-bambu-gray/30" />
         )}
-        {/* Duplicate badge */}
-        {file.duplicate_count > 0 && (
-          <div className="absolute top-2 left-2 flex items-center gap-1 bg-amber-500/90 text-white text-xs px-1.5 py-0.5 rounded">
-            <Copy className="w-3 h-3" />
-            {file.duplicate_count}
-          </div>
-        )}
         {/* File type badge */}
         <div className={`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${
           file.file_type === '3mf' ? 'bg-bambu-green/90 text-white'
@@ -1061,6 +1113,22 @@ export function FileManagerPage() {
     },
   });
 
+  const bulkDeleteMutation = useMutation({
+    mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []),
+    onSuccess: (_, fileIds) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      showToast(`Deleted ${fileIds.length} files`, 'success');
+      setSelectedFiles([]);
+      setDeleteConfirm(null);
+    },
+    onError: (error: Error) => {
+      setDeleteConfirm(null);
+      showToast(error.message, 'error');
+    },
+  });
+
   const moveFilesMutation = useMutation({
     mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) =>
       api.moveLibraryFiles(fileIds, folderId),
@@ -1191,21 +1259,12 @@ export function FileManagerPage() {
     } else if (deleteConfirm.type === 'folder') {
       deleteFolderMutation.mutate(deleteConfirm.id);
     } else if (deleteConfirm.type === 'bulk') {
-      // Bulk delete selected files
-      api.bulkDeleteLibrary(selectedFiles, []).then(() => {
-        queryClient.invalidateQueries({ queryKey: ['library-files'] });
-        queryClient.invalidateQueries({ queryKey: ['library-folders'] });
-        queryClient.invalidateQueries({ queryKey: ['library-stats'] });
-        showToast(`Deleted ${selectedFiles.length} files`, 'success');
-        setSelectedFiles([]);
-        setDeleteConfirm(null);
-      }).catch((err) => {
-        showToast(err.message, 'error');
-        setDeleteConfirm(null);
-      });
+      bulkDeleteMutation.mutate(selectedFiles);
     }
   };
 
+  const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending;
+
   const handleViewModeChange = (mode: 'grid' | 'list') => {
     setViewMode(mode);
     localStorage.setItem('library-view-mode', mode);
@@ -1609,12 +1668,6 @@ export function FileManagerPage() {
                       </div>
                       <div className="min-w-0">
                         <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
-                        {file.duplicate_count > 0 && (
-                          <div className="flex items-center gap-1 text-xs text-amber-400">
-                            <Copy className="w-3 h-3" />
-                            {file.duplicate_count} duplicate(s)
-                          </div>
-                        )}
                       </div>
                     </div>
                     {/* Type */}
@@ -1739,6 +1792,8 @@ export function FileManagerPage() {
           }
           confirmText="Delete"
           variant="danger"
+          isLoading={isDeleting}
+          loadingText="Deleting..."
           onConfirm={handleDeleteConfirm}
           onCancel={() => setDeleteConfirm(null)}
         />

Plik diff jest za duży
+ 0 - 0
static/assets/index-CVDQtTMh.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-C_p2QVEb.css


Plik diff jest za duży
+ 0 - 0
static/assets/index-DbOfgMyu.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-XGGhfA3J.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-C_p2QVEb.css">
+    <script type="module" crossorigin src="/assets/index-DbOfgMyu.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CVDQtTMh.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików