CombineFilesModal.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { Combine, Loader2, X } from 'lucide-react';
  5. import { api, type LibraryFileListItem, type LibraryFileUploadResponse } from '../api/client';
  6. import { Button } from './Button';
  7. import { useToast } from '../contexts/ToastContext';
  8. // Mirrors MAX_COMBINE_INSTANCES in backend/app/services/mesh_combine.py.
  9. const MAX_OBJECTS = 100;
  10. interface CombineFilesModalProps {
  11. files: LibraryFileListItem[];
  12. folderId: number | null;
  13. // Offer "open the slicer next" only where in-app slicing is available.
  14. canSlice: boolean;
  15. onClose: () => void;
  16. onCombined: (file: LibraryFileUploadResponse, sliceNext: boolean) => void;
  17. }
  18. function stem(filename: string): string {
  19. const dot = filename.lastIndexOf('.');
  20. return dot > 0 ? filename.slice(0, dot) : filename;
  21. }
  22. /**
  23. * Combine selected STLs (or copies of one, #2999) into a single multi-object
  24. * 3MF in the library, ready to slice onto one plate with auto-arrange. The
  25. * sidecar slices one file at a time, so this is how several separate models
  26. * end up on the same plate without a desktop slicer.
  27. */
  28. export function CombineFilesModal({ files, folderId, canSlice, onClose, onCombined }: CombineFilesModalProps) {
  29. const { t } = useTranslation();
  30. const queryClient = useQueryClient();
  31. const { showToast } = useToast();
  32. const [copies, setCopies] = useState<Record<number, number>>(() =>
  33. Object.fromEntries(files.map((f) => [f.id, 1])),
  34. );
  35. const [name, setName] = useState<string | null>(null);
  36. const [sliceNext, setSliceNext] = useState(canSlice);
  37. const totalObjects = files.reduce((sum, f) => sum + (copies[f.id] || 0), 0);
  38. // Until the user types a name, keep suggesting one that describes the plate.
  39. const defaultName = useMemo(() => {
  40. if (files.length === 1) return `${stem(files[0].filename)} x${copies[files[0].id] || 1}`;
  41. return t('fileManager.combine.defaultName', { name: stem(files[0]?.filename ?? ''), count: files.length - 1 });
  42. }, [files, copies, t]);
  43. const effectiveName = (name ?? defaultName).trim();
  44. const combineMutation = useMutation({
  45. mutationFn: () =>
  46. api.combineLibraryFiles(
  47. files.map((f) => ({ file_id: f.id, copies: copies[f.id] || 1 })),
  48. effectiveName,
  49. folderId,
  50. ),
  51. onSuccess: (result) => {
  52. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  53. queryClient.invalidateQueries({ queryKey: ['library-stats'] });
  54. showToast(t('fileManager.combine.done', { filename: result.filename, count: totalObjects }), 'success');
  55. onCombined(result, sliceNext && canSlice);
  56. },
  57. onError: (error: Error) => showToast(error.message, 'error'),
  58. });
  59. useEffect(() => {
  60. const onKey = (e: KeyboardEvent) => {
  61. if (e.key === 'Escape' && !combineMutation.isPending) onClose();
  62. };
  63. document.addEventListener('keydown', onKey);
  64. return () => document.removeEventListener('keydown', onKey);
  65. }, [onClose, combineMutation.isPending]);
  66. const tooMany = totalObjects > MAX_OBJECTS;
  67. const invalidCopies = files.some((f) => !copies[f.id] || copies[f.id] < 1);
  68. const titleId = 'combine-files-title';
  69. return (
  70. <div className="fixed inset-0 z-[60] flex items-center justify-center">
  71. <div className="absolute inset-0 bg-black/60" onClick={() => !combineMutation.isPending && onClose()} />
  72. <div
  73. className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col"
  74. role="dialog"
  75. aria-modal="true"
  76. aria-labelledby={titleId}
  77. >
  78. <div className="flex items-center justify-between gap-4 px-5 py-4 border-b border-bambu-dark-tertiary">
  79. <h3 id={titleId} className="text-base font-semibold text-white flex items-center gap-2">
  80. <Combine className="w-4 h-4 text-bambu-green" />
  81. {t('fileManager.combine.title')}
  82. </h3>
  83. <button
  84. type="button"
  85. className="p-1.5 text-bambu-gray hover:text-white rounded"
  86. onClick={onClose}
  87. aria-label={t('common.close')}
  88. >
  89. <X className="w-5 h-5" />
  90. </button>
  91. </div>
  92. <p className="px-5 pt-3 text-sm text-bambu-gray">{t('fileManager.combine.description')}</p>
  93. <div className="px-5 py-3">
  94. <label htmlFor="combine-name" className="block text-xs text-bambu-gray mb-1">
  95. {t('fileManager.combine.nameLabel')}
  96. </label>
  97. <div className="flex items-center gap-1">
  98. <input
  99. id="combine-name"
  100. type="text"
  101. value={name ?? defaultName}
  102. onChange={(e) => setName(e.target.value)}
  103. maxLength={200}
  104. 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"
  105. />
  106. <span className="text-sm text-bambu-gray">.3mf</span>
  107. </div>
  108. </div>
  109. <div className="overflow-y-auto flex-1 max-h-[20rem] border-y border-bambu-dark-tertiary">
  110. <div className="flex justify-between px-5 py-2 text-xs text-bambu-gray">
  111. <span>{t('fileManager.combine.model')}</span>
  112. <span>{t('fileManager.combine.copies')}</span>
  113. </div>
  114. <ul className="divide-y divide-bambu-dark-tertiary/40">
  115. {files.map((f) => (
  116. <li key={f.id} className="flex items-center gap-3 px-5 py-2">
  117. <span className="text-sm text-white truncate flex-1" title={f.filename}>
  118. {f.filename}
  119. </span>
  120. <input
  121. type="number"
  122. min={1}
  123. max={MAX_OBJECTS}
  124. value={copies[f.id] || ''}
  125. onChange={(e) => {
  126. const n = parseInt(e.target.value, 10);
  127. setCopies((prev) => ({ ...prev, [f.id]: Number.isNaN(n) ? 0 : n }));
  128. }}
  129. aria-label={t('fileManager.combine.copiesFor', { filename: f.filename })}
  130. 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"
  131. />
  132. </li>
  133. ))}
  134. </ul>
  135. </div>
  136. <div className="px-5 py-3 space-y-2 text-sm">
  137. <div className={tooMany ? 'text-red-400' : 'text-bambu-gray'}>
  138. {tooMany
  139. ? t('fileManager.combine.tooMany', { max: MAX_OBJECTS })
  140. : t('fileManager.combine.total', { count: totalObjects })}
  141. </div>
  142. {canSlice && (
  143. <label className="flex items-center gap-2 cursor-pointer">
  144. <input
  145. type="checkbox"
  146. checked={sliceNext}
  147. onChange={(e) => setSliceNext(e.target.checked)}
  148. className="accent-bambu-green"
  149. />
  150. <span className="text-white">{t('fileManager.combine.sliceNext')}</span>
  151. </label>
  152. )}
  153. </div>
  154. <div className="px-5 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
  155. <Button type="button" variant="secondary" onClick={onClose} disabled={combineMutation.isPending}>
  156. {t('common.cancel')}
  157. </Button>
  158. <Button
  159. type="button"
  160. onClick={() => combineMutation.mutate()}
  161. disabled={combineMutation.isPending || tooMany || invalidCopies || !effectiveName}
  162. >
  163. {combineMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
  164. {t('fileManager.combine.submit')}
  165. </Button>
  166. </div>
  167. </div>
  168. </div>
  169. );
  170. }