UploadModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { useState, useCallback, useRef, useEffect } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { BulkUploadResult } from '../api/client';
  6. import { Card, CardContent } from './Card';
  7. import { Button } from './Button';
  8. import { useToast } from '../contexts/ToastContext';
  9. interface FileWithStatus {
  10. file: File;
  11. status: 'pending' | 'uploading' | 'success' | 'error';
  12. error?: string;
  13. archiveId?: number;
  14. }
  15. interface UploadModalProps {
  16. onClose: () => void;
  17. initialFiles?: File[];
  18. }
  19. export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
  20. const queryClient = useQueryClient();
  21. const { showToast } = useToast();
  22. const fileInputRef = useRef<HTMLInputElement>(null);
  23. const [files, setFiles] = useState<FileWithStatus[]>(() =>
  24. initialFiles?.filter(f => f.name.endsWith('.3mf')).map(file => ({ file, status: 'pending' as const })) || []
  25. );
  26. const [isDragging, setIsDragging] = useState(false);
  27. const [selectedPrinter, setSelectedPrinter] = useState<number | undefined>();
  28. const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
  29. // Close on Escape key
  30. useEffect(() => {
  31. const handleKeyDown = (e: KeyboardEvent) => {
  32. if (e.key === 'Escape') onClose();
  33. };
  34. window.addEventListener('keydown', handleKeyDown);
  35. return () => window.removeEventListener('keydown', handleKeyDown);
  36. }, [onClose]);
  37. const { data: printers } = useQuery({
  38. queryKey: ['printers'],
  39. queryFn: api.getPrinters,
  40. });
  41. const uploadMutation = useMutation({
  42. mutationFn: (filesToUpload: File[]) =>
  43. api.uploadArchivesBulk(filesToUpload, selectedPrinter),
  44. onSuccess: (result) => {
  45. setUploadResult(result);
  46. queryClient.invalidateQueries({ queryKey: ['archives'] });
  47. queryClient.invalidateQueries({ queryKey: ['archiveStats'] });
  48. // Update file statuses based on result
  49. setFiles((prev) =>
  50. prev.map((f) => {
  51. const success = result.results.find((r) => r.filename === f.file.name);
  52. const error = result.errors.find((e) => e.filename === f.file.name);
  53. if (success) {
  54. return { ...f, status: 'success', archiveId: success.id };
  55. }
  56. if (error) {
  57. return { ...f, status: 'error', error: error.error };
  58. }
  59. return f;
  60. })
  61. );
  62. // Show toast
  63. if (result.failed === 0) {
  64. showToast(`${result.uploaded} file${result.uploaded !== 1 ? 's' : ''} uploaded`);
  65. } else if (result.uploaded === 0) {
  66. showToast(`Failed to upload ${result.failed} file${result.failed !== 1 ? 's' : ''}`, 'error');
  67. } else {
  68. showToast(`${result.uploaded} uploaded, ${result.failed} failed`, 'warning');
  69. }
  70. },
  71. onError: () => {
  72. setFiles((prev) =>
  73. prev.map((f) => ({ ...f, status: 'error', error: 'Upload failed' }))
  74. );
  75. showToast('Upload failed', 'error');
  76. },
  77. });
  78. const handleDragOver = useCallback((e: React.DragEvent) => {
  79. e.preventDefault();
  80. setIsDragging(true);
  81. }, []);
  82. const handleDragLeave = useCallback((e: React.DragEvent) => {
  83. e.preventDefault();
  84. setIsDragging(false);
  85. }, []);
  86. const handleDrop = useCallback((e: React.DragEvent) => {
  87. e.preventDefault();
  88. setIsDragging(false);
  89. const droppedFiles = Array.from(e.dataTransfer.files).filter((f) =>
  90. f.name.endsWith('.3mf')
  91. );
  92. if (droppedFiles.length > 0) {
  93. setFiles((prev) => [
  94. ...prev,
  95. ...droppedFiles.map((file) => ({ file, status: 'pending' as const })),
  96. ]);
  97. }
  98. }, []);
  99. const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  100. const selectedFiles = Array.from(e.target.files || []).filter((f) =>
  101. f.name.endsWith('.3mf')
  102. );
  103. if (selectedFiles.length > 0) {
  104. setFiles((prev) => [
  105. ...prev,
  106. ...selectedFiles.map((file) => ({ file, status: 'pending' as const })),
  107. ]);
  108. }
  109. // Reset input so same file can be selected again
  110. if (fileInputRef.current) {
  111. fileInputRef.current.value = '';
  112. }
  113. }, []);
  114. const removeFile = useCallback((index: number) => {
  115. setFiles((prev) => prev.filter((_, i) => i !== index));
  116. }, []);
  117. const handleUpload = () => {
  118. if (files.length === 0) return;
  119. const pendingFiles = files.filter((f) => f.status === 'pending');
  120. if (pendingFiles.length === 0) return;
  121. setFiles((prev) =>
  122. prev.map((f) =>
  123. f.status === 'pending' ? { ...f, status: 'uploading' } : f
  124. )
  125. );
  126. uploadMutation.mutate(pendingFiles.map((f) => f.file));
  127. };
  128. const pendingCount = files.filter((f) => f.status === 'pending').length;
  129. const isUploading = uploadMutation.isPending;
  130. return (
  131. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
  132. <Card className="w-full max-w-2xl max-h-[90vh] flex flex-col">
  133. <CardContent className="p-0 flex flex-col h-full">
  134. {/* Header */}
  135. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  136. <h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
  137. <button
  138. onClick={onClose}
  139. className="text-bambu-gray hover:text-white transition-colors"
  140. >
  141. <X className="w-5 h-5" />
  142. </button>
  143. </div>
  144. {/* Drop Zone */}
  145. <div className="p-4">
  146. <div
  147. className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
  148. isDragging
  149. ? 'border-bambu-green bg-bambu-green/10'
  150. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  151. }`}
  152. onDragOver={handleDragOver}
  153. onDragLeave={handleDragLeave}
  154. onDrop={handleDrop}
  155. >
  156. <Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
  157. <p className="text-white mb-2">
  158. Drag & drop .3mf files here
  159. </p>
  160. <p className="text-bambu-gray text-sm mb-4">or</p>
  161. <Button
  162. variant="secondary"
  163. onClick={() => fileInputRef.current?.click()}
  164. disabled={isUploading}
  165. >
  166. Browse Files
  167. </Button>
  168. <input
  169. ref={fileInputRef}
  170. type="file"
  171. accept=".3mf"
  172. multiple
  173. className="hidden"
  174. onChange={handleFileSelect}
  175. />
  176. </div>
  177. </div>
  178. {/* Optional Printer Selection */}
  179. <div className="px-4 pb-4">
  180. <label className="block text-sm text-bambu-gray mb-2">
  181. Associate with printer (optional)
  182. </label>
  183. <select
  184. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  185. value={selectedPrinter || ''}
  186. onChange={(e) =>
  187. setSelectedPrinter(e.target.value ? Number(e.target.value) : undefined)
  188. }
  189. disabled={isUploading}
  190. >
  191. <option value="">No printer</option>
  192. {printers?.map((p) => (
  193. <option key={p.id} value={p.id}>
  194. {p.name}
  195. </option>
  196. ))}
  197. </select>
  198. </div>
  199. {/* File List */}
  200. {files.length > 0 && (
  201. <div className="px-4 pb-4 max-h-60 overflow-y-auto">
  202. <div className="space-y-2">
  203. {files.map((f, index) => (
  204. <div
  205. key={`${f.file.name}-${index}`}
  206. className="flex items-center gap-3 p-3 bg-bambu-dark rounded-lg"
  207. >
  208. <File className="w-5 h-5 text-bambu-gray flex-shrink-0" />
  209. <span className="flex-1 text-white text-sm truncate">
  210. {f.file.name}
  211. </span>
  212. <span className="text-xs text-bambu-gray">
  213. {(f.file.size / (1024 * 1024)).toFixed(1)} MB
  214. </span>
  215. {f.status === 'pending' && (
  216. <button
  217. onClick={() => removeFile(index)}
  218. className="text-bambu-gray hover:text-red-400 transition-colors"
  219. disabled={isUploading}
  220. >
  221. <X className="w-4 h-4" />
  222. </button>
  223. )}
  224. {f.status === 'uploading' && (
  225. <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
  226. )}
  227. {f.status === 'success' && (
  228. <CheckCircle className="w-4 h-4 text-bambu-green" />
  229. )}
  230. {f.status === 'error' && (
  231. <div className="flex items-center gap-2">
  232. <span className="text-xs text-red-400">{f.error}</span>
  233. <AlertCircle className="w-4 h-4 text-red-400" />
  234. </div>
  235. )}
  236. </div>
  237. ))}
  238. </div>
  239. </div>
  240. )}
  241. {/* Upload Result Summary */}
  242. {uploadResult && (
  243. <div className="px-4 pb-4">
  244. <div className="p-3 bg-bambu-dark rounded-lg">
  245. <p className="text-sm text-white">
  246. <span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
  247. {uploadResult.failed > 0 && (
  248. <>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
  249. )}
  250. </p>
  251. </div>
  252. </div>
  253. )}
  254. {/* Footer */}
  255. <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
  256. <Button variant="secondary" onClick={onClose} className="flex-1">
  257. {uploadResult ? 'Close' : 'Cancel'}
  258. </Button>
  259. {!uploadResult && (
  260. <Button
  261. onClick={handleUpload}
  262. disabled={pendingCount === 0 || isUploading}
  263. className="flex-1"
  264. >
  265. {isUploading ? (
  266. <>
  267. <Loader2 className="w-4 h-4 animate-spin" />
  268. Uploading...
  269. </>
  270. ) : (
  271. <>
  272. <Upload className="w-4 h-4" />
  273. Upload {pendingCount > 0 && `(${pendingCount})`}
  274. </>
  275. )}
  276. </Button>
  277. )}
  278. </div>
  279. </CardContent>
  280. </Card>
  281. </div>
  282. );
  283. }