EditArchiveModal.tsx 25 KB

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