EditArchiveModal.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import { useState, useEffect, useRef } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { Archive } from '../api/client';
  6. import { Button } from './Button';
  7. const FAILURE_REASONS = [
  8. 'Adhesion failure',
  9. 'Spaghetti / Detached',
  10. 'Layer shift',
  11. 'Clogged nozzle',
  12. 'Filament runout',
  13. 'Warping',
  14. 'Stringing',
  15. 'Under-extrusion',
  16. 'Power failure',
  17. 'User cancelled',
  18. 'Other',
  19. ];
  20. const ARCHIVE_STATUSES = [
  21. { value: 'completed', label: 'Completed' },
  22. { value: 'failed', label: 'Failed' },
  23. { value: 'aborted', label: 'Cancelled' },
  24. { value: 'printing', label: 'Printing' },
  25. ];
  26. interface EditArchiveModalProps {
  27. archive: Archive;
  28. onClose: () => void;
  29. existingTags?: string[];
  30. }
  31. export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
  32. // Close on Escape key
  33. useEffect(() => {
  34. const handleKeyDown = (e: KeyboardEvent) => {
  35. if (e.key === 'Escape') onClose();
  36. };
  37. window.addEventListener('keydown', handleKeyDown);
  38. return () => window.removeEventListener('keydown', handleKeyDown);
  39. }, [onClose]);
  40. const queryClient = useQueryClient();
  41. const [printName, setPrintName] = useState(archive.print_name || '');
  42. const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
  43. const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
  44. const [notes, setNotes] = useState(archive.notes || '');
  45. const [tags, setTags] = useState(archive.tags || '');
  46. const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
  47. const [status, setStatus] = useState(archive.status);
  48. const [quantity, setQuantity] = useState(archive.quantity ?? 1);
  49. const [photos, setPhotos] = useState<string[]>(archive.photos || []);
  50. const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
  51. const [uploadingPhoto, setUploadingPhoto] = useState(false);
  52. const [showTagSuggestions, setShowTagSuggestions] = useState(false);
  53. const tagInputRef = useRef<HTMLInputElement>(null);
  54. const photoInputRef = useRef<HTMLInputElement>(null);
  55. const blurTimeoutRef = useRef<number | null>(null);
  56. const { data: printers } = useQuery({
  57. queryKey: ['printers'],
  58. queryFn: api.getPrinters,
  59. });
  60. const { data: projects } = useQuery({
  61. queryKey: ['projects'],
  62. queryFn: () => api.getProjects(),
  63. });
  64. // Get all archives to extract existing tags if not provided
  65. const { data: archives } = useQuery({
  66. queryKey: ['archives'],
  67. queryFn: () => api.getArchives(undefined, 1000, 0),
  68. enabled: existingTags.length === 0,
  69. });
  70. // Extract unique tags from all archives
  71. const allTags = existingTags.length > 0
  72. ? existingTags
  73. : [...new Set(
  74. archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
  75. )].sort();
  76. // Get current tags as array
  77. const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
  78. // Filter suggestions based on what's not already added
  79. const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
  80. // Add a tag
  81. const addTag = (tag: string) => {
  82. if (!currentTags.includes(tag)) {
  83. const newTags = [...currentTags, tag].join(', ');
  84. setTags(newTags);
  85. }
  86. // Clear any pending blur timeout to prevent hiding suggestions
  87. if (blurTimeoutRef.current !== null) {
  88. clearTimeout(blurTimeoutRef.current);
  89. }
  90. tagInputRef.current?.focus();
  91. };
  92. // Remove a tag
  93. const removeTag = (tagToRemove: string) => {
  94. const newTags = currentTags.filter(t => t !== tagToRemove).join(', ');
  95. setTags(newTags);
  96. };
  97. const updateMutation = useMutation({
  98. mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
  99. api.updateArchive(archive.id, data),
  100. onSuccess: () => {
  101. queryClient.invalidateQueries({ queryKey: ['archives'] });
  102. queryClient.invalidateQueries({ queryKey: ['projects'] });
  103. onClose();
  104. },
  105. });
  106. const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  107. const file = e.target.files?.[0];
  108. if (!file) return;
  109. setUploadingPhoto(true);
  110. try {
  111. const result = await api.uploadArchivePhoto(archive.id, file);
  112. setPhotos(result.photos);
  113. queryClient.invalidateQueries({ queryKey: ['archives'] });
  114. } catch (error) {
  115. console.error('Failed to upload photo:', error);
  116. } finally {
  117. setUploadingPhoto(false);
  118. if (photoInputRef.current) {
  119. photoInputRef.current.value = '';
  120. }
  121. }
  122. };
  123. const handlePhotoDelete = async (filename: string) => {
  124. try {
  125. const result = await api.deleteArchivePhoto(archive.id, filename);
  126. setPhotos(result.photos || []);
  127. queryClient.invalidateQueries({ queryKey: ['archives'] });
  128. } catch (error) {
  129. console.error('Failed to delete photo:', error);
  130. }
  131. };
  132. const handleSubmit = (e: React.FormEvent) => {
  133. e.preventDefault();
  134. // Build update data
  135. const updateData: Parameters<typeof api.updateArchive>[1] = {
  136. print_name: printName || undefined,
  137. printer_id: printerId,
  138. project_id: projectId,
  139. notes: notes || undefined,
  140. tags: tags || undefined,
  141. quantity: quantity,
  142. external_url: externalUrl || null,
  143. };
  144. // Only include status if changed
  145. if (status !== archive.status) {
  146. updateData.status = status;
  147. }
  148. // Handle failure_reason based on status
  149. if (status === 'failed' || status === 'aborted') {
  150. updateData.failure_reason = failureReason || undefined;
  151. } else if (archive.status === 'failed' || archive.status === 'aborted') {
  152. // Clear failure_reason when changing from failed/aborted to another status
  153. updateData.failure_reason = null;
  154. }
  155. updateMutation.mutate(updateData);
  156. };
  157. return (
  158. <div
  159. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  160. onClick={onClose}
  161. >
  162. <div
  163. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
  164. onClick={(e) => e.stopPropagation()}
  165. >
  166. {/* Header */}
  167. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  168. <h2 className="text-lg font-semibold text-white">Edit Archive</h2>
  169. <button
  170. onClick={onClose}
  171. className="text-bambu-gray hover:text-white transition-colors"
  172. >
  173. <X className="w-5 h-5" />
  174. </button>
  175. </div>
  176. {/* Form */}
  177. <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
  178. {/* Print Name */}
  179. <div>
  180. <label className="block text-sm text-bambu-gray mb-1">Name</label>
  181. <input
  182. type="text"
  183. value={printName}
  184. onChange={(e) => setPrintName(e.target.value)}
  185. 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"
  186. placeholder="Print name"
  187. />
  188. </div>
  189. {/* Printer */}
  190. <div>
  191. <label className="block text-sm text-bambu-gray mb-1">Printer</label>
  192. <select
  193. value={printerId ?? ''}
  194. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  195. 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"
  196. >
  197. <option value="">No printer</option>
  198. {printers?.map((p) => (
  199. <option key={p.id} value={p.id}>
  200. {p.name}
  201. </option>
  202. ))}
  203. </select>
  204. </div>
  205. {/* Project */}
  206. <div>
  207. <label className="block text-sm text-bambu-gray mb-1">
  208. <FolderKanban className="w-4 h-4 inline mr-1" />
  209. Project
  210. </label>
  211. <select
  212. value={projectId ?? ''}
  213. onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
  214. 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"
  215. >
  216. <option value="">No project</option>
  217. {projects?.map((p) => (
  218. <option key={p.id} value={p.id}>
  219. {p.name}
  220. </option>
  221. ))}
  222. </select>
  223. </div>
  224. {/* Quantity - number of items printed */}
  225. <div>
  226. <label className="block text-sm text-bambu-gray mb-1">
  227. <Hash className="w-4 h-4 inline mr-1" />
  228. Items Printed
  229. </label>
  230. <input
  231. type="number"
  232. min={1}
  233. value={quantity}
  234. onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
  235. 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"
  236. placeholder="1"
  237. />
  238. <p className="text-xs text-bambu-gray mt-1">
  239. Number of items produced in this print job
  240. </p>
  241. </div>
  242. {/* Notes */}
  243. <div>
  244. <label className="block text-sm text-bambu-gray mb-1">Notes</label>
  245. <textarea
  246. value={notes}
  247. onChange={(e) => setNotes(e.target.value)}
  248. rows={3}
  249. 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 resize-none"
  250. placeholder="Add notes about this print..."
  251. />
  252. </div>
  253. {/* External Link */}
  254. <div>
  255. <label className="block text-sm text-bambu-gray mb-1">
  256. <Link className="w-4 h-4 inline mr-1" />
  257. External Link
  258. </label>
  259. <input
  260. type="url"
  261. value={externalUrl}
  262. onChange={(e) => setExternalUrl(e.target.value)}
  263. 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"
  264. placeholder="https://printables.com/model/..."
  265. />
  266. <p className="text-xs text-bambu-gray mt-1">
  267. Link to Printables, Thingiverse, or other source
  268. </p>
  269. </div>
  270. {/* Tags */}
  271. <div>
  272. <label className="block text-sm text-bambu-gray mb-1">Tags</label>
  273. {/* Current tags as chips */}
  274. {currentTags.length > 0 && (
  275. <div className="flex flex-wrap gap-1.5 mb-2">
  276. {currentTags.map((tag) => (
  277. <span
  278. key={tag}
  279. className="inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white"
  280. >
  281. <Tag className="w-3 h-3" />
  282. {tag}
  283. <button
  284. type="button"
  285. onClick={() => removeTag(tag)}
  286. className="ml-0.5 text-bambu-gray hover:text-white"
  287. >
  288. <X className="w-3 h-3" />
  289. </button>
  290. </span>
  291. ))}
  292. </div>
  293. )}
  294. {/* Tag input with suggestions */}
  295. <div className="relative">
  296. <input
  297. ref={tagInputRef}
  298. type="text"
  299. value={tags}
  300. onChange={(e) => setTags(e.target.value)}
  301. onFocus={() => {
  302. if (blurTimeoutRef.current !== null) {
  303. clearTimeout(blurTimeoutRef.current);
  304. }
  305. setShowTagSuggestions(true);
  306. }}
  307. onBlur={() => {
  308. blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
  309. }}
  310. 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"
  311. placeholder={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
  312. />
  313. {/* Suggestions dropdown */}
  314. {showTagSuggestions && tagSuggestions.length > 0 && (
  315. <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
  316. <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
  317. Existing tags (click to add)
  318. </div>
  319. <div className="p-2 flex flex-wrap gap-1.5">
  320. {tagSuggestions.map((tag) => (
  321. <button
  322. key={tag}
  323. type="button"
  324. onClick={() => addTag(tag)}
  325. className="px-2 py-0.5 bg-bambu-dark-tertiary hover:bg-bambu-green/20 rounded text-sm text-bambu-gray hover:text-white transition-colors"
  326. >
  327. {tag}
  328. </button>
  329. ))}
  330. </div>
  331. </div>
  332. )}
  333. </div>
  334. </div>
  335. {/* Status */}
  336. <div>
  337. <label className="block text-sm text-bambu-gray mb-1">Status</label>
  338. <select
  339. value={status}
  340. onChange={(e) => {
  341. setStatus(e.target.value);
  342. // Clear failure reason when changing to completed
  343. if (e.target.value === 'completed') {
  344. setFailureReason('');
  345. }
  346. }}
  347. 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"
  348. >
  349. {ARCHIVE_STATUSES.map((s) => (
  350. <option key={s.value} value={s.value}>
  351. {s.label}
  352. </option>
  353. ))}
  354. </select>
  355. </div>
  356. {/* Failure Reason - only show for failed/aborted prints */}
  357. {(status === 'failed' || status === 'aborted') && (
  358. <div>
  359. <label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
  360. <select
  361. value={failureReason}
  362. onChange={(e) => setFailureReason(e.target.value)}
  363. 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"
  364. >
  365. <option value="">Select reason...</option>
  366. {FAILURE_REASONS.map((reason) => (
  367. <option key={reason} value={reason}>
  368. {reason}
  369. </option>
  370. ))}
  371. </select>
  372. </div>
  373. )}
  374. {/* Photos */}
  375. <div>
  376. <label className="block text-sm text-bambu-gray mb-1">
  377. <Camera className="w-4 h-4 inline mr-1" />
  378. Photos of Printed Result
  379. </label>
  380. {/* Photo grid */}
  381. <div className="flex flex-wrap gap-2 mb-2">
  382. {photos.map((filename) => (
  383. <div key={filename} className="relative group">
  384. <img
  385. src={api.getArchivePhotoUrl(archive.id, filename)}
  386. alt="Print result"
  387. className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
  388. />
  389. <button
  390. type="button"
  391. onClick={() => handlePhotoDelete(filename)}
  392. className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
  393. >
  394. <Trash2 className="w-3 h-3 text-white" />
  395. </button>
  396. </div>
  397. ))}
  398. {/* Upload button */}
  399. <label className="w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors">
  400. <input
  401. ref={photoInputRef}
  402. type="file"
  403. accept="image/jpeg,image/png,image/webp"
  404. onChange={handlePhotoUpload}
  405. className="hidden"
  406. disabled={uploadingPhoto}
  407. />
  408. {uploadingPhoto ? (
  409. <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
  410. ) : (
  411. <Plus className="w-6 h-6 text-bambu-gray" />
  412. )}
  413. </label>
  414. </div>
  415. <p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
  416. </div>
  417. {/* Actions */}
  418. <div className="flex gap-3 pt-2">
  419. <Button
  420. type="button"
  421. variant="secondary"
  422. onClick={onClose}
  423. className="flex-1"
  424. >
  425. Cancel
  426. </Button>
  427. <Button
  428. type="submit"
  429. disabled={updateMutation.isPending}
  430. className="flex-1"
  431. >
  432. <Save className="w-4 h-4" />
  433. {updateMutation.isPending ? 'Saving...' : 'Save'}
  434. </Button>
  435. </div>
  436. </form>
  437. </div>
  438. </div>
  439. );
  440. }