Implement hybrid upload: advanced 3MF extraction + basic upload for other files

Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-04 00:36:07 +00:00
co-authored by cadtoolbox
parent 3c2631348a
commit 556493ff5b
7 changed files with 831 additions and 416 deletions
@@ -159,11 +159,11 @@ describe('FileManagerPage', () => {
});
});
it('shows Upload 3MF button', async () => {
it('shows Upload button', async () => {
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Upload 3MF')).toBeInTheDocument();
expect(screen.getByText('Upload')).toBeInTheDocument();
});
});
});
@@ -454,7 +454,7 @@ describe('FileManagerPage', () => {
await waitFor(() => {
expect(screen.getByText('No files yet')).toBeInTheDocument();
expect(screen.getByText('Upload 3MF Files')).toBeInTheDocument();
expect(screen.getByText('Upload Files')).toBeInTheDocument();
});
});
});
@@ -535,36 +535,82 @@ describe('FileManagerPage', () => {
});
});
describe('upload modal 3MF support', () => {
describe('upload modal with advanced 3MF support', () => {
it('opens upload modal', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Upload 3MF')).toBeInTheDocument();
expect(screen.getByText('Upload')).toBeInTheDocument();
});
await user.click(screen.getByText('Upload 3MF'));
await user.click(screen.getByText('Upload'));
await waitFor(() => {
expect(screen.getByText('Upload 3MF Files')).toBeInTheDocument();
expect(screen.getByText('Upload Files')).toBeInTheDocument();
expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
});
});
it('shows printer model extraction info', async () => {
it('shows 3MF extraction info when 3MF file is added', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Upload 3MF')).toBeInTheDocument();
expect(screen.getByText('Upload')).toBeInTheDocument();
});
await user.click(screen.getByText('Upload 3MF'));
await user.click(screen.getByText('Upload'));
await waitFor(() => {
// The shared UploadModal shows info about printer model extraction
expect(screen.getByText(/printer model/i)).toBeInTheDocument();
expect(screen.getByText('Upload Files')).toBeInTheDocument();
});
// Create a mock 3MF file
const threemfFile = new File(['content'], 'model.gcode.3mf', { type: 'application/octet-stream' });
// Get the hidden file input
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeInTheDocument();
// Simulate file selection
await user.upload(fileInput, threemfFile);
// 3MF extraction info should appear
await waitFor(() => {
expect(screen.getByText('3MF files detected')).toBeInTheDocument();
expect(screen.getByText(/Printer model.*will be automatically extracted/i)).toBeInTheDocument();
});
});
it('shows STL thumbnail option when STL file is added', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => {
expect(screen.getByText('Upload')).toBeInTheDocument();
});
await user.click(screen.getByText('Upload'));
await waitFor(() => {
expect(screen.getByText('Upload Files')).toBeInTheDocument();
});
// Create a mock STL file
const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
// Get the hidden file input
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(fileInput).toBeInTheDocument();
// Simulate file selection
await user.upload(fileInput, stlFile);
// STL thumbnail option should appear
await waitFor(() => {
expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
expect(screen.getByText(/Thumbnails can be generated/i)).toBeInTheDocument();
});
});
});
+2
View File
@@ -1837,6 +1837,8 @@ export default {
zipMayContainStl: 'ZIP files may contain STL files. Thumbnails can be generated during extraction.',
thumbnailsCanBeGenerated: 'Thumbnails can be generated for STL files. Large models may take longer to process.',
generateThumbnailsForStl: 'Generate thumbnails for STL files',
threemfDetected: '3MF files detected',
threemfExtractionInfo: 'Printer model, material, color, and print settings will be automatically extracted from 3MF files.',
willBeExtracted: 'Will be extracted',
filesExtracted: '{{count}} files extracted',
uploadComplete: 'Upload complete: {{succeeded}} succeeded',
+376 -9
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useState, useRef, useCallback, useMemo, useEffect, type DragEvent } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -27,6 +27,8 @@ import {
AlertTriangle,
Filter,
X,
CheckCircle,
XCircle,
Link2,
Unlink,
Archive as ArchiveIcon,
@@ -49,7 +51,6 @@ import type {
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
import { UploadModal } from '../components/UploadModal';
import { useToast } from '../contexts/ToastContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuth } from '../contexts/AuthContext';
@@ -415,6 +416,373 @@ function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderMo
);
}
// Upload Modal with Drag & Drop
interface UploadModalProps {
folderId: number | null;
onClose: () => void;
onUploadComplete: () => void;
t: TFunction;
}
interface UploadFile {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
isZip?: boolean;
is3mf?: boolean;
extractedCount?: number;
archiveId?: number;
}
function UploadModal({ folderId, onClose, onUploadComplete, t }: UploadModalProps) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [preserveZipStructure, setPreserveZipStructure] = useState(true);
const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
addFiles(droppedFiles);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
addFiles(Array.from(e.target.files));
}
};
const addFiles = (newFiles: File[]) => {
const uploadFiles: UploadFile[] = newFiles.map((file) => ({
file,
status: 'pending',
isZip: file.name.toLowerCase().endsWith('.zip'),
is3mf: file.name.toLowerCase().endsWith('.3mf'),
}));
setFiles((prev) => [...prev, ...uploadFiles]);
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');
const handleUpload = async () => {
if (files.length === 0) return;
setIsUploading(true);
// Handle .3mf files with bulk upload API (advanced extraction)
const threemfFiles = files.filter((f) => f.is3mf && f.status === 'pending');
if (threemfFiles.length > 0) {
try {
// Mark files as uploading
setFiles((prev) =>
prev.map((f) => (f.is3mf && f.status === 'pending' ? { ...f, status: 'uploading' } : f))
);
// Use the archives bulk upload API for .3mf files (extracts printer model)
const result = await api.uploadArchivesBulk(threemfFiles.map((f) => f.file));
// Update file statuses based on result
setFiles((prev) =>
prev.map((f) => {
if (!f.is3mf || f.status !== 'uploading') return f;
const success = result.results.find((r) => r.filename === f.file.name);
const error = result.errors.find((e) => e.filename === f.file.name);
if (success) {
return { ...f, status: 'success', archiveId: success.id };
}
if (error) {
return { ...f, status: 'error', error: error.error };
}
return f;
})
);
} catch (err) {
setFiles((prev) =>
prev.map((f) =>
f.is3mf && f.status === 'uploading'
? { ...f, status: 'error', error: err instanceof Error ? err.message : 'Upload failed' }
: f
)
);
}
}
// Handle other files (ZIP and regular files) with library upload
for (let i = 0; i < files.length; i++) {
if (files[i].status !== 'pending' || files[i].is3mf) continue;
setFiles((prev) =>
prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f))
);
try {
if (files[i].isZip) {
// Extract ZIP file
const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
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 (STL, etc.)
await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
setFiles((prev) =>
prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
);
}
} catch (err) {
setFiles((prev) =>
prev.map((f, idx) =>
idx === i
? { ...f, status: 'error', error: err instanceof Error ? err.message : 'Upload failed' }
: f
)
);
}
}
setIsUploading(false);
onUploadComplete();
// Auto-close modal after upload completes
onClose();
};
const pendingCount = files.filter((f) => f.status === 'pending').length;
const successCount = files.filter((f) => f.status === 'success').length;
const errorCount = files.filter((f) => f.status === 'error').length;
const allDone = files.length > 0 && pendingCount === 0 && !isUploading;
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary">
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{t('fileManager.uploadFiles')}</h2>
<button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
<X className="w-5 h-5 text-bambu-gray" />
</button>
</div>
<div className="p-4 space-y-4">
{/* Drop Zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragging
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary hover:border-bambu-green/50'
}`}
>
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
<p className="text-white font-medium">
{isDragging ? t('fileManager.dropFilesHere') : t('fileManager.dragDropFiles')}
</p>
<p className="text-sm text-bambu-gray mt-1">{t('fileManager.orClickToBrowse')}</p>
<p className="text-xs text-bambu-gray/70 mt-2">{t('fileManager.allFileTypesSupported')}</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple
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">{t('fileManager.zipFilesDetected')}</p>
<p className="text-xs text-blue-300/70 mt-1">
{t('fileManager.zipExtractOptions')}
</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">{t('fileManager.preserveZipStructure')}</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">{t('fileManager.createFolderFromZip')}</span>
</label>
</div>
</div>
</div>
)}
{/* 3MF File Info - Advanced Extraction */}
{has3mfFiles && (
<div className="p-3 bg-purple-500/10 border border-purple-500/30 rounded-lg">
<div className="flex items-start gap-3">
<Printer className="w-5 h-5 text-purple-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-purple-300 font-medium">{t('fileManager.threemfDetected')}</p>
<p className="text-xs text-purple-300/70 mt-1">
{t('fileManager.threemfExtractionInfo')}
</p>
</div>
</div>
</div>
)}
{/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */}
{(hasStlFiles || hasZipFiles) && (
<div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
<div className="flex items-start gap-3">
<Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm text-bambu-green font-medium">{t('fileManager.stlThumbnailGeneration')}</p>
<p className="text-xs text-bambu-green/70 mt-1">
{hasZipFiles && !hasStlFiles
? t('fileManager.zipMayContainStl')
: t('fileManager.thumbnailsCanBeGenerated')}
</p>
<label className="flex items-center gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={generateStlThumbnails}
onChange={(e) => setGenerateStlThumbnails(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">{t('fileManager.generateThumbnailsForStl')}</span>
</label>
</div>
</div>
</div>
)}
{/* File List */}
{files.length > 0 && (
<div className="max-h-48 overflow-y-auto space-y-2">
{files.map((uploadFile, index) => (
<div
key={index}
className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
>
{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">• {t('fileManager.willBeExtracted')}</span>
)}
{uploadFile.extractedCount !== undefined && (
<span className="text-green-400 ml-2">• {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })}</span>
)}
</p>
</div>
{uploadFile.status === 'pending' && (
<button
onClick={() => removeFile(index)}
className="p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray" />
</button>
)}
{uploadFile.status === 'uploading' && (
<Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
)}
{uploadFile.status === 'success' && (
<CheckCircle className="w-4 h-4 text-green-500" />
)}
{uploadFile.status === 'error' && (
<span title={uploadFile.error}>
<XCircle className="w-4 h-4 text-red-500" />
</span>
)}
</div>
))}
</div>
)}
{/* Summary */}
{allDone && (
<div className="p-3 bg-bambu-dark rounded-lg">
<p className="text-sm text-white">
{t('fileManager.uploadComplete', { succeeded: successCount })}
{errorCount > 0 && <span className="text-red-400">, {t('fileManager.uploadFailed', { count: errorCount })}</span>}
</p>
</div>
)}
</div>
<div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>
{allDone ? t('common.close') : t('common.cancel')}
</Button>
{!allDone && (
<Button
onClick={handleUpload}
disabled={pendingCount === 0 || isUploading}
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('fileManager.uploading')}
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
{t('common.upload')} {pendingCount > 0 ? `(${pendingCount})` : ''}
</>
)}
</Button>
)}
</div>
</div>
</div>
);
}
// Folder Tree Item
interface FolderTreeItemProps {
folder: LibraryFolderTree;
@@ -1248,7 +1616,7 @@ export function FileManagerPage() {
title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : undefined}
>
<Upload className="w-4 h-4 mr-2" />
Upload 3MF
{t('common.upload')}
</Button>
</div>
</div>
@@ -1601,7 +1969,7 @@ export function FileManagerPage() {
title={!hasPermission('library:upload') ? t('fileManager.noPermissionUpload') : undefined}
>
<Plus className="w-4 h-4 mr-2" />
Upload 3MF Files
{t('fileManager.uploadFiles')}
</Button>
</div>
) : filteredAndSortedFiles.length === 0 ? (
@@ -1832,11 +2200,10 @@ export function FileManagerPage() {
{showUploadModal && (
<UploadModal
onClose={() => {
setShowUploadModal(false);
// Also refresh library in case files are used in library context
handleUploadComplete();
}}
folderId={selectedFolderId}
onClose={() => setShowUploadModal(false)}
onUploadComplete={handleUploadComplete}
t={t}
/>
)}
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-DvkTQXdN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-E0e2gAOg.css">
<script type="module" crossorigin src="/assets/index-ChuiDDfR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Mcnrrd7v.css">
</head>
<body>
<div id="root"></div>