LYPSC7BOH6T45FCPRHSCXILAJSJ74D5WSQTUIKPWD5ECXOYGUY5AC
7ESBJZLIH3TAERLH2HIZAAQHVNVHQWLWCOBLMJRFTIL33YITJNIAC
HBM7XFBGVMKW3P3VZFNQTJMINZ4DC3D4TZMT4TPTJRXC62HKZMMQC
YX7LU4WRAUDMWS3DEDXZDSF6DXBHLYDWVSMSRK6KIW3MO6GRXSVQC
J2RLNDEXTGAV4BB6ANIIR7XJLJBHSB4NFQWSBWHNAFB6DMLGS5RAC
ROQGXQWL2V363K3W7TVVYKIAX4N4IWRERN5BJ7NYJRRVB6OMIJ4QC
4RBE543WLHA7PIYT4W7YEJPF6XKZ2UGKPJBQ3CTLJ44AOMGHCEYQC
POIBWSL3JFHT2KN3STFSJX3INSYKEJTX6KSW3N7BVEKWX2GJ6T7QC
M3JUJ2WWZGCVMBITKRM5FUJMHFYL2QRMXJUVRUE4AC2RF74AOL5AC
DOQBQX4IQSDBYYSBP4BEMTMJUKZPSC33KKXPAGZ3A5BRJMMKHCRQC
4M3EBLTLSS2BRCM42ZP7WVD4YMRRLGV2P2XF47IAV5XHHJD52HTQC
const countResult = await db
.select({
count: sqlExpr<number>`COUNT(*)`
})
.from(file)
.where(whereConditions);
// If filtering by species, we need a more complex query that joins with selections and labels
let countResult;
if (speciesId) {
// Count only files that have at least one selection labeled with the specified species
countResult = await db
.select({
count: sqlExpr<number>`COUNT(DISTINCT ${file.id})`
})
.from(file)
.innerJoin(selection, eq(selection.fileId, file.id))
.innerJoin(label, eq(label.selectionId, selection.id))
.where(sqlExpr`${whereConditions} AND ${label.speciesId} = ${speciesId} AND ${label.active} = true`);
} else {
// Standard count without species filter
countResult = await db
.select({
count: sqlExpr<number>`COUNT(*)`
})
.from(file)
.where(whereConditions);
}
const filesResult = await db
.select({
id: file.id,
fileName: file.fileName,
path: file.path,
timestampLocal: file.timestampLocal,
duration: file.duration,
sampleRate: file.sampleRate,
locationId: file.locationId,
description: file.description,
maybeSolarNight: file.maybeSolarNight,
maybeCivilNight: file.maybeCivilNight,
moonPhase: file.moonPhase,
})
.from(file)
.where(whereConditions)
.orderBy(file.timestampLocal)
.limit(limitedPageSize)
.offset(offset);
let filesResult;
if (speciesId) {
// Get only files that have at least one selection labeled with the specified species
filesResult = await db
.select({
id: file.id,
fileName: file.fileName,
path: file.path,
timestampLocal: file.timestampLocal,
duration: file.duration,
sampleRate: file.sampleRate,
locationId: file.locationId,
description: file.description,
maybeSolarNight: file.maybeSolarNight,
maybeCivilNight: file.maybeCivilNight,
moonPhase: file.moonPhase,
})
.from(file)
.innerJoin(selection, eq(selection.fileId, file.id))
.innerJoin(label, eq(label.selectionId, selection.id))
.where(sqlExpr`${whereConditions} AND ${label.speciesId} = ${speciesId} AND ${label.active} = true`)
.orderBy(file.timestampLocal)
.groupBy(file.id, file.fileName, file.path, file.timestampLocal, file.duration,
file.sampleRate, file.locationId, file.description, file.maybeSolarNight,
file.maybeCivilNight, file.moonPhase)
.limit(limitedPageSize)
.offset(offset);
} else {
// Standard query without species filter
filesResult = await db
.select({
id: file.id,
fileName: file.fileName,
path: file.path,
timestampLocal: file.timestampLocal,
duration: file.duration,
sampleRate: file.sampleRate,
locationId: file.locationId,
description: file.description,
maybeSolarNight: file.maybeSolarNight,
maybeCivilNight: file.maybeCivilNight,
moonPhase: file.moonPhase,
})
.from(file)
.where(whereConditions)
.orderBy(file.timestampLocal)
.limit(limitedPageSize)
.offset(offset);
}
// Fetch species data via selections and labels
// This query gets all species that have been labeled in any selection within these files
const speciesQueryCondition = speciesId
? sqlExpr`${selection.fileId} IN (${sqlExpr.raw(fileIdsQuoted)}) AND ${label.speciesId} = ${speciesId} AND ${label.active} = true`
: sqlExpr`${selection.fileId} IN (${sqlExpr.raw(fileIdsQuoted)}) AND ${label.active} = true`;
const speciesResults = await db
.select({
fileId: selection.fileId,
speciesId: species.id,
speciesLabel: species.label,
ebirdCode: species.ebirdCode,
description: species.description
})
.from(selection)
.innerJoin(label, eq(label.selectionId, selection.id))
.innerJoin(species, eq(species.id, label.speciesId))
.where(speciesQueryCondition);
// Create a map of file ID to species data, grouping species by file
speciesMap = speciesResults.reduce((acc, item) => {
if (!acc[item.fileId]) {
acc[item.fileId] = [];
}
// Only add the species if it's not already in the array for this file
// (we might have multiple selections with the same species in one file)
const existingSpecies = acc[item.fileId].find((s: { id: string }) => s.id === item.speciesId);
if (!existingSpecies) {
acc[item.fileId].push({
id: item.speciesId,
label: item.speciesLabel,
ebirdCode: item.ebirdCode,
description: item.description
});
}
return acc;
}, {} as Record<string, Array<{
id: string;
label: string;
ebirdCode: string | null;
description: string | null;
}>>);
{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>
)}
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);
// 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]);
{/* Removed header section - now handled in AuthorisedContent */}
{/* 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>
)}
{files.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium whitespace-normal break-words">{file.fileName}</TableCell>
<TableCell className="whitespace-normal break-words">{formatDuration(Number(file.duration))}</TableCell>
<TableCell className="whitespace-normal break-words">
{formatMoonPhase(file.moonPhase)}
</TableCell>
{hasMothMetadata && (
<>
{files.length > 0 ? (
files.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium whitespace-normal break-words">{file.fileName}</TableCell>
{hasSpecies && (
{file.mothMetadata?.gain || "—"}
{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 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 && (
{formatBatteryVoltage(file.mothMetadata?.batteryV)}
</TableCell>
<TableCell className="whitespace-normal break-words">
{formatTemperature(file.mothMetadata?.tempC)}
</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}`;
});
{file.metadata ? (
(() => {
try {
// Parse metadata if it's a string
const metaObj = typeof file.metadata === 'string'
? JSON.parse(file.metadata)
: file.metadata;
return pairs.join(', ');
// 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";
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>
)}
})()
) : "—"}
</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-yellow-800 font-medium">
No files found matching the current filters
</div>
<div className="text-gray-500 text-sm mt-2">
Try adjusting your filters to see more results
</div>
</TableCell>
const [filesFilter, setFilesFilter] = useState<"none" | "solarNight" | "solarDay" | "civilNight" | "civilDay">("none");
// This filter is now handled in the Files component
const [filesFilter] = useState<"none" | "solarNight" | "solarDay" | "civilNight" | "civilDay">("none");
{/* Filters moved to breadcrumb line */}
{selectedClusterId && (
<FilesFilter
clusterId={selectedClusterId}
currentFilter={filesFilter}
onFilterChange={(filter) => setFilesFilter(filter)}
/>
)}
{/* 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 */}