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 { Button } from "./ui/button";
interface FileMetadata {
noiseType?: string[];
noiseLevel?: string;
[key: string]: unknown;
}
interface File {
id: string;
fileName: string;
path: string | null;
timestampLocal: string;
duration: number;
sampleRate: number;
locationId: string;
description: string | null;
maybeSolarNight: boolean | null;
maybeCivilNight: boolean | null;
moonPhase: number | null;
metadata: FileMetadata | null;
}
interface PaginationMetadata {
currentPage: number;
pageSize: number;
totalPages: number;
totalItems: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
interface FilesResponse {
data: File[];
pagination: PaginationMetadata;
}
interface FilesProps {
clusterId: string;
clusterName: string;
hideHeaderInfo?: boolean;
}
const Files: React.FC<FilesProps> = ({
clusterId,
clusterName,
hideHeaderInfo = false
}) => {
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 formatDuration = (durationSec: number): string => {
const minutes = Math.floor(durationSec / 60);
const seconds = Math.floor(durationSec % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
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 handlePageChange = (newPage: number) => {
if (newPage >= 1 && (!pagination || newPage <= pagination.totalPages)) {
setCurrentPage(newPage);
}
};
useEffect(() => {
// Reset state when cluster changes
setFiles([]);
setPagination(null);
setLoading(true);
setError(null);
const fetchFiles = async () => {
if (!isAuthenticated || !clusterId) {
if (!authLoading) {
setLoading(false);
}
return;
}
try {
const accessToken = await getAccessToken();
// Fetch files for the selected cluster with pagination
const url = `/api/files?clusterId=${encodeURIComponent(clusterId)}&page=${currentPage}&pageSize=100`;
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");
}
// Debug log to see the structure of the data
console.log("Files API response:", data.data);
// Check if valid metadata is present in any file
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;
}
});
console.log("Has valid metadata:", validMetadata, "Sample file:", data.data[0]);
setHasMetadata(validMetadata);
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) {
fetchFiles();
}
}, [isAuthenticated, authLoading, getAccessToken, clusterId, currentPage]);
return (
<div className="card p-6 bg-white shadow-sm rounded-lg">
{!hideHeaderInfo && (
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">
<span className="text-gray-800">{clusterName}</span>
<span className="ml-2">Files</span>
</h2>
{pagination && (
<div className="text-gray-600 text-sm">
{pagination.totalItems} files
</div>
)}
</div>
)}
{hideHeaderInfo && (
<h2 className="text-xl font-bold mb-4">
<span className="text-gray-800">{clusterName}</span>
<span className="ml-2">Files</span>
</h2>
)}
{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 && files.length > 0 && (
<>
<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 Name</TableHead>
<TableHead className="py-3 font-bold text-sm uppercase">Timestamp</TableHead>
<TableHead className="py-3 font-bold text-sm uppercase">Duration</TableHead>
<TableHead className="py-3 font-bold text-sm uppercase">Sample Rate</TableHead>
<TableHead className="py-3 font-bold text-sm uppercase">Night</TableHead>
{hasMetadata && (
<TableHead className="py-3 font-bold text-sm uppercase">Metadata</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium whitespace-normal break-words">{file.fileName}</TableCell>
<TableCell className="whitespace-normal break-words">{formatDateTime(file.timestampLocal)}</TableCell>
<TableCell className="whitespace-normal break-words">{formatDuration(Number(file.duration))}</TableCell>
<TableCell className="whitespace-normal break-words">{formatSampleRate(file.sampleRate)}</TableCell>
<TableCell className="whitespace-normal break-words">
{file.maybeSolarNight ? "True" : "False"}
</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>
))}
</TableBody>
</Table>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex justify-center items-center mt-6">
<nav className="flex items-center gap-1" aria-label="Pagination">
{/* First page button */}
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
aria-label="First page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="11 17 6 12 11 7"></polyline>
<polyline points="18 17 13 12 18 7"></polyline>
</svg>
</Button>
{/* Previous page button */}
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</Button>
{/* Page number buttons */}
{(() => {
const totalPages = pagination.totalPages;
const current = currentPage;
const pages = [];
// Always show first page
if (current > 3) {
pages.push(
<Button
key="page-1"
variant={current === 1 ? "default" : "outline"}
size="icon"
className="h-8 w-8 rounded-md"
onClick={() => handlePageChange(1)}
>
1
</Button>
);
// Add ellipsis if not showing page 2
if (current > 4) {
pages.push(
<span key="ellipsis1" className="px-1">…</span>
);
}
}
// Show current page and surrounding pages
const startPage = Math.max(1, current - 1);
const endPage = Math.min(totalPages, current + 1);
for (let i = startPage; i <= endPage; i++) {
if (i === 1 || i === totalPages) continue; // Skip first and last pages as they're handled separately
pages.push(
<Button
key={`page-${i}`}
variant={current === i ? "default" : "outline"}
size="icon"
className="h-8 w-8 rounded-md"
onClick={() => handlePageChange(i)}
>
{i}
</Button>
);
}
// Add intermediate points
const checkpoints = [10, 20, 30, 40];
if (totalPages > 5) {
for (const checkpoint of checkpoints) {
if (checkpoint > current + 2 && checkpoint < totalPages - 2) {
// Only insert checkpoint if it's not close to what we already display
if (!pages.some(p => p.key === `page-${checkpoint}`)) {
pages.push(
<Button
key={`page-${checkpoint}`}
variant="outline"
size="icon"
className="h-8 w-8 rounded-md"
onClick={() => handlePageChange(checkpoint)}
>
{checkpoint}
</Button>
);
// Insert only one checkpoint button
break;
}
}
}
}
// Add ellipsis if needed
if (current < totalPages - 3) {
pages.push(
<span key="ellipsis2" className="px-1">…</span>
);
}
// Always show last page
if (totalPages > 1) {
pages.push(
<Button
key={`page-${totalPages}`}
variant={current === totalPages ? "default" : "outline"}
size="icon"
className="h-8 w-8 rounded-md"
onClick={() => handlePageChange(totalPages)}
>
{totalPages}
</Button>
);
}
return pages;
})()}
{/* Next page button */}
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === pagination.totalPages}
aria-label="Next page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</Button>
{/* Last page button */}
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md"
onClick={() => handlePageChange(pagination.totalPages)}
disabled={currentPage === pagination.totalPages}
aria-label="Last page"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="13 17 18 12 13 7"></polyline>
<polyline points="6 17 11 12 6 7"></polyline>
</svg>
</Button>
</nav>
</div>
)}
</>
)}
{!loading && !error && files.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 Files;