feat: Enhanced UI and added species images - Added iNaturalist integration, improved header styling, project info, species cards, loading states, and dark mode support

This commit is contained in:
Magnus Smari Smarason
2025-03-25 15:15:42 +00:00
parent a5a1e2bdf5
commit bfcdd8cd5b
5 changed files with 1050 additions and 5 deletions

719
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,13 +18,16 @@
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@react-pdf/renderer": "^4.3.0",
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.24.1",
"@tanstack/react-query-devtools": "^5.24.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.3.1",
"html2canvas": "^1.4.1",
"jotai": "^2.6.4",
"jspdf": "^3.0.1",
"lucide-react": "^0.338.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,319 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { FileText, Loader2 } from 'lucide-react';
import { SpeciesDetails } from '@/lib/api';
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import { formatDate } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { getCitesTradeRecords } from '@/lib/api';
interface SpeciesReportProps {
species: SpeciesDetails;
imageUrl?: string | null;
}
export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
const [isGenerating, setIsGenerating] = useState(false);
// Fetch trade records for the report
const { data: tradeRecords } = useQuery({
queryKey: ["tradeRecords", species.id],
queryFn: () => getCitesTradeRecords(species.id),
});
const generatePDF = async () => {
try {
setIsGenerating(true);
// Create a new PDF document
const doc = new jsPDF('p', 'mm', 'a4');
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 20; // Standard margin
let currentY = margin;
// Helper function to check if we need a new page
const checkPageBreak = (neededSpace: number) => {
if (currentY + neededSpace > pageHeight - margin) {
doc.addPage();
currentY = margin;
return true;
}
return false;
};
// Helper function for wrapped text
const addWrappedText = (text: string, x: number, fontSize: number, maxWidth: number) => {
doc.setFontSize(fontSize);
const lines = doc.splitTextToSize(text, maxWidth);
lines.forEach((line: string) => {
checkPageBreak(fontSize / 2);
doc.text(line, x, currentY);
currentY += fontSize / 2 + 2;
});
};
// Add title
doc.setFontSize(20);
doc.text('Species Report', pageWidth / 2, currentY, { align: 'center' });
currentY += 10;
// Add species name
doc.setFontSize(14);
doc.setFont('helvetica', 'italic');
const wrappedScientificName = doc.splitTextToSize(species.scientific_name, pageWidth - 2 * margin);
wrappedScientificName.forEach((line: string) => {
doc.text(line, pageWidth / 2, currentY, { align: 'center' });
currentY += 6;
});
doc.setFont('helvetica', 'normal');
// Add common name if available
if (species.common_name) {
checkPageBreak(10);
doc.setFontSize(12);
const wrappedCommonName = doc.splitTextToSize(species.common_name, pageWidth - 2 * margin);
wrappedCommonName.forEach((line: string) => {
doc.text(line, pageWidth / 2, currentY, { align: 'center' });
currentY += 5;
});
currentY += 5;
}
// Add image if available
if (imageUrl) {
try {
const img = new Image();
img.crossOrigin = 'Anonymous';
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = imageUrl;
});
const maxImgWidth = pageWidth - 2 * margin;
const imgWidth = Math.min(140, maxImgWidth);
const imgHeight = (img.height * imgWidth) / img.width;
// Check if image needs a new page
if (checkPageBreak(imgHeight + 10)) {
currentY = margin;
}
doc.addImage(
img,
'JPEG',
(pageWidth - imgWidth) / 2,
currentY,
imgWidth,
imgHeight
);
currentY += imgHeight + 10;
} catch (error) {
console.error('Error adding image to PDF:', error);
}
}
// Add taxonomic information
checkPageBreak(30);
doc.setFontSize(14);
doc.text('Taxonomic Information', margin, currentY);
currentY += 8;
doc.setFontSize(11);
const taxonomicInfo = [
['Kingdom', species.kingdom],
['Phylum', species.phylum],
['Class', species.class],
['Order', species.order_name],
['Family', species.family],
['Genus', species.genus],
['Species', species.species_name],
];
taxonomicInfo.forEach(([label, value]) => {
if (value) {
checkPageBreak(6);
const text = `${label}: ${value}`;
addWrappedText(text, margin + 3, 11, pageWidth - 2.5 * margin);
}
});
currentY += 8;
// Add conservation status
if (species.latest_assessment || species.current_cites_listing) {
checkPageBreak(30);
doc.setFontSize(14);
doc.text('Conservation Status', margin, currentY);
currentY += 8;
doc.setFontSize(11);
if (species.latest_assessment) {
checkPageBreak(12);
doc.text('IUCN Red List Status:', margin + 3, currentY);
currentY += 6;
const status = `${species.latest_assessment.status} (${species.latest_assessment.year_published})`;
addWrappedText(status, margin + 8, 11, pageWidth - 3 * margin);
}
if (species.current_cites_listing) {
checkPageBreak(12);
doc.text('CITES Listing:', margin + 3, currentY);
currentY += 6;
const listing = `Appendix ${species.current_cites_listing.appendix} (Listed: ${formatDate(species.current_cites_listing.listing_date)})`;
addWrappedText(listing, margin + 8, 11, pageWidth - 3 * margin);
}
}
// Add CITES listing history
if (species.cites_listings && species.cites_listings.length > 0) {
doc.addPage();
currentY = margin;
doc.setFontSize(16);
doc.text('CITES Listing History', margin, currentY);
currentY += 10;
doc.setFontSize(12);
species.cites_listings.forEach((listing) => {
checkPageBreak(20);
const listingText = `Appendix ${listing.appendix} - ${formatDate(listing.listing_date)}`;
addWrappedText(listingText, margin + 5, 12, pageWidth - 3 * margin);
if (listing.notes) {
checkPageBreak(10);
addWrappedText(listing.notes, margin + 10, 10, pageWidth - 4 * margin);
}
currentY += 5;
});
}
// Add trade data summary if available
if (tradeRecords && tradeRecords.length > 0) {
doc.addPage();
currentY = margin;
doc.setFontSize(16);
doc.setFont("helvetica", "bold");
doc.text("CITES Trade Data Summary", margin, currentY);
currentY += 10;
doc.setFontSize(12);
doc.setFont("helvetica", "normal");
// Total records
checkPageBreak(7);
doc.text(`Total Trade Records: ${tradeRecords.length}`, margin + 5, currentY);
currentY += 7;
// Year range
checkPageBreak(7);
const years = tradeRecords.map(r => r.year);
const yearRange = `${Math.min(...years)} - ${Math.max(...years)}`;
doc.text(`Time Period: ${yearRange}`, margin + 5, currentY);
currentY += 7;
// Total quantity
checkPageBreak(7);
const totalQuantity = tradeRecords
.reduce((sum, record) => sum + (Number(record.quantity) || 0), 0)
.toLocaleString();
doc.text(`Total Quantity Traded: ${totalQuantity}`, margin + 5, currentY);
currentY += 15;
// Top terms traded
checkPageBreak(40);
doc.setFont("helvetica", "bold");
doc.text("Most Traded Terms:", margin + 5, currentY);
currentY += 7;
doc.setFont("helvetica", "normal");
const termCounts: Record<string, number> = {};
tradeRecords.forEach(record => {
termCounts[record.term] = (termCounts[record.term] || 0) + 1;
});
const topTerms = Object.entries(termCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 5);
topTerms.forEach(([term, count]) => {
checkPageBreak(7);
const termText = `${term}: ${count} records`;
addWrappedText(termText, margin + 10, 12, pageWidth - 4 * margin);
});
currentY += 10;
// Top importers/exporters
checkPageBreak(40);
doc.setFont("helvetica", "bold");
doc.text("Top Trading Countries:", margin + 5, currentY);
currentY += 7;
doc.setFont("helvetica", "normal");
const countryData: Record<string, { imports: number; exports: number }> = {};
tradeRecords.forEach(record => {
if (record.importer) {
if (!countryData[record.importer]) {
countryData[record.importer] = { imports: 0, exports: 0 };
}
countryData[record.importer].imports++;
}
if (record.exporter) {
if (!countryData[record.exporter]) {
countryData[record.exporter] = { imports: 0, exports: 0 };
}
countryData[record.exporter].exports++;
}
});
const topCountries = Object.entries(countryData)
.sort(([, a], [, b]) => (b.imports + b.exports) - (a.imports + a.exports))
.slice(0, 5);
topCountries.forEach(([country, data]) => {
checkPageBreak(7);
const countryText = `${country}: ${data.imports} imports, ${data.exports} exports`;
addWrappedText(countryText, margin + 10, 12, pageWidth - 4 * margin);
});
}
// Add footer with generation date on each page
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
const footerText = `Generated on ${new Date().toLocaleDateString()} - Arctic Species Tracker | Page ${i} of ${pageCount}`;
doc.setFontSize(10);
doc.text(footerText, pageWidth / 2, pageHeight - margin / 2, { align: 'center' });
}
// Save the PDF
doc.save(`${species.scientific_name.replace(/\s+/g, '_')}_report.pdf`);
} catch (error) {
console.error('Error generating PDF:', error);
} finally {
setIsGenerating(false);
}
};
return (
<Button
onClick={generatePDF}
variant="outline"
disabled={isGenerating}
className="w-full"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating Report...
</>
) : (
<>
<FileText className="mr-2 h-4 w-4" />
Export PDF Report
</>
)}
</Button>
);
}

View File

@ -32,6 +32,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SpeciesReport } from './species-report';
type SpeciesTabsProps = {
species: SpeciesDetails;
@ -342,10 +343,16 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
return (
<div className="w-full flex flex-col">
<div className="text-xs text-muted-foreground mb-4">
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 &lt;www.iucnredlist.org&gt;,
Species+/CITES Checklist API (https://api.speciesplus.net/), and CITES Trade Database extracted March 2025 (https://trade.cites.org/).
<div className="flex justify-between items-center mb-4">
<div className="text-xs text-muted-foreground">
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 &lt;www.iucnredlist.org&gt;,
Species+/CITES Checklist API (https://api.speciesplus.net/), and CITES Trade Database extracted March 2025 (https://trade.cites.org/).
</div>
<div className="w-48">
<SpeciesReport species={species} imageUrl={imageData?.url} />
</div>
</div>
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="w-full">
<TabsTrigger value="overview">Overview</TabsTrigger>