mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Multiple fixes and ZIP file support in File Manager
Features: - ZIP file support in File Manager (Issue #121): Upload ZIP files to automatically extract contents with option to preserve folder structure - Delete operation loading indicator: Shows progress during folder/file deletion operations Fixed: - Print time stats using actual elapsed time instead of slicer estimates (Issue #137) - Skip objects modal overflow with scrollable list for many objects (Issue #134) Removed: - Duplicate badge feature from File Manager (per user request) Tests: - Added ZIP extraction backend tests (5 tests) - Added ConfirmModal loading state frontend tests (5 tests)
This commit is contained in:
@@ -28,6 +28,11 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Photo is captured before notification is sent (ensures image is available)
|
||||
- New "External URL" setting in Settings → Network (auto-detects from browser)
|
||||
- Full URL constructed for external notification services (Telegram, Email, Discord, etc.)
|
||||
- **ZIP File Support in File Manager** - Upload and extract ZIP files directly in the library (Issue #121):
|
||||
- Drop or select ZIP files to automatically extract contents
|
||||
- Option to preserve folder structure from ZIP or extract flat
|
||||
- Extracts thumbnails and metadata from 3MF/gcode files inside ZIP
|
||||
- Progress indicator shows number of files extracted
|
||||
|
||||
### Fixed
|
||||
- **Print time stats using slicer estimates** - Quick Stats "Print Time" now uses actual elapsed time (`completed_at - started_at`) instead of slicer estimates; cancelled prints only count time actually printed (Issue #137)
|
||||
|
||||
@@ -38,6 +38,9 @@ from backend.app.schemas.library import (
|
||||
FolderResponse,
|
||||
FolderTreeItem,
|
||||
FolderUpdate,
|
||||
ZipExtractError,
|
||||
ZipExtractResponse,
|
||||
ZipExtractResult,
|
||||
)
|
||||
from backend.app.services.archive import ArchiveService, ThreeMFParser
|
||||
|
||||
@@ -740,6 +743,226 @@ async def upload_file(
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/files/extract-zip", response_model=ZipExtractResponse)
|
||||
async def extract_zip_file(
|
||||
file: UploadFile = File(...),
|
||||
folder_id: int | None = None,
|
||||
preserve_structure: bool = True,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload and extract a ZIP file to the library.
|
||||
|
||||
Args:
|
||||
file: The ZIP file to extract
|
||||
folder_id: Target folder ID (None = root)
|
||||
preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
|
||||
"""
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
if not file.filename or not file.filename.lower().endswith(".zip"):
|
||||
raise HTTPException(status_code=400, detail="Only ZIP files are supported")
|
||||
|
||||
# Verify target folder exists if specified
|
||||
if folder_id is not None:
|
||||
folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
||||
if not folder_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Target folder not found")
|
||||
|
||||
# Save ZIP to temp file
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}")
|
||||
|
||||
extracted_files: list[ZipExtractResult] = []
|
||||
errors: list[ZipExtractError] = []
|
||||
folders_created = 0
|
||||
folder_cache: dict[str, int] = {} # path -> folder_id
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "r") as zf:
|
||||
# Filter out directories and hidden/system files
|
||||
file_list = [
|
||||
name
|
||||
for name in zf.namelist()
|
||||
if not name.endswith("/")
|
||||
and not name.startswith("__MACOSX")
|
||||
and not os.path.basename(name).startswith(".")
|
||||
]
|
||||
|
||||
for zip_path in file_list:
|
||||
try:
|
||||
# Determine target folder
|
||||
target_folder_id = folder_id
|
||||
|
||||
if preserve_structure:
|
||||
# Get directory path from ZIP
|
||||
dir_path = os.path.dirname(zip_path)
|
||||
if dir_path:
|
||||
# Create folder structure
|
||||
parts = dir_path.split("/")
|
||||
current_parent = folder_id
|
||||
current_path = ""
|
||||
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
current_path = f"{current_path}/{part}" if current_path else part
|
||||
|
||||
if current_path in folder_cache:
|
||||
current_parent = folder_cache[current_path]
|
||||
else:
|
||||
# Check if folder exists
|
||||
existing = await db.execute(
|
||||
select(LibraryFolder).where(
|
||||
LibraryFolder.name == part,
|
||||
LibraryFolder.parent_id == current_parent
|
||||
if current_parent
|
||||
else LibraryFolder.parent_id.is_(None),
|
||||
)
|
||||
)
|
||||
existing_folder = existing.scalar_one_or_none()
|
||||
|
||||
if existing_folder:
|
||||
current_parent = existing_folder.id
|
||||
else:
|
||||
# Create folder
|
||||
new_folder = LibraryFolder(name=part, parent_id=current_parent)
|
||||
db.add(new_folder)
|
||||
await db.flush()
|
||||
current_parent = new_folder.id
|
||||
folders_created += 1
|
||||
|
||||
folder_cache[current_path] = current_parent
|
||||
|
||||
target_folder_id = current_parent
|
||||
|
||||
# Extract file
|
||||
filename = os.path.basename(zip_path)
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
file_type = ext[1:] if ext else "unknown"
|
||||
|
||||
# Generate unique filename for storage
|
||||
unique_filename = f"{uuid.uuid4().hex}{ext}"
|
||||
file_path = get_library_files_dir() / unique_filename
|
||||
|
||||
# Extract and save file
|
||||
file_content = zf.read(zip_path)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_content)
|
||||
|
||||
# Calculate hash
|
||||
file_hash = calculate_file_hash(file_path)
|
||||
|
||||
# Extract metadata and thumbnail for 3MF files
|
||||
metadata = {}
|
||||
thumbnail_path = None
|
||||
thumbnails_dir = get_library_thumbnails_dir()
|
||||
|
||||
if ext == ".3mf":
|
||||
try:
|
||||
parser = ThreeMFParser(str(file_path))
|
||||
raw_metadata = parser.parse()
|
||||
|
||||
thumbnail_data = raw_metadata.get("_thumbnail_data")
|
||||
thumbnail_ext = raw_metadata.get("_thumbnail_ext", ".png")
|
||||
|
||||
if thumbnail_data:
|
||||
thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}"
|
||||
thumb_path = thumbnails_dir / thumb_filename
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(thumbnail_data)
|
||||
thumbnail_path = str(thumb_path)
|
||||
|
||||
def clean_metadata(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
k: clean_metadata(v)
|
||||
for k, v in obj.items()
|
||||
if not isinstance(v, bytes) and k not in ("_thumbnail_data", "_thumbnail_ext")
|
||||
}
|
||||
elif isinstance(obj, list):
|
||||
return [clean_metadata(i) for i in obj if not isinstance(i, bytes)]
|
||||
elif isinstance(obj, bytes):
|
||||
return None
|
||||
return obj
|
||||
|
||||
metadata = clean_metadata(raw_metadata)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse 3MF from ZIP: {e}")
|
||||
|
||||
elif ext == ".gcode":
|
||||
try:
|
||||
thumbnail_data = extract_gcode_thumbnail(file_path)
|
||||
if thumbnail_data:
|
||||
thumb_filename = f"{uuid.uuid4().hex}.png"
|
||||
thumb_path = thumbnails_dir / thumb_filename
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(thumbnail_data)
|
||||
thumbnail_path = str(thumb_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract gcode thumbnail from ZIP: {e}")
|
||||
|
||||
elif ext.lower() in IMAGE_EXTENSIONS:
|
||||
thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
|
||||
|
||||
# Create database entry
|
||||
library_file = LibraryFile(
|
||||
folder_id=target_folder_id,
|
||||
filename=filename,
|
||||
file_path=str(file_path),
|
||||
file_type=file_type,
|
||||
file_size=len(file_content),
|
||||
file_hash=file_hash,
|
||||
thumbnail_path=thumbnail_path,
|
||||
file_metadata=metadata if metadata else None,
|
||||
)
|
||||
db.add(library_file)
|
||||
await db.flush()
|
||||
await db.refresh(library_file)
|
||||
|
||||
extracted_files.append(
|
||||
ZipExtractResult(
|
||||
filename=filename,
|
||||
file_id=library_file.id,
|
||||
folder_id=target_folder_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Commit after each file to release database lock
|
||||
# This prevents long-running transactions from blocking other requests
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract {zip_path}: {e}")
|
||||
errors.append(ZipExtractError(filename=os.path.basename(zip_path), error=str(e)))
|
||||
# Rollback the failed file but continue with others
|
||||
await db.rollback()
|
||||
|
||||
return ZipExtractResponse(
|
||||
extracted=len(extracted_files),
|
||||
folders_created=folders_created,
|
||||
files=extracted_files,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
|
||||
except Exception as e:
|
||||
logger.error(f"ZIP extraction failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============ Queue Operations ============
|
||||
# NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts
|
||||
|
||||
|
||||
@@ -235,3 +235,30 @@ class AddToQueueResponse(BaseModel):
|
||||
|
||||
added: list[AddToQueueResult]
|
||||
errors: list[AddToQueueError]
|
||||
|
||||
|
||||
# ============ ZIP Extraction ============
|
||||
|
||||
|
||||
class ZipExtractResult(BaseModel):
|
||||
"""Result for a single file extracted from ZIP."""
|
||||
|
||||
filename: str
|
||||
file_id: int
|
||||
folder_id: int | None = None
|
||||
|
||||
|
||||
class ZipExtractError(BaseModel):
|
||||
"""Error for a file that couldn't be extracted."""
|
||||
|
||||
filename: str
|
||||
error: str
|
||||
|
||||
|
||||
class ZipExtractResponse(BaseModel):
|
||||
"""Schema for ZIP extraction response."""
|
||||
|
||||
extracted: int
|
||||
folders_created: int
|
||||
files: list[ZipExtractResult]
|
||||
errors: list[ZipExtractError]
|
||||
|
||||
@@ -342,3 +342,106 @@ class TestLibraryAddToQueueAPI:
|
||||
assert len(result["added"]) == 0
|
||||
assert len(result["errors"]) == 1
|
||||
assert "sliced" in result["errors"][0]["error"].lower()
|
||||
|
||||
|
||||
class TestLibraryZipExtractAPI:
|
||||
"""Integration tests for ZIP extraction endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
|
||||
"""Verify non-ZIP files are rejected."""
|
||||
# Create a fake file that's not a ZIP
|
||||
files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
|
||||
response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
|
||||
assert response.status_code == 400
|
||||
assert "ZIP" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
|
||||
"""Verify basic ZIP extraction works."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
# Create a simple ZIP file in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("test1.txt", "Content of file 1")
|
||||
zf.writestr("test2.txt", "Content of file 2")
|
||||
zip_buffer.seek(0)
|
||||
|
||||
files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
|
||||
response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["extracted"] == 2
|
||||
assert len(result["files"]) == 2
|
||||
assert len(result["errors"]) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
|
||||
"""Verify ZIP extraction preserves folder structure."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
# Create a ZIP file with folder structure
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("folder1/file1.txt", "Content 1")
|
||||
zf.writestr("folder1/subfolder/file2.txt", "Content 2")
|
||||
zf.writestr("folder2/file3.txt", "Content 3")
|
||||
zip_buffer.seek(0)
|
||||
|
||||
files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
|
||||
params = {"preserve_structure": "true"}
|
||||
response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["extracted"] == 3
|
||||
assert result["folders_created"] >= 3 # folder1, folder1/subfolder, folder2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
|
||||
"""Verify ZIP extraction can extract flat (no folders)."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
# Create a ZIP file with folder structure
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("folder/file1.txt", "Content 1")
|
||||
zf.writestr("folder/file2.txt", "Content 2")
|
||||
zip_buffer.seek(0)
|
||||
|
||||
files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
|
||||
params = {"preserve_structure": "false"}
|
||||
response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["extracted"] == 2
|
||||
assert result["folders_created"] == 0 # No folders created when flat
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
|
||||
"""Verify ZIP extraction skips __MACOSX and hidden files."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
# Create a ZIP file with macOS junk files
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("real_file.txt", "Real content")
|
||||
zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
|
||||
zf.writestr(".hidden_file", "Hidden content")
|
||||
zip_buffer.seek(0)
|
||||
|
||||
files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
|
||||
response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["extracted"] == 1 # Only real_file.txt
|
||||
assert result["files"][0]["filename"] == "real_file.txt"
|
||||
|
||||
@@ -122,4 +122,46 @@ describe('ConfirmModal', () => {
|
||||
expect(screen.getByText('Confirm Action')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows loading text when isLoading is true', () => {
|
||||
render(<ConfirmModal {...defaultProps} isLoading={true} loadingText="Deleting..." />);
|
||||
expect(screen.getByText('Deleting...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows default loading text when loadingText not provided', () => {
|
||||
render(<ConfirmModal {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByText('Processing...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables buttons when loading', () => {
|
||||
render(<ConfirmModal {...defaultProps} isLoading={true} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach(button => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call onCancel when clicking backdrop while loading', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
const { container } = render(
|
||||
<ConfirmModal {...defaultProps} onCancel={onCancel} isLoading={true} />
|
||||
);
|
||||
|
||||
const backdrop = container.querySelector('.fixed');
|
||||
if (backdrop) {
|
||||
await user.click(backdrop);
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not call onCancel on Escape key while loading', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<ConfirmModal {...defaultProps} onCancel={onCancel} isLoading={true} />);
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2720,6 +2720,26 @@ export const api = {
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
extractZipFile: async (
|
||||
file: File,
|
||||
folderId?: number | null,
|
||||
preserveStructure: boolean = true
|
||||
): Promise<ZipExtractResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const params = new URLSearchParams();
|
||||
if (folderId) params.set('folder_id', String(folderId));
|
||||
params.set('preserve_structure', String(preserveStructure));
|
||||
const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
updateLibraryFile: (id: number, data: LibraryFileUpdate) =>
|
||||
request<LibraryFile>(`/library/files/${id}`, {
|
||||
method: 'PUT',
|
||||
@@ -3007,6 +3027,24 @@ export interface LibraryStats {
|
||||
disk_used_bytes: number;
|
||||
}
|
||||
|
||||
export interface ZipExtractResult {
|
||||
filename: string;
|
||||
file_id: number;
|
||||
folder_id: number | null;
|
||||
}
|
||||
|
||||
export interface ZipExtractError {
|
||||
filename: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface ZipExtractResponse {
|
||||
extracted: number;
|
||||
folders_created: number;
|
||||
files: ZipExtractResult[];
|
||||
errors: ZipExtractError[];
|
||||
}
|
||||
|
||||
// Library Queue types
|
||||
export interface AddToQueueResult {
|
||||
file_id: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
|
||||
@@ -9,6 +9,8 @@ interface ConfirmModalProps {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'default';
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -19,17 +21,19 @@ export function ConfirmModal({
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
variant = 'default',
|
||||
isLoading = false,
|
||||
loadingText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
// Close on Escape key
|
||||
// Close on Escape key (but not while loading)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if (e.key === 'Escape' && !isLoading) onCancel();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onCancel]);
|
||||
}, [onCancel, isLoading]);
|
||||
|
||||
const variantStyles = {
|
||||
danger: {
|
||||
@@ -51,7 +55,7 @@ export function ConfirmModal({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onCancel}
|
||||
onClick={isLoading ? undefined : onCancel}
|
||||
>
|
||||
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<CardContent className="p-6">
|
||||
@@ -65,14 +69,22 @@ export function ConfirmModal({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button variant="secondary" onClick={onCancel} className="flex-1">
|
||||
<Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 ${styles.button}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{confirmText}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{loadingText || 'Processing...'}
|
||||
</>
|
||||
) : (
|
||||
confirmText
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
FileBox,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Copy,
|
||||
File,
|
||||
MoveRight,
|
||||
CheckSquare,
|
||||
@@ -418,12 +417,15 @@ interface UploadFile {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
error?: string;
|
||||
isZip?: boolean;
|
||||
extractedCount?: number;
|
||||
}
|
||||
|
||||
function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) {
|
||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [preserveZipStructure, setPreserveZipStructure] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
@@ -453,6 +455,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
||||
const uploadFiles: UploadFile[] = newFiles.map((file) => ({
|
||||
file,
|
||||
status: 'pending',
|
||||
isZip: file.name.toLowerCase().endsWith('.zip'),
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...uploadFiles]);
|
||||
};
|
||||
@@ -461,6 +464,8 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
@@ -474,10 +479,28 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
||||
);
|
||||
|
||||
try {
|
||||
await api.uploadLibraryFile(files[i].file, folderId);
|
||||
setFiles((prev) =>
|
||||
prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
|
||||
);
|
||||
if (files[i].isZip) {
|
||||
// Extract ZIP file
|
||||
const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure);
|
||||
setFiles((prev) =>
|
||||
prev.map((f, idx) =>
|
||||
idx === i
|
||||
? {
|
||||
...f,
|
||||
status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
|
||||
extractedCount: result.extracted,
|
||||
error: result.errors.length > 0 ? `${result.errors.length} files failed` : undefined,
|
||||
}
|
||||
: f
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Regular file upload
|
||||
await api.uploadLibraryFile(files[i].file, folderId);
|
||||
setFiles((prev) =>
|
||||
prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setFiles((prev) =>
|
||||
prev.map((f, idx) =>
|
||||
@@ -528,16 +551,42 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
||||
{isDragging ? 'Drop files here' : 'Drag & drop files here'}
|
||||
</p>
|
||||
<p className="text-sm text-bambu-gray mt-1">or click to browse</p>
|
||||
<p className="text-xs text-bambu-gray/70 mt-2">ZIP files will be automatically extracted</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="*/*,.zip"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
{/* ZIP Options */}
|
||||
{hasZipFiles && (
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-blue-300 font-medium">ZIP files detected</p>
|
||||
<p className="text-xs text-blue-300/70 mt-1">
|
||||
ZIP files will be extracted. Choose how to handle folder structure:
|
||||
</p>
|
||||
<label className="flex items-center gap-2 mt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveZipStructure}
|
||||
onChange={(e) => setPreserveZipStructure(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
||||
/>
|
||||
<span className="text-sm text-white">Preserve folder structure from ZIP</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||
@@ -546,11 +595,21 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
|
||||
>
|
||||
<File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
|
||||
{uploadFile.isZip ? (
|
||||
<ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{uploadFile.file.name}</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
{uploadFile.isZip && uploadFile.status === 'pending' && (
|
||||
<span className="text-blue-400 ml-2">• Will be extracted</span>
|
||||
)}
|
||||
{uploadFile.extractedCount !== undefined && (
|
||||
<span className="text-green-400 ml-2">• {uploadFile.extractedCount} files extracted</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{uploadFile.status === 'pending' && (
|
||||
@@ -787,13 +846,6 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
|
||||
) : (
|
||||
<FileBox className="w-12 h-12 text-bambu-gray/30" />
|
||||
)}
|
||||
{/* Duplicate badge */}
|
||||
{file.duplicate_count > 0 && (
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1 bg-amber-500/90 text-white text-xs px-1.5 py-0.5 rounded">
|
||||
<Copy className="w-3 h-3" />
|
||||
{file.duplicate_count}
|
||||
</div>
|
||||
)}
|
||||
{/* File type badge */}
|
||||
<div className={`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${
|
||||
file.file_type === '3mf' ? 'bg-bambu-green/90 text-white'
|
||||
@@ -1061,6 +1113,22 @@ export function FileManagerPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []),
|
||||
onSuccess: (_, fileIds) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['library-folders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['library-stats'] });
|
||||
showToast(`Deleted ${fileIds.length} files`, 'success');
|
||||
setSelectedFiles([]);
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setDeleteConfirm(null);
|
||||
showToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
|
||||
const moveFilesMutation = useMutation({
|
||||
mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) =>
|
||||
api.moveLibraryFiles(fileIds, folderId),
|
||||
@@ -1191,21 +1259,12 @@ export function FileManagerPage() {
|
||||
} else if (deleteConfirm.type === 'folder') {
|
||||
deleteFolderMutation.mutate(deleteConfirm.id);
|
||||
} else if (deleteConfirm.type === 'bulk') {
|
||||
// Bulk delete selected files
|
||||
api.bulkDeleteLibrary(selectedFiles, []).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['library-files'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['library-folders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['library-stats'] });
|
||||
showToast(`Deleted ${selectedFiles.length} files`, 'success');
|
||||
setSelectedFiles([]);
|
||||
setDeleteConfirm(null);
|
||||
}).catch((err) => {
|
||||
showToast(err.message, 'error');
|
||||
setDeleteConfirm(null);
|
||||
});
|
||||
bulkDeleteMutation.mutate(selectedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending;
|
||||
|
||||
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('library-view-mode', mode);
|
||||
@@ -1609,12 +1668,6 @@ export function FileManagerPage() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
|
||||
{file.duplicate_count > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-amber-400">
|
||||
<Copy className="w-3 h-3" />
|
||||
{file.duplicate_count} duplicate(s)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Type */}
|
||||
@@ -1739,6 +1792,8 @@ export function FileManagerPage() {
|
||||
}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
isLoading={isDeleting}
|
||||
loadingText="Deleting..."
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setDeleteConfirm(null)}
|
||||
/>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-XGGhfA3J.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C_p2QVEb.css">
|
||||
<script type="module" crossorigin src="/assets/index-DbOfgMyu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CVDQtTMh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user