import React, { useCallback, useEffect, useRef, useState } from 'react'; import { sessionFilesApi } from '../api/client'; // ── Helpers ─────────────────────────────────────────────────────────────────── function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Build a regex that matches an animal's identifier in a filename. * Tries both animal_id_string and animal_name (OR logic). * * Rules: * - Match must not be preceded or followed by an alphanumeric char, * so "2852" matches "rat_2852_session" but not "28520" or "X2852Y". * - Multi-word names ("Rat A") allow any separator (space, dash, underscore) * between words, so "Rat A" matches "rat_a_session" and "Rat-A-data". */ function buildAnimalRegex(animal) { const patterns = []; if (animal.animal_id_string) { patterns.push(escapeRegex(animal.animal_id_string)); } if (animal.animal_name) { const words = animal.animal_name.trim().split(/\s+/).map(escapeRegex); // Single-word name: use as-is. Multi-word: allow space/dash/underscore between words. patterns.push(words.length === 1 ? words[0] : words.join('[\\s_\\-]+')); } if (patterns.length === 0) return null; return new RegExp(`(?= 1e9) return `${(num / 1e9).toFixed(1)} GB`; if (num >= 1e6) return `${(num / 1e6).toFixed(1)} MB`; if (num >= 1e3) return `${(num / 1e3).toFixed(1)} KB`; return `${num} B`; } /** Match an array of File objects against animals. Returns { matched: Map, unmatched: File[] } */ function matchFilesToAnimals(files, animals) { const patterns = animals.map((a) => ({ animal: a, re: buildAnimalRegex(a) })).filter((x) => x.re); const matched = new Map(animals.map((a) => [a.id, []])); const unmatched = []; for (const file of files) { const hit = patterns.find(({ re }) => re.test(file.name)); if (hit) { matched.get(hit.animal.id).push(file); } else { unmatched.push(file); } } return { matched, unmatched }; } // ── Sub-components ──────────────────────────────────────────────────────────── function FileRow({ file, onRemove }) { return (
{file.name} {fmtBytes(file.size)} {classifyFile(file.name)} {onRemove && ( )}
); } function SavedFileRow({ file, onDelete, deleting }) { const size = typeof file.file_size === 'string' ? BigInt(file.file_size) : file.file_size; return (
{file.filename} {fmtBytes(size)} {file.file_type ?? '?'}
); } // ── Main component ──────────────────────────────────────────────────────────── /** * FileDropZone * * Props: * rows — array of { animal, status } from ExperimentDayView * (only rows with a status are shown; status.id is required to save files) * date — YYYY-MM-DD string, shown in naming hint */ export default function FileDropZone({ rows, date }) { const [isDragging, setIsDragging] = useState(false); const [dropped, setDropped] = useState(null); // { matched: Map, unmatched: File[] } | null const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [savedByStatus, setSavedByStatus] = useState({}); // statusId → SavedFile[] const [loadingIds, setLoadingIds] = useState(new Set()); const [deletingId, setDeletingId] = useState(null); const dragCounter = useRef(0); const statusRows = rows.filter((r) => r.status); const animals = statusRows.map((r) => r.animal); // Load existing saved files for all statuses in this day view useEffect(() => { if (statusRows.length === 0) return; const ids = statusRows.map((r) => r.status.id); setLoadingIds(new Set(ids)); Promise.all( ids.map((sid) => sessionFilesApi.list(sid).then((files) => ({ sid, files }))) ).then((results) => { const map = {}; results.forEach(({ sid, files }) => { map[sid] = files; }); setSavedByStatus(map); setLoadingIds(new Set()); }).catch(() => setLoadingIds(new Set())); }, [statusRows.map((r) => r.status?.id).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps // ── Drag handlers ────────────────────────────────────────────────────────── const onDragEnter = useCallback((e) => { e.preventDefault(); dragCounter.current += 1; if (dragCounter.current === 1) setIsDragging(true); }, []); const onDragLeave = useCallback((e) => { e.preventDefault(); dragCounter.current -= 1; if (dragCounter.current === 0) setIsDragging(false); }, []); const onDragOver = useCallback((e) => { e.preventDefault(); }, []); const onDrop = useCallback((e) => { e.preventDefault(); dragCounter.current = 0; setIsDragging(false); const files = Array.from(e.dataTransfer.files); if (files.length === 0) return; setDropped(matchFilesToAnimals(files, animals)); setSaveError(null); }, [animals]); function removeMatchedFile(animalId, fileIdx) { setDropped((prev) => { const next = new Map(prev.matched); const arr = [...next.get(animalId)]; arr.splice(fileIdx, 1); next.set(animalId, arr); return { matched: next, unmatched: prev.unmatched }; }); } function removeUnmatched(idx) { setDropped((prev) => { const u = [...prev.unmatched]; u.splice(idx, 1); return { matched: prev.matched, unmatched: u }; }); } // ── Save ─────────────────────────────────────────────────────────────────── async function save() { if (!dropped) return; setSaving(true); setSaveError(null); try { const results = await Promise.all( statusRows.map(async ({ animal, status }) => { const files = dropped.matched.get(animal.id) ?? []; if (files.length === 0) return { statusId: status.id, saved: [] }; const payload = files.map((f) => ({ filename: f.name, file_size: f.size, file_type: classifyFile(f.name), last_modified: f.lastModified ?? null, })); const saved = await sessionFilesApi.create(status.id, payload); return { statusId: status.id, saved }; }) ); setSavedByStatus((prev) => { const next = { ...prev }; results.forEach(({ statusId, saved }) => { next[statusId] = [...(next[statusId] ?? []), ...saved]; }); return next; }); setDropped(null); } catch (err) { setSaveError(err.message ?? 'Failed to save file metadata'); } finally { setSaving(false); } } // ── Delete ───────────────────────────────────────────────────────────────── async function deleteFile(fileId, statusId) { setDeletingId(fileId); try { await sessionFilesApi.delete(fileId); setSavedByStatus((prev) => ({ ...prev, [statusId]: prev[statusId].filter((f) => f.id !== fileId), })); } catch { // silently ignore — UI stays consistent, user can retry } finally { setDeletingId(null); } } // ── Derived counts ───────────────────────────────────────────────────────── const totalDropped = dropped ? [...dropped.matched.values()].reduce((n, a) => n + a.length, 0) : 0; const totalUnmatched = dropped?.unmatched.length ?? 0; const totalSaved = Object.values(savedByStatus).reduce((n, a) => n + a.length, 0); const hasSaved = totalSaved > 0; const hasMatchedDropped = totalDropped > 0; // Hint string for naming convention const sampleId = animals[0]?.animal_id_string || animals[0]?.animal_name || ''; const namingHint = `${date}_${sampleId}_session1.csv`; return (

Session File Registry

Drop all session files here. Filenames are matched to subjects by ID — only metadata (name, size) is recorded; files are never uploaded.
Naming convention: {namingHint}

{/* Drop zone */}
{isDragging ? (

Release to register files

) : ( <>

Drag & drop session files here

All subjects at once · files are not uploaded

)}
{/* Dropped files preview */} {dropped && (
{/* Per-subject matched files */} {statusRows.map(({ animal, status }) => { const files = dropped.matched.get(animal.id) ?? []; if (files.length === 0) return null; return (
{animal.animal_name} {animal.animal_id_string && ( {animal.animal_id_string} )} {files.length} file{files.length !== 1 ? 's' : ''}
{files.map((f, i) => ( removeMatchedFile(animal.id, i)} /> ))}
); })} {/* Subjects with no matched files */} {statusRows.some(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0) && (

No files matched for:

{statusRows .filter(({ animal }) => (dropped.matched.get(animal.id) ?? []).length === 0) .map(({ animal }) => ( {animal.animal_id_string || animal.animal_name} ))}
)} {/* Unmatched files */} {dropped.unmatched.length > 0 && (

Unmatched ({dropped.unmatched.length}) — no subject ID found in filename

{dropped.unmatched.map((f, i) => ( removeUnmatched(i)} /> ))}
)} {saveError && (

{saveError}

)} {/* Action row */}
{hasMatchedDropped && ( )}
)} {/* Saved files */} {hasSaved && (

Registered files ({totalSaved})

{statusRows.map(({ animal, status }) => { const files = savedByStatus[status.id] ?? []; if (files.length === 0) return null; return (
{animal.animal_name} {animal.animal_id_string && ( {animal.animal_id_string} )} {files.length} file{files.length !== 1 ? 's' : ''}
{files.map((f) => ( deleteFile(f.id, status.id)} deleting={deletingId === f.id} /> ))}
); })}
)}
); }