import { useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Save, Link, Camera, Trash2, Loader2, Plus, ExternalLink, StickyNote } from 'lucide-react'; import { api } from '../api/client'; import type { LibraryFileListItem } from '../api/client'; import { Button } from './Button'; import { PhotoGalleryModal } from './PhotoGalleryModal'; import { useToast } from '../contexts/ToastContext'; import { formatDate, formatDuration } from '../utils/date'; import { formatFileSize } from '../utils/file'; interface LibraryFileDetailsModalProps { file: LibraryFileListItem; // Notes, link and photos are edits to the file, so they follow the same // ownership gate as rename (`canModify('library', 'update', ...)`). canEdit: boolean; onClose: () => void; } // Notes, an external link and photos of the printed result on a library // file (#3077) — the same trio EditArchiveModal offers for an archive. Photos // are saved as they are added; notes and the link on Save. export function LibraryFileDetailsModal({ file, canEdit, onClose }: LibraryFileDetailsModalProps) { const { t } = useTranslation(); const { showToast } = useToast(); const queryClient = useQueryClient(); const { data: details } = useQuery({ queryKey: ['library-file', file.id], queryFn: () => api.getLibraryFile(file.id), }); const [notes, setNotes] = useState(''); const [externalUrl, setExternalUrl] = useState(''); const [photos, setPhotos] = useState([]); const [uploadingPhoto, setUploadingPhoto] = useState(false); // The photo the lightbox opens on, or null while it is closed. const [galleryIndex, setGalleryIndex] = useState(null); const photoInputRef = useRef(null); // Seed the form once per file. Later refetches (photo changes below, window // focus) must not overwrite notes or a link the user is still typing. const seededForId = useRef(null); useEffect(() => { if (!details || seededForId.current === details.id) return; seededForId.current = details.id; setNotes(details.notes ?? ''); setExternalUrl(details.external_url ?? ''); setPhotos(details.photos ?? []); }, [details]); // Escape closes the modal, as it does in the rest of the app. Not while the // lightbox is open on top of it — that handles Escape itself, and a second // listener here would close both at once. useEffect(() => { if (galleryIndex !== null) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [galleryIndex, onClose]); const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['library-file', file.id] }); }; const saveMutation = useMutation({ mutationFn: () => api.updateLibraryFile(file.id, { notes, external_url: externalUrl.trim() }), onSuccess: () => { invalidate(); showToast(t('fileManager.details.saved'), 'success'); onClose(); }, onError: () => { showToast(t('fileManager.details.saveFailed'), 'error'); }, }); const hasChanges = !!details && (notes !== (details.notes ?? '') || externalUrl.trim() !== (details.external_url ?? '')); const handlePhotoUpload = async (e: React.ChangeEvent) => { const picked = e.target.files?.[0]; if (!picked) return; setUploadingPhoto(true); try { const result = await api.uploadLibraryFilePhoto(file.id, picked); setPhotos(result.photos); invalidate(); } catch { showToast(t('fileManager.details.uploadFailed'), 'error'); } finally { setUploadingPhoto(false); if (photoInputRef.current) { photoInputRef.current.value = ''; } } }; const handlePhotoDelete = async (filename: string) => { try { const result = await api.deleteLibraryFilePhoto(file.id, filename); const remaining = result.photos ?? []; setPhotos(remaining); // Deleting the last photo unmounts the lightbox through the render // guard below before its own "nothing left to show" branch can call // onClose, so the index has to be cleared here. Left set, it keeps // Escape disabled for good and re-opens the lightbox unasked as soon // as another photo is uploaded. if (remaining.length === 0) setGalleryIndex(null); invalidate(); } catch { showToast(t('fileManager.details.deleteFailed'), 'error'); } }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!canEdit || !hasChanges) return; saveMutation.mutate(); }; const trimmedUrl = externalUrl.trim(); const facts: Array<{ label: string; value: React.ReactNode }> = [ { label: t('fileManager.details.size'), value: formatFileSize(file.file_size) }, { label: t('fileManager.details.type'), value: file.file_type.toUpperCase() }, ]; if (details?.print_name) facts.push({ label: t('fileManager.details.printName'), value: details.print_name }); if (details?.print_time_seconds) { facts.push({ label: t('fileManager.details.printTime'), value: formatDuration(details.print_time_seconds) }); } if (details?.filament_used_grams) { facts.push({ label: t('fileManager.details.filament'), value: `${details.filament_used_grams.toFixed(1)} g` }); } if (details?.sliced_for_model) facts.push({ label: t('fileManager.details.slicedFor'), value: details.sliced_for_model }); if (details?.source_url) { facts.push({ label: t('fileManager.details.source'), value: ( {details.source_url} ), }); } facts.push({ label: t('fileManager.details.created'), value: formatDate(file.created_at) }); facts.push({ label: t('fileManager.details.modified'), value: formatDate(file.fs_modified_at ?? details?.updated_at ?? file.created_at), }); return (
e.stopPropagation()} > {/* Header */}

{t('fileManager.details.title')}

{file.filename}

{file.file_type.toUpperCase()}
{/* Facts */}
{facts.map((fact) => (
{fact.label}
{fact.value}
))}
{/* Notes */}