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
This commit is contained in:
maziggy
2026-01-24 08:13:20 +01:00
parent 721eab1f67
commit 8ba04dfa89
10 changed files with 367 additions and 75 deletions
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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:
@@ -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
View File
@@ -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,
@@ -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
View File
@@ -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
View File
@@ -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>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>