LibraryFileDetailsModal.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  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. facts.push({
  128. label: t('fileManager.details.source'),
  129. value: (
  130. <a
  131. href={details.source_url}
  132. target="_blank"
  133. rel="noopener noreferrer"
  134. className="text-bambu-green hover:underline inline-flex items-center gap-1 min-w-0"
  135. >
  136. <span className="truncate">{details.source_url}</span>
  137. <ExternalLink className="w-3 h-3 flex-shrink-0" />
  138. </a>
  139. ),
  140. });
  141. }
  142. facts.push({ label: t('fileManager.details.created'), value: formatDate(file.created_at) });
  143. facts.push({
  144. label: t('fileManager.details.modified'),
  145. value: formatDate(file.fs_modified_at ?? details?.updated_at ?? file.created_at),
  146. });
  147. return (
  148. <div
  149. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  150. onClick={onClose}
  151. >
  152. <div
  153. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] flex flex-col"
  154. onClick={(e) => e.stopPropagation()}
  155. >
  156. {/* Header */}
  157. <div className="flex items-center justify-between gap-3 px-6 py-4 border-b border-bambu-dark-tertiary">
  158. <div className="min-w-0">
  159. <p className="text-xs text-bambu-gray">{t('fileManager.details.title')}</p>
  160. <div className="flex items-center gap-2 min-w-0">
  161. <h2 className="text-lg font-semibold text-white truncate" title={file.filename}>
  162. {file.filename}
  163. </h2>
  164. <span className="flex-shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-bambu-dark-tertiary text-bambu-gray">
  165. {file.file_type.toUpperCase()}
  166. </span>
  167. </div>
  168. </div>
  169. <button
  170. type="button"
  171. onClick={onClose}
  172. className="text-bambu-gray hover:text-white transition-colors flex-shrink-0"
  173. aria-label={t('common.close')}
  174. >
  175. <X className="w-5 h-5" />
  176. </button>
  177. </div>
  178. <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
  179. {/* Facts */}
  180. <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
  181. {facts.map((fact) => (
  182. <div key={fact.label} className="contents">
  183. <dt className="text-bambu-gray whitespace-nowrap">{fact.label}</dt>
  184. <dd className="text-white min-w-0 truncate">{fact.value}</dd>
  185. </div>
  186. ))}
  187. </dl>
  188. {/* Notes */}
  189. <div>
  190. <label className="block text-sm text-bambu-gray mb-1">
  191. <StickyNote className="w-4 h-4 inline mr-1" />
  192. {t('fileManager.details.notes')}
  193. </label>
  194. <textarea
  195. value={notes}
  196. onChange={(e) => setNotes(e.target.value)}
  197. rows={3}
  198. disabled={!canEdit}
  199. 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"
  200. placeholder={t('fileManager.details.notesPlaceholder')}
  201. />
  202. </div>
  203. {/* External link */}
  204. <div>
  205. <label className="block text-sm text-bambu-gray mb-1">
  206. <Link className="w-4 h-4 inline mr-1" />
  207. {t('fileManager.details.externalLink')}
  208. </label>
  209. <div className="flex items-center gap-2">
  210. <input
  211. type="url"
  212. value={externalUrl}
  213. onChange={(e) => setExternalUrl(e.target.value)}
  214. disabled={!canEdit}
  215. 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"
  216. placeholder={t('fileManager.details.externalLinkPlaceholder')}
  217. />
  218. {trimmedUrl && (
  219. <a
  220. href={trimmedUrl}
  221. target="_blank"
  222. rel="noopener noreferrer"
  223. className="p-2 rounded-lg text-bambu-gray hover:text-bambu-green hover:bg-bambu-dark transition-colors"
  224. title={t('fileManager.details.openLink')}
  225. aria-label={t('fileManager.details.openLink')}
  226. >
  227. <ExternalLink className="w-4 h-4" />
  228. </a>
  229. )}
  230. </div>
  231. </div>
  232. {/* Photos */}
  233. <div>
  234. <label className="block text-sm text-bambu-gray mb-1">
  235. <Camera className="w-4 h-4 inline mr-1" />
  236. {t('fileManager.details.photos')}
  237. </label>
  238. <div className="flex flex-wrap gap-2">
  239. {photos.map((filename, index) => (
  240. <div key={filename} className="relative group">
  241. <button
  242. type="button"
  243. onClick={() => setGalleryIndex(index)}
  244. className="block w-20 h-20 rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
  245. >
  246. <img
  247. src={api.getLibraryFilePhotoUrl(file.id, filename)}
  248. alt={t('fileManager.details.photos')}
  249. className="w-full h-full object-cover"
  250. />
  251. </button>
  252. {canEdit && (
  253. <button
  254. type="button"
  255. onClick={() => handlePhotoDelete(filename)}
  256. 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"
  257. title={t('fileManager.details.deletePhoto')}
  258. aria-label={t('fileManager.details.deletePhoto')}
  259. >
  260. <Trash2 className="w-3 h-3 text-white" />
  261. </button>
  262. )}
  263. </div>
  264. ))}
  265. {canEdit && (
  266. <label
  267. 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"
  268. title={t('fileManager.details.addPhoto')}
  269. >
  270. <input
  271. ref={photoInputRef}
  272. type="file"
  273. accept="image/jpeg,image/png,image/webp"
  274. onChange={handlePhotoUpload}
  275. className="hidden"
  276. disabled={uploadingPhoto}
  277. aria-label={t('fileManager.details.addPhoto')}
  278. />
  279. {uploadingPhoto ? (
  280. <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
  281. ) : (
  282. <Plus className="w-6 h-6 text-bambu-gray" />
  283. )}
  284. </label>
  285. )}
  286. {photos.length === 0 && !canEdit && (
  287. <p className="text-xs text-bambu-gray">{t('fileManager.details.noPhotos')}</p>
  288. )}
  289. </div>
  290. </div>
  291. {/* Actions */}
  292. <div className="flex gap-3 pt-2">
  293. <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
  294. {t('common.close')}
  295. </Button>
  296. {canEdit && (
  297. <Button
  298. type="submit"
  299. variant="primary"
  300. className="flex-1"
  301. disabled={!hasChanges || saveMutation.isPending}
  302. >
  303. {saveMutation.isPending ? (
  304. <Loader2 className="w-4 h-4 animate-spin" />
  305. ) : (
  306. <Save className="w-4 h-4" />
  307. )}
  308. {t('fileManager.details.save')}
  309. </Button>
  310. )}
  311. </div>
  312. </form>
  313. </div>
  314. {galleryIndex !== null && photos.length > 0 && (
  315. <PhotoGalleryModal
  316. archiveName={file.filename}
  317. photos={photos}
  318. initialIndex={galleryIndex}
  319. getPhotoUrl={(filename) => api.getLibraryFilePhotoUrl(file.id, filename)}
  320. onClose={() => setGalleryIndex(null)}
  321. onDelete={canEdit ? handlePhotoDelete : undefined}
  322. />
  323. )}
  324. </div>
  325. );
  326. }