Z3XA4BHNMZB6I34LZ626NQRUPHARWJ5UYHGT65IFQVSQ5B2TY6OQC
OP2N5NWO6KOLP7X6ZAIYUAPTLMV2PAK5XPOBA7CHEB6K7IZ4OQIAC
E7L6ECZK5DLB27CDJSB3USBUKU4E2HE4GDNUIQJB6HNU4J65MRMAC
UQZD6FFK2L7ZZQGBQZRV4UDTFZ7QHNRFESMUZRH4XK3HBBSGA6LAC
E3WSKRJTPPRD6ZEEURTO77N6GTHYJY7ISWQL2LN2VCCXA4YRH2CAC
M3JUJ2WWZGCVMBITKRM5FUJMHFYL2QRMXJUVRUE4AC2RF74AOL5AC
ROQGXQWL2V363K3W7TVVYKIAX4N4IWRERN5BJ7NYJRRVB6OMIJ4QC
4FBIL6IZUDNCXTM6EUHTEOJRHVI4LIIX4BU2IXPXKR362GKIAJMQC
PVQBFR72OCQGYF2G2KDWNKBHWJ24N6D653X6KARBGUSYBIHIXPRQC
LYPSC7BOH6T45FCPRHSCXILAJSJ74D5WSQTUIKPWD5ECXOYGUY5AC
HBM7XFBGVMKW3P3VZFNQTJMINZ4DC3D4TZMT4TPTJRXC62HKZMMQC
J2RLNDEXTGAV4BB6ANIIR7XJLJBHSB4NFQWSBWHNAFB6DMLGS5RAC
4RBE543WLHA7PIYT4W7YEJPF6XKZ2UGKPJBQ3CTLJ44AOMGHCEYQC
KGUU3ZMRY65ZN5G6QC7P7ORPAXXTIMTDBJ37IC6AOKMGQJQ7FVMQC
POIBWSL3JFHT2KN3STFSJX3INSYKEJTX6KSW3N7BVEKWX2GJ6T7QC
4M3EBLTLSS2BRCM42ZP7WVD4YMRRLGV2P2XF47IAV5XHHJD52HTQC
import { useKindeAuth } from "@kinde-oss/kinde-auth-react";
import React, { useEffect, useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
import {
File,
FilesResponse,
NightFilter,
PaginationMetadata,
SpeciesOption
} from "../types";
// Define a component for just the filter controls
export const FilesFilter: React.FC<{
clusterId: string;
onFilterChange: (filter: NightFilter) => void;
currentFilter: NightFilter;
onSpeciesFilterChange: (speciesId: string | null) => void;
currentSpeciesId: string | null;
speciesOptions: SpeciesOption[];
totalFiles?: number;
}> = ({
currentFilter,
onFilterChange,
currentSpeciesId,
onSpeciesFilterChange,
speciesOptions,
totalFiles
}) => {
return (
<div className="flex items-center">
<select
id="nightFilter"
value={currentFilter}
onChange={(e) => onFilterChange(e.target.value as NightFilter)}
className="rounded-md border border-gray-300 bg-white py-1 px-3 text-sm shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="solarNight">Solar night</option>
<option value="solarDay">Solar day</option>
<option value="civilNight">Civil night</option>
<option value="civilDay">Civil day</option>
</select>
</div>
{speciesOptions.length > 0 && (
<div className="flex items-center">
<select
id="speciesFilter"
value={currentSpeciesId || ''}
onChange={(e) => onSpeciesFilterChange(e.target.value || null)}
className="rounded-md border border-gray-300 bg-white py-1 px-3 text-sm shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">No Species Filter</option>
{speciesOptions.map(species => (
<option key={species.id} value={species.id}>
{species.label}
</option>
))}
</select>
</div>
)}
{totalFiles !== undefined && (
<div className="text-gray-600 text-sm">
{totalFiles} files
</div>
)}
</div>
);
};
interface FilesProps {
clusterId: string;
clusterName: string;
datasetId: string | null; // Add datasetId to fetch species for the dataset (can be null)
hideHeaderInfo?: boolean;
nightFilter?: NightFilter;
}
const Files: React.FC<FilesProps> = ({
clusterId,
hideHeaderInfo = false,
nightFilter: externalNightFilter
}) => {
// Force usage of parameters by logging their values in development
React.useEffect(() => {
if (process.env.NODE_ENV === 'development') {
}
}, [clusterId, clusterName, datasetId, hideHeaderInfo]);
console.log('Files component props:', { clusterId, clusterName, datasetId, hideHeaderInfo });
const { isAuthenticated, isLoading: authLoading, getAccessToken } = useKindeAuth();
const [files, setFiles] = useState<File[]>([]);
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState<number>(1);
const [hasMetadata, setHasMetadata] = useState<boolean>(false);
const [hasMothMetadata, setHasMothMetadata] = useState<boolean>(false);
const [nightFilter, setNightFilter] = useState<NightFilter>(externalNightFilter || 'none');
const [speciesFilter, setSpeciesFilter] = useState<string | null>(null);
const [speciesOptions, setSpeciesOptions] = useState<SpeciesOption[]>([]);
const [hasSpecies, setHasSpecies] = useState<boolean>(false);
// We track loading state but don't need to display it since we're showing species in the table, not as a filter header
const [, setLoadingSpecies] = useState<boolean>(true);
const formatDuration = (durationSec: number): string => {
const minutes = Math.floor(durationSec / 60);
const seconds = Math.floor(durationSec % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
try {
} catch {
}
};
};
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && (!pagination || newPage <= pagination.totalPages)) {
setCurrentPage(newPage);
}
}
}
}
return (
<div className="card p-6 bg-white shadow-sm rounded-lg">
{error && (
<div className="p-4 bg-red-50 text-red-700 rounded-md mb-4">
<p className="font-medium">Error loading files</p>
<p className="text-sm mt-1">{error}</p>
</div>
)}
<>
<div className="w-full overflow-visible">
<Table>
<TableHeader className="bg-muted">
<TableRow className="border-b-2 border-primary/20">
<TableHead className="py-3 font-bold text-sm uppercase">Duration</TableHead>
{hasMetadata && (
<TableHead className="py-3 font-bold text-sm uppercase">Metadata</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
}
</TableRow>
</TableBody>
</Table>
</div>
)}
</>
)}
<div className="p-4 bg-yellow-50 text-yellow-800 rounded-md">
<p className="font-medium">No files found for this cluster</p>
</div>
)}
</div>
);
};
export default Files;
{/* Initial state - completely empty cluster */}
{!loading && !error && files.length === 0 && !pagination && speciesOptions.length === 0 && (
{pagination && pagination.totalPages > 1 && files.length > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
/>
)}
})()
) : "—"}
</TableCell>
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
// Calculate total columns based on optional columns
3 + // File, Duration, Moon Phase (always present)
(hasSpecies ? 1 : 0) +
(hasMothMetadata ? 3 : 0) +
(hasMetadata ? 1 : 0)
}
className="text-center py-8"
>
Try adjusting your filters to see more results
</div>
</TableCell>
<div className="text-gray-500">
// Format as key-value pairs
if (typeof metaObj === 'object' && metaObj !== null) {
const pairs = Object.entries(metaObj).map(([key, value]) => {
// Handle arrays or objects as values
let displayValue = value;
if (Array.isArray(value)) {
// Remove quotes and brackets from array string
displayValue = value.join(', ').replace(/[[\]"]*/g, '');
} else if (typeof value === 'string') {
// Remove quotes from string values
displayValue = value.replace(/^"|"$/g, '');
}
return `${key}: ${displayValue}`;
});
return pairs.join(', ');
}
return JSON.stringify(metaObj);
} catch (e) {
console.error("Error formatting metadata:", e);
return typeof file.metadata === 'string'
? (file.metadata as string).substring(0, 50)
: "Invalid metadata";
<TableCell className="whitespace-normal break-words">
</TableCell>
)}
<TableCell className="whitespace-normal break-words">{formatDuration(Number(file.duration))}</TableCell>
{hasMothMetadata && (
<>
<TableCell className="whitespace-normal break-words">
{file.mothMetadata?.gain || "—"}
</TableCell>
<TableCell className="whitespace-normal break-words">
{formatBatteryVoltage(file.mothMetadata?.batteryV)}
</TableCell>
<TableCell className="whitespace-normal break-words">
{formatTemperature(file.mothMetadata?.tempC)}
</TableCell>
</>
)}
<TableCell className="whitespace-normal break-words">
{formatMoonPhase(file.moonPhase)}
</TableCell>
{hasMetadata && (
<TableCell className="whitespace-normal break-words">
{file.metadata ? (
(() => {
try {
// Parse metadata if it's a string
const metaObj = typeof file.metadata === 'string'
? JSON.parse(file.metadata)
: file.metadata;
{file.species && file.species.length > 0 ? (
<div className="flex flex-wrap gap-1">
{file.species.map(species => (
<div
key={species.id}
className="inline-block px-3 py-1 rounded-full text-xs font-medium bg-stone-200 text-stone-800 text-center whitespace-nowrap overflow-hidden text-ellipsis"
style={{ minWidth: '80px', maxWidth: '150px' }}
title={species.label} // Show full name on hover
>
{species.label}
</div>
))}
</div>
) : "—"}
{files.length > 0 ? (
files.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium whitespace-normal break-words">{file.fileName}</TableCell>
{hasSpecies && (
{hasMothMetadata && (
<>
<TableHead className="py-3 font-bold text-sm uppercase">Gain</TableHead>
<TableHead className="py-3 font-bold text-sm uppercase">Battery</TableHead>
<TableHead className="py-3 font-bold text-sm uppercase">Temp</TableHead>
</>
)}
<TableHead className="py-3 font-bold text-sm uppercase">Moon Phase</TableHead>
<TableHead className="w-[240px] py-3 font-bold text-sm uppercase">File</TableHead>
{hasSpecies && (
<TableHead className="py-3 font-bold text-sm uppercase">Species</TableHead>
)}
{!loading && !error && (
{loading && (
<div className="py-4 text-center text-gray-500 animate-fade-in">
Loading files...
</div>
)}
{/* Filter controls - always show filters when we have data to filter, even if current filters result in no files */}
{!loading && !error && (pagination || speciesOptions.length > 0) && (
<div className="mb-4">
<FilesFilter
clusterId={clusterId}
currentFilter={nightFilter}
onFilterChange={setNightFilter}
currentSpeciesId={speciesFilter}
onSpeciesFilterChange={handleSpeciesFilterChange}
speciesOptions={speciesOptions}
totalFiles={pagination?.totalItems}
/>
</div>
)}
// Effect to handle clusterId changes and initial load
useEffect(() => {
// Reset state when cluster changes
setFiles([]);
setPagination(null);
setLoading(true);
setError(null);
// Use a small delay before fetching to allow the UI to show loading state
// This prevents the brief flash of empty content
const timeoutId = setTimeout(() => {
if (isAuthenticated && !authLoading) {
fetchFiles();
}
}, 50);
return () => clearTimeout(timeoutId);
}, [isAuthenticated, authLoading, clusterId, fetchFiles]);
// Update internal state when external filter changes
// This doesn't trigger a fetch because we use 'setNightFilter' which
// will be caught by fetchFiles dependency
useEffect(() => {
if (externalNightFilter !== undefined) {
setNightFilter(externalNightFilter);
}
}, [externalNightFilter]);
// Fetch species for the dataset to populate the species filter dropdown
useEffect(() => {
setLoadingSpecies(true);
setSpeciesOptions([]);
const fetchSpecies = async () => {
if (!isAuthenticated || !datasetId) {
setLoadingSpecies(false);
setSpeciesOptions([]);
return;
}
try {
const accessToken = await getAccessToken();
const url = `/api/species?datasetId=${encodeURIComponent(datasetId)}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json() as { data: SpeciesOption[] };
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Invalid response format");
}
setSpeciesOptions(data.data);
setHasSpecies(data.data.length > 0);
} catch (err) {
console.error("Error fetching species:", err);
// Don't show error for species, just hide the filter
setSpeciesOptions([]);
} finally {
setLoadingSpecies(false);
}
};
if (isAuthenticated && !authLoading) {
fetchSpecies();
}
}, [isAuthenticated, authLoading, getAccessToken, datasetId]);
// Effect to handle pagination and filter changes
useEffect(() => {
// Skip the initial render caused by the dependency array initialization
// Only fetch if we're not already in the initial loading state (first load)
if (!loading && (currentPage > 1 || nightFilter !== 'none' || speciesFilter !== null)) {
// Use skipLoadingState=true to avoid flashing when changing filters or pagination
// This allows data to be replaced smoothly without showing loading indicators
fetchFiles(true);
}
}, [currentPage, nightFilter, speciesFilter, fetchFiles, loading]);
}, [isAuthenticated, authLoading, getAccessToken, clusterId, currentPage, nightFilter, speciesFilter, speciesOptions.length]);
});
const validMothMetadata = data.data.some(file =>
file.mothMetadata &&
(file.mothMetadata.gain !== null ||
file.mothMetadata.batteryV !== null ||
file.mothMetadata.tempC !== null)
);
const validSpeciesData = data.data.some(file =>
file.species && file.species.length > 0
);
// Use React 18's batch updates to minimize re-renders
// All of these state updates will be batched into a single render
setHasMetadata(validMetadata);
setHasMothMetadata(validMothMetadata);
setHasSpecies(validSpeciesData || speciesOptions.length > 0);
setFiles(data.data);
setPagination(data.pagination);
setError(null);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to fetch files";
setError(errorMessage);
// Clear data when there's an error
setFiles([]);
setPagination(null);
} finally {
setLoading(false);
if (!data.data || !Array.isArray(data.data) || !data.pagination) {
throw new Error("Invalid response format");
}
// Process all metadata checks and state updates in a batch
// to reduce multiple re-renders
const validMetadata = data.data.some(file => {
if (!file.metadata) return false;
try {
const meta = typeof file.metadata === 'string'
? JSON.parse(file.metadata)
: file.metadata;
return meta && typeof meta === 'object' && Object.keys(meta).length > 0;
} catch {
return false;
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json() as FilesResponse;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
// Build URL with filters
let url = `/api/files?clusterId=${encodeURIComponent(clusterId)}&page=${currentPage}&pageSize=100`;
// Add night filters
switch (nightFilter) {
case 'solarNight':
url += '&solarNight=true';
break;
case 'solarDay':
url += '&solarNight=false';
break;
case 'civilNight':
url += '&civilNight=true';
break;
case 'civilDay':
url += '&civilNight=false';
break;
// 'none' doesn't add any filter parameters
}
// Add species filter if selected
if (speciesFilter) {
url += `&speciesId=${encodeURIComponent(speciesFilter)}`;
}
try {
const accessToken = await getAccessToken();
if (!skipLoadingState) {
setLoading(true);
}
return;
}
const fetchFiles = React.useCallback(async (skipLoadingState = false) => {
if (!isAuthenticated || !clusterId) {
if (!authLoading) {
setLoading(false);
};
const handleSpeciesFilterChange = (newSpeciesId: string | null) => {
setSpeciesFilter(newSpeciesId);
// Reset to first page when changing filters
setCurrentPage(1);
};
// Removed handleFilterChange function since we now use external filter
};
try {
} catch {
return "—";
}
return Number(temp).toFixed(1) + '°C';
const formatTemperature = (temp: number | null | undefined): string => {
if (temp === null || temp === undefined) return "—";
const formatBatteryVoltage = (volts: number | null | undefined): string => {
if (volts === null || volts === undefined) return "—";
try {
return Number(volts).toFixed(1) + 'V';
} catch {
return "—";
}
return "—";
return Number(phase).toFixed(2);
// Keep these formatters commented out for future use
// const formatDateTime = (isoString: string): string => {
// try {
// const date = new Date(isoString);
// return new Intl.DateTimeFormat('en-GB', {
// year: 'numeric',
// month: 'short',
// day: '2-digit',
// hour: '2-digit',
// minute: '2-digit',
// hour12: false
// }).format(date);
// } catch {
// return isoString;
// }
// };
// const formatSampleRate = (rate: number): string => {
// return `${(rate / 1000).toFixed(1)} kHz`;
// };
const formatMoonPhase = (phase: number | null | undefined): string => {
if (phase === null || phase === undefined) return "—";
clusterName,
datasetId,
<option value="none">No Time Filter</option>
<div className="flex flex-wrap items-center gap-4">
import { Pagination } from "./ui/pagination";
import { useKindeAuth } from "@kinde-oss/kinde-auth-react";
import React, { useEffect, useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";
import { Pagination } from "./ui/pagination";
import {
File,
FilesResponse,
NightFilter,
PaginationMetadata,
SpeciesOption
} from "../types";
// Define a component for just the filter controls
export const ClusterFilter: React.FC<{
clusterId: string;
onFilterChange: (filter: NightFilter) => void;
currentFilter: NightFilter;
onSpeciesFilterChange: (speciesId: string | null) => void;
currentSpeciesId: string | null;
speciesOptions: SpeciesOption[];
totalFiles?: number;
}> = ({
currentFilter,
onFilterChange,
currentSpeciesId,
onSpeciesFilterChange,
speciesOptions,
totalFiles
}) => {
return (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center">
<select
id="nightFilter"
value={currentFilter}
onChange={(e) => onFilterChange(e.target.value as NightFilter)}
className="rounded-md border border-gray-300 bg-white py-1 px-3 text-sm shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="none">No Time Filter</option>
<option value="solarNight">Solar night</option>
<option value="solarDay">Solar day</option>
<option value="civilNight">Civil night</option>
<option value="civilDay">Civil day</option>
</select>
</div>
{speciesOptions.length > 0 && (
<div className="flex items-center">
<select
id="speciesFilter"
value={currentSpeciesId || ''}
onChange={(e) => onSpeciesFilterChange(e.target.value || null)}
className="rounded-md border border-gray-300 bg-white py-1 px-3 text-sm shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">No Species Filter</option>
{speciesOptions.map(species => (
<option key={species.id} value={species.id}>
{species.label}
</option>
))}
</select>
</div>
)}
{totalFiles !== undefined && (
<div className="text-gray-600 text-sm">
{totalFiles} files
</div>
)}
</div>
);
};
interface ClusterProps {
clusterId: string;
clusterName: string;
datasetId: string | null; // Add datasetId to fetch species for the dataset (can be null)
hideHeaderInfo?: boolean;
nightFilter?: NightFilter;
}
const Cluster: React.FC<ClusterProps> = ({
clusterId,
clusterName,
datasetId,
hideHeaderInfo = false,
nightFilter: externalNightFilter
}) => {
// Force usage of parameters by logging their values in development
React.useEffect(() => {
if (process.env.NODE_ENV === 'development') {
console.log('Cluster component props:', { clusterId, clusterName, datasetId, hideHeaderInfo });
}
}, [clusterId, clusterName, datasetId, hideHeaderInfo]);
const { isAuthenticated, isLoading: authLoading, getAccessToken } = useKindeAuth();
const [files, setFiles] = useState<File[]>([]);
const [pagination, setPagination] = useState<PaginationMetadata | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState<number>(1);
const [hasMetadata, setHasMetadata] = useState<boolean>(false);
const [hasMothMetadata, setHasMothMetadata] = useState<boolean>(false);
const [nightFilter, setNightFilter] = useState<NightFilter>(externalNightFilter || 'none');
const [speciesFilter, setSpeciesFilter] = useState<string | null>(null);
const [speciesOptions, setSpeciesOptions] = useState<SpeciesOption[]>([]);
const [hasSpecies, setHasSpecies] = useState<boolean>(false);
// We track loading state but don't need to display it since we're showing species in the table, not as a filter header
const [, setLoadingSpecies] = useState<boolean>(true);
const formatDuration = (durationSec: number): string => {
const minutes = Math.floor(durationSec / 60);
const seconds = Math.floor(durationSec % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
// Keep these formatters commented out for future use
// const formatDateTime = (isoString: string): string => {
// try {
// const date = new Date(isoString);
// return new Intl.DateTimeFormat('en-GB', {
// year: 'numeric',
// month: 'short',
// day: '2-digit',
// hour: '2-digit',
// minute: '2-digit',
// hour12: false
// }).format(date);
// } catch {
// return isoString;
// }
// };
// const formatSampleRate = (rate: number): string => {
// return `${(rate / 1000).toFixed(1)} kHz`;
// };
const formatMoonPhase = (phase: number | null | undefined): string => {
if (phase === null || phase === undefined) return "—";
try {
return Number(phase).toFixed(2);
} catch {
return "—";
}
};
const formatBatteryVoltage = (volts: number | null | undefined): string => {
if (volts === null || volts === undefined) return "—";
try {
return Number(volts).toFixed(1) + 'V';
} catch {
return "—";
}
};
const formatTemperature = (temp: number | null | undefined): string => {
if (temp === null || temp === undefined) return "—";
try {
return Number(temp).toFixed(1) + '°C';
} catch {
return "—";
}
};
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && (!pagination || newPage <= pagination.totalPages)) {
setCurrentPage(newPage);
}
};
const handleSpeciesFilterChange = (newSpeciesId: string | null) => {
setSpeciesFilter(newSpeciesId);
// Reset to first page when changing filters
setCurrentPage(1);
};
// Removed handleFilterChange function since we now use external filter
const fetchFiles = React.useCallback(async (skipLoadingState = false) => {
if (!isAuthenticated || !clusterId) {
if (!authLoading) {
setLoading(false);
}
return;
}
if (!skipLoadingState) {
setLoading(true);
}
try {
const accessToken = await getAccessToken();
// Build URL with filters
let url = `/api/files?clusterId=${encodeURIComponent(clusterId)}&page=${currentPage}&pageSize=100`;
// Add night filters
switch (nightFilter) {
case 'solarNight':
url += '&solarNight=true';
break;
case 'solarDay':
url += '&solarNight=false';
break;
case 'civilNight':
url += '&civilNight=true';
break;
case 'civilDay':
url += '&civilNight=false';
break;
// 'none' doesn't add any filter parameters
}
// Add species filter if selected
if (speciesFilter) {
url += `&speciesId=${encodeURIComponent(speciesFilter)}`;
}
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json() as FilesResponse;
if (!data.data || !Array.isArray(data.data) || !data.pagination) {
throw new Error("Invalid response format");
}
// Process all metadata checks and state updates in a batch
// to reduce multiple re-renders
const validMetadata = data.data.some(file => {
if (!file.metadata) return false;
try {
const meta = typeof file.metadata === 'string'
? JSON.parse(file.metadata)
: file.metadata;
return meta && typeof meta === 'object' && Object.keys(meta).length > 0;
} catch {
return false;
}
});
const validMothMetadata = data.data.some(file =>
file.mothMetadata &&
(file.mothMetadata.gain !== null ||
file.mothMetadata.batteryV !== null ||
file.mothMetadata.tempC !== null)
);
const validSpeciesData = data.data.some(file =>
file.species && file.species.length > 0
);
// Use React 18's batch updates to minimize re-renders
// All of these state updates will be batched into a single render
setHasMetadata(validMetadata);
setHasMothMetadata(validMothMetadata);
setHasSpecies(validSpeciesData || speciesOptions.length > 0);
setFiles(data.data);
setPagination(data.pagination);
setError(null);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to fetch files";
setError(errorMessage);
// Clear data when there's an error
setFiles([]);
setPagination(null);
} finally {
setLoading(false);
}
}, [isAuthenticated, authLoading, getAccessToken, clusterId, currentPage, nightFilter, speciesFilter, speciesOptions.length]);
// Effect to handle clusterId changes and initial load
useEffect(() => {
// Reset state when cluster changes
setFiles([]);
setPagination(null);
setLoading(true);
setError(null);
// Use a small delay before fetching to allow the UI to show loading state
// This prevents the brief flash of empty content
const timeoutId = setTimeout(() => {
if (isAuthenticated && !authLoading) {
fetchFiles();
}
}, 50);
return () => clearTimeout(timeoutId);
}, [isAuthenticated, authLoading, clusterId, fetchFiles]);
// Update internal state when external filter changes
// This doesn't trigger a fetch because we use 'setNightFilter' which
// will be caught by fetchFiles dependency
useEffect(() => {
if (externalNightFilter !== undefined) {
setNightFilter(externalNightFilter);
}
}, [externalNightFilter]);
// Effect to handle pagination and filter changes
useEffect(() => {
// Skip the initial render caused by the dependency array initialization
// Only fetch if we're not already in the initial loading state (first load)
if (!loading && (currentPage > 1 || nightFilter !== 'none' || speciesFilter !== null)) {
// Use skipLoadingState=true to avoid flashing when changing filters or pagination
// This allows data to be replaced smoothly without showing loading indicators
fetchFiles(true);
}
}, [currentPage, nightFilter, speciesFilter, fetchFiles, loading]);
// Fetch species for the dataset to populate the species filter dropdown
useEffect(() => {
setLoadingSpecies(true);
setSpeciesOptions([]);
const fetchSpecies = async () => {
if (!isAuthenticated || !datasetId) {
setLoadingSpecies(false);
setSpeciesOptions([]);
return;
}
try {
const accessToken = await getAccessToken();
const url = `/api/species?datasetId=${encodeURIComponent(datasetId)}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json() as { data: SpeciesOption[] };
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Invalid response format");
}
setSpeciesOptions(data.data);
setHasSpecies(data.data.length > 0);
} catch (err) {
console.error("Error fetching species:", err);
// Don't show error for species, just hide the filter
setSpeciesOptions([]);
} finally {
setLoadingSpecies(false);
}
};
if (isAuthenticated && !authLoading) {
fetchSpecies();
}
}, [isAuthenticated, authLoading, getAccessToken, datasetId]);
return (
<div className="card p-6 bg-white shadow-sm rounded-lg">
{/* Filter controls - always show filters when we have data to filter, even if current filters result in no files */}
{!loading && !error && (pagination || speciesOptions.length > 0) && (
<div className="mb-4">
<ClusterFilter
clusterId={clusterId}
currentFilter={nightFilter}
onFilterChange={setNightFilter}
currentSpeciesId={speciesFilter}
onSpeciesFilterChange={handleSpeciesFilterChange}
speciesOptions={speciesOptions}
totalFiles={pagination?.totalItems}
/>
</div>
)}
{loading && (
<div className="py-4 text-center text-gray-500 animate-fade-in">
Loading files...
</div>
)}
{error && (
<div className="p-4 bg-red-50 text-red-700 rounded-md mb-4">
<p className="font-medium">Error loading files</p>
<p className="text-sm mt-1">{error}</p>
</div>
)}
{!loading && !error && (
<>
<div className="w-full overflow-visible">
<Table>
<TableHeader className="bg-muted">
<TableRow className="border-b-2 border-primary/20">
<TableHead className="w-[240px] py-3 font-bold text-sm uppercase">File</TableHead>
{hasSpecies && (
<TableHead className="py-3 font-bold text-sm uppercase">Species</TableHead>
)}
<TableHead className="py-3 font-bold text-sm uppercase">Duration</TableHead>
{hasMothMetadata && (
<>
<TableHead className="py-3 font-bold text-sm uppercase">Gain</TableHead>
<TableHead className="py-3 font-bold text-sm uppercase">Battery</TableHead>
<TableHead className="py-3 font-bold text-sm uppercase">Temp</TableHead>
</>
)}
<TableHead className="py-3 font-bold text-sm uppercase">Moon Phase</TableHead>
{hasMetadata && (
<TableHead className="py-3 font-bold text-sm uppercase">Metadata</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{files.length > 0 ? (
files.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium whitespace-normal break-words">{file.fileName}</TableCell>
{hasSpecies && (
<TableCell className="whitespace-normal break-words">
{file.species && file.species.length > 0 ? (
<div className="flex flex-wrap gap-1">
{file.species.map(species => (
<div
key={species.id}
className="inline-block px-3 py-1 rounded-full text-xs font-medium bg-stone-200 text-stone-800 text-center whitespace-nowrap overflow-hidden text-ellipsis"
style={{ minWidth: '80px', maxWidth: '150px' }}
title={species.label} // Show full name on hover
>
{species.label}
</div>
))}
</div>
) : "—"}
</TableCell>
)}
<TableCell className="whitespace-normal break-words">{formatDuration(Number(file.duration))}</TableCell>
{hasMothMetadata && (
<>
<TableCell className="whitespace-normal break-words">
{file.mothMetadata?.gain || "—"}
</TableCell>
<TableCell className="whitespace-normal break-words">
{formatBatteryVoltage(file.mothMetadata?.batteryV)}
</TableCell>
<TableCell className="whitespace-normal break-words">
{formatTemperature(file.mothMetadata?.tempC)}
</TableCell>
</>
)}
<TableCell className="whitespace-normal break-words">
{formatMoonPhase(file.moonPhase)}
</TableCell>
{hasMetadata && (
<TableCell className="whitespace-normal break-words">
{file.metadata ? (
(() => {
try {
// Parse metadata if it's a string
const metaObj = typeof file.metadata === 'string'
? JSON.parse(file.metadata)
: file.metadata;
// Format as key-value pairs
if (typeof metaObj === 'object' && metaObj !== null) {
const pairs = Object.entries(metaObj).map(([key, value]) => {
// Handle arrays or objects as values
let displayValue = value;
if (Array.isArray(value)) {
// Remove quotes and brackets from array string
displayValue = value.join(', ').replace(/[[\]"]*/g, '');
} else if (typeof value === 'string') {
// Remove quotes from string values
displayValue = value.replace(/^"|"$/g, '');
}
return `${key}: ${displayValue}`;
});
return pairs.join(', ');
}
return JSON.stringify(metaObj);
} catch (e) {
console.error("Error formatting metadata:", e);
return typeof file.metadata === 'string'
? (file.metadata as string).substring(0, 50)
: "Invalid metadata";
}
})()
) : "—"}
</TableCell>
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={
// Calculate total columns based on optional columns
3 + // File, Duration, Moon Phase (always present)
(hasSpecies ? 1 : 0) +
(hasMothMetadata ? 3 : 0) +
(hasMetadata ? 1 : 0)
}
className="text-center py-8"
>
<div className="text-gray-500">
Try adjusting your filters to see more results
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{pagination && pagination.totalPages > 1 && files.length > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
/>
)}
</>
)}
{/* Initial state - completely empty cluster */}
{!loading && !error && files.length === 0 && !pagination && speciesOptions.length === 0 && (
<div className="p-4 bg-yellow-50 text-yellow-800 rounded-md">
<p className="font-medium">No files found for this cluster</p>
</div>
)}
</div>
);
};
export default Cluster;
{/* Filters moved to breadcrumb line - no species filter here since we're using it in Files component */}
{/* Removing this filter component since we now have it integrated into the Files component */}
{/* Filters moved to breadcrumb line - no species filter here since we're using it in Cluster component */}
{/* Removing this filter component since we now have it integrated into the Cluster component */}