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-select": "^2.1.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
"@supabase/supabase-js": "^2.39.7",
|
"@supabase/supabase-js": "^2.39.7",
|
||||||
"@tanstack/react-query": "^5.24.1",
|
"@tanstack/react-query": "^5.24.1",
|
||||||
"@tanstack/react-query-devtools": "^5.24.1",
|
"@tanstack/react-query-devtools": "^5.24.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"jotai": "^2.6.4",
|
"jotai": "^2.6.4",
|
||||||
|
"jspdf": "^3.0.1",
|
||||||
"lucide-react": "^0.338.0",
|
"lucide-react": "^0.338.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^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,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { SpeciesReport } from './species-report';
|
||||||
|
|
||||||
type SpeciesTabsProps = {
|
type SpeciesTabsProps = {
|
||||||
species: SpeciesDetails;
|
species: SpeciesDetails;
|
||||||
@ -342,10 +343,16 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col">
|
<div className="w-full flex flex-col">
|
||||||
<div className="text-xs text-muted-foreground mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 <www.iucnredlist.org>,
|
<div className="text-xs text-muted-foreground">
|
||||||
Species+/CITES Checklist API (https://api.speciesplus.net/), and CITES Trade Database extracted March 2025 (https://trade.cites.org/).
|
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>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue={defaultTab} className="w-full">
|
<Tabs defaultValue={defaultTab} className="w-full">
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
Reference in New Issue
Block a user