DBDM3MJDJ5TRAH3FA7MLBGFUPZF4GIM4CPZAKFNS5MV3UYQOAFNQC CWCD3MTTPIKDNDPLRTLU4S6JQ35YYRDW34NW25DQ5KHJYMQP3SNAC LJINJQ4PMHP2JYM5EVW2XEO5GEXUPVY4OGGWQP56HUXJV43MLQGAC B74R5YOFFEHPTIAGTFHBBQHDAU4RLWS457OCK3ZDVNVPGP45NNMAC PRWK4DR3SF23CM2FPIASFOVSFFNRBEY52GP4ZSZN3BTJ6MYWIWAQC FPAKT2I5LC6WRIS4DX3VK63UBQMZLM7TI6CTCEWS6ZR6MYVNIEAAC GIAIXNFCQIP5ZHBFXWFWDTLDUGV35MDC3ARQCVAN6FEWIX7Q32GQC IUHUM6OZ5KYEAYQCIYNG5Q4QLQRAQNMBWKYGV2ZDJFNY5W4DOUNQC I27QGYUJ66RJXCXQPY2MMSODRGL3SB7KO4UOGQSK2WRFG7LG23GAC GNFVHZOAE4XDLONBDFDXM6AJD6HKIMVASU4BFAF6QSDP5OTIVTQAC NYBASAG4KWZNPSFBXWQJWQRBCGT7C5B24IYO2IBCXCE2WGJQJOEQC RELTAIDEZ4Y2SC2WQROQ4IEJO5BVPULTVLLJ4BA23WE5FVT5QOQAC M3JUJ2WWZGCVMBITKRM5FUJMHFYL2QRMXJUVRUE4AC2RF74AOL5AC import type { CallRateResponse, CallTypeOption, CallTypeResponse, FilterOption, FiltersResponse, SpeciesOption, SpeciesResponse, TimeFilter } from "../types/statistics";
import type { CallRateResponse, CallTypeOption, CallTypeResponse, FilterOption, FiltersResponse, StatisticsSpeciesOption, SpeciesResponse, TimeFilter } from "../types/statistics";
const params = new URLSearchParams({clusterId,speciesId: selectedSpeciesId,filterId: selectedFilterId,timeFilter,});
const params = new URLSearchParams();if (clusterId) params.append('clusterId', clusterId);params.append('speciesId', selectedSpeciesId);params.append('filterId', selectedFilterId);params.append('timeFilter', timeFilter);
import { useKindeAuth } from "@kinde-oss/kinde-auth-react";import React, { useEffect, useState } from "react";import { Check, X } from "lucide-react";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";import { Pagination } from "./ui/pagination";import File from "./File";import {File as FileType,FilesResponse,NightFilter,PaginationMetadata,SpeciesOption} from "../types";
import React, { useState } from "react";import FileTable from "./FileTable";import Statistics from "./Statistics";import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";import { NightFilter } from "../types";
// Define a component for just the filter controlsexport const SelectionFilter: React.FC<{datasetId: string;onFilterChange: (filter: NightFilter) => void;currentFilter: NightFilter;onSpeciesFilterChange: (speciesId: string | null) => void;currentSpeciesId: string | null;speciesOptions: SpeciesOption[];totalFiles?: number | undefined;}> = ({currentFilter,onFilterChange,currentSpeciesId,onSpeciesFilterChange,speciesOptions,totalFiles}) => {return (<div className="flex flex-wrap items-center gap-4"><div className="flex items-center"><selectid="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">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"><selectid="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">{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>);};
const { isAuthenticated, isLoading: authLoading, getAccessToken } = useKindeAuth();const [files, setFiles] = useState<FileType[]>([]);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>(initialSpeciesId || null);const [speciesOptions, setSpeciesOptions] = useState<SpeciesOption[]>([]);const [hasSpecies, setHasSpecies] = useState<boolean>(false);const [, setLoadingSpecies] = useState<boolean>(true);// File modal stateconst [selectedFile, setSelectedFile] = useState<FileType | null>(null);const [isFileModalOpen, setIsFileModalOpen] = useState<boolean>(false);const [datasetOwnerId, setDatasetOwnerId] = useState<string | null>(null);const formatDuration = (durationSec: number): string => {const minutes = Math.floor(durationSec / 60);const seconds = Math.floor(durationSec % 60);return `${minutes}:${seconds.toString().padStart(2, '0')}`;};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 "—";}};// Generate different gray shades for different metadata keysconst getMetadataKeyColor = (key: string): string => {const grayShades = ['bg-stone-200 text-stone-800','bg-gray-200 text-gray-800','bg-slate-200 text-slate-800','bg-zinc-200 text-zinc-800','bg-neutral-200 text-neutral-800','bg-stone-300 text-stone-800','bg-gray-300 text-gray-800','bg-slate-300 text-slate-800'];// Simple hash function to get consistent shade for each keylet hash = 0;for (let i = 0; i < key.length; i++) {hash = ((hash << 5) - hash + key.charCodeAt(i)) & 0xffffffff;}return grayShades[Math.abs(hash) % grayShades.length];};// Render metadata as visual tagsconst renderMetadata = (metadata: unknown) => {if (!metadata) return "—";try {const metaObj = typeof metadata === 'string'? JSON.parse(metadata): metadata;if (typeof metaObj === 'object' && metaObj !== null) {const entries = Object.entries(metaObj);return (<div className="flex flex-wrap gap-1">{entries.map(([key, value]) => {const colorClass = getMetadataKeyColor(key);if (Array.isArray(value)) {// Render array values as separate tags with same colorreturn value.map((item, index) => {const cleanItem = String(item).replace(/[[\]"]/g, '').trim();return (<divkey={`${key}-${index}`}className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${colorClass} text-center whitespace-nowrap overflow-hidden text-ellipsis`}title={`${key}: ${cleanItem}`}>{cleanItem}</div>);});} else {// Render single valueconst displayValue = String(value).replace(/[[\]"]/g, '').trim();return (<divkey={key}className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${colorClass} text-center whitespace-nowrap overflow-hidden text-ellipsis`}title={`${key}: ${displayValue}`}>{displayValue}</div>);}})}</div>);}return JSON.stringify(metaObj);} catch (e) {console.error("Error formatting metadata:", e);return typeof metadata === 'string'? (metadata as string).substring(0, 50): "Invalid metadata";}};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 filterssetCurrentPage(1);// Notify parent component about species filter change for breadcrumb updateif (onSpeciesFilterChange && newSpeciesId) {// Find the species name from optionsconst selectedSpecies = speciesOptions.find(s => s.id === newSpeciesId);if (selectedSpecies) {onSpeciesFilterChange(newSpeciesId, selectedSpecies.label);}}};// Handle file name clickconst handleFileClick = (file: FileType) => {setSelectedFile(file);setIsFileModalOpen(true);// Fetch dataset owner if we don't have it yetif (!datasetOwnerId && datasetId) {void fetchDatasetOwner(datasetId).catch(console.error);}};// Fetch dataset ownerconst fetchDatasetOwner = React.useCallback(async (targetDatasetId: string) => {if (!isAuthenticated) return;
const [currentTab, setCurrentTab] = useState<string>("files");
try {const accessToken = await getAccessToken();console.log('Fetching dataset owner for:', targetDatasetId);const response = await fetch(`/api/datasets/${targetDatasetId}`, {headers: {Authorization: `Bearer ${accessToken}`,},});if (response.ok) {const result = await response.json();console.log('Dataset API response:', result);const owner = result.data?.owner || result.owner || null;console.log('Setting dataset owner to:', owner);setDatasetOwnerId(owner);} else {console.error('Failed to fetch dataset:', response.status, response.statusText);}} catch (error) {console.error("Error fetching dataset owner:", error);}}, [isAuthenticated, getAccessToken]);useEffect(() => {// Reset state when dataset changessetFiles([]);setPagination(null);setLoading(true);setError(null);const fetchFiles = async () => {if (!isAuthenticated || !datasetId || !speciesFilter) {if (!authLoading) {setLoading(false);}return;}try {const accessToken = await getAccessToken();// Build URL with required parameterslet url = `/api/selection?datasetId=${encodeURIComponent(datasetId)}&speciesId=${encodeURIComponent(speciesFilter)}&page=${currentPage}&pageSize=100`;
return (<div className="space-y-4"><TabsdefaultValue="files"value={currentTab}onValueChange={setCurrentTab}className="w-full"><TabsList className="grid w-full grid-cols-2"><TabsTrigger value="files">Files</TabsTrigger><TabsTrigger value="statistics">Statistics</TabsTrigger></TabsList>
// Add night filtersswitch (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}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");}// Check if valid metadata is present in any fileconst 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;}});// Check if any files have moth metadataconst validMothMetadata = data.data.some(file =>file.mothMetadata &&(file.mothMetadata.gain !== null ||file.mothMetadata.batteryV !== null ||file.mothMetadata.tempC !== null));// Check if any files have species informationconst validSpeciesData = data.data.some(file =>file.species && file.species.length > 0);setHasMetadata(validMetadata);setHasMothMetadata(validMothMetadata);setHasSpecies(validSpeciesData || speciesOptions.length > 0);setFiles(data.data);setPagination(data.pagination);} catch (err) {const errorMessage = err instanceof Error ? err.message : "Failed to fetch files";setError(errorMessage);} finally {setLoading(false);}};if (isAuthenticated && !authLoading && speciesFilter) {void fetchFiles().catch(console.error);} else if (isAuthenticated && !authLoading && !speciesFilter) {// If no species filter is set, don't make a fetch request but clear loading statesetLoading(false);}}, [isAuthenticated, authLoading, getAccessToken, datasetId, speciesFilter, currentPage, nightFilter, speciesOptions.length]);// Update internal state when external filter changesuseEffect(() => {if (externalNightFilter !== undefined) {setNightFilter(externalNightFilter);}}, [externalNightFilter]);// Update species filter when initialSpeciesId changesuseEffect(() => {setSpeciesFilter(initialSpeciesId || null);}, [initialSpeciesId]);// Fetch species for the dataset to populate the species filter dropdownuseEffect(() => {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)}`;
<TabsContent value="files" className="mt-4"><FileTableapiEndpoint="selection"requiredParams={{datasetId,...(initialSpeciesId && { speciesId: initialSpeciesId })}}allowOptionalSpecies={false}externalNightFilter={externalNightFilter}{...(onSpeciesFilterChange && { onSpeciesFilterChange })}/></TabsContent>
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 filtersetSpeciesOptions([]);} finally {setLoadingSpecies(false);}};if (isAuthenticated && !authLoading) {void fetchSpecies().catch(console.error);}}, [isAuthenticated, authLoading, getAccessToken, datasetId]);return (<div className="card p-6 bg-white shadow-sm rounded-lg">{/* Header removed as requested */}{/* Filter controls - always show filters when we have data to filter */}{!loading && !error && (<div className="mb-4"><SelectionFilter
<TabsContent value="statistics" className="mt-4"><Statistics
currentFilter={nightFilter}onFilterChange={setNightFilter}currentSpeciesId={speciesFilter}onSpeciesFilterChange={handleSpeciesFilterChange}speciesOptions={speciesOptions}totalFiles={pagination?.totalItems}
{...(initialSpeciesId && { speciesId: initialSpeciesId })}
</div>)}{/* Select species prompt when no species is selected */}{!loading && !error && !speciesFilter && speciesOptions.length > 0 && (<div className="py-4 text-center text-gray-600 bg-gray-50 rounded-md">Please select a species to view files</div>)}{loading && <div className="py-4 text-center text-gray-500">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 && speciesFilter && (<><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><TableHead className="py-3 font-bold text-sm uppercase">Online</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} className={`hover:bg-primary/5 ${file.upload ? "bg-gray-50" : ""}`}><TableCellclassName="font-medium whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.fileName}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.upload ? (<Check className="h-4 w-4 text-gray-600" />) : (<X className="h-4 w-4 text-gray-600" />)}</TableCell>{hasSpecies && (<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.species && file.species.length > 0 ? (<div className="flex flex-wrap gap-1">{file.species.map(species => (<divkey={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>)}<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatDuration(Number(file.duration))}</TableCell>{hasMothMetadata && (<><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.mothMetadata?.gain || "—"}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatBatteryVoltage(file.mothMetadata?.batteryV)}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatTemperature(file.mothMetadata?.tempC)}</TableCell></>)}<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatMoonPhase(file.moonPhase)}</TableCell>{hasMetadata && (<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{renderMetadata(file.metadata)}</TableCell>)}</TableRow>))) : (<TableRow><TableCellcolSpan={// Calculate total columns based on optional columns4 + // File, Status, Duration, Moon Phase (always present)(hasSpecies ? 1 : 0) +(hasMothMetadata ? 3 : 0) +(hasMetadata ? 1 : 0)}className="text-center py-8"><div className="text-gray-500">No files found with the selected filters</div></TableCell></TableRow>)}</TableBody></Table></div>{pagination && pagination.totalPages > 1 && files.length > 0 && (<PaginationcurrentPage={currentPage}totalPages={pagination.totalPages}onPageChange={handlePageChange}/>)}</>)}{/* No species available message */}{!loading && !error && speciesOptions.length === 0 && (<div className="p-4 bg-yellow-50 text-yellow-800 rounded-md"><p className="font-medium">No species found for this dataset</p><p className="text-sm mt-1">Try selecting a different dataset or adding species to this dataset</p></div>)}{/* File Modal */}<Filefile={selectedFile}isOpen={isFileModalOpen}onClose={() => setIsFileModalOpen(false)}datasetOwnerId={datasetOwnerId}datasetId={datasetId}/>
</TabsContent></Tabs>
import { useKindeAuth } from "@kinde-oss/kinde-auth-react";import React, { useEffect, useState } from "react";import { Check, X } from "lucide-react";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";import { Pagination } from "./ui/pagination";import File from "./File";import {File as FileType,FilesResponse,NightFilter,PaginationMetadata,SpeciesOption} from "../types";
import React from "react";import FileTable from "./FileTable";import { NightFilter } from "../types";
// Define a component for just the filter controlsexport const FilesFilter: React.FC<{clusterId: string;onFilterChange: (filter: NightFilter) => void;currentFilter: NightFilter;onSpeciesFilterChange: (speciesId: string | null) => void;currentSpeciesId: string | null;speciesOptions: SpeciesOption[];totalFiles?: number | undefined;}> = ({currentFilter,onFilterChange,currentSpeciesId,onSpeciesFilterChange,speciesOptions,totalFiles}) => {return (<div className="flex flex-wrap items-center gap-4"><div className="flex items-center"><selectid="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">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"><selectid="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="">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>);};
const { isAuthenticated, isLoading: authLoading, getAccessToken } = useKindeAuth();const [files, setFiles] = useState<FileType[]>([]);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 headerconst [, setLoadingSpecies] = useState<boolean>(true);// File modal stateconst [selectedFile, setSelectedFile] = useState<FileType | null>(null);const [isFileModalOpen, setIsFileModalOpen] = useState<boolean>(false);const [datasetOwnerId, setDatasetOwnerId] = useState<string | null>(null);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 "—";}};// Generate different gray shades for different metadata keysconst getMetadataKeyColor = (key: string): string => {const grayShades = ['bg-stone-200 text-stone-800','bg-gray-200 text-gray-800','bg-slate-200 text-slate-800','bg-zinc-200 text-zinc-800','bg-neutral-200 text-neutral-800','bg-stone-300 text-stone-800','bg-gray-300 text-gray-800','bg-slate-300 text-slate-800'];// Simple hash function to get consistent shade for each keylet hash = 0;for (let i = 0; i < key.length; i++) {hash = ((hash << 5) - hash + key.charCodeAt(i)) & 0xffffffff;}return grayShades[Math.abs(hash) % grayShades.length];};// Generate different gray shades for different speciesconst getSpeciesColor = (speciesId: string): string => {const grayShades = ['bg-stone-200 text-stone-800','bg-gray-200 text-gray-800','bg-slate-200 text-slate-800','bg-zinc-200 text-zinc-800','bg-neutral-200 text-neutral-800','bg-stone-300 text-stone-800','bg-gray-300 text-gray-800','bg-slate-300 text-slate-800'];// Simple hash function to get consistent shade for each specieslet hash = 0;for (let i = 0; i < speciesId.length; i++) {hash = ((hash << 5) - hash + speciesId.charCodeAt(i)) & 0xffffffff;}return grayShades[Math.abs(hash) % grayShades.length];};// Render metadata as visual tagsconst renderMetadata = (metadata: unknown) => {if (!metadata) return "—";try {const metaObj = typeof metadata === 'string'? JSON.parse(metadata): metadata;if (typeof metaObj === 'object' && metaObj !== null) {const entries = Object.entries(metaObj);return (<div className="flex flex-wrap gap-1">{entries.map(([key, value]) => {const colorClass = getMetadataKeyColor(key);if (Array.isArray(value)) {// Render array values as separate tags with same colorreturn value.map((item, index) => {const cleanItem = String(item).replace(/[[\]"]/g, '').trim();return (<divkey={`${key}-${index}`}className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${colorClass} text-center whitespace-nowrap overflow-hidden text-ellipsis`}title={`${key}: ${cleanItem}`}>{cleanItem}</div>);});} else {// Render single valueconst displayValue = String(value).replace(/[[\]"]/g, '').trim();return (<divkey={key}className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${colorClass} text-center whitespace-nowrap overflow-hidden text-ellipsis`}title={`${key}: ${displayValue}`}>{displayValue}</div>);}})}</div>);}return JSON.stringify(metaObj);} catch (e) {console.error("Error formatting metadata:", e);return typeof metadata === 'string'? (metadata as string).substring(0, 50): "Invalid metadata";}};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 filterssetCurrentPage(1);};// Handle file name clickconst handleFileClick = (file: FileType) => {setSelectedFile(file);setIsFileModalOpen(true);// Fetch dataset owner if we don't have it yetif (!datasetOwnerId && datasetId) {void fetchDatasetOwner(datasetId).catch(console.error);}};// Fetch dataset ownerconst fetchDatasetOwner = React.useCallback(async (targetDatasetId: string) => {if (!isAuthenticated) return;try {const accessToken = await getAccessToken();console.log('Fetching dataset owner for:', targetDatasetId);const response = await fetch(`/api/datasets/${targetDatasetId}`, {headers: {Authorization: `Bearer ${accessToken}`,},});if (response.ok) {const result = await response.json();console.log('Dataset API response:', result);const owner = result.data?.owner || result.owner || null;console.log('Setting dataset owner to:', owner);setDatasetOwnerId(owner);} else {console.error('Failed to fetch dataset:', response.status, response.statusText);}} catch (error) {console.error("Error fetching dataset owner:", error);}}, [isAuthenticated, getAccessToken]);// Removed handleFilterChange function since we now use external filterconst 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 filterslet url = `/api/files?clusterId=${encodeURIComponent(clusterId)}&page=${currentPage}&pageSize=100`;// Add night filtersswitch (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 selectedif (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-rendersconst 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 rendersetHasMetadata(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 errorsetFiles([]);setPagination(null);} finally {setLoading(false);}}, [isAuthenticated, authLoading, getAccessToken, clusterId, currentPage, nightFilter, speciesFilter, speciesOptions.length]);// Effect to handle clusterId changes and initial loaduseEffect(() => {// Reset state when cluster changessetFiles([]);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 contentconst timeoutId = setTimeout(() => {if (isAuthenticated && !authLoading) {void fetchFiles().catch(console.error);}}, 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 dependencyuseEffect(() => {if (externalNightFilter !== undefined) {setNightFilter(externalNightFilter);}}, [externalNightFilter]);// Effect to handle pagination and filter changesuseEffect(() => {// 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 indicatorsvoid fetchFiles(true).catch(console.error);}}, [currentPage, nightFilter, speciesFilter, fetchFiles, loading]);// Fetch species for the dataset to populate the species filter dropdownuseEffect(() => {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 filtersetSpeciesOptions([]);} finally {setLoadingSpecies(false);}};if (isAuthenticated && !authLoading) {void fetchSpecies().catch(console.error);}}, [isAuthenticated, authLoading, getAccessToken, datasetId]);
<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"><FilesFilterclusterId={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><TableHead className="py-3 font-bold text-sm uppercase">Online</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} className={`hover:bg-primary/5 ${file.upload ? "bg-gray-50" : ""}`}><TableCellclassName="font-medium whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.fileName}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.upload ? (<Check className="h-4 w-4 text-gray-600" />) : (<X className="h-4 w-4 text-gray-600" />)}</TableCell>{hasSpecies && (<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.species && file.species.length > 0 ? (<div className="flex flex-wrap gap-1">{file.species.map(species => (<divkey={species.id}className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${getSpeciesColor(species.id)} 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>)}<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatDuration(Number(file.duration))}</TableCell>{hasMothMetadata && (<><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.mothMetadata?.gain || "—"}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatBatteryVoltage(file.mothMetadata?.batteryV)}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatTemperature(file.mothMetadata?.tempC)}</TableCell></>)}<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatMoonPhase(file.moonPhase)}</TableCell>{hasMetadata && (<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{renderMetadata(file.metadata)}</TableCell>)}</TableRow>))) : (<TableRow><TableCellcolSpan={// Calculate total columns based on optional columns4 + // File, Status, 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 && (<PaginationcurrentPage={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>)}{/* File Modal */}<Filefile={selectedFile}isOpen={isFileModalOpen}onClose={() => setIsFileModalOpen(false)}datasetOwnerId={datasetOwnerId}datasetId={datasetId}/></div>
<FileTableapiEndpoint="files"requiredParams={{ clusterId }}allowOptionalSpecies={true}speciesFilterLabel="Filter by Species"datasetId={datasetId}{...(externalNightFilter !== undefined && { externalNightFilter })}/>
import { useKindeAuth } from "@kinde-oss/kinde-auth-react";import React, { useEffect, useState } from "react";import { Check, X } from "lucide-react";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table";import { Pagination } from "./ui/pagination";import File from "./File";import {File as FileType,FilesResponse,NightFilter,PaginationMetadata,SpeciesOption} from "../types";interface FileTableProps {// API configurationapiEndpoint: 'files' | 'selection';requiredParams: {clusterId?: string;datasetId?: string;speciesId?: string;};// Filter configurationallowOptionalSpecies?: boolean;speciesFilterLabel?: string;hideHeaderInfo?: boolean;datasetId?: string | null;externalNightFilter?: NightFilter | undefined;// CallbacksonSpeciesFilterChange?: (speciesId: string, speciesName: string) => void;}// Unified filter componentexport const FileFilter: React.FC<{onFilterChange: (filter: NightFilter) => void;currentFilter: NightFilter;onSpeciesFilterChange: (speciesId: string | null) => void;currentSpeciesId: string | null;speciesOptions: SpeciesOption[];totalFiles?: number | undefined;allowOptionalSpecies?: boolean;speciesFilterLabel?: string;}> = ({currentFilter,onFilterChange,currentSpeciesId,onSpeciesFilterChange,speciesOptions,totalFiles,allowOptionalSpecies = true,speciesFilterLabel = "Species Filter"}) => {return (<div className="flex flex-wrap items-center gap-4"><div className="flex items-center"><selectid="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">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"><selectid="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">{allowOptionalSpecies && <option value="">{speciesFilterLabel}</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>);};const FileTable: React.FC<FileTableProps> = ({apiEndpoint,requiredParams,allowOptionalSpecies = true,speciesFilterLabel = "Species Filter",datasetId,externalNightFilter,onSpeciesFilterChange}) => {const { isAuthenticated, isLoading: authLoading, getAccessToken } = useKindeAuth();const [files, setFiles] = useState<FileType[]>([]);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>(requiredParams.speciesId || null);const [speciesOptions, setSpeciesOptions] = useState<SpeciesOption[]>([]);const [hasSpecies, setHasSpecies] = useState<boolean>(false);const [, setLoadingSpecies] = useState<boolean>(true);// File modal stateconst [selectedFile, setSelectedFile] = useState<FileType | null>(null);const [isFileModalOpen, setIsFileModalOpen] = useState<boolean>(false);const [datasetOwnerId, setDatasetOwnerId] = useState<string | null>(null);const formatDuration = (durationSec: number): string => {const minutes = Math.floor(durationSec / 60);const seconds = Math.floor(durationSec % 60);return `${minutes}:${seconds.toString().padStart(2, '0')}`;};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 "—";}};// Generate different gray shades for different metadata keysconst getMetadataKeyColor = (key: string): string => {const grayShades = ['bg-stone-200 text-stone-800','bg-gray-200 text-gray-800','bg-slate-200 text-slate-800','bg-zinc-200 text-zinc-800','bg-neutral-200 text-neutral-800','bg-stone-300 text-stone-800','bg-gray-300 text-gray-800','bg-slate-300 text-slate-800'];// Simple hash function to get consistent shade for each keylet hash = 0;for (let i = 0; i < key.length; i++) {hash = ((hash << 5) - hash + key.charCodeAt(i)) & 0xffffffff;}return grayShades[Math.abs(hash) % grayShades.length];};// Generate different gray shades for different speciesconst getSpeciesColor = (speciesId: string): string => {const grayShades = ['bg-stone-200 text-stone-800','bg-gray-200 text-gray-800','bg-slate-200 text-slate-800','bg-zinc-200 text-zinc-800','bg-neutral-200 text-neutral-800','bg-stone-300 text-stone-800','bg-gray-300 text-gray-800','bg-slate-300 text-slate-800'];// Simple hash function to get consistent shade for each specieslet hash = 0;for (let i = 0; i < speciesId.length; i++) {hash = ((hash << 5) - hash + speciesId.charCodeAt(i)) & 0xffffffff;}return grayShades[Math.abs(hash) % grayShades.length];};// Render metadata as visual tagsconst renderMetadata = (metadata: unknown) => {if (!metadata) return "—";try {const metaObj = typeof metadata === 'string'? JSON.parse(metadata): metadata;if (typeof metaObj === 'object' && metaObj !== null) {const entries = Object.entries(metaObj);return (<div className="flex flex-wrap gap-1">{entries.map(([key, value]) => {const colorClass = getMetadataKeyColor(key);if (Array.isArray(value)) {// Render array values as separate tags with same colorreturn value.map((item, index) => {const cleanItem = String(item).replace(/[[\]"]/g, '').trim();return (<divkey={`${key}-${index}`}className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${colorClass} text-center whitespace-nowrap overflow-hidden text-ellipsis`}title={`${key}: ${cleanItem}`}>{cleanItem}</div>);});} else {// Render single valueconst displayValue = String(value).replace(/[[\]"]/g, '').trim();return (<divkey={key}className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${colorClass} text-center whitespace-nowrap overflow-hidden text-ellipsis`}title={`${key}: ${displayValue}`}>{displayValue}</div>);}})}</div>);}return JSON.stringify(metaObj);} catch (e) {console.error("Error formatting metadata:", e);return typeof metadata === 'string'? (metadata as string).substring(0, 50): "Invalid metadata";}};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 filterssetCurrentPage(1);// Notify parent component about species filter change for breadcrumb updateif (onSpeciesFilterChange && newSpeciesId) {// Find the species name from optionsconst selectedSpecies = speciesOptions.find(s => s.id === newSpeciesId);if (selectedSpecies) {onSpeciesFilterChange(newSpeciesId, selectedSpecies.label);}}};// Handle file name clickconst handleFileClick = (file: FileType) => {setSelectedFile(file);setIsFileModalOpen(true);// Fetch dataset owner if we don't have it yetif (!datasetOwnerId && requiredParams.datasetId) {void fetchDatasetOwner(requiredParams.datasetId).catch(console.error);}};// Fetch dataset ownerconst fetchDatasetOwner = React.useCallback(async (targetDatasetId: string) => {if (!isAuthenticated) return;try {const accessToken = await getAccessToken();const response = await fetch(`/api/datasets/${targetDatasetId}`, {headers: {Authorization: `Bearer ${accessToken}`,},});if (response.ok) {const result = await response.json();const owner = result.data?.owner || result.owner || null;setDatasetOwnerId(owner);}} catch (error) {console.error("Error fetching dataset owner:", error);}}, [isAuthenticated, getAccessToken]);useEffect(() => {// Reset state when params changesetFiles([]);setPagination(null);setLoading(true);setError(null);const fetchFiles = async () => {if (!isAuthenticated) {if (!authLoading) {setLoading(false);}return;}// For selection endpoint, require both datasetId and speciesIdif (apiEndpoint === 'selection' && (!requiredParams.datasetId || !speciesFilter)) {if (!authLoading) {setLoading(false);}return;}// For files endpoint, require clusterIdif (apiEndpoint === 'files' && !requiredParams.clusterId) {if (!authLoading) {setLoading(false);}return;}try {const accessToken = await getAccessToken();// Build URL based on endpointlet url = '';if (apiEndpoint === 'files') {url = `/api/files?clusterId=${encodeURIComponent(requiredParams.clusterId!)}&page=${currentPage}&pageSize=100`;// Add species filter if selectedif (speciesFilter) {url += `&speciesId=${encodeURIComponent(speciesFilter)}`;}} else {url = `/api/selection?datasetId=${encodeURIComponent(requiredParams.datasetId!)}&speciesId=${encodeURIComponent(speciesFilter!)}&page=${currentPage}&pageSize=100`;}// Add night filters for both endpointsswitch (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}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");}// Check if valid metadata is present in any fileconst 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;}});// Check if any files have moth metadataconst validMothMetadata = data.data.some(file =>file.mothMetadata &&(file.mothMetadata.gain !== null ||file.mothMetadata.batteryV !== null ||file.mothMetadata.tempC !== null));// Check if any files have species informationconst validSpeciesData = data.data.some(file =>file.species && file.species.length > 0);setHasMetadata(validMetadata);setHasMothMetadata(validMothMetadata);setHasSpecies(validSpeciesData || speciesOptions.length > 0);setFiles(data.data);setPagination(data.pagination);} catch (err) {const errorMessage = err instanceof Error ? err.message : "Failed to fetch files";setError(errorMessage);} finally {setLoading(false);}};if (isAuthenticated && !authLoading) {if (apiEndpoint === 'selection' && speciesFilter) {void fetchFiles().catch(console.error);} else if (apiEndpoint === 'files') {void fetchFiles().catch(console.error);} else if (apiEndpoint === 'selection' && !speciesFilter) {// If no species filter is set, don't make a fetch request but clear loading statesetLoading(false);}}}, [isAuthenticated, authLoading, getAccessToken, requiredParams.clusterId, requiredParams.datasetId, speciesFilter, currentPage, nightFilter, speciesOptions.length, apiEndpoint]);// Update internal state when external filter changesuseEffect(() => {if (externalNightFilter !== undefined) {setNightFilter(externalNightFilter);}}, [externalNightFilter]);// Update species filter when requiredParams.speciesId changesuseEffect(() => {setSpeciesFilter(requiredParams.speciesId || null);}, [requiredParams.speciesId]);// Fetch species for the dataset to populate the species filter dropdownuseEffect(() => {setLoadingSpecies(true);setSpeciesOptions([]);const fetchSpecies = async () => {if (!isAuthenticated || !requiredParams.datasetId) {setLoadingSpecies(false);setSpeciesOptions([]);return;}try {const accessToken = await getAccessToken();const url = `/api/species?datasetId=${encodeURIComponent(requiredParams.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 filtersetSpeciesOptions([]);} finally {setLoadingSpecies(false);}};if (isAuthenticated && !authLoading) {void fetchSpecies().catch(console.error);}}, [isAuthenticated, authLoading, getAccessToken, requiredParams.datasetId]);return (<div className="card p-6 bg-white shadow-sm rounded-lg">{/* Filter controls */}{!loading && !error && (<div className="mb-4"><FileFiltercurrentFilter={nightFilter}onFilterChange={setNightFilter}currentSpeciesId={speciesFilter}onSpeciesFilterChange={handleSpeciesFilterChange}speciesOptions={speciesOptions}totalFiles={pagination?.totalItems}allowOptionalSpecies={allowOptionalSpecies}speciesFilterLabel={speciesFilterLabel}/></div>)}{/* Select species prompt when no species is selected (selection endpoint only) */}{!loading && !error && !speciesFilter && speciesOptions.length > 0 && apiEndpoint === 'selection' && (<div className="py-4 text-center text-gray-600 bg-gray-50 rounded-md">Please select a species to view files</div>)}{loading && <div className="py-4 text-center text-gray-500">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 && (apiEndpoint === 'files' || speciesFilter) && (<><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><TableHead className="py-3 font-bold text-sm uppercase">Online</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} className={`hover:bg-primary/5 ${file.upload ? "bg-gray-50" : ""}`}><TableCellclassName="font-medium whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.fileName}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.upload ? (<Check className="h-4 w-4 text-gray-600" />) : (<X className="h-4 w-4 text-gray-600" />)}</TableCell>{hasSpecies && (<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.species && file.species.length > 0 ? (<div className="flex flex-wrap gap-1">{file.species.map(species => (<divkey={species.id}className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${getSpeciesColor(species.id)} text-center whitespace-nowrap overflow-hidden text-ellipsis`}style={{ minWidth: '80px', maxWidth: '150px' }}title={species.label}>{species.label}</div>))}</div>) : "—"}</TableCell>)}<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatDuration(Number(file.duration))}</TableCell>{hasMothMetadata && (<><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{file.mothMetadata?.gain || "—"}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatBatteryVoltage(file.mothMetadata?.batteryV)}</TableCell><TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatTemperature(file.mothMetadata?.tempC)}</TableCell></>)}<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{formatMoonPhase(file.moonPhase)}</TableCell>{hasMetadata && (<TableCellclassName="whitespace-normal break-words cursor-pointer"onClick={() => handleFileClick(file)}>{renderMetadata(file.metadata)}</TableCell>)}</TableRow>))) : (<TableRow><TableCellcolSpan={// Calculate total columns based on optional columns4 + // File, Status, Duration, Moon Phase (always present)(hasSpecies ? 1 : 0) +(hasMothMetadata ? 3 : 0) +(hasMetadata ? 1 : 0)}className="text-center py-8"><div className="text-gray-500">{apiEndpoint === 'selection' ? "No files found with the selected filters" : "Try adjusting your filters to see more results"}</div></TableCell></TableRow>)}</TableBody></Table></div>{pagination && pagination.totalPages > 1 && files.length > 0 && (<PaginationcurrentPage={currentPage}totalPages={pagination.totalPages}onPageChange={handlePageChange}/>)}</>)}{/* No species available message (selection endpoint only) */}{!loading && !error && speciesOptions.length === 0 && apiEndpoint === 'selection' && (<div className="p-4 bg-yellow-50 text-yellow-800 rounded-md"><p className="font-medium">No species found for this dataset</p><p className="text-sm mt-1">Try selecting a different dataset or adding species to this dataset</p></div>)}{/* Initial state - completely empty cluster (files endpoint only) */}{!loading && !error && files.length === 0 && !pagination && speciesOptions.length === 0 && apiEndpoint === 'files' && (<div className="p-4 bg-yellow-50 text-yellow-800 rounded-md"><p className="font-medium">No files found for this cluster</p></div>)}{/* File Modal */}<Filefile={selectedFile}isOpen={isFileModalOpen}onClose={() => setIsFileModalOpen(false)}datasetOwnerId={datasetOwnerId}datasetId={requiredParams.datasetId || datasetId || null}/></div>);};export default FileTable;