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:
maziggy
2026-01-23 09:02:25 +01:00
parent ea570a12f5
commit 6225fac398
12 changed files with 614 additions and 109 deletions
+5
View File
@@ -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)
+223
View File
@@ -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
+27
View File
@@ -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();
});
});
});
+38
View File
@@ -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;
+19 -7
View File
@@ -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>
+86 -31
View File
@@ -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
View File
@@ -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>