mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user