import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Combine, Loader2, X } from 'lucide-react'; import { api, type LibraryFileListItem, type LibraryFileUploadResponse } from '../api/client'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; // Mirrors MAX_COMBINE_INSTANCES in backend/app/services/mesh_combine.py. const MAX_OBJECTS = 100; interface CombineFilesModalProps { files: LibraryFileListItem[]; folderId: number | null; // Offer "open the slicer next" only where in-app slicing is available. canSlice: boolean; onClose: () => void; onCombined: (file: LibraryFileUploadResponse, sliceNext: boolean) => void; } function stem(filename: string): string { const dot = filename.lastIndexOf('.'); return dot > 0 ? filename.slice(0, dot) : filename; } /** * Combine selected STLs (or copies of one, #2999) into a single multi-object * 3MF in the library, ready to slice onto one plate with auto-arrange. The * sidecar slices one file at a time, so this is how several separate models * end up on the same plate without a desktop slicer. */ export function CombineFilesModal({ files, folderId, canSlice, onClose, onCombined }: CombineFilesModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [copies, setCopies] = useState>(() => Object.fromEntries(files.map((f) => [f.id, 1])), ); const [name, setName] = useState(null); const [sliceNext, setSliceNext] = useState(canSlice); const totalObjects = files.reduce((sum, f) => sum + (copies[f.id] || 0), 0); // Until the user types a name, keep suggesting one that describes the plate. const defaultName = useMemo(() => { if (files.length === 1) return `${stem(files[0].filename)} x${copies[files[0].id] || 1}`; return t('fileManager.combine.defaultName', { name: stem(files[0]?.filename ?? ''), count: files.length - 1 }); }, [files, copies, t]); const effectiveName = (name ?? defaultName).trim(); const combineMutation = useMutation({ mutationFn: () => api.combineLibraryFiles( files.map((f) => ({ file_id: f.id, copies: copies[f.id] || 1 })), effectiveName, folderId, ), onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-stats'] }); showToast(t('fileManager.combine.done', { filename: result.filename, count: totalObjects }), 'success'); onCombined(result, sliceNext && canSlice); }, onError: (error: Error) => showToast(error.message, 'error'), }); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !combineMutation.isPending) onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [onClose, combineMutation.isPending]); const tooMany = totalObjects > MAX_OBJECTS; const invalidCopies = files.some((f) => !copies[f.id] || copies[f.id] < 1); const titleId = 'combine-files-title'; return (
!combineMutation.isPending && onClose()} />

{t('fileManager.combine.title')}

{t('fileManager.combine.description')}

setName(e.target.value)} maxLength={200} className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" /> .3mf
{t('fileManager.combine.model')} {t('fileManager.combine.copies')}
    {files.map((f) => (
  • {f.filename} { const n = parseInt(e.target.value, 10); setCopies((prev) => ({ ...prev, [f.id]: Number.isNaN(n) ? 0 : n })); }} aria-label={t('fileManager.combine.copiesFor', { filename: f.filename })} className="w-20 px-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white text-right focus:outline-none focus:border-bambu-green" />
  • ))}
{tooMany ? t('fileManager.combine.tooMany', { max: MAX_OBJECTS }) : t('fileManager.combine.total', { count: totalObjects })}
{canSlice && ( )}
); }