Просмотр исходного кода

Add ZIP folder creation option and improve camera zoom/pan (#121, #132)

File Manager improvements (#121):
- New option to create folder from ZIP filename (e.g., MyProject.zip → MyProject/)
- Upload modal now accepts all file types, not just ZIP files
- Updated drop zone text to clarify all files are supported
- Both options can be combined: create folder + preserve internal structure

Camera zoom/pan improvements (#132):
- Pan range now based on actual container size instead of fixed pixels
- Users can pan across the entire zoomed image at any zoom level
- Added pinch-to-zoom gesture for mobile (two fingers)
- Added single finger pan when zoomed in on touch devices
- Pan while pinching to reposition zoom focus
- Both EmbeddedCameraViewer and standalone CameraPage updated
maziggy 4 месяцев назад
Родитель
Сommit
8ba04dfa89

+ 9 - 0
CHANGELOG.md

@@ -6,8 +6,17 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ### New Features
 ### New Features
 - **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
 - **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
+- **Create Folder from ZIP** - New option in File Manager upload to automatically create a folder named after the ZIP file (Issue #121)
+
 ### Fixes
 ### Fixes
 - **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
 - **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
+- **HA Energy Sensors Not Detected** - Home Assistant energy sensors with lowercase units (w, kwh) are now properly detected; unit matching is now case-insensitive (Issue #119)
+- **File Manager Upload** - Upload modal now accepts all file types, not just ZIP files
+- **Camera Zoom & Pan Improvements** - Enhanced camera viewer zoom/pan functionality (Issue #132):
+  - Pan range now based on actual container size, allowing full navigation of zoomed image
+  - Added pinch-to-zoom support for mobile/touch devices
+  - Added touch-based panning when zoomed in
+  - Both embedded camera viewer and standalone camera page updated
 
 
 ## [0.1.6b11] - 2026-01-22
 ## [0.1.6b11] - 2026-01-22
 
 

+ 2 - 0
README.md

@@ -83,6 +83,8 @@
 
 
 ### 📁 File Manager (Library)
 ### 📁 File Manager (Library)
 - Upload and organize sliced files (3MF, gcode)
 - Upload and organize sliced files (3MF, gcode)
+- ZIP file extraction with folder structure preservation
+- Option to create folder from ZIP filename
 - Folder structure with drag-and-drop
 - Folder structure with drag-and-drop
 - Rename files and folders via context menu
 - Rename files and folders via context menu
 - Print directly to any printer with full options
 - Print directly to any printer with full options

+ 37 - 6
backend/app/api/routes/library.py

@@ -9,7 +9,7 @@ import shutil
 import uuid
 import uuid
 from pathlib import Path
 from pathlib import Path
 
 
-from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from fastapi.responses import FileResponse as FastAPIFileResponse
 from sqlalchemy import func, select
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -746,8 +746,9 @@ async def upload_file(
 @router.post("/files/extract-zip", response_model=ZipExtractResponse)
 @router.post("/files/extract-zip", response_model=ZipExtractResponse)
 async def extract_zip_file(
 async def extract_zip_file(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
-    folder_id: int | None = None,
-    preserve_structure: bool = True,
+    folder_id: int | None = Query(default=None),
+    preserve_structure: bool = Query(default=True),
+    create_folder_from_zip: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Upload and extract a ZIP file to the library.
     """Upload and extract a ZIP file to the library.
@@ -756,6 +757,7 @@ async def extract_zip_file(
         file: The ZIP file to extract
         file: The ZIP file to extract
         folder_id: Target folder ID (None = root)
         folder_id: Target folder ID (None = root)
         preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
         preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
+        create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it
     """
     """
     import tempfile
     import tempfile
     import zipfile
     import zipfile
@@ -783,6 +785,35 @@ async def extract_zip_file(
     folders_created = 0
     folders_created = 0
     folder_cache: dict[str, int] = {}  # path -> folder_id
     folder_cache: dict[str, int] = {}  # path -> folder_id
 
 
+    # If create_folder_from_zip is True, create a folder named after the ZIP file
+    zip_folder_id = folder_id
+    logger.info(
+        f"ZIP extraction: create_folder_from_zip={create_folder_from_zip}, folder_id={folder_id}, filename={file.filename}"
+    )
+    if create_folder_from_zip and file.filename:
+        # Remove .zip extension to get folder name
+        zip_folder_name = file.filename[:-4] if file.filename.lower().endswith(".zip") else file.filename
+        # Check if folder already exists
+        existing = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == zip_folder_name,
+                LibraryFolder.parent_id == folder_id if folder_id else LibraryFolder.parent_id.is_(None),
+            )
+        )
+        existing_folder = existing.scalar_one_or_none()
+        if existing_folder:
+            zip_folder_id = existing_folder.id
+            logger.info(f"Reusing existing folder '{zip_folder_name}' with id={zip_folder_id}")
+        else:
+            # Create folder
+            new_folder = LibraryFolder(name=zip_folder_name, parent_id=folder_id)
+            db.add(new_folder)
+            await db.flush()
+            await db.commit()  # Commit folder creation immediately
+            zip_folder_id = new_folder.id
+            folders_created += 1
+            logger.info(f"Created new folder '{zip_folder_name}' with id={zip_folder_id}")
+
     try:
     try:
         with zipfile.ZipFile(tmp_path, "r") as zf:
         with zipfile.ZipFile(tmp_path, "r") as zf:
             # Filter out directories and hidden/system files
             # Filter out directories and hidden/system files
@@ -796,8 +827,8 @@ async def extract_zip_file(
 
 
             for zip_path in file_list:
             for zip_path in file_list:
                 try:
                 try:
-                    # Determine target folder
-                    target_folder_id = folder_id
+                    # Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)
+                    target_folder_id = zip_folder_id
 
 
                     if preserve_structure:
                     if preserve_structure:
                         # Get directory path from ZIP
                         # Get directory path from ZIP
@@ -805,7 +836,7 @@ async def extract_zip_file(
                         if dir_path:
                         if dir_path:
                             # Create folder structure
                             # Create folder structure
                             parts = dir_path.split("/")
                             parts = dir_path.split("/")
-                            current_parent = folder_id
+                            current_parent = zip_folder_id
                             current_path = ""
                             current_path = ""
 
 
                             for part in parts:
                             for part in parts:

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

@@ -445,3 +445,37 @@ class TestLibraryZipExtractAPI:
         result = response.json()
         result = response.json()
         assert result["extracted"] == 1  # Only real_file.txt
         assert result["extracted"] == 1  # Only real_file.txt
         assert result["files"][0]["filename"] == "real_file.txt"
         assert result["files"][0]["filename"] == "real_file.txt"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_create_folder_from_zip(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction creates a folder from the ZIP filename."""
+        import io
+        import zipfile
+
+        # Create a ZIP file with some files
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("file1.txt", "Content 1")
+            zf.writestr("file2.txt", "Content 2")
+        zip_buffer.seek(0)
+
+        files = {"file": ("MyProject.zip", zip_buffer.read(), "application/zip")}
+        params = {"create_folder_from_zip": "true", "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"] == 1  # MyProject folder created
+
+        # Verify the files are in a folder
+        assert result["files"][0]["folder_id"] is not None
+        assert result["files"][1]["folder_id"] is not None
+        # Both files should be in the same folder
+        assert result["files"][0]["folder_id"] == result["files"][1]["folder_id"]
+
+        # Verify the folder was created with the right name
+        folder_response = await async_client.get(f"/api/v1/library/folders/{result['files'][0]['folder_id']}")
+        assert folder_response.status_code == 200
+        folder = folder_response.json()
+        assert folder["name"] == "MyProject"

+ 3 - 1
frontend/src/api/client.ts

@@ -2725,13 +2725,15 @@ export const api = {
   extractZipFile: async (
   extractZipFile: async (
     file: File,
     file: File,
     folderId?: number | null,
     folderId?: number | null,
-    preserveStructure: boolean = true
+    preserveStructure: boolean = true,
+    createFolderFromZip: boolean = false
   ): Promise<ZipExtractResponse> => {
   ): Promise<ZipExtractResponse> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (folderId) params.set('folder_id', String(folderId));
     if (folderId) params.set('folder_id', String(folderId));
     params.set('preserve_structure', String(preserveStructure));
     params.set('preserve_structure', String(preserveStructure));
+    params.set('create_folder_from_zip', String(createFolderFromZip));
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,

+ 106 - 4
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -70,6 +70,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
   const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
   const [isPanning, setIsPanning] = useState(false);
   const [isPanning, setIsPanning] = useState(false);
   const [panStart, setPanStart] = useState({ x: 0, y: 0 });
   const [panStart, setPanStart] = useState({ x: 0, y: 0 });
+  const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
+  const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);
 
 
   // Stream state
   // Stream state
   const [streamError, setStreamError] = useState(false);
   const [streamError, setStreamError] = useState(false);
@@ -260,15 +262,26 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     }
     }
   };
   };
 
 
+  // Calculate max pan based on container size and zoom level
+  const getMaxPan = useCallback(() => {
+    if (!containerRef.current || !imgRef.current) {
+      return { x: 200, y: 150 };
+    }
+    const container = containerRef.current.getBoundingClientRect();
+    // Allow panning up to half the zoomed overflow in each direction
+    const maxX = (container.width * (zoomLevel - 1)) / 2;
+    const maxY = (container.height * (zoomLevel - 1)) / 2;
+    return { x: Math.max(50, maxX), y: Math.max(50, maxY) };
+  }, [zoomLevel]);
+
   const handleImageMouseMove = (e: React.MouseEvent) => {
   const handleImageMouseMove = (e: React.MouseEvent) => {
     if (isPanning && zoomLevel > 1) {
     if (isPanning && zoomLevel > 1) {
       const newX = e.clientX - panStart.x;
       const newX = e.clientX - panStart.x;
       const newY = e.clientY - panStart.y;
       const newY = e.clientY - panStart.y;
-      // Limit panning based on zoom level
-      const maxPan = (zoomLevel - 1) * 150;
+      const maxPan = getMaxPan();
       setPanOffset({
       setPanOffset({
-        x: Math.max(-maxPan, Math.min(maxPan, newX)),
-        y: Math.max(-maxPan, Math.min(maxPan, newY)),
+        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
+        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
       });
       });
     }
     }
   };
   };
@@ -277,6 +290,91 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     setIsPanning(false);
     setIsPanning(false);
   };
   };
 
 
+  // Touch event handlers for mobile
+  const getTouchDistance = (touches: React.TouchList) => {
+    if (touches.length < 2) return 0;
+    const dx = touches[0].clientX - touches[1].clientX;
+    const dy = touches[0].clientY - touches[1].clientY;
+    return Math.sqrt(dx * dx + dy * dy);
+  };
+
+  const getTouchCenter = (touches: React.TouchList) => {
+    if (touches.length < 2) {
+      return { x: touches[0].clientX, y: touches[0].clientY };
+    }
+    return {
+      x: (touches[0].clientX + touches[1].clientX) / 2,
+      y: (touches[0].clientY + touches[1].clientY) / 2,
+    };
+  };
+
+  const handleTouchStart = (e: React.TouchEvent) => {
+    if (e.touches.length === 2) {
+      // Pinch gesture start
+      e.preventDefault();
+      setLastTouchDistance(getTouchDistance(e.touches));
+      setLastTouchCenter(getTouchCenter(e.touches));
+    } else if (e.touches.length === 1 && zoomLevel > 1) {
+      // Single touch pan start
+      e.preventDefault();
+      setIsPanning(true);
+      setPanStart({
+        x: e.touches[0].clientX - panOffset.x,
+        y: e.touches[0].clientY - panOffset.y,
+      });
+    }
+  };
+
+  const handleTouchMove = (e: React.TouchEvent) => {
+    if (e.touches.length === 2 && lastTouchDistance !== null) {
+      // Pinch gesture
+      e.preventDefault();
+      const newDistance = getTouchDistance(e.touches);
+      const scale = newDistance / lastTouchDistance;
+
+      setZoomLevel(prev => {
+        const newZoom = Math.max(1, Math.min(4, prev * scale));
+        if (newZoom === 1) {
+          setPanOffset({ x: 0, y: 0 });
+        }
+        return newZoom;
+      });
+
+      setLastTouchDistance(newDistance);
+
+      // Also handle pan during pinch
+      const newCenter = getTouchCenter(e.touches);
+      if (lastTouchCenter) {
+        const maxPan = getMaxPan();
+        setPanOffset(prev => ({
+          x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),
+          y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),
+        }));
+      }
+      setLastTouchCenter(newCenter);
+    } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {
+      // Single touch pan
+      e.preventDefault();
+      const newX = e.touches[0].clientX - panStart.x;
+      const newY = e.touches[0].clientY - panStart.y;
+      const maxPan = getMaxPan();
+      setPanOffset({
+        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
+        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
+      });
+    }
+  };
+
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (e.touches.length < 2) {
+      setLastTouchDistance(null);
+      setLastTouchCenter(null);
+    }
+    if (e.touches.length === 0) {
+      setIsPanning(false);
+    }
+  };
+
   const resetZoom = () => {
   const resetZoom = () => {
     setZoomLevel(1);
     setZoomLevel(1);
     setPanOffset({ x: 0, y: 0 });
     setPanOffset({ x: 0, y: 0 });
@@ -435,6 +533,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           onMouseMove={handleImageMouseMove}
           onMouseMove={handleImageMouseMove}
           onMouseUp={handleImageMouseUp}
           onMouseUp={handleImageMouseUp}
           onMouseLeave={handleImageMouseUp}
           onMouseLeave={handleImageMouseUp}
+          onTouchStart={handleTouchStart}
+          onTouchMove={handleTouchMove}
+          onTouchEnd={handleTouchEnd}
+          style={{ touchAction: 'none' }}
         >
         >
           {streamLoading && !isReconnecting && (
           {streamLoading && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">

+ 106 - 4
frontend/src/pages/CameraPage.tsx

@@ -26,6 +26,8 @@ export function CameraPage() {
   const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
   const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
   const [isPanning, setIsPanning] = useState(false);
   const [isPanning, setIsPanning] = useState(false);
   const [panStart, setPanStart] = useState({ x: 0, y: 0 });
   const [panStart, setPanStart] = useState({ x: 0, y: 0 });
+  const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
+  const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);
   const imgRef = useRef<HTMLImageElement>(null);
   const imgRef = useRef<HTMLImageElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -408,15 +410,26 @@ export function CameraPage() {
     }
     }
   };
   };
 
 
+  // Calculate max pan based on container size and zoom level
+  const getMaxPan = useCallback(() => {
+    if (!containerRef.current) {
+      return { x: 300, y: 200 };
+    }
+    const container = containerRef.current.getBoundingClientRect();
+    // Allow panning up to half the zoomed overflow in each direction
+    const maxX = (container.width * (zoomLevel - 1)) / 2;
+    const maxY = (container.height * (zoomLevel - 1)) / 2;
+    return { x: Math.max(50, maxX), y: Math.max(50, maxY) };
+  }, [zoomLevel]);
+
   const handleImageMouseMove = (e: React.MouseEvent) => {
   const handleImageMouseMove = (e: React.MouseEvent) => {
     if (isPanning && zoomLevel > 1) {
     if (isPanning && zoomLevel > 1) {
       const newX = e.clientX - panStart.x;
       const newX = e.clientX - panStart.x;
       const newY = e.clientY - panStart.y;
       const newY = e.clientY - panStart.y;
-      // Limit panning based on zoom level
-      const maxPan = (zoomLevel - 1) * 200;
+      const maxPan = getMaxPan();
       setPanOffset({
       setPanOffset({
-        x: Math.max(-maxPan, Math.min(maxPan, newX)),
-        y: Math.max(-maxPan, Math.min(maxPan, newY)),
+        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
+        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
       });
       });
     }
     }
   };
   };
@@ -425,6 +438,91 @@ export function CameraPage() {
     setIsPanning(false);
     setIsPanning(false);
   };
   };
 
 
+  // Touch event handlers for mobile
+  const getTouchDistance = (touches: React.TouchList) => {
+    if (touches.length < 2) return 0;
+    const dx = touches[0].clientX - touches[1].clientX;
+    const dy = touches[0].clientY - touches[1].clientY;
+    return Math.sqrt(dx * dx + dy * dy);
+  };
+
+  const getTouchCenter = (touches: React.TouchList) => {
+    if (touches.length < 2) {
+      return { x: touches[0].clientX, y: touches[0].clientY };
+    }
+    return {
+      x: (touches[0].clientX + touches[1].clientX) / 2,
+      y: (touches[0].clientY + touches[1].clientY) / 2,
+    };
+  };
+
+  const handleTouchStart = (e: React.TouchEvent) => {
+    if (e.touches.length === 2) {
+      // Pinch gesture start
+      e.preventDefault();
+      setLastTouchDistance(getTouchDistance(e.touches));
+      setLastTouchCenter(getTouchCenter(e.touches));
+    } else if (e.touches.length === 1 && zoomLevel > 1) {
+      // Single touch pan start
+      e.preventDefault();
+      setIsPanning(true);
+      setPanStart({
+        x: e.touches[0].clientX - panOffset.x,
+        y: e.touches[0].clientY - panOffset.y,
+      });
+    }
+  };
+
+  const handleTouchMove = (e: React.TouchEvent) => {
+    if (e.touches.length === 2 && lastTouchDistance !== null) {
+      // Pinch gesture
+      e.preventDefault();
+      const newDistance = getTouchDistance(e.touches);
+      const scale = newDistance / lastTouchDistance;
+
+      setZoomLevel(prev => {
+        const newZoom = Math.max(1, Math.min(4, prev * scale));
+        if (newZoom === 1) {
+          setPanOffset({ x: 0, y: 0 });
+        }
+        return newZoom;
+      });
+
+      setLastTouchDistance(newDistance);
+
+      // Also handle pan during pinch
+      const newCenter = getTouchCenter(e.touches);
+      if (lastTouchCenter) {
+        const maxPan = getMaxPan();
+        setPanOffset(prev => ({
+          x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),
+          y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),
+        }));
+      }
+      setLastTouchCenter(newCenter);
+    } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {
+      // Single touch pan
+      e.preventDefault();
+      const newX = e.touches[0].clientX - panStart.x;
+      const newY = e.touches[0].clientY - panStart.y;
+      const maxPan = getMaxPan();
+      setPanOffset({
+        x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
+        y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
+      });
+    }
+  };
+
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    if (e.touches.length < 2) {
+      setLastTouchDistance(null);
+      setLastTouchCenter(null);
+    }
+    if (e.touches.length === 0) {
+      setIsPanning(false);
+    }
+  };
+
   const resetZoom = () => {
   const resetZoom = () => {
     setZoomLevel(1);
     setZoomLevel(1);
     setPanOffset({ x: 0, y: 0 });
     setPanOffset({ x: 0, y: 0 });
@@ -509,6 +607,10 @@ export function CameraPage() {
         onMouseMove={handleImageMouseMove}
         onMouseMove={handleImageMouseMove}
         onMouseUp={handleImageMouseUp}
         onMouseUp={handleImageMouseUp}
         onMouseLeave={handleImageMouseUp}
         onMouseLeave={handleImageMouseUp}
+        onTouchStart={handleTouchStart}
+        onTouchMove={handleTouchMove}
+        onTouchEnd={handleTouchEnd}
+        style={{ touchAction: 'none' }}
       >
       >
         <div className="relative w-full h-full flex items-center justify-center">
         <div className="relative w-full h-full flex items-center justify-center">
           {(streamLoading || transitioning) && !isReconnecting && (
           {(streamLoading || transitioning) && !isReconnecting && (

+ 13 - 3
frontend/src/pages/FileManagerPage.tsx

@@ -426,6 +426,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
   const [isDragging, setIsDragging] = useState(false);
   const [isDragging, setIsDragging] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
+  const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
 
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -481,7 +482,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
       try {
       try {
         if (files[i].isZip) {
         if (files[i].isZip) {
           // Extract ZIP file
           // Extract ZIP file
-          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure);
+          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip);
           setFiles((prev) =>
           setFiles((prev) =>
             prev.map((f, idx) =>
             prev.map((f, idx) =>
               idx === i
               idx === i
@@ -551,14 +552,14 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
               {isDragging ? 'Drop files here' : 'Drag & drop files here'}
               {isDragging ? 'Drop files here' : 'Drag & drop files here'}
             </p>
             </p>
             <p className="text-sm text-bambu-gray mt-1">or click to browse</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>
+            <p className="text-xs text-bambu-gray/70 mt-2">All file types supported. ZIP files will be extracted.</p>
           </div>
           </div>
 
 
           <input
           <input
             ref={fileInputRef}
             ref={fileInputRef}
             type="file"
             type="file"
             multiple
             multiple
-            accept="*/*,.zip"
+            accept="*"
             className="hidden"
             className="hidden"
             onChange={handleFileSelect}
             onChange={handleFileSelect}
           />
           />
@@ -582,6 +583,15 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
                     />
                     />
                     <span className="text-sm text-white">Preserve folder structure from ZIP</span>
                     <span className="text-sm text-white">Preserve folder structure from ZIP</span>
                   </label>
                   </label>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={createFolderFromZip}
+                      onChange={(e) => setCreateFolderFromZip(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">Create folder from ZIP filename</span>
+                  </label>
                 </div>
                 </div>
               </div>
               </div>
             </div>
             </div>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C0ILfTrK.js


+ 1 - 1
static/index.html

@@ -23,7 +23,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-oIN8PllK.js"></script>
+    <script type="module" crossorigin src="/assets/index-C0ILfTrK.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DVKqcow3.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DVKqcow3.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов