UploadModal.tsx 10 KB

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