LibraryFileDetailsModal.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { X, Save, Link, Camera, Trash2, Loader2, Plus, ExternalLink, StickyNote } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { LibraryFileListItem } from '../api/client';
  7. import { Button } from './Button';
  8. import { PhotoGalleryModal } from './PhotoGalleryModal';
  9. import { useToast } from '../contexts/ToastContext';
  10. import { formatDate, formatDuration } from '../utils/date';
  11. import { formatFileSize } from '../utils/file';
  12. interface LibraryFileDetailsModalProps {
  13. file: LibraryFileListItem;
  14. // Notes, link and photos are edits to the file, so they follow the same
  15. // ownership gate as rename (`canModify('library', 'update', ...)`).
  16. canEdit: boolean;
  17. onClose: () => void;
  18. }
  19. // Notes, an external link and photos of the printed result on a library
  20. // file (#3077) — the same trio EditArchiveModal offers for an archive. Photos
  21. // are saved as they are added; notes and the link on Save.
  22. export function LibraryFileDetailsModal({ file, canEdit, onClose }: LibraryFileDetailsModalProps) {
  23. const { t } = useTranslation();
  24. const { showToast } = useToast();
  25. const queryClient = useQueryClient();
  26. const { data: details } = useQuery({
  27. queryKey: ['library-file', file.id],
  28. queryFn: () => api.getLibraryFile(file.id),
  29. });
  30. const [notes, setNotes] = useState('');
  31. const [externalUrl, setExternalUrl] = useState('');
  32. const [photos, setPhotos] = useState<string[]>([]);
  33. const [uploadingPhoto, setUploadingPhoto] = useState(false);
  34. // The photo the lightbox opens on, or null while it is closed.
  35. const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
  36. const photoInputRef = useRef<HTMLInputElement>(null);
  37. // Seed the form once per file. Later refetches (photo changes below, window
  38. // focus) must not overwrite notes or a link the user is still typing.
  39. const seededForId = useRef<number | null>(null);
  40. useEffect(() => {
  41. if (!details || seededForId.current === details.id) return;
  42. seededForId.current = details.id;
  43. setNotes(details.notes ?? '');
  44. setExternalUrl(details.external_url ?? '');
  45. setPhotos(details.photos ?? []);
  46. }, [details]);
  47. // Escape closes the modal, as it does in the rest of the app. Not while the
  48. // lightbox is open on top of it — that handles Escape itself, and a second
  49. // listener here would close both at once.
  50. useEffect(() => {
  51. if (galleryIndex !== null) return;
  52. const handleKeyDown = (e: KeyboardEvent) => {
  53. if (e.key === 'Escape') onClose();
  54. };
  55. window.addEventListener('keydown', handleKeyDown);
  56. return () => window.removeEventListener('keydown', handleKeyDown);
  57. }, [galleryIndex, onClose]);
  58. const invalidate = () => {
  59. queryClient.invalidateQueries({ queryKey: ['library-files'] });
  60. queryClient.invalidateQueries({ queryKey: ['library-file', file.id] });
  61. };
  62. const saveMutation = useMutation({
  63. mutationFn: () => api.updateLibraryFile(file.id, { notes, external_url: externalUrl.trim() }),
  64. onSuccess: () => {
  65. invalidate();
  66. showToast(t('fileManager.details.saved'), 'success');
  67. onClose();
  68. },
  69. onError: () => {
  70. showToast(t('fileManager.details.saveFailed'), 'error');
  71. },
  72. });
  73. const hasChanges =
  74. !!details && (notes !== (details.notes ?? '') || externalUrl.trim() !== (details.external_url ?? ''));
  75. const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  76. const picked = e.target.files?.[0];
  77. if (!picked) return;
  78. setUploadingPhoto(true);
  79. try {
  80. const result = await api.uploadLibraryFilePhoto(file.id, picked);
  81. setPhotos(result.photos);
  82. invalidate();
  83. } catch {
  84. showToast(t('fileManager.details.uploadFailed'), 'error');
  85. } finally {
  86. setUploadingPhoto(false);
  87. if (photoInputRef.current) {
  88. photoInputRef.current.value = '';
  89. }
  90. }
  91. };
  92. const handlePhotoDelete = async (filename: string) => {
  93. try {
  94. const result = await api.deleteLibraryFilePhoto(file.id, filename);
  95. const remaining = result.photos ?? [];
  96. setPhotos(remaining);
  97. // Deleting the last photo unmounts the lightbox through the render
  98. // guard below before its own "nothing left to show" branch can call
  99. // onClose, so the index has to be cleared here. Left set, it keeps
  100. // Escape disabled for good and re-opens the lightbox unasked as soon
  101. // as another photo is uploaded.
  102. if (remaining.length === 0) setGalleryIndex(null);
  103. invalidate();
  104. } catch {
  105. showToast(t('fileManager.details.deleteFailed'), 'error');
  106. }
  107. };
  108. const handleSubmit = (e: React.FormEvent) => {
  109. e.preventDefault();
  110. if (!canEdit || !hasChanges) return;
  111. saveMutation.mutate();
  112. };
  113. const trimmedUrl = externalUrl.trim();
  114. const facts: Array<{ label: string; value: React.ReactNode }> = [
  115. { label: t('fileManager.details.size'), value: formatFileSize(file.file_size) },
  116. { label: t('fileManager.details.type'), value: file.file_type.toUpperCase() },
  117. ];
  118. if (details?.print_name) facts.push({ label: t('fileManager.details.printName'), value: details.print_name });
  119. if (details?.print_time_seconds) {
  120. facts.push({ label: t('fileManager.details.printTime'), value: formatDuration(details.print_time_seconds) });
  121. }
  122. if (details?.filament_used_grams) {
  123. facts.push({ label: t('fileManager.details.filament'), value: `${details.filament_used_grams.toFixed(1)} g` });
  124. }
  125. if (details?.sliced_for_model) facts.push({ label: t('fileManager.details.slicedFor'), value: details.sliced_for_model });
  126. if (details?.source_url) {
  127. // Only web addresses are links. A Manyfold import (#1471) records
  128. // `manyfold:<model>/<file>`, which a browser can't open.
  129. const isWebLink = /^https?:\/\//i.test(details.source_url);
  130. facts.push({
  131. label: t('fileManager.details.source'),
  132. value: !isWebLink ? (
  133. <span className="truncate">{details.source_url}</span>
  134. ) : (
  135. <a
  136. href={details.source_url}
  137. target="_blank"
  138. rel="noopener noreferrer"
  139. className="text-bambu-green hover:underline inline-flex items-center gap-1 min-w-0"
  140. >
  141. <span className="truncate">{details.source_url}</span>
  142. <ExternalLink className="w-3 h-3 flex-shrink-0" />
  143. </a>
  144. ),
  145. });
  146. }
  147. facts.push({ label: t('fileManager.details.created'), value: formatDate(file.created_at) });
  148. facts.push({
  149. label: t('fileManager.details.modified'),
  150. value: formatDate(file.fs_modified_at ?? details?.updated_at ?? file.created_at),
  151. });
  152. return (
  153. <div
  154. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  155. onClick={onClose}
  156. >
  157. <div
  158. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] flex flex-col"
  159. onClick={(e) => e.stopPropagation()}
  160. >
  161. {/* Header */}
  162. <div className="flex items-center justify-between gap-3 px-6 py-4 border-b border-bambu-dark-tertiary">
  163. <div className="min-w-0">
  164. <p className="text-xs text-bambu-gray">{t('fileManager.details.title')}</p>
  165. <div className="flex items-center gap-2 min-w-0">
  166. <h2 className="text-lg font-semibold text-white truncate" title={file.filename}>
  167. {file.filename}
  168. </h2>
  169. <span className="flex-shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-bambu-dark-tertiary text-bambu-gray">
  170. {file.file_type.toUpperCase()}
  171. </span>
  172. </div>
  173. </div>
  174. <button
  175. type="button"
  176. onClick={onClose}
  177. className="text-bambu-gray hover:text-white transition-colors flex-shrink-0"
  178. aria-label={t('common.close')}
  179. >
  180. <X className="w-5 h-5" />
  181. </button>
  182. </div>
  183. <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
  184. {/* Facts */}
  185. <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
  186. {facts.map((fact) => (
  187. <div key={fact.label} className="contents">
  188. <dt className="text-bambu-gray whitespace-nowrap">{fact.label}</dt>
  189. <dd className="text-white min-w-0 truncate">{fact.value}</dd>
  190. </div>
  191. ))}
  192. </dl>
  193. {/* Notes */}
  194. <div>
  195. <label className="block text-sm text-bambu-gray mb-1">
  196. <StickyNote className="w-4 h-4 inline mr-1" />
  197. {t('fileManager.details.notes')}
  198. </label>
  199. <textarea
  200. value={notes}
  201. onChange={(e) => setNotes(e.target.value)}
  202. rows={3}
  203. disabled={!canEdit}
  204. 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 disabled:opacity-60"
  205. placeholder={t('fileManager.details.notesPlaceholder')}
  206. />
  207. </div>
  208. {/* External link */}
  209. <div>
  210. <label className="block text-sm text-bambu-gray mb-1">
  211. <Link className="w-4 h-4 inline mr-1" />
  212. {t('fileManager.details.externalLink')}
  213. </label>
  214. <div className="flex items-center gap-2">
  215. <input
  216. type="url"
  217. value={externalUrl}
  218. onChange={(e) => setExternalUrl(e.target.value)}
  219. disabled={!canEdit}
  220. className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-60"
  221. placeholder={t('fileManager.details.externalLinkPlaceholder')}
  222. />
  223. {trimmedUrl && (
  224. <a
  225. href={trimmedUrl}
  226. target="_blank"
  227. rel="noopener noreferrer"
  228. className="p-2 rounded-lg text-bambu-gray hover:text-bambu-green hover:bg-bambu-dark transition-colors"
  229. title={t('fileManager.details.openLink')}
  230. aria-label={t('fileManager.details.openLink')}
  231. >
  232. <ExternalLink className="w-4 h-4" />
  233. </a>
  234. )}
  235. </div>
  236. </div>
  237. {/* Photos */}
  238. <div>
  239. <label className="block text-sm text-bambu-gray mb-1">
  240. <Camera className="w-4 h-4 inline mr-1" />
  241. {t('fileManager.details.photos')}
  242. </label>
  243. <div className="flex flex-wrap gap-2">
  244. {photos.map((filename, index) => (
  245. <div key={filename} className="relative group">
  246. <button
  247. type="button"
  248. onClick={() => setGalleryIndex(index)}
  249. className="block w-20 h-20 rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
  250. >
  251. <img
  252. src={api.getLibraryFilePhotoUrl(file.id, filename)}
  253. alt={t('fileManager.details.photos')}
  254. className="w-full h-full object-cover"
  255. />
  256. </button>
  257. {canEdit && (
  258. <button
  259. type="button"
  260. onClick={() => handlePhotoDelete(filename)}
  261. 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"
  262. title={t('fileManager.details.deletePhoto')}
  263. aria-label={t('fileManager.details.deletePhoto')}
  264. >
  265. <Trash2 className="w-3 h-3 text-white" />
  266. </button>
  267. )}
  268. </div>
  269. ))}
  270. {canEdit && (
  271. <label
  272. 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"
  273. title={t('fileManager.details.addPhoto')}
  274. >
  275. <input
  276. ref={photoInputRef}
  277. type="file"
  278. accept="image/jpeg,image/png,image/webp"
  279. onChange={handlePhotoUpload}
  280. className="hidden"
  281. disabled={uploadingPhoto}
  282. aria-label={t('fileManager.details.addPhoto')}
  283. />
  284. {uploadingPhoto ? (
  285. <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
  286. ) : (
  287. <Plus className="w-6 h-6 text-bambu-gray" />
  288. )}
  289. </label>
  290. )}
  291. {photos.length === 0 && !canEdit && (
  292. <p className="text-xs text-bambu-gray">{t('fileManager.details.noPhotos')}</p>
  293. )}
  294. </div>
  295. </div>
  296. {/* Actions */}
  297. <div className="flex gap-3 pt-2">
  298. <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
  299. {t('common.close')}
  300. </Button>
  301. {canEdit && (
  302. <Button
  303. type="submit"
  304. variant="primary"
  305. className="flex-1"
  306. disabled={!hasChanges || saveMutation.isPending}
  307. >
  308. {saveMutation.isPending ? (
  309. <Loader2 className="w-4 h-4 animate-spin" />
  310. ) : (
  311. <Save className="w-4 h-4" />
  312. )}
  313. {t('fileManager.details.save')}
  314. </Button>
  315. )}
  316. </div>
  317. </form>
  318. </div>
  319. {galleryIndex !== null && photos.length > 0 && (
  320. <PhotoGalleryModal
  321. archiveName={file.filename}
  322. photos={photos}
  323. initialIndex={galleryIndex}
  324. getPhotoUrl={(filename) => api.getLibraryFilePhotoUrl(file.id, filename)}
  325. onClose={() => setGalleryIndex(null)}
  326. onDelete={canEdit ? handlePhotoDelete : undefined}
  327. />
  328. )}
  329. </div>
  330. );
  331. }