Selaa lähdekoodia

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 kuukautta sitten
vanhempi
sitoutus
8ba04dfa89

+ 9 - 0
CHANGELOG.md

@@ -6,8 +6,17 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### New Features
 - **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
 - **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
 

+ 2 - 0
README.md

@@ -83,6 +83,8 @@
 
 ### 📁 File Manager (Library)
 - 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
 - Rename files and folders via context menu
 - Print directly to any printer with full options

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

@@ -9,7 +9,7 @@ import shutil
 import uuid
 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 sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -746,8 +746,9 @@ async def upload_file(
 @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,
+    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),
 ):
     """Upload and extract a ZIP file to the library.
@@ -756,6 +757,7 @@ async def extract_zip_file(
         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
+        create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it
     """
     import tempfile
     import zipfile
@@ -783,6 +785,35 @@ async def extract_zip_file(
     folders_created = 0
     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:
         with zipfile.ZipFile(tmp_path, "r") as zf:
             # Filter out directories and hidden/system files
@@ -796,8 +827,8 @@ async def extract_zip_file(
 
             for zip_path in file_list:
                 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:
                         # Get directory path from ZIP
@@ -805,7 +836,7 @@ async def extract_zip_file(
                         if dir_path:
                             # Create folder structure
                             parts = dir_path.split("/")
-                            current_parent = folder_id
+                            current_parent = zip_folder_id
                             current_path = ""
 
                             for part in parts:

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

@@ -445,3 +445,37 @@ class TestLibraryZipExtractAPI:
         result = response.json()
         assert result["extracted"] == 1  # Only 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 (
     file: File,
     folderId?: number | null,
-    preserveStructure: boolean = true
+    preserveStructure: boolean = true,
+    createFolderFromZip: boolean = false
   ): 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));
+    params.set('create_folder_from_zip', String(createFolderFromZip));
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
       method: 'POST',
       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 [isPanning, setIsPanning] = useState(false);
   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
   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) => {
     if (isPanning && zoomLevel > 1) {
       const newX = e.clientX - panStart.x;
       const newY = e.clientY - panStart.y;
-      // Limit panning based on zoom level
-      const maxPan = (zoomLevel - 1) * 150;
+      const maxPan = getMaxPan();
       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);
   };
 
+  // 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 = () => {
     setZoomLevel(1);
     setPanOffset({ x: 0, y: 0 });
@@ -435,6 +533,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           onMouseMove={handleImageMouseMove}
           onMouseUp={handleImageMouseUp}
           onMouseLeave={handleImageMouseUp}
+          onTouchStart={handleTouchStart}
+          onTouchMove={handleTouchMove}
+          onTouchEnd={handleTouchEnd}
+          style={{ touchAction: 'none' }}
         >
           {streamLoading && !isReconnecting && (
             <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 [isPanning, setIsPanning] = useState(false);
   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 containerRef = useRef<HTMLDivElement>(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) => {
     if (isPanning && zoomLevel > 1) {
       const newX = e.clientX - panStart.x;
       const newY = e.clientY - panStart.y;
-      // Limit panning based on zoom level
-      const maxPan = (zoomLevel - 1) * 200;
+      const maxPan = getMaxPan();
       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);
   };
 
+  // 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 = () => {
     setZoomLevel(1);
     setPanOffset({ x: 0, y: 0 });
@@ -509,6 +607,10 @@ export function CameraPage() {
         onMouseMove={handleImageMouseMove}
         onMouseUp={handleImageMouseUp}
         onMouseLeave={handleImageMouseUp}
+        onTouchStart={handleTouchStart}
+        onTouchMove={handleTouchMove}
+        onTouchEnd={handleTouchEnd}
+        style={{ touchAction: 'none' }}
       >
         <div className="relative w-full h-full flex items-center justify-center">
           {(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 [isUploading, setIsUploading] = useState(false);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
+  const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -481,7 +482,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
       try {
         if (files[i].isZip) {
           // 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) =>
             prev.map((f, idx) =>
               idx === i
@@ -551,14 +552,14 @@ 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>
+            <p className="text-xs text-bambu-gray/70 mt-2">All file types supported. ZIP files will be extracted.</p>
           </div>
 
           <input
             ref={fileInputRef}
             type="file"
             multiple
-            accept="*/*,.zip"
+            accept="*"
             className="hidden"
             onChange={handleFileSelect}
           />
@@ -582,6 +583,15 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
                     />
                     <span className="text-sm text-white">Preserve folder structure from ZIP</span>
                   </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>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-C0ILfTrK.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <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">
   </head>
   <body>

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä