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:
719
package-lock.json
generated
719
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
1
scripts/import_cites_trade.ts
Normal file
1
scripts/import_cites_trade.ts
Normal file
@ -0,0 +1 @@
|
||||
|
319
src/components/species-report.tsx
Normal file
319
src/components/species-report.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 <www.iucnredlist.org>,
|
||||
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 <www.iucnredlist.org>,
|
||||
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>
|
||||
|
Reference in New Issue
Block a user