EditArchiveModal.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import { useState, useEffect, useRef } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { Archive } from '../api/client';
  7. import { Button } from './Button';
  8. import { PrintLogTable } from './PrintLogTable';
  9. import { invalidateArchiveAndProjectViews } from '../utils/projectQueries';
  10. // Keys for failure reasons - translated at render time.
  11. // Exported so the Print Log per-row classification editor (#1687 part 4)
  12. // can share the same vocabulary as the Archive Edit modal — the backend
  13. // PATCH /print-log/{id} validator gates writes against this exact list.
  14. export const FAILURE_REASON_KEYS = [
  15. 'adhesionFailure',
  16. 'spaghettiDetached',
  17. 'layerShift',
  18. 'cloggedNozzle',
  19. 'filamentRunout',
  20. 'warping',
  21. 'stringing',
  22. 'underExtrusion',
  23. 'powerFailure',
  24. 'userCancelled',
  25. 'other',
  26. ] as const;
  27. // Keys for archive statuses - translated at render time
  28. const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;
  29. interface EditArchiveModalProps {
  30. archive: Archive;
  31. onClose: () => void;
  32. existingTags?: string[];
  33. }
  34. export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
  35. const { t } = useTranslation();
  36. // Close on Escape key
  37. useEffect(() => {
  38. const handleKeyDown = (e: KeyboardEvent) => {
  39. if (e.key === 'Escape') onClose();
  40. };
  41. window.addEventListener('keydown', handleKeyDown);
  42. return () => window.removeEventListener('keydown', handleKeyDown);
  43. }, [onClose]);
  44. const queryClient = useQueryClient();
  45. const [printName, setPrintName] = useState(archive.print_name || '');
  46. const [printerId, setPrinterId] = useState<number | null>(archive.printer_id);
  47. const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
  48. const [notes, setNotes] = useState(archive.notes || '');
  49. const [tags, setTags] = useState(archive.tags || '');
  50. // Failure reason is stored as a camelCase key (`filamentRunout`), but earlier
  51. // versions of this modal saved the translated label as the value. Reverse-
  52. // lookup any legacy translated text against the current locale so the
  53. // dropdown pre-selects the right option, then any save converts it forward.
  54. const [failureReason, setFailureReason] = useState(() => {
  55. const raw = archive.failure_reason || '';
  56. if (!raw) return '';
  57. if ((FAILURE_REASON_KEYS as readonly string[]).includes(raw)) return raw;
  58. const match = FAILURE_REASON_KEYS.find(
  59. (k) => t(`editArchive.failureReasons.${k}`) === raw,
  60. );
  61. return match || '';
  62. });
  63. const [status, setStatus] = useState(archive.status);
  64. const [quantity, setQuantity] = useState(archive.quantity ?? 1);
  65. const [photos, setPhotos] = useState<string[]>(archive.photos || []);
  66. const [externalUrl, setExternalUrl] = useState(archive.external_url || '');
  67. const [uploadingPhoto, setUploadingPhoto] = useState(false);
  68. const [showTagSuggestions, setShowTagSuggestions] = useState(false);
  69. const tagInputRef = useRef<HTMLInputElement>(null);
  70. const photoInputRef = useRef<HTMLInputElement>(null);
  71. const blurTimeoutRef = useRef<number | null>(null);
  72. const { data: printers } = useQuery({
  73. queryKey: ['printers'],
  74. queryFn: api.getPrinters,
  75. });
  76. const { data: projects } = useQuery({
  77. queryKey: ['projects'],
  78. queryFn: () => api.getProjects(),
  79. select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
  80. });
  81. // Fetch all tags using the dedicated API
  82. const { data: tagsData } = useQuery({
  83. queryKey: ['tags'],
  84. queryFn: api.getTags,
  85. enabled: existingTags.length === 0,
  86. });
  87. // Use existing tags prop if provided, otherwise use fetched tags
  88. const allTags = existingTags.length > 0
  89. ? existingTags
  90. : (tagsData?.map(t => t.name) || []);
  91. // Get current tags as array
  92. const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
  93. // Get the text being typed after the last comma (for autocomplete filtering)
  94. const currentInput = tags.includes(',')
  95. ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
  96. : tags.trim().toLowerCase();
  97. // Filter suggestions: not already added AND matches current input (if any)
  98. const tagSuggestions = allTags.filter(t =>
  99. !currentTags.includes(t) &&
  100. (currentInput === '' || t.toLowerCase().includes(currentInput))
  101. );
  102. // Add a tag (replaces any partial input with the selected tag)
  103. const addTag = (tag: string) => {
  104. // If there's partial input being typed, replace it with the selected tag
  105. // Otherwise, just append the tag
  106. let baseTags: string[];
  107. if (currentInput && !allTags.includes(currentInput)) {
  108. // User is typing a partial tag - replace it with the selected one
  109. baseTags = tags.includes(',')
  110. ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
  111. : [];
  112. } else {
  113. // No partial input or input is already a complete tag - append
  114. baseTags = currentTags;
  115. }
  116. if (!baseTags.includes(tag)) {
  117. const newTags = [...baseTags, tag].join(', ');
  118. setTags(newTags);
  119. }
  120. // Clear any pending blur timeout to prevent hiding suggestions
  121. if (blurTimeoutRef.current !== null) {
  122. clearTimeout(blurTimeoutRef.current);
  123. }
  124. tagInputRef.current?.focus();
  125. };
  126. // Remove a tag
  127. const removeTag = (tagToRemove: string) => {
  128. const newTags = currentTags.filter(t => t !== tagToRemove).join(', ');
  129. setTags(newTags);
  130. };
  131. const updateMutation = useMutation({
  132. mutationFn: (data: Parameters<typeof api.updateArchive>[1]) =>
  133. api.updateArchive(archive.id, data),
  134. onSuccess: () => {
  135. // This form can change the archive's project, so the project detail
  136. // views need refreshing too — not just the overview cards (#2731).
  137. invalidateArchiveAndProjectViews(queryClient);
  138. onClose();
  139. },
  140. });
  141. const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  142. const file = e.target.files?.[0];
  143. if (!file) return;
  144. setUploadingPhoto(true);
  145. try {
  146. const result = await api.uploadArchivePhoto(archive.id, file);
  147. setPhotos(result.photos);
  148. queryClient.invalidateQueries({ queryKey: ['archives'] });
  149. } catch (error) {
  150. console.error('Failed to upload photo:', error);
  151. } finally {
  152. setUploadingPhoto(false);
  153. if (photoInputRef.current) {
  154. photoInputRef.current.value = '';
  155. }
  156. }
  157. };
  158. const handlePhotoDelete = async (filename: string) => {
  159. try {
  160. const result = await api.deleteArchivePhoto(archive.id, filename);
  161. setPhotos(result.photos || []);
  162. queryClient.invalidateQueries({ queryKey: ['archives'] });
  163. } catch (error) {
  164. console.error('Failed to delete photo:', error);
  165. }
  166. };
  167. const handleSubmit = (e: React.FormEvent) => {
  168. e.preventDefault();
  169. // Build update data
  170. const updateData: Parameters<typeof api.updateArchive>[1] = {
  171. print_name: printName || undefined,
  172. printer_id: printerId,
  173. project_id: projectId,
  174. notes: notes || undefined,
  175. tags: tags || undefined,
  176. quantity: quantity,
  177. external_url: externalUrl || null,
  178. };
  179. // Only include status if changed
  180. if (status !== archive.status) {
  181. updateData.status = status;
  182. }
  183. // Handle failure_reason based on status
  184. if (status === 'failed' || status === 'aborted') {
  185. updateData.failure_reason = failureReason || undefined;
  186. } else if (archive.status === 'failed' || archive.status === 'aborted') {
  187. // Clear failure_reason when changing from failed/aborted to another status
  188. updateData.failure_reason = null;
  189. }
  190. updateMutation.mutate(updateData);
  191. };
  192. return (
  193. <div
  194. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  195. onClick={onClose}
  196. >
  197. <div
  198. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
  199. onClick={(e) => e.stopPropagation()}
  200. >
  201. {/* Header */}
  202. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  203. <h2 className="text-lg font-semibold text-white">{t('editArchive.title')}</h2>
  204. <button
  205. onClick={onClose}
  206. className="text-bambu-gray hover:text-white transition-colors"
  207. >
  208. <X className="w-5 h-5" />
  209. </button>
  210. </div>
  211. {/* Form */}
  212. <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
  213. {/* Print Log — per-run history pulled from PrintLogEntry (#1378). Shown
  214. first so users can see which runs contributed to the aggregate stats. */}
  215. <div>
  216. <label className="block text-sm text-bambu-gray mb-1">{t('archives.runLog.title')}</label>
  217. <PrintLogTable archiveId={archive.id} />
  218. </div>
  219. {/* Print Name */}
  220. <div>
  221. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
  222. <input
  223. type="text"
  224. value={printName}
  225. onChange={(e) => setPrintName(e.target.value)}
  226. 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"
  227. placeholder={t('editArchive.namePlaceholder')}
  228. />
  229. </div>
  230. {/* Printer */}
  231. <div>
  232. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.printer')}</label>
  233. <select
  234. value={printerId ?? ''}
  235. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  236. 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"
  237. >
  238. <option value="">{t('editArchive.noPrinter')}</option>
  239. {printers?.map((p) => (
  240. <option key={p.id} value={p.id}>
  241. {p.name}
  242. </option>
  243. ))}
  244. </select>
  245. </div>
  246. {/* Project */}
  247. <div>
  248. <label className="block text-sm text-bambu-gray mb-1">
  249. <FolderKanban className="w-4 h-4 inline mr-1" />
  250. {t('editArchive.project')}
  251. </label>
  252. <select
  253. value={projectId ?? ''}
  254. onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
  255. 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"
  256. >
  257. <option value="">{t('editArchive.noProject')}</option>
  258. {projects?.map((p) => (
  259. <option key={p.id} value={p.id}>
  260. {p.name}
  261. </option>
  262. ))}
  263. </select>
  264. </div>
  265. {/* Quantity - number of items printed */}
  266. <div>
  267. <label className="block text-sm text-bambu-gray mb-1">
  268. <Hash className="w-4 h-4 inline mr-1" />
  269. {t('editArchive.itemsPrinted')}
  270. </label>
  271. <input
  272. type="number"
  273. min={1}
  274. value={quantity}
  275. onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
  276. 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"
  277. placeholder="1"
  278. />
  279. <p className="text-xs text-bambu-gray mt-1">
  280. {t('editArchive.itemsPrintedHelp')}
  281. </p>
  282. </div>
  283. {/* Notes */}
  284. <div>
  285. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>
  286. <textarea
  287. value={notes}
  288. onChange={(e) => setNotes(e.target.value)}
  289. rows={3}
  290. 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"
  291. placeholder={t('editArchive.notesPlaceholder')}
  292. />
  293. </div>
  294. {/* External Link */}
  295. <div>
  296. <label className="block text-sm text-bambu-gray mb-1">
  297. <Link className="w-4 h-4 inline mr-1" />
  298. {t('editArchive.externalLink')}
  299. </label>
  300. <input
  301. type="url"
  302. value={externalUrl}
  303. onChange={(e) => setExternalUrl(e.target.value)}
  304. 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"
  305. placeholder="https://printables.com/model/..."
  306. />
  307. <p className="text-xs text-bambu-gray mt-1">
  308. {t('editArchive.externalLinkHelp')}
  309. </p>
  310. </div>
  311. {/* Tags */}
  312. <div>
  313. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.tags')}</label>
  314. {/* Current tags as chips */}
  315. {currentTags.length > 0 && (
  316. <div className="flex flex-wrap gap-1.5 mb-2">
  317. {currentTags.map((tag) => (
  318. <span
  319. key={tag}
  320. className="inline-flex items-center gap-1 px-2 py-0.5 bg-bambu-dark-tertiary rounded text-sm text-white"
  321. >
  322. <Tag className="w-3 h-3" />
  323. {tag}
  324. <button
  325. type="button"
  326. onClick={() => removeTag(tag)}
  327. className="ml-0.5 text-bambu-gray hover:text-white"
  328. >
  329. <X className="w-3 h-3" />
  330. </button>
  331. </span>
  332. ))}
  333. </div>
  334. )}
  335. {/* Tag input with suggestions */}
  336. <div className="relative">
  337. <input
  338. ref={tagInputRef}
  339. type="text"
  340. value={tags}
  341. onChange={(e) => setTags(e.target.value)}
  342. onFocus={() => {
  343. if (blurTimeoutRef.current !== null) {
  344. clearTimeout(blurTimeoutRef.current);
  345. }
  346. setShowTagSuggestions(true);
  347. }}
  348. onBlur={() => {
  349. blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
  350. }}
  351. 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"
  352. placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}
  353. />
  354. {/* Suggestions dropdown */}
  355. {showTagSuggestions && tagSuggestions.length > 0 && (
  356. <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">
  357. <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
  358. {currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}
  359. </div>
  360. <div className="p-2 flex flex-wrap gap-1.5">
  361. {tagSuggestions.map((tag) => (
  362. <button
  363. key={tag}
  364. type="button"
  365. onClick={() => addTag(tag)}
  366. 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"
  367. >
  368. {tag}
  369. </button>
  370. ))}
  371. </div>
  372. </div>
  373. )}
  374. </div>
  375. </div>
  376. {/* Status */}
  377. <div>
  378. <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.status')}</label>
  379. <select
  380. value={status}
  381. onChange={(e) => {
  382. setStatus(e.target.value);
  383. // Clear failure reason when changing to completed
  384. if (e.target.value === 'completed') {
  385. setFailureReason('');
  386. }
  387. }}
  388. 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"
  389. >
  390. {ARCHIVE_STATUS_KEYS.map((statusKey) => (
  391. <option key={statusKey} value={statusKey}>
  392. {t(`editArchive.statuses.${statusKey}`)}
  393. </option>
  394. ))}
  395. </select>
  396. </div>
  397. {/* Failure Reason - only show for failed/aborted prints */}
  398. {(status === 'failed' || status === 'aborted') && (
  399. <div>
  400. <label htmlFor="failure-reason-select" className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
  401. <select
  402. id="failure-reason-select"
  403. value={failureReason}
  404. onChange={(e) => setFailureReason(e.target.value)}
  405. 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"
  406. >
  407. <option value="">{t('editArchive.selectReason')}</option>
  408. {FAILURE_REASON_KEYS.map((reasonKey) => (
  409. <option key={reasonKey} value={reasonKey}>
  410. {t(`editArchive.failureReasons.${reasonKey}`)}
  411. </option>
  412. ))}
  413. </select>
  414. </div>
  415. )}
  416. {/* Photos */}
  417. <div>
  418. <label className="block text-sm text-bambu-gray mb-1">
  419. <Camera className="w-4 h-4 inline mr-1" />
  420. {t('editArchive.photos')}
  421. </label>
  422. {/* Photo grid */}
  423. <div className="flex flex-wrap gap-2 mb-2">
  424. {photos.map((filename) => (
  425. <div key={filename} className="relative group">
  426. <img
  427. src={api.getArchivePhotoUrl(archive.id, filename)}
  428. alt={t('editArchive.printResult')}
  429. className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
  430. />
  431. <button
  432. type="button"
  433. onClick={() => handlePhotoDelete(filename)}
  434. className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
  435. >
  436. <Trash2 className="w-3 h-3 text-white" />
  437. </button>
  438. </div>
  439. ))}
  440. {/* Upload button */}
  441. <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">
  442. <input
  443. ref={photoInputRef}
  444. type="file"
  445. accept="image/jpeg,image/png,image/webp"
  446. onChange={handlePhotoUpload}
  447. className="hidden"
  448. disabled={uploadingPhoto}
  449. />
  450. {uploadingPhoto ? (
  451. <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
  452. ) : (
  453. <Plus className="w-6 h-6 text-bambu-gray" />
  454. )}
  455. </label>
  456. </div>
  457. <p className="text-xs text-bambu-gray">{t('editArchive.photosHelp')}</p>
  458. </div>
  459. {/* Actions */}
  460. <div className="flex gap-3 pt-2">
  461. <Button
  462. type="button"
  463. variant="secondary"
  464. onClick={onClose}
  465. className="flex-1"
  466. >
  467. {t('common.cancel')}
  468. </Button>
  469. <Button
  470. type="submit"
  471. disabled={updateMutation.isPending}
  472. className="flex-1"
  473. >
  474. <Save className="w-4 h-4" />
  475. {updateMutation.isPending ? t('common.saving') : t('common.save')}
  476. </Button>
  477. </div>
  478. </form>
  479. </div>
  480. </div>
  481. );
  482. }