About CITES Trade Records
CITES trade records document international trade in wildlife listed in the CITES Appendices.
diff --git a/src/components/species/visualizations/CatchChart.tsx b/src/components/species/visualizations/CatchChart.tsx
new file mode 100644
index 0000000..77281cb
--- /dev/null
+++ b/src/components/species/visualizations/CatchChart.tsx
@@ -0,0 +1,93 @@
+import {
+ ResponsiveContainer,
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend
+} from 'recharts';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { CatchRecord } from '@/lib/api'; // Assuming CatchRecord type is exported from api.ts
+import { Fish } from 'lucide-react';
+
+interface CatchChartProps {
+ catchRecords: CatchRecord[];
+}
+
+// Helper function to format numbers for tooltip/axis
+function formatNumber(value: number | string | undefined | null): string {
+ if (value === null || value === undefined) return 'N/A';
+ const num = Number(value);
+ if (isNaN(num)) return 'N/A';
+ return num.toLocaleString();
+}
+
+export function CatchChart({ catchRecords }: CatchChartProps) {
+ // Prepare data for the chart - handle potential nulls if necessary
+ // Recharts typically handles nulls by breaking the line, which is often desired.
+ const chartData = catchRecords.map(record => ({
+ ...record,
+ // Ensure numeric types for charting if needed, though Recharts might coerce
+ year: Number(record.year),
+ catch_total: record.catch_total !== null ? Number(record.catch_total) : null,
+ quota: record.quota !== null ? Number(record.quota) : null,
+ }));
+
+ return (
+
+
+
+
+ Catch Data Over Time (NAMMCO)
+
+ Reported catch totals and quotas by year
+
+
+
+
+
+
+
+
+ [
+ formatNumber(value),
+ name // Use default name mapping ('Catch Total', 'Quota')
+ ]}
+ />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/species/visualizations/TradeCharts.tsx b/src/components/species/visualizations/TradeCharts.tsx
index db3fd26..423044f 100644
--- a/src/components/species/visualizations/TradeCharts.tsx
+++ b/src/components/species/visualizations/TradeCharts.tsx
@@ -14,43 +14,96 @@ import {
PieChart as RechartsPieChart,
Pie,
Cell,
+ PieLabelRenderProps,
+ ReferenceLine
} from "recharts";
import React from "react";
+import { TimelineEvent } from "@/lib/api";
-// Colors for charts
+// Simplified formatNumber for tooltip values
+const formatNumber = (value: number | string | undefined): string => {
+ const num = Number(value || 0);
+ return Math.round(num).toLocaleString();
+}
+
+// Keep colors and basic interfaces
const CHART_COLORS = [
"#8884d8", "#83a6ed", "#8dd1e1", "#82ca9d", "#a4de6c",
"#d0ed57", "#ffc658", "#ff8042", "#ff6361", "#bc5090"
];
-type TradeChartsProps = {
- visualizationData: {
- recordsByYear: { year: number; count: number }[];
- topImporters: { country: string; count: number }[];
- topExporters: { country: string; count: number }[];
- termsTraded: { term: string; count: number }[];
- tradePurposes: { purpose: string; count: number; description: string }[];
- tradeSources: { source: string; count: number; description: string }[];
- termQuantitiesByYear: { year: number; [term: string]: number }[];
- topTerms: string[];
- };
+interface VisualizationData {
+ recordsByYear: { year: number; count: number }[];
+ topImporters: { country: string; count: number }[];
+ topExporters: { country: string; count: number }[];
+ termsTraded: { term: string; count: number }[];
+ tradePurposes: { purpose: string; count: number; description: string }[];
+ tradeSources: { source: string; count: number; description: string }[];
+ termQuantitiesByYear: { year: number; [term: string]: number }[];
+ topTerms: string[];
+}
+
+interface TradeChartsProps {
+ visualizationData: VisualizationData;
PURPOSE_DESCRIPTIONS: Record;
SOURCE_DESCRIPTIONS: Record;
+ timelineEvents?: TimelineEvent[];
+}
+
+// Expected structure within the 'payload' object from Recharts Tooltip item
+interface BarChartTooltipInternalPayload {
+ purpose?: string;
+ source?: string;
+ description?: string;
+ term?: string;
+ count?: number;
+}
+
+// Custom tooltip for pie chart
+const renderCustomizedLabel = ({ cx, cy, midAngle, outerRadius, percent, payload }: PieLabelRenderProps) => {
+ const numCx = Number(cx || 0);
+ const numCy = Number(cy || 0);
+ const numOuterRadius = Number(outerRadius || 0);
+ const numMidAngle = Number(midAngle || 0);
+
+ if (!percent || !payload) return null;
+
+ const RADIAN = Math.PI / 180;
+ const radius = numOuterRadius * 1.15;
+ const x = numCx + radius * Math.cos(-numMidAngle * RADIAN);
+ const y = numCy + radius * Math.sin(-numMidAngle * RADIAN);
+
+ return (
+ numCx ? 'start' : 'end'}
+ dominantBaseline="central"
+ fontSize={12}
+ fontWeight="500"
+ >
+ {`${(payload as { term: string }).term} (${(percent * 100).toFixed(0)}%)`}
+
+ );
};
-export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DESCRIPTIONS }: TradeChartsProps) {
- // Process terms data to combine small segments
+// Type for the 'item' argument passed to the complex bar chart formatter
+// Aims to align with Recharts internal Payload structure for Tooltip
+interface BarTooltipItem {
+ payload?: BarChartTooltipInternalPayload;
+ value?: number | string;
+ name?: string;
+ color?: string;
+ dataKey?: string;
+}
+
+export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DESCRIPTIONS, timelineEvents }: TradeChartsProps) {
const processedTermsData = React.useMemo(() => {
- const threshold = 0.02; // 2% threshold
+ const threshold = 0.02;
let otherCount = 0;
-
- // Sort by count in descending order
const sortedTerms = [...visualizationData.termsTraded].sort((a, b) => b.count - a.count);
-
- // Calculate total for percentage
const total = sortedTerms.reduce((sum, item) => sum + item.count, 0);
-
- // Filter and combine small segments
const significantTerms = sortedTerms.filter(item => {
const percentage = item.count / total;
if (percentage < threshold) {
@@ -59,49 +112,54 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
}
return true;
});
-
- // Add "Other" category if there are small segments
if (otherCount > 0) {
significantTerms.push({ term: 'Other', count: otherCount });
}
-
return significantTerms;
}, [visualizationData.termsTraded]);
- // Custom tooltip for pie chart
- const renderCustomizedLabel = ({ cx, cy, midAngle, outerRadius, percent, payload }: any) => {
- const RADIAN = Math.PI / 180;
- const radius = outerRadius * 1.15; // Increased radius for better spacing
- const x = cx + radius * Math.cos(-midAngle * RADIAN);
- const y = cy + radius * Math.sin(-midAngle * RADIAN);
-
- return (
- cx ? 'start' : 'end'}
- dominantBaseline="central"
- fontSize={12}
- fontWeight="500"
- >
- {`${payload.term} (${(percent * 100).toFixed(0)}%)`}
-
- );
+ // Formatter for Purpose/Source Bar charts with refined item type
+ const barChartDetailFormatter = (
+ value: number | string,
+ name: string,
+ item: BarTooltipItem
+ ): [React.ReactNode, React.ReactNode] => {
+ const internalPayload = item.payload;
+ let label = name;
+ if (internalPayload?.purpose) {
+ const purpose = internalPayload.purpose;
+ const description = internalPayload.description || PURPOSE_DESCRIPTIONS[purpose] || 'Unknown';
+ label = `${purpose} - ${description}`;
+ } else if (internalPayload?.source) {
+ const source = internalPayload.source;
+ const description = internalPayload.description || SOURCE_DESCRIPTIONS[source] || 'Unknown';
+ label = `${source} - ${description}`;
+ }
+ const valueToFormat = item.value ?? value;
+ const formattedValue = formatNumber(valueToFormat);
+ return [formattedValue, label];
};
return (
-
Trade Visualizations
+
Trade Visualizations
{/* Records Over Time */}
- Records Over Time
-
- Number of trade records by year
+
+ Records Over Time
+
+
+
+ Number of trade records by year
+
+
+ Green dashed lines indicate important timeline events. Hover over the year to see event details.
+
+
@@ -117,8 +175,35 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
textAnchor="end"
tick={{ fontSize: 12 }}
/>
-
-
+
+
{
+ if (!active || !payload || payload.length === 0) return null;
+
+ return (
+
+
Year: {label}
+ {payload.map((entry, idx) => {
+ const value = Array.isArray(entry.value) ? entry.value[0] : entry.value;
+ return (
+
+ {entry.name}: {formatNumber(value)}
+
+ );
+ })}
+ {timelineEvents
+ ?.filter(e => e.year === label)
+ .map((event, idx) => (
+
+
Event Type: {event.event_type}
+
Title: {event.title}
+ {event.description &&
Description: {event.description}
}
+
+ ))}
+
+ );
+ }}
+ />
+
+ {timelineEvents && timelineEvents.map(event => (
+
+
+ ))}
+
@@ -134,11 +230,11 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Top Importers and Exporters */}
-
+
{/* Top Importers */}
-
+
Top Importers
@@ -159,8 +255,13 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
tick={{ fontSize: 12 }}
interval={0}
/>
-
-
+
+
[
+ formatNumber(value),
+ name || 'Value'
+ ]}
+ />
@@ -170,8 +271,8 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Top Exporters */}
-
-
+
+
Top Exporters
@@ -192,8 +293,13 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
tick={{ fontSize: 12 }}
interval={0}
/>
-
-
+
+ [
+ formatNumber(value),
+ name || 'Value'
+ ]}
+ />
@@ -204,8 +310,8 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Terms Traded */}
-
-
+
+
@@ -230,7 +336,12 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
|
))}
- [`${value} records`, props.payload.term]} />
+ [
+ `${formatNumber(value)} records`,
+ name || 'Term' // name is the nameKey (term)
+ ]}
+ />
@@ -238,11 +349,11 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Trade Purposes and Sources */}
-
+
{/* Trade Purposes */}
-
+
Trade Purposes
@@ -257,7 +368,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
layout="vertical"
>
-
+
`${value} - ${PURPOSE_DESCRIPTIONS[value] || 'Unknown'}`}
width={150}
/>
- [
- `${value} records`,
- `${props.payload.purpose} - ${props.payload.description}`
- ]} />
+ {/* Revert to using as any due to persistent type issues */}
+
@@ -279,7 +388,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Trade Sources */}
-
+
Trade Sources
@@ -294,7 +403,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
layout="vertical"
>
-
+
`${value} - ${SOURCE_DESCRIPTIONS[value] || 'Unknown'}`}
width={150}
/>
- [
- `${value} records`,
- `${props.payload.source} - ${props.payload.description}`
- ]} />
+ {/* Revert to using as any due to persistent type issues */}
+
@@ -317,7 +424,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Quantity of Top Terms Over Time */}
-
+
Quantity of Top Terms Over Time
@@ -337,15 +444,20 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
textAnchor="end"
tick={{ fontSize: 12 }}
/>
-
-
+
+
[
+ formatNumber(value),
+ name || 'Value' // Use name prop from
+ ]}
+ />
{visualizationData.topTerms.map((term, index) => (
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 0000000..5afd41d
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index 12daad7..1e03d26 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
- "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+ "inline-flex items-center rounded-full border px-4 py-1.5 text-base font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
@@ -15,6 +15,8 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
+ success:
+ "border-transparent bg-green-500 text-white hover:bg-green-500/80",
},
},
defaultVariants: {
@@ -33,4 +35,4 @@ function Badge({ className, variant, ...props }: BadgeProps) {
)
}
-export { Badge, badgeVariants }
\ No newline at end of file
+export { Badge, badgeVariants }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index a26e60f..830ebcf 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
- "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-base font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
@@ -19,10 +19,10 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: "h-10 px-4 py-2",
- sm: "h-9 rounded-md px-3",
- lg: "h-11 rounded-md px-8",
- icon: "h-10 w-10",
+ default: "h-12 px-8 py-3",
+ sm: "h-10 rounded-md px-4 py-2",
+ lg: "h-14 rounded-md px-10 py-3",
+ icon: "h-12 w-12",
},
},
defaultVariants: {
@@ -52,4 +52,4 @@ const Button = React.forwardRef(
);
Button.displayName = "Button";
-export { Button, buttonVariants };
\ No newline at end of file
+export { Button, buttonVariants };
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
index 938aa22..3cd4bc3 100644
--- a/src/components/ui/card.tsx
+++ b/src/components/ui/card.tsx
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
))
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
(({ className, ...props }, ref) => (
))
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
))
CardContent.displayName = "CardContent"
@@ -76,4 +76,4 @@ const CardFooter = React.forwardRef<
))
CardFooter.displayName = "CardFooter"
-export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
\ No newline at end of file
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..0a6a9a5
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index b3e6779..c33212d 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
>
{children}
-
+
Close
@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
(({ className, ...props }, ref) => (
))
@@ -119,4 +119,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
-}
\ No newline at end of file
+}
diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx
new file mode 100644
index 0000000..2003743
--- /dev/null
+++ b/src/components/ui/form.tsx
@@ -0,0 +1,176 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 522915b..22cbeb1 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -11,7 +11,7 @@ const Input = React.forwardRef(
(
)
Input.displayName = "Input"
-export { Input }
\ No newline at end of file
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
index a9eec77..42dfab2 100644
--- a/src/components/ui/label.tsx
+++ b/src/components/ui/label.tsx
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
- "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ "text-lg font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
@@ -21,4 +21,4 @@ const Label = React.forwardRef<
))
Label.displayName = LabelPrimitive.Root.displayName
-export { Label }
\ No newline at end of file
+export { Label }
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..d331105
--- /dev/null
+++ b/src/components/ui/pagination.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { ButtonProps, buttonVariants } from "@/components/ui/button"
+
+const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
+
+)
+Pagination.displayName = "Pagination"
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationContent.displayName = "PaginationContent"
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationItem.displayName = "PaginationItem"
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick &
+ React.ComponentProps<"a">
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) => (
+
+)
+PaginationLink.displayName = "PaginationLink"
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+)
+PaginationPrevious.displayName = "PaginationPrevious"
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+)
+PaginationNext.displayName = "PaginationNext"
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+)
+PaginationEllipsis.displayName = "PaginationEllipsis"
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationLink,
+ PaginationItem,
+ PaginationPrevious,
+ PaginationNext,
+ PaginationEllipsis,
+}
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..3fd47ad
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+
+import { cn } from "@/lib/utils"
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+))
+Progress.displayName = ProgressPrimitive.Root.displayName
+
+export { Progress }
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..cf253cf
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+))
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+))
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+
+export { ScrollArea, ScrollBar }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index c6bde11..500261d 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -17,14 +17,14 @@ const SelectTrigger = React.forwardRef<
span]:line-clamp-1",
+ "flex h-12 w-full items-center justify-between rounded-md border border-input bg-background px-4 py-3 text-base ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
-
+
))
@@ -42,7 +42,7 @@ const SelectScrollUpButton = React.forwardRef<
)}
{...props}
>
-
+
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
@@ -59,7 +59,7 @@ const SelectScrollDownButton = React.forwardRef<
)}
{...props}
>
-
+
))
SelectScrollDownButton.displayName =
@@ -116,14 +116,14 @@ const SelectItem = React.forwardRef<
-
+
@@ -155,4 +155,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
-}
\ No newline at end of file
+}
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
new file mode 100644
index 0000000..c0df655
--- /dev/null
+++ b/src/components/ui/table.tsx
@@ -0,0 +1,120 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
index 221608b..2e8d493 100644
--- a/src/components/ui/tabs.tsx
+++ b/src/components/ui/tabs.tsx
@@ -11,7 +11,8 @@ const TabsList = React.forwardRef<
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
new file mode 100644
index 0000000..2ddb7c5
--- /dev/null
+++ b/src/components/ui/toast.tsx
@@ -0,0 +1,127 @@
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
+ {
+ variants: {
+ variant: {
+ default: "border bg-background text-foreground",
+ destructive:
+ "destructive group border-destructive bg-destructive text-destructive-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx
new file mode 100644
index 0000000..6c67edf
--- /dev/null
+++ b/src/components/ui/toaster.tsx
@@ -0,0 +1,33 @@
+import { useToast } from "@/hooks/use-toast"
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast"
+
+export function Toaster() {
+ const { toasts } = useToast()
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/contexts/auth/AuthContext.tsx b/src/contexts/auth/AuthContext.tsx
new file mode 100644
index 0000000..93a66fb
--- /dev/null
+++ b/src/contexts/auth/AuthContext.tsx
@@ -0,0 +1,179 @@
+import React, { createContext, useState, useEffect, useContext } from 'react';
+import { supabase } from '@/lib/supabase'; // Import the shared client
+import type { User as SupabaseUser, Session as SupabaseSession } from '@supabase/supabase-js';
+
+interface Profile {
+ id: string;
+ email: string;
+ role: string;
+}
+
+// This will be the type for our main 'user' state
+type AppUser = Profile | null;
+
+// Temporary user info derived directly from Supabase session
+interface SessionUserInfo {
+ id: string;
+ email: string;
+ needsProfileFetch: boolean; // Flag to trigger profile fetch
+}
+
+type AuthContextType = {
+ user: AppUser; // Changed from User | null
+ loading: boolean;
+ signIn: (email: string, password: string) => Promise;
+ signOut: () => Promise;
+ isAdmin: boolean;
+};
+
+const AuthContext = createContext(undefined);
+
+export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [user, setUser] = useState(null);
+ const [sessionUserInfo, setSessionUserInfo] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Effect 1: Check initial session and fetch profile if session exists
+ useEffect(() => {
+ const checkUserSession = async () => {
+ console.log('[AuthContext] checkUserSession: Checking for existing session...');
+ setLoading(true); // Ensure loading is true at the start
+ const { data: { session }, error: sessionError } = await supabase.auth.getSession();
+
+ if (sessionError) {
+ console.error('[AuthContext] checkUserSession: Error getting session:', sessionError);
+ setUser(null);
+ setLoading(false);
+ return;
+ }
+
+ if (session && session.user) {
+ console.log('[AuthContext] checkUserSession: Session found. User ID:', session.user.id);
+ try {
+ const { data: profileData, error: profileError } = await supabase
+ .from('profiles')
+ .select('id, email, role')
+ .eq('id', session.user.id)
+ .single();
+
+ if (profileError) {
+ console.error('[AuthContext] checkUserSession: Error fetching profile:', profileError);
+ setUser(null);
+ } else if (profileData) {
+ console.log('[AuthContext] checkUserSession: Profile fetched:', profileData);
+ setUser(profileData);
+ } else {
+ console.warn('[AuthContext] checkUserSession: Profile not found for user ID:', session.user.id);
+ setUser(null); // Or handle as partial user
+ }
+ } catch (e) {
+ console.error('[AuthContext] checkUserSession: Exception fetching profile:', e);
+ setUser(null);
+ }
+ } else {
+ console.log('[AuthContext] checkUserSession: No existing session.');
+ setUser(null);
+ }
+ console.log('[AuthContext] checkUserSession: Setting loading to false.');
+ setLoading(false);
+ };
+
+ checkUserSession();
+ }, []);
+
+ // Effect 2: Listen to onAuthStateChange to get basic session info
+ useEffect(() => {
+ const { data: { subscription } } = supabase.auth.onAuthStateChange(
+ (_event, session) => {
+ console.log('[AuthContext] onAuthStateChange: Event:', _event, 'Session User ID:', session?.user?.id);
+ if (_event === 'SIGNED_IN' && session && session.user) {
+ setSessionUserInfo({
+ id: session.user.id,
+ email: session.user.email || 'No email in session',
+ needsProfileFetch: true,
+ });
+ } else if (_event === 'SIGNED_OUT') {
+ setSessionUserInfo(null);
+ setUser(null); // Clear full user profile on sign out
+ }
+ // INITIAL_SESSION is handled by checkUserSession
+ // We don't setLoading(false) here anymore, it's handled by profile fetch or checkUserSession
+ }
+ );
+ return () => {
+ subscription?.unsubscribe();
+ };
+ }, []);
+
+ // Effect 3: Fetch full profile when sessionUserInfo indicates a new login
+ useEffect(() => {
+ if (sessionUserInfo && sessionUserInfo.needsProfileFetch) {
+ const fetchFullProfile = async () => {
+ console.log('[AuthContext] fetchFullProfile: Needs profile fetch for user ID:', sessionUserInfo.id);
+ setLoading(true);
+ try {
+ const { data: profileData, error: profileError } = await supabase
+ .from('profiles')
+ .select('id, email, role')
+ .eq('id', sessionUserInfo.id)
+ .single();
+
+ if (profileError) {
+ console.error('[AuthContext] fetchFullProfile: Error fetching profile:', profileError);
+ setUser(null);
+ } else if (profileData) {
+ console.log('[AuthContext] fetchFullProfile: Profile fetched successfully:', profileData);
+ setUser(profileData);
+ } else {
+ console.warn('[AuthContext] fetchFullProfile: Profile not found for user ID:', sessionUserInfo.id);
+ setUser(null);
+ }
+ } catch (e) {
+ console.error('[AuthContext] fetchFullProfile: Exception during profile fetch:', e);
+ setUser(null);
+ } finally {
+ console.log('[AuthContext] fetchFullProfile: Setting loading to false.');
+ setLoading(false);
+ setSessionUserInfo(prev => prev ? { ...prev, needsProfileFetch: false } : null); // Reset flag
+ }
+ };
+ fetchFullProfile();
+ }
+ }, [sessionUserInfo]);
+
+ const signIn = async (email: string, password: string) => {
+ console.log('[AuthContext] signIn: Attempting to sign in with email:', email);
+ // setLoading(true) will be handled by onAuthStateChange -> fetchFullProfile effect
+ const { error } = await supabase.auth.signInWithPassword({ email, password });
+ if (error) {
+ console.error('[AuthContext] signIn: Supabase signIn error:', error);
+ // setLoading(false); // Ensure loading is reset on error
+ throw error;
+ }
+ // Successful signIn will trigger onAuthStateChange, which then triggers profile fetch
+ console.log('[AuthContext] signIn: Supabase signInWithPassword call successful. onAuthStateChange will handle next steps.');
+ };
+
+ const signOut = async () => {
+ console.log('[AuthContext] signOut: Signing out...');
+ await supabase.auth.signOut();
+ // onAuthStateChange will set user to null and sessionUserInfo to null
+ console.log('[AuthContext] signOut: signOut call complete.');
+ };
+
+ const isAdmin = user?.role === 'admin';
+
+ return (
+
+ {children}
+
+ );
+};
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+};
diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts
new file mode 100644
index 0000000..02e111d
--- /dev/null
+++ b/src/hooks/use-toast.ts
@@ -0,0 +1,194 @@
+"use client"
+
+// Inspired by react-hot-toast library
+import * as React from "react"
+
+import type {
+ ToastActionElement,
+ ToastProps,
+} from "@/components/ui/toast"
+
+const TOAST_LIMIT = 1
+const TOAST_REMOVE_DELAY = 1000000
+
+type ToasterToast = ToastProps & {
+ id: string
+ title?: React.ReactNode
+ description?: React.ReactNode
+ action?: ToastActionElement
+}
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+let count = 0
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
+ return count.toString()
+}
+
+type ActionType = typeof actionTypes
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"]
+ toast: ToasterToast
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"]
+ toast: Partial
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId)
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+}
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ }
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId)
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id)
+ })
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ }
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ }
+ }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+type Toast = Omit
+
+function toast({ ...props }: Toast) {
+ const id = genId()
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ })
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss()
+ },
+ },
+ })
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ }
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState)
+
+ React.useEffect(() => {
+ listeners.push(setState)
+ return () => {
+ const index = listeners.indexOf(setState)
+ if (index > -1) {
+ listeners.splice(index, 1)
+ }
+ }
+ }, [state])
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ }
+}
+
+export { useToast, toast }
diff --git a/src/hooks/useTradeRecordFilters.ts b/src/hooks/useTradeRecordFilters.ts
index a38feff..39ea1e4 100644
--- a/src/hooks/useTradeRecordFilters.ts
+++ b/src/hooks/useTradeRecordFilters.ts
@@ -1,20 +1,58 @@
-import { useState, useMemo } from "react";
-import { CitesTradeRecord } from "@/lib/api";
+import { useState, useMemo } from 'react';
+import { CitesTradeRecord } from '@/lib/api';
+import { getCountryName } from '@/lib/countries'; // Import the helper function
+
+export type CountryOption = {
+ code: string;
+ name: string;
+};
export function useTradeRecordFilters(tradeRecords: CitesTradeRecord[] | undefined) {
// State for trade record filters
const [startYearFilter, setStartYearFilter] = useState("all");
const [endYearFilter, setEndYearFilter] = useState("all");
const [termFilter, setTermFilter] = useState("all");
+ const [importerFilter, setImporterFilter] = useState("all");
+ const [exporterFilter, setExporterFilter] = useState('all');
+
+ // Get unique importers, exporters (with names), and year range
+ const { uniqueImporters, uniqueExporters, yearRange } = useMemo(() => {
+ if (!tradeRecords || tradeRecords.length === 0) {
+ return { uniqueImporters: [], uniqueExporters: [], yearRange: { min: 0, max: 0 } };
+ }
+
+ const years = new Set();
+ const importerCodes = new Set();
+ const exporterCodes = new Set();
+
+ tradeRecords.forEach((r) => {
+ const yearNum = Number(r.year);
+ if (!isNaN(yearNum)) {
+ years.add(yearNum);
+ }
+ if (r.importer) importerCodes.add(r.importer);
+ if (r.exporter) exporterCodes.add(r.exporter);
+ });
+
+ const sortedYears = Array.from(years).sort((a, b) => a - b);
+
+ // Map codes to { code, name } objects and sort by name
+ const mapAndSortCountries = (codes: Set): CountryOption[] => {
+ return Array.from(codes)
+ .map(code => ({ code, name: getCountryName(code) }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ };
+
+ const sortedImporters = mapAndSortCountries(importerCodes);
+ const sortedExporters = mapAndSortCountries(exporterCodes);
- // Get min/max year range
- const yearRange = useMemo(() => {
- if (!tradeRecords || tradeRecords.length === 0) return { min: 0, max: 0 };
-
- const years = tradeRecords.map(r => Number(r.year)).filter(y => !isNaN(y));
return {
- min: Math.min(...years),
- max: Math.max(...years)
+ uniqueImporters: sortedImporters,
+ uniqueExporters: sortedExporters,
+ yearRange: {
+ min: sortedYears[0] ?? 0,
+ max: sortedYears[sortedYears.length - 1] ?? 0
+ }
};
}, [tradeRecords]);
@@ -29,16 +67,20 @@ export function useTradeRecordFilters(tradeRecords: CitesTradeRecord[] | undefin
return (
(recordYear >= startYearNum && recordYear <= endYearNum) &&
- (termFilter === "all" || !termFilter || record.term === termFilter)
+ (termFilter === "all" || !termFilter || record.term === termFilter) &&
+ (importerFilter === "all" || !importerFilter || record.importer === importerFilter) &&
+ (exporterFilter === "all" || !exporterFilter || record.exporter === exporterFilter)
);
});
- }, [tradeRecords, startYearFilter, endYearFilter, termFilter, yearRange]);
+ }, [tradeRecords, startYearFilter, endYearFilter, termFilter, importerFilter, exporterFilter, yearRange]);
// Reset filters function
const resetFilters = () => {
setStartYearFilter("all");
setEndYearFilter("all");
setTermFilter("all");
+ setImporterFilter("all");
+ setExporterFilter("all");
};
return {
@@ -48,8 +90,14 @@ export function useTradeRecordFilters(tradeRecords: CitesTradeRecord[] | undefin
setEndYearFilter,
termFilter,
setTermFilter,
+ importerFilter,
+ setImporterFilter,
+ exporterFilter,
+ setExporterFilter,
filteredRecords,
resetFilters,
- yearRange
+ yearRange,
+ uniqueImporters,
+ uniqueExporters
};
}
diff --git a/src/index.css b/src/index.css
index 4ef0baf..e7587ee 100644
--- a/src/index.css
+++ b/src/index.css
@@ -33,6 +33,16 @@
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
+
+ --chart-1: 12 76% 61%;
+
+ --chart-2: 173 58% 39%;
+
+ --chart-3: 197 37% 24%;
+
+ --chart-4: 43 74% 66%;
+
+ --chart-5: 27 87% 67%;
}
.dark {
@@ -63,14 +73,45 @@
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
}
}
@layer base {
+ /* html { */
+ /* Let browser default (usually 16px) be the base */
+ /* font-size: 20px; */
+ /* } */
+
+ body {
+ @apply bg-background text-foreground;
+ /* Body inherits font-size from html, no need to redeclare unless different */
+ }
+
* {
@apply border-border;
}
- body {
- @apply bg-background text-foreground;
+
+ /* Let Tailwind's utility classes handle font sizes based on the html base */
+
+ /* Adjust base styles for form elements if needed, but avoid !important */
+ input,
+ select,
+ textarea,
+ button,
+ .btn,
+ [type="button"],
+ [type="submit"] {
+ /* Example: Ensure a minimum readable size, but allow utilities to override */
+ /* font-size: 0.9rem; */
+ /* Or leave it to default/utility classes */
}
-}
\ No newline at end of file
+
+ /* Let Tailwind's padding and height utilities work as intended */
+}
+
+/* Removed custom override for species detail tabs wrap */
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 9108275..e69464b 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -27,6 +27,17 @@ export type SpeciesDetails = Species & {
current_cites_listing?: CitesListing;
};
+// Manually define CatchRecord based on README as generated types seem outdated
+export interface CatchRecord {
+ id: number; // Assuming BIGINT maps to number
+ species_id: string; // UUID
+ country: string | null;
+ year: number;
+ catch_total: number | null;
+ quota: number | null;
+ source: string | null;
+}
+
export async function getAllSpecies() {
try {
console.log("Fetching all species from database...");
@@ -176,6 +187,29 @@ export async function getSpeciesById(id: string): Promise
}
}
+/**
+ * Get the total count of CITES trade records
+ */
+export async function getTotalTradeCount() {
+ console.log('Fetching total trade count...');
+ try {
+ const { count, error } = await supabase
+ .from('cites_trade_records')
+ .select('*', { count: 'exact', head: true }); // Use head: true for count only
+
+ if (error) {
+ console.error('Error fetching total trade count:', error);
+ throw error;
+ }
+
+ console.log('Total trade count:', count);
+ return { count: count ?? 0 }; // Return count, defaulting to 0 if null
+ } catch (error) {
+ console.error('Error in getTotalTradeCount:', error);
+ return { count: 0 }; // Return 0 on error
+ }
+}
+
export async function getTimelineEvents(speciesId: string) {
const { data, error } = await supabase
.from('timeline_events')
@@ -775,3 +809,487 @@ async function updateIucnAssessmentsLatest(speciesId: string, latestId: string)
throw error;
}
}
+
+// New API functions to support the enhanced species detail page
+
+/**
+ * Get species distribution and range data
+ */
+export async function getSpeciesDistribution(speciesId: string) {
+ console.log('Fetching distribution data for species:', speciesId);
+
+ try {
+ const { data, error } = await supabase
+ .from('distribution_ranges')
+ .select('*')
+ .eq('species_id', speciesId)
+ .order('region');
+
+ if (error) {
+ console.error('Error fetching distribution ranges:', error);
+ throw error;
+ }
+
+ return data || [];
+ } catch (error) {
+ console.error('Error in getSpeciesDistribution:', error);
+ return [];
+ }
+}
+
+/**
+ * Create a new distribution range entry
+ */
+export async function createDistributionRange(distribution: {
+ species_id: string;
+ region: string;
+ presence_code: string;
+ origin_code: string;
+ seasonal_code?: string;
+ geojson?: any;
+ notes?: string;
+}) {
+ try {
+ const newDistribution = {
+ ...distribution,
+ id: uuidv4(),
+ created_at: new Date().toISOString()
+ };
+
+ console.log('Creating new distribution range:', newDistribution);
+
+ const { data, error } = await supabase
+ .from('distribution_ranges')
+ .insert(newDistribution)
+ .select()
+ .single();
+
+ if (error) {
+ console.error('Error creating distribution range:', error);
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error in createDistributionRange:', error);
+ throw error;
+ }
+}
+
+/**
+ * Update an existing distribution range entry
+ */
+export async function updateDistributionRange(id: string, updates: {
+ region?: string;
+ presence_code?: string;
+ origin_code?: string;
+ seasonal_code?: string;
+ geojson?: any;
+ notes?: string;
+}) {
+ try {
+ console.log(`Updating distribution range ${id}:`, updates);
+
+ const { data, error } = await supabase
+ .from('distribution_ranges')
+ .update(updates)
+ .eq('id', id)
+ .select()
+ .single();
+
+ if (error) {
+ console.error('Error updating distribution range:', error);
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error in updateDistributionRange:', error);
+ throw error;
+ }
+}
+
+/**
+ * Delete a distribution range entry
+ */
+export async function deleteDistributionRange(id: string) {
+ try {
+ console.log(`Deleting distribution range ${id}`);
+
+ const { error } = await supabase
+ .from('distribution_ranges')
+ .delete()
+ .eq('id', id);
+
+ if (error) {
+ console.error('Error deleting distribution range:', error);
+ throw error;
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error in deleteDistributionRange:', error);
+ throw error;
+ }
+}
+
+/**
+ * Get threats for a specific species
+ */
+export async function getSpeciesThreats(speciesId: string) {
+ console.log('Fetching threats data for species:', speciesId);
+
+ try {
+ const { data, error } = await supabase
+ .from('species_threats')
+ .select('*')
+ .eq('species_id', speciesId)
+ .order('threat_type');
+
+ if (error) {
+ console.error('Error fetching species threats:', error);
+ throw error;
+ }
+
+ return data || [];
+ } catch (error) {
+ console.error('Error in getSpeciesThreats:', error);
+ return [];
+ }
+}
+
+/**
+ * Create a new species threat entry
+ */
+export async function createSpeciesThreat(threat: {
+ species_id: string;
+ threat_type: string;
+ threat_code: string;
+ severity?: string;
+ scope?: string;
+ timing?: string;
+ description?: string;
+}) {
+ try {
+ const newThreat = {
+ ...threat,
+ id: uuidv4(),
+ created_at: new Date().toISOString()
+ };
+
+ console.log('Creating new species threat:', newThreat);
+
+ const { data, error } = await supabase
+ .from('species_threats')
+ .insert(newThreat)
+ .select()
+ .single();
+
+ if (error) {
+ console.error('Error creating species threat:', error);
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error in createSpeciesThreat:', error);
+ throw error;
+ }
+}
+
+/**
+ * Update an existing species threat entry
+ */
+export async function updateSpeciesThreat(id: string, updates: {
+ threat_type?: string;
+ threat_code?: string;
+ severity?: string;
+ scope?: string;
+ timing?: string;
+ description?: string;
+}) {
+ try {
+ console.log(`Updating species threat ${id}:`, updates);
+
+ const { data, error } = await supabase
+ .from('species_threats')
+ .update(updates)
+ .eq('id', id)
+ .select()
+ .single();
+
+ if (error) {
+ console.error('Error updating species threat:', error);
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error in updateSpeciesThreat:', error);
+ throw error;
+ }
+}
+
+/**
+ * Delete a species threat entry
+ */
+export async function deleteSpeciesThreat(id: string) {
+ try {
+ console.log(`Deleting species threat ${id}`);
+
+ const { error } = await supabase
+ .from('species_threats')
+ .delete()
+ .eq('id', id);
+
+ if (error) {
+ console.error('Error deleting species threat:', error);
+ throw error;
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error in deleteSpeciesThreat:', error);
+ throw error;
+ }
+}
+
+/**
+ * Get conservation measures for a specific species
+ */
+export async function getConservationMeasures(speciesId: string) {
+ console.log('Fetching conservation measures for species:', speciesId);
+
+ try {
+ const { data, error } = await supabase
+ .from('conservation_measures')
+ .select('*')
+ .eq('species_id', speciesId)
+ .order('measure_type');
+
+ if (error) {
+ console.error('Error fetching conservation measures:', error);
+ throw error;
+ }
+
+ return data || [];
+ } catch (error) {
+ console.error('Error in getConservationMeasures:', error);
+ return [];
+ }
+}
+
+/**
+ * Create a new conservation measure entry
+ */
+export async function createConservationMeasure(measure: {
+ species_id: string;
+ measure_type: string;
+ measure_code: string;
+ status?: string;
+ implementing_organizations?: string[];
+ start_date?: string;
+ end_date?: string;
+ description?: string;
+ effectiveness?: string;
+}) {
+ try {
+ const newMeasure = {
+ ...measure,
+ id: uuidv4(),
+ created_at: new Date().toISOString()
+ };
+
+ console.log('Creating new conservation measure:', newMeasure);
+
+ const { data, error } = await supabase
+ .from('conservation_measures')
+ .insert(newMeasure)
+ .select()
+ .single();
+
+ if (error) {
+ console.error('Error creating conservation measure:', error);
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error in createConservationMeasure:', error);
+ throw error;
+ }
+}
+
+/**
+ * Update an existing conservation measure entry
+ */
+export async function updateConservationMeasure(id: string, updates: {
+ measure_type?: string;
+ measure_code?: string;
+ status?: string;
+ implementing_organizations?: string[];
+ start_date?: string;
+ end_date?: string;
+ description?: string;
+ effectiveness?: string;
+}) {
+ try {
+ console.log(`Updating conservation measure ${id}:`, updates);
+
+ const { data, error } = await supabase
+ .from('conservation_measures')
+ .update(updates)
+ .eq('id', id)
+ .select()
+ .single();
+
+ if (error) {
+ console.error('Error updating conservation measure:', error);
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error in updateConservationMeasure:', error);
+ throw error;
+ }
+}
+
+/**
+ * Delete a conservation measure entry
+ */
+export async function deleteConservationMeasure(id: string) {
+ try {
+ console.log(`Deleting conservation measure ${id}`);
+
+ const { error } = await supabase
+ .from('conservation_measures')
+ .delete()
+ .eq('id', id);
+
+ if (error) {
+ console.error('Error deleting conservation measure:', error);
+ throw error;
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error in deleteConservationMeasure:', error);
+ throw error;
+ }
+}
+
+/**
+ * Get extended species description
+ * This will fetch the additional fields from the species table
+ */
+export async function getSpeciesExtendedInfo(speciesId: string) {
+ console.log('Fetching extended info for species:', speciesId);
+
+ try {
+ const { data, error } = await supabase
+ .from('species')
+ .select(`
+ description,
+ habitat_description,
+ population_trend,
+ population_size,
+ generation_length,
+ movement_patterns,
+ use_and_trade,
+ threats_overview,
+ conservation_overview
+ `)
+ .eq('id', speciesId)
+ .single();
+
+ if (error) {
+ console.error('Error fetching extended species info:', error);
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error in getSpeciesExtendedInfo:', error);
+ return {
+ description: null,
+ habitat_description: null,
+ population_trend: null,
+ population_size: null,
+ generation_length: null,
+ movement_patterns: null,
+ use_and_trade: null,
+ threats_overview: null,
+ conservation_overview: null
+ };
+ }
+}
+
+/**
+ * Update extended info for a species
+ */
+export async function updateSpeciesExtendedInfo(speciesId: string, updates: {
+ description?: string;
+ habitat_description?: string;
+ population_trend?: string;
+ population_size?: string;
+ generation_length?: number;
+ movement_patterns?: string;
+ use_and_trade?: string;
+ threats_overview?: string;
+ conservation_overview?: string;
+}) {
+ try {
+ console.log(`Updating extended info for species ${speciesId}:`, updates);
+
+ const { data, error } = await supabase
+ .from('species')
+ .update(updates)
+ .eq('id', speciesId)
+ .select(`
+ description,
+ habitat_description,
+ population_trend,
+ population_size,
+ generation_length,
+ movement_patterns,
+ use_and_trade,
+ threats_overview,
+ conservation_overview
+ `)
+ .single();
+
+ if (error) {
+ console.error('Error updating extended species info:', error);
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ console.error('Error in updateSpeciesExtendedInfo:', error);
+ throw error;
+ }
+}
+
+// Add function to get catch records by species ID
+export async function getCatchRecords(speciesId: string): Promise {
+ console.log('Fetching catch records for species ID:', speciesId);
+ try {
+ const { data, error } = await supabase
+ .from('catch_records')
+ .select('*')
+ .eq('species_id', speciesId)
+ .order('year', { ascending: true }); // Order by year ascending for charting
+
+ if (error) {
+ console.error('Error fetching catch records:', error);
+ throw error;
+ }
+
+ console.log(`Fetched ${data?.length ?? 0} catch records`);
+ return data || [];
+ } catch (error) {
+ console.error('Error in getCatchRecords:', error);
+ return []; // Return empty array on error
+ }
+}
diff --git a/src/lib/countries.ts b/src/lib/countries.ts
new file mode 100644
index 0000000..cab09d6
--- /dev/null
+++ b/src/lib/countries.ts
@@ -0,0 +1,260 @@
+// Mapping from ISO 3166-1 alpha-2 country codes to full names
+// Source: https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes (Partial list)
+
+export const countryCodeToNameMap: Record = {
+ AF: "Afghanistan",
+ AL: "Albania",
+ DZ: "Algeria",
+ AS: "American Samoa",
+ AD: "Andorra",
+ AO: "Angola",
+ AI: "Anguilla",
+ AQ: "Antarctica",
+ AG: "Antigua and Barbuda",
+ AR: "Argentina",
+ AM: "Armenia",
+ AW: "Aruba",
+ AU: "Australia",
+ AT: "Austria",
+ AZ: "Azerbaijan",
+ BS: "Bahamas",
+ BH: "Bahrain",
+ BD: "Bangladesh",
+ BB: "Barbados",
+ BY: "Belarus",
+ BE: "Belgium",
+ BZ: "Belize",
+ BJ: "Benin",
+ BM: "Bermuda",
+ BT: "Bhutan",
+ BO: "Bolivia",
+ BA: "Bosnia and Herzegovina",
+ BW: "Botswana",
+ BR: "Brazil",
+ IO: "British Indian Ocean Territory",
+ BN: "Brunei Darussalam",
+ BG: "Bulgaria",
+ BF: "Burkina Faso",
+ BI: "Burundi",
+ CV: "Cabo Verde",
+ KH: "Cambodia",
+ CM: "Cameroon",
+ CA: "Canada",
+ KY: "Cayman Islands",
+ CF: "Central African Republic",
+ TD: "Chad",
+ CL: "Chile",
+ CN: "China",
+ CX: "Christmas Island",
+ CC: "Cocos (Keeling) Islands",
+ CO: "Colombia",
+ KM: "Comoros",
+ CG: "Congo",
+ CD: "Congo (Democratic Republic of the)",
+ CK: "Cook Islands",
+ CR: "Costa Rica",
+ CI: "Côte d'Ivoire",
+ HR: "Croatia",
+ CU: "Cuba",
+ CW: "Curaçao",
+ CY: "Cyprus",
+ CZ: "Czech Republic",
+ DK: "Denmark",
+ DJ: "Djibouti",
+ DM: "Dominica",
+ DO: "Dominican Republic",
+ EC: "Ecuador",
+ EG: "Egypt",
+ SV: "El Salvador",
+ GQ: "Equatorial Guinea",
+ ER: "Eritrea",
+ EE: "Estonia",
+ SZ: "Eswatini",
+ ET: "Ethiopia",
+ FK: "Falkland Islands (Malvinas)",
+ FO: "Faroe Islands",
+ FJ: "Fiji",
+ FI: "Finland",
+ FR: "France",
+ GF: "French Guiana",
+ PF: "French Polynesia",
+ TF: "French Southern Territories",
+ GA: "Gabon",
+ GM: "Gambia",
+ GE: "Georgia",
+ DE: "Germany",
+ GH: "Ghana",
+ GI: "Gibraltar",
+ GR: "Greece",
+ GL: "Greenland",
+ GD: "Grenada",
+ GP: "Guadeloupe",
+ GU: "Guam",
+ GT: "Guatemala",
+ GG: "Guernsey",
+ GN: "Guinea",
+ GW: "Guinea-Bissau",
+ GY: "Guyana",
+ HT: "Haiti",
+ HN: "Honduras",
+ HK: "Hong Kong",
+ HU: "Hungary",
+ IS: "Iceland",
+ IN: "India",
+ ID: "Indonesia",
+ IR: "Iran",
+ IQ: "Iraq",
+ IE: "Ireland",
+ IM: "Isle of Man",
+ IL: "Israel",
+ IT: "Italy",
+ JM: "Jamaica",
+ JP: "Japan",
+ JE: "Jersey",
+ JO: "Jordan",
+ KZ: "Kazakhstan",
+ KE: "Kenya",
+ KI: "Kiribati",
+ KP: "Korea (Democratic People's Republic of)",
+ KR: "Korea (Republic of)",
+ KW: "Kuwait",
+ KG: "Kyrgyzstan",
+ LA: "Lao People's Democratic Republic",
+ LV: "Latvia",
+ LB: "Lebanon",
+ LS: "Lesotho",
+ LR: "Liberia",
+ LY: "Libya",
+ LI: "Liechtenstein",
+ LT: "Lithuania",
+ LU: "Luxembourg",
+ MO: "Macao",
+ MG: "Madagascar",
+ MW: "Malawi",
+ MY: "Malaysia",
+ MV: "Maldives",
+ ML: "Mali",
+ MT: "Malta",
+ MH: "Marshall Islands",
+ MQ: "Martinique",
+ MR: "Mauritania",
+ MU: "Mauritius",
+ YT: "Mayotte",
+ MX: "Mexico",
+ FM: "Micronesia (Federated States of)",
+ MD: "Moldova (Republic of)",
+ MC: "Monaco",
+ MN: "Mongolia",
+ ME: "Montenegro",
+ MS: "Montserrat",
+ MA: "Morocco",
+ MZ: "Mozambique",
+ MM: "Myanmar",
+ NA: "Namibia",
+ NR: "Nauru",
+ NP: "Nepal",
+ NL: "Netherlands",
+ NC: "New Caledonia",
+ NZ: "New Zealand",
+ NI: "Nicaragua",
+ NE: "Niger",
+ NG: "Nigeria",
+ NU: "Niue",
+ NF: "Norfolk Island",
+ MK: "North Macedonia",
+ MP: "Northern Mariana Islands",
+ NO: "Norway",
+ OM: "Oman",
+ PK: "Pakistan",
+ PW: "Palau",
+ PS: "Palestine, State of",
+ PA: "Panama",
+ PG: "Papua New Guinea",
+ PY: "Paraguay",
+ PE: "Peru",
+ PH: "Philippines",
+ PN: "Pitcairn",
+ PL: "Poland",
+ PT: "Portugal",
+ PR: "Puerto Rico",
+ QA: "Qatar",
+ RE: "Réunion",
+ RO: "Romania",
+ RU: "Russian Federation",
+ RW: "Rwanda",
+ BL: "Saint Barthélemy",
+ SH: "Saint Helena, Ascension and Tristan da Cunha",
+ KN: "Saint Kitts and Nevis",
+ LC: "Saint Lucia",
+ MF: "Saint Martin (French part)",
+ PM: "Saint Pierre and Miquelon",
+ VC: "Saint Vincent and the Grenadines",
+ WS: "Samoa",
+ SM: "San Marino",
+ ST: "Sao Tome and Principe",
+ SA: "Saudi Arabia",
+ SN: "Senegal",
+ RS: "Serbia",
+ SC: "Seychelles",
+ SL: "Sierra Leone",
+ SG: "Singapore",
+ SX: "Sint Maarten (Dutch part)",
+ SK: "Slovakia",
+ SI: "Slovenia",
+ SB: "Solomon Islands",
+ SO: "Somalia",
+ ZA: "South Africa",
+ GS: "South Georgia and the South Sandwich Islands",
+ SS: "South Sudan",
+ ES: "Spain",
+ LK: "Sri Lanka",
+ SD: "Sudan",
+ SR: "Suriname",
+ SJ: "Svalbard and Jan Mayen",
+ SE: "Sweden",
+ CH: "Switzerland",
+ SY: "Syrian Arab Republic",
+ TW: "Taiwan, Province of China",
+ TJ: "Tajikistan",
+ TZ: "Tanzania, United Republic of",
+ TH: "Thailand",
+ TL: "Timor-Leste",
+ TG: "Togo",
+ TK: "Tokelau",
+ TO: "Tonga",
+ TT: "Trinidad and Tobago",
+ TN: "Tunisia",
+ TR: "Turkey",
+ TM: "Turkmenistan",
+ TC: "Turks and Caicos Islands",
+ TV: "Tuvalu",
+ UG: "Uganda",
+ UA: "Ukraine",
+ AE: "United Arab Emirates",
+ GB: "United Kingdom",
+ US: "United States of America",
+ UM: "United States Minor Outlying Islands",
+ UY: "Uruguay",
+ UZ: "Uzbekistan",
+ VU: "Vanuatu",
+ VE: "Venezuela",
+ VN: "Viet Nam",
+ VG: "Virgin Islands (British)",
+ VI: "Virgin Islands (U.S.)",
+ WF: "Wallis and Futuna",
+ EH: "Western Sahara",
+ YE: "Yemen",
+ ZM: "Zambia",
+ ZW: "Zimbabwe",
+};
+
+/**
+ * Gets the full country name from an ISO 3166-1 alpha-2 code.
+ * Returns the code itself if no name is found.
+ * @param code - The 2-letter country code.
+ * @returns The full country name or the original code.
+ */
+export function getCountryName(code: string | null | undefined): string {
+ if (!code) return '-'; // Handle null or undefined codes
+ return countryCodeToNameMap[code.toUpperCase()] || code; // Return code if not found
+}
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
index 0fb48ef..f90ad54 100644
--- a/src/lib/supabase.ts
+++ b/src/lib/supabase.ts
@@ -22,19 +22,6 @@ const client = createClient(supabaseUrl, supabaseAnonKey, {
}
});
-// Test the connection
-(async () => {
- try {
- const { error } = await client.from('species').select('id').limit(1);
- if (error) {
- console.error('Failed to connect to Supabase:', error.message);
- } else {
- console.log('Successfully connected to Supabase');
- }
- } catch (e) {
- console.error('Failed to test Supabase connection:', e);
- }
-})();
-
// Export the client
-export const supabase = client;
\ No newline at end of file
+export const supabase = client;
+// Removed the self-executing connection test
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index ec58ece..6f32e25 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,49 +1,30 @@
-import { type ClassValue, clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
+ return twMerge(clsx(inputs))
}
-export function formatDate(date: string | Date): string {
- return new Date(date).toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- });
-}
-
-export const IUCN_STATUS_COLORS: Record = {
- 'EX': 'bg-black text-white', // Extinct
- 'EW': 'bg-gray-800 text-white', // Extinct in the Wild
- 'CR': 'bg-red-600 text-white', // Critically Endangered
- 'EN': 'bg-orange-600 text-white', // Endangered
- 'VU': 'bg-yellow-500 text-black', // Vulnerable
- 'NT': 'bg-yellow-300 text-black', // Near Threatened
- 'LC': 'bg-green-500 text-black', // Least Concern
- 'DD': 'bg-gray-500 text-white', // Data Deficient
- 'NE': 'bg-gray-300 text-black', // Not Evaluated
+export const IUCN_STATUS_COLORS = {
+ NE: 'bg-gray-400 text-gray-900',
+ DD: 'bg-gray-500 text-white',
+ LC: 'bg-green-500 text-white',
+ NT: 'bg-yellow-400 text-gray-900',
+ VU: 'bg-orange-500 text-white',
+ EN: 'bg-red-500 text-white',
+ CR: 'bg-red-700 text-white',
+ EW: 'bg-purple-500 text-white',
+ EX: 'bg-black text-white',
};
-export const IUCN_STATUS_FULL_NAMES: Record = {
- 'EX': 'Extinct',
- 'EW': 'Extinct in the Wild',
- 'CR': 'Critically Endangered',
- 'EN': 'Endangered',
- 'VU': 'Vulnerable',
- 'NT': 'Near Threatened',
- 'LC': 'Least Concern',
- 'DD': 'Data Deficient',
- 'NE': 'Not Evaluated',
+export const IUCN_STATUS_PROGRESS = {
+ NE: 0,
+ DD: 10,
+ LC: 25,
+ NT: 40,
+ VU: 55,
+ EN: 70,
+ CR: 85,
+ EW: 95,
+ EX: 100,
};
-
-export const CITES_APPENDIX_COLORS: Record = {
- 'I': 'bg-red-600 text-white', // Appendix I
- 'II': 'bg-blue-600 text-white', // Appendix II
- 'III': 'bg-green-600 text-white', // Appendix III
-};
-
-export function truncateString(str: string, maxLength: number): string {
- if (str.length <= maxLength) return str;
- return str.slice(0, maxLength) + '...';
-}
\ No newline at end of file
diff --git a/src/pages/admin/cites-listings/create.tsx b/src/pages/admin/cites-listings/create.tsx
new file mode 100644
index 0000000..267f9d7
--- /dev/null
+++ b/src/pages/admin/cites-listings/create.tsx
@@ -0,0 +1,161 @@
+import { useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { CitesListingForm } from '@/components/admin/CitesListingForm';
+import { citesListingsApi, speciesApi, CitesListing, Species } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { ChevronLeft } from 'lucide-react';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Label } from '@/components/ui/label';
+
+export default function CreateCitesListing() {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [searchParams] = useSearchParams();
+ const { toast } = useToast();
+ const [selectedSpeciesId, setSelectedSpeciesId] = useState(
+ searchParams.get('species_id')
+ );
+ const navigate = useNavigate();
+
+ // Fetch all species for the dropdown
+ const { data: species, isLoading: speciesLoading } = useQuery({
+ queryKey: ['admin', 'species', 'all'],
+ queryFn: async () => {
+ const { data } = await speciesApi.getAll(1, 500); // Fetch up to 500 species
+ return data;
+ }
+ });
+
+ // If a species_id was provided in the URL params, fetch that species details
+ const { data: selectedSpecies } = useQuery({
+ queryKey: ['admin', 'species', selectedSpeciesId],
+ queryFn: () => selectedSpeciesId ? speciesApi.getById(selectedSpeciesId) : null,
+ enabled: !!selectedSpeciesId
+ });
+
+ // Create CITES listing mutation
+ const createMutation = useMutation({
+ mutationFn: (data: CitesListing) => citesListingsApi.create(data),
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'CITES listing has been created successfully.',
+ });
+
+ // Navigate back to species detail page if we came from there
+ // if (selectedSpeciesId) {
+ // navigate(`/admin/species/${selectedSpeciesId}`);
+ // } else {
+ // navigate('/admin/cites-listings');
+ // }
+ },
+ onError: (error) => {
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: `Failed to create CITES listing: ${(error as Error).message}`,
+ });
+ // setIsSubmitting(false); // Will be handled by onSettled
+ },
+ onSettled: () => {
+ setIsSubmitting(false);
+ }
+ });
+
+ const handleSubmit = (data: CitesListing) => {
+ setIsSubmitting(true);
+ createMutation.mutate(data);
+ };
+
+ const handleSpeciesChange = (value: string) => {
+ setSelectedSpeciesId(value);
+ };
+
+ return (
+
+
+
+
+ Add New CITES Listing
+ Create a new CITES appendix listing
+
+
+ {/* Species selection dropdown (only if not pre-selected) */}
+ {!searchParams.get('species_id') && (
+
+
+
+ Choose the species for this CITES listing
+
+
+
+
+
+
+ )}
+
+ {/* Only show the form if a species is selected */}
+ {selectedSpeciesId ? (
+
+
+
+ {selectedSpecies ? (
+ <>
+ CITES Listing for {selectedSpecies.scientific_name}
+ >
+ ) : (
+ 'New CITES Listing'
+ )}
+
+
+
+
+
+ ) : (
+ !searchParams.get('species_id') && (
+
+ Please select a species to continue
+
+ )
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/cites-listings/edit.tsx b/src/pages/admin/cites-listings/edit.tsx
new file mode 100644
index 0000000..2952789
--- /dev/null
+++ b/src/pages/admin/cites-listings/edit.tsx
@@ -0,0 +1,141 @@
+import { useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { CitesListingForm } from '@/components/admin/CitesListingForm';
+import { citesListingsApi, speciesApi, CitesListing } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { ChevronLeft } from 'lucide-react';
+
+export default function EditCitesListing() {
+ const { id } = useParams<{ id: string }>();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ // Fetch the CITES listing data
+ const { data: listing, isLoading: listingLoading, error: listingError } = useQuery({
+ queryKey: ['admin', 'cites-listing', id],
+ queryFn: async () => {
+ // Since there's no direct API to get a listing by ID in our current API,
+ // we would need to implement this in a real application.
+ // For now, let's simulate by fetching all listings for the species and finding the right one
+
+ // This is a workaround for demo purposes
+ // In a real app, you would add a getById method to citesListingsApi
+
+ // First, we need to get all species
+ const { data: allSpecies } = await speciesApi.getAll(1, 100);
+
+ // Then for each species, get its listings and find the one with matching ID
+ for (const species of allSpecies) {
+ const listings = await citesListingsApi.getBySpeciesId(species.id!);
+ const foundListing = listings.find(l => l.id === id);
+
+ if (foundListing) {
+ // Attach the species data to the listing for UI display
+ return {
+ ...foundListing,
+ species
+ };
+ }
+ }
+
+ throw new Error('CITES listing not found');
+ },
+ enabled: !!id,
+ });
+
+ // Fetch species data to display in the form context
+ const { data: species, isLoading: speciesLoading } = useQuery({
+ queryKey: ['admin', 'species', listing?.species_id],
+ queryFn: () => listing?.species_id ? speciesApi.getById(listing.species_id) : null,
+ enabled: !!listing?.species_id,
+ });
+
+ // Update CITES listing mutation
+ const updateMutation = useMutation({
+ mutationFn: (data: CitesListing) => id ? citesListingsApi.update(id, data) : Promise.reject('Listing ID is required'),
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'CITES listing has been updated successfully.',
+ });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'cites-listing'] });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'cites-listings'] });
+ // navigate('/admin/cites-listings');
+ },
+ onError: (error) => {
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: `Failed to update CITES listing: ${(error as Error).message}`,
+ });
+ // setIsSubmitting(false); // Will be handled by onSettled
+ },
+ onSettled: () => {
+ setIsSubmitting(false);
+ }
+ });
+
+ const handleSubmit = (data: CitesListing) => {
+ setIsSubmitting(true);
+ updateMutation.mutate(data);
+ };
+
+ const isLoading = listingLoading || speciesLoading;
+
+ if (isLoading) {
+ return (
+
+ Loading CITES listing data...
+
+ );
+ }
+
+ if (listingError) {
+ return (
+
+
+ Error loading CITES listing: {(listingError as Error).message}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Edit CITES Listing
+
+ {species && (
+ <>
+ Edit CITES Appendix {listing?.appendix} listing for {species.scientific_name}
+ >
+ )}
+
+
+
+
+ {listing && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/cites-listings/list.tsx b/src/pages/admin/cites-listings/list.tsx
new file mode 100644
index 0000000..5837757
--- /dev/null
+++ b/src/pages/admin/cites-listings/list.tsx
@@ -0,0 +1,272 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Plus, Pencil, Trash, Search } from 'lucide-react';
+import { citesListingsApi, speciesApi, CitesListing, Species } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { format } from 'date-fns';
+import { Badge } from '@/components/ui/badge';
+
+export default function CitesListingsList() {
+ const [searchQuery, setSearchQuery] = useState('');
+ const { toast } = useToast();
+ const [isSearching, setIsSearching] = useState(false);
+ const [deleteDialog, setDeleteDialog] = useState<{open: boolean, listing?: CitesListing}>({open: false});
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ // Data fetching for listings
+ const { data: listings, isLoading, error } = useQuery({
+ queryKey: ['admin', 'cites-listings'],
+ queryFn: async () => {
+ // Fetch all listings
+ // In a real-world scenario, you would implement pagination and more complex filtering
+ const speciesData = await speciesApi.getAll(1, 100); // Get all species (limit 100)
+
+ // For each species, fetch its CITES listings
+ const allListings: (CitesListing & { species?: Species })[] = [];
+
+ await Promise.all(speciesData.data.map(async (species) => {
+ const listings = await citesListingsApi.getBySpeciesId(species.id!);
+ // Attach species data to each listing for display
+ const listingsWithSpecies = listings.map(listing => ({
+ ...listing,
+ species,
+ }));
+ allListings.push(...listingsWithSpecies);
+ }));
+
+ return allListings;
+ }
+ });
+
+ // Filter the listings based on search query
+ const filteredListings = listings && searchQuery
+ ? listings.filter(listing =>
+ listing.species?.scientific_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ listing.species?.common_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ listing.appendix.toLowerCase().includes(searchQuery.toLowerCase())
+ )
+ : listings;
+
+ // Delete mutation
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => citesListingsApi.delete(id),
+ onSuccess: () => {
+ toast({
+ title: 'Listing deleted',
+ description: 'The CITES listing has been successfully deleted.',
+ });
+ queryClient.invalidateQueries({queryKey: ['admin', 'cites-listings']});
+ setDeleteDialog({open: false});
+ },
+ onError: (err) => {
+ toast({
+ title: 'Error',
+ description: `Failed to delete listing. ${(err as Error).message}`,
+ variant: 'destructive',
+ });
+ }
+ });
+
+ // Handle search
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSearching(!!searchQuery);
+ };
+
+ // Clear search
+ const clearSearch = () => {
+ setSearchQuery('');
+ setIsSearching(false);
+ };
+
+ // Open delete dialog
+ const confirmDelete = (listing: CitesListing) => {
+ setDeleteDialog({ open: true, listing });
+ };
+
+ // Handle delete
+ const handleDelete = () => {
+ if (deleteDialog.listing?.id) {
+ deleteMutation.mutate(deleteDialog.listing.id);
+ }
+ };
+
+ // Format date for display
+ const formatDate = (dateString: string) => {
+ try {
+ return format(new Date(dateString), 'MMM d, yyyy');
+ } catch (e) {
+ return dateString;
+ }
+ };
+
+ return (
+
+
+
+ CITES Listings
+ Manage CITES appendix listings for species
+
+
+
+
+
+ {/* Search bar */}
+
+
+ {/* Listings table */}
+ {isLoading ? (
+ Loading CITES listings...
+ ) : error ? (
+
+ Error loading listings: {(error as Error).message}
+
+ ) : (
+
+
+
+
+ Species
+ Appendix
+ Listing Date
+ Status
+ Actions
+
+
+
+ {filteredListings && filteredListings.length > 0 ? (
+ filteredListings.map((listing) => (
+
+
+
+ {listing.species?.scientific_name}
+ {listing.species?.common_name}
+
+
+
+
+ Appendix {listing.appendix}
+
+
+ {formatDate(listing.listing_date)}
+
+ {listing.is_current ? (
+ Current
+ ) : (
+ Historical
+ )}
+
+
+
+
+
+
+
+
+ ))
+ ) : (
+
+
+ No CITES listings found.
+
+
+ )}
+
+
+
+ )}
+
+ {/* Delete confirmation dialog */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/common-names/create.tsx b/src/pages/admin/common-names/create.tsx
new file mode 100644
index 0000000..fd62916
--- /dev/null
+++ b/src/pages/admin/common-names/create.tsx
@@ -0,0 +1,161 @@
+import { useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { CommonNameForm } from '@/components/admin/CommonNameForm';
+import { commonNamesApi, speciesApi, CommonName, Species } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { ChevronLeft } from 'lucide-react';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Label } from '@/components/ui/label';
+
+export default function CreateCommonName() {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [searchParams] = useSearchParams();
+ const { toast } = useToast();
+ const [selectedSpeciesId, setSelectedSpeciesId] = useState(
+ searchParams.get('species_id')
+ );
+ const navigate = useNavigate();
+
+ // Fetch all species for the dropdown
+ const { data: species, isLoading: speciesLoading } = useQuery({
+ queryKey: ['admin', 'species', 'all'],
+ queryFn: async () => {
+ const { data } = await speciesApi.getAll(1, 500); // Fetch up to 500 species
+ return data;
+ }
+ });
+
+ // If a species_id was provided in the URL params, fetch that species details
+ const { data: selectedSpecies } = useQuery({
+ queryKey: ['admin', 'species', selectedSpeciesId],
+ queryFn: () => selectedSpeciesId ? speciesApi.getById(selectedSpeciesId) : null,
+ enabled: !!selectedSpeciesId
+ });
+
+ // Create common name mutation
+ const createMutation = useMutation({
+ mutationFn: (data: CommonName) => commonNamesApi.create(data),
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Common name has been added successfully.',
+ });
+
+ // Navigate back to species detail page if we came from there
+ // if (selectedSpeciesId) {
+ // navigate(`/admin/species/${selectedSpeciesId}`);
+ // } else {
+ // navigate('/admin/common-names');
+ // }
+ },
+ onError: (error) => {
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: `Failed to create common name: ${(error as Error).message}`,
+ });
+ // setIsSubmitting(false); // Will be handled by onSettled
+ },
+ onSettled: () => {
+ setIsSubmitting(false);
+ }
+ });
+
+ const handleSubmit = (data: CommonName) => {
+ setIsSubmitting(true);
+ createMutation.mutate(data);
+ };
+
+ const handleSpeciesChange = (value: string) => {
+ setSelectedSpeciesId(value);
+ };
+
+ return (
+
+
+
+
+ Add New Common Name
+ Create a new common name for a species
+
+
+ {/* Species selection dropdown (only if not pre-selected) */}
+ {!searchParams.get('species_id') && (
+
+
+
+ Choose the species for this common name
+
+
+
+
+
+
+ )}
+
+ {/* Only show the form if a species is selected */}
+ {selectedSpeciesId ? (
+
+
+
+ {selectedSpecies ? (
+ <>
+ New Common Name for {selectedSpecies.scientific_name}
+ >
+ ) : (
+ 'New Common Name'
+ )}
+
+
+
+
+
+ ) : (
+ !searchParams.get('species_id') && (
+
+ Please select a species to continue
+
+ )
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/common-names/edit.tsx b/src/pages/admin/common-names/edit.tsx
new file mode 100644
index 0000000..95ceea1
--- /dev/null
+++ b/src/pages/admin/common-names/edit.tsx
@@ -0,0 +1,118 @@
+import { useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { CommonNameForm } from '@/components/admin/CommonNameForm';
+import { commonNamesApi, speciesApi, CommonName } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { ChevronLeft } from 'lucide-react';
+
+export default function EditCommonName() {
+ const { id } = useParams<{ id: string }>();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ // Fetch the common name data
+ const { data: commonName, isLoading: commonNameLoading, error: commonNameError } = useQuery({
+ queryKey: ['admin', 'common-name', id],
+ queryFn: async () => {
+ if (!id) throw new Error('Common Name ID is required');
+ return await commonNamesApi.getById(id);
+ },
+ enabled: !!id,
+ });
+
+ // Fetch species data to display in the form context
+ const { data: species, isLoading: speciesLoading } = useQuery({
+ queryKey: ['admin', 'species', commonName?.species_id],
+ queryFn: () => commonName?.species_id ? speciesApi.getById(commonName.species_id) : null,
+ enabled: !!commonName?.species_id,
+ });
+
+ // Update common name mutation
+ const updateMutation = useMutation({
+ mutationFn: (data: CommonName) => id ? commonNamesApi.update(id, data) : Promise.reject('Common Name ID is required'),
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'Common name has been updated successfully.',
+ });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'common-name'] });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'common-names'] });
+ // navigate('/admin/common-names');
+ },
+ onError: (error) => {
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: `Failed to update common name: ${(error as Error).message}`,
+ });
+ // setIsSubmitting(false); // Will be handled by onSettled
+ },
+ onSettled: () => {
+ setIsSubmitting(false);
+ }
+ });
+
+ const handleSubmit = (data: CommonName) => {
+ setIsSubmitting(true);
+ updateMutation.mutate(data);
+ };
+
+ const isLoading = commonNameLoading || speciesLoading;
+
+ if (isLoading) {
+ return (
+
+ Loading common name data...
+
+ );
+ }
+
+ if (commonNameError) {
+ return (
+
+
+ Error loading common name: {(commonNameError as Error).message}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Edit Common Name
+
+ {species && (
+ <>
+ Edit common name "{commonName?.name}" for {species.scientific_name}
+ >
+ )}
+
+
+
+
+ {commonName && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/common-names/list.tsx b/src/pages/admin/common-names/list.tsx
new file mode 100644
index 0000000..06d9317
--- /dev/null
+++ b/src/pages/admin/common-names/list.tsx
@@ -0,0 +1,274 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Plus, Pencil, Trash, Search, Flag } from 'lucide-react';
+import { commonNamesApi, speciesApi, CommonName } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Badge } from '@/components/ui/badge';
+
+export default function CommonNamesList() {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isSearching, setIsSearching] = useState(false);
+ const [deleteDialog, setDeleteDialog] = useState<{open: boolean, commonName?: CommonName & { species?: any }}>({open: false});
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ // Data fetching for common names
+ const { data: commonNames, isLoading, error } = useQuery({
+ queryKey: ['admin', 'common-names'],
+ queryFn: async () => {
+ try {
+ // Fetch paginated common names with species information
+ const { data } = await commonNamesApi.getAll(1, 100); // Limit to 100 for simplicity
+ return data;
+ } catch (error) {
+ console.error('Error fetching common names:', error);
+ throw error;
+ }
+ }
+ });
+
+ // Filter the common names based on search query
+ const filteredCommonNames = commonNames && searchQuery
+ ? commonNames.filter(name =>
+ name.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ name.language.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ (name.species?.scientific_name &&
+ name.species.scientific_name.toLowerCase().includes(searchQuery.toLowerCase()))
+ )
+ : commonNames;
+
+ // Delete mutation
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => commonNamesApi.delete(id),
+ onSuccess: () => {
+ toast({
+ title: 'Common name deleted',
+ description: 'The common name has been successfully deleted.',
+ });
+ queryClient.invalidateQueries({queryKey: ['admin', 'common-names']});
+ setDeleteDialog({open: false});
+ },
+ onError: (err) => {
+ toast({
+ title: 'Error',
+ description: `Failed to delete common name. ${(err as Error).message}`,
+ variant: 'destructive',
+ });
+ }
+ });
+
+ // Handle search
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSearching(!!searchQuery);
+ };
+
+ // Clear search
+ const clearSearch = () => {
+ setSearchQuery('');
+ setIsSearching(false);
+ };
+
+ // Open delete dialog
+ const confirmDelete = (commonName: CommonName & { species?: any }) => {
+ setDeleteDialog({ open: true, commonName });
+ };
+
+ // Handle delete
+ const handleDelete = () => {
+ if (deleteDialog.commonName?.id) {
+ deleteMutation.mutate(deleteDialog.commonName.id);
+ }
+ };
+
+ // Map language codes to language names for display
+ const getLanguageName = (code: string) => {
+ const languageMap: Record = {
+ 'en': 'English',
+ 'fr': 'French',
+ 'es': 'Spanish',
+ 'ru': 'Russian',
+ 'de': 'German',
+ 'zh': 'Chinese',
+ 'ja': 'Japanese',
+ 'inuktitut': 'Inuktitut',
+ 'greenlandic': 'Greenlandic',
+ 'norwegian': 'Norwegian',
+ 'swedish': 'Swedish',
+ 'finnish': 'Finnish',
+ 'danish': 'Danish',
+ 'icelandic': 'Icelandic',
+ 'faroese': 'Faroese',
+ 'sami': 'Sami',
+ };
+
+ return languageMap[code] || code;
+ };
+
+ return (
+
+
+
+ Common Names
+ Manage common names for species in different languages
+
+
+
+
+
+ {/* Search bar */}
+
+
+ {/* Common names table */}
+ {isLoading ? (
+ Loading common names...
+ ) : error ? (
+
+ Error loading common names: {(error as Error).message}
+
+ ) : (
+
+
+
+
+ Common Name
+ Language
+ Species
+ Status
+ Actions
+
+
+
+ {filteredCommonNames && filteredCommonNames.length > 0 ? (
+ filteredCommonNames.map((commonName) => (
+
+
+ {commonName.name}
+
+
+ {getLanguageName(commonName.language)}
+
+
+ {commonName.species && (
+
+ {commonName.species.scientific_name}
+ {commonName.species.common_name}
+
+ )}
+
+
+ {commonName.is_main && (
+
+ Main
+
+ )}
+
+
+
+
+
+
+
+
+ ))
+ ) : (
+
+
+ No common names found.
+
+
+ )}
+
+
+
+ )}
+
+ {/* Delete confirmation dialog */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/dashboard.tsx b/src/pages/admin/dashboard.tsx
new file mode 100644
index 0000000..f518196
--- /dev/null
+++ b/src/pages/admin/dashboard.tsx
@@ -0,0 +1,126 @@
+import { useState, useEffect } from 'react';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import {
+ Card,
+ CardContent,
+ // CardDescription, // Not used in StatsCard, can be removed if not used elsewhere
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { BarChart, Layers, FileCheck, GitBranch } from 'lucide-react';
+import { supabase } from '@/lib/supabase'; // Import Supabase client
+import { speciesApi } from '@/services/adminApi'; // Import speciesApi
+
+interface StatsCardProps {
+ title: string;
+ value: number | string;
+ description: string;
+ icon: React.ReactNode;
+}
+
+export default function AdminDashboard() {
+ const [stats, setStats] = useState({
+ totalSpecies: 0,
+ totalTradeRecords: 0,
+ totalIucnAssessments: 0,
+ totalCitesListings: 0,
+ });
+ const [loadingStats, setLoadingStats] = useState(true);
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ console.log('[Dashboard] Fetching stats...');
+ setLoadingStats(true);
+ try {
+ // Using Promise.all to fetch all counts concurrently
+ const [
+ speciesRes,
+ tradeRecordsRes,
+ iucnAssessmentsRes,
+ citesListingsRes,
+ ] = await Promise.all([
+ speciesApi.getAll(1, 1), // Fetches total count for species from speciesApi
+ supabase.from('cites_trade_records').select('*', { count: 'exact', head: true }), // Corrected table name
+ supabase.from('iucn_assessments').select('*', { count: 'exact', head: true }),
+ supabase.from('cites_listings').select('*', { count: 'exact', head: true }),
+ ]);
+
+ // Log errors if any, but still try to set counts for successful fetches
+ // speciesApi.getAll() throws on error, so speciesRes won't have an .error property here if successful.
+ // Errors from speciesApi.getAll() will be caught by the main try...catch block.
+ if (tradeRecordsRes.error) console.error('[Dashboard] Error fetching CITES trade records count:', tradeRecordsRes.error);
+ if (iucnAssessmentsRes.error) console.error('[Dashboard] Error fetching IUCN assessments count:', iucnAssessmentsRes.error);
+ if (citesListingsRes.error) console.error('[Dashboard] Error fetching CITES listings count:', citesListingsRes.error);
+
+ setStats({
+ totalSpecies: speciesRes.count || 0, // speciesRes.count is available if speciesApi.getAll() succeeded
+ totalTradeRecords: tradeRecordsRes.count || 0,
+ totalIucnAssessments: iucnAssessmentsRes.count || 0,
+ totalCitesListings: citesListingsRes.count || 0,
+ });
+ } catch (error) {
+ console.error('[Dashboard] Error in fetchStats Promise.all:', error);
+ // In case of a major error in Promise.all itself, stats will remain 0
+ } finally {
+ setLoadingStats(false);
+ console.log('[Dashboard] Finished fetching stats. Current stats:', stats); // Log current stats after fetch
+ }
+ };
+
+ fetchStats();
+ }, []); // Empty dependency array ensures this runs once on mount
+
+ return (
+
+
+ Admin Dashboard
+ Manage your Arctic species database
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+ {/* TODO: Add loading indicator for the whole stats section if loadingStats is true */}
+ {/* Recent activity, quick links or other dashboard elements */}
+
+ );
+}
+
+function StatsCard({ title, value, description, icon }: StatsCardProps) {
+ return (
+
+
+ {title}
+ {icon}
+
+
+ {value}
+ {description}
+
+
+ );
+}
diff --git a/src/pages/admin/iucn-assessments/create.tsx b/src/pages/admin/iucn-assessments/create.tsx
new file mode 100644
index 0000000..3c2d1a1
--- /dev/null
+++ b/src/pages/admin/iucn-assessments/create.tsx
@@ -0,0 +1,161 @@
+import { useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { IucnAssessmentForm } from '@/components/admin/IucnAssessmentForm';
+import { iucnAssessmentsApi, speciesApi, IucnAssessment, Species } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { ChevronLeft } from 'lucide-react';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Label } from '@/components/ui/label';
+
+export default function CreateIucnAssessment() {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [searchParams] = useSearchParams();
+ const { toast } = useToast();
+ const [selectedSpeciesId, setSelectedSpeciesId] = useState(
+ searchParams.get('species_id')
+ );
+ const navigate = useNavigate();
+
+ // Fetch all species for the dropdown
+ const { data: species, isLoading: speciesLoading } = useQuery({
+ queryKey: ['admin', 'species', 'all'],
+ queryFn: async () => {
+ const { data } = await speciesApi.getAll(1, 500); // Fetch up to 500 species
+ return data;
+ }
+ });
+
+ // If a species_id was provided in the URL params, fetch that species details
+ const { data: selectedSpecies } = useQuery({
+ queryKey: ['admin', 'species', selectedSpeciesId],
+ queryFn: () => selectedSpeciesId ? speciesApi.getById(selectedSpeciesId) : null,
+ enabled: !!selectedSpeciesId
+ });
+
+ // Create IUCN assessment mutation
+ const createMutation = useMutation({
+ mutationFn: (data: IucnAssessment) => iucnAssessmentsApi.create(data),
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'IUCN assessment has been created successfully.',
+ });
+
+ // Navigate back to species detail page if we came from there
+ // if (selectedSpeciesId) {
+ // navigate(`/admin/species/${selectedSpeciesId}`);
+ // } else {
+ // navigate('/admin/iucn-assessments');
+ // }
+ },
+ onError: (error) => {
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: `Failed to create IUCN assessment: ${(error as Error).message}`,
+ });
+ // setIsSubmitting(false); // Will be handled by onSettled
+ },
+ onSettled: () => {
+ setIsSubmitting(false);
+ }
+ });
+
+ const handleSubmit = (data: IucnAssessment) => {
+ setIsSubmitting(true);
+ createMutation.mutate(data);
+ };
+
+ const handleSpeciesChange = (value: string) => {
+ setSelectedSpeciesId(value);
+ };
+
+ return (
+
+
+
+
+ Add New IUCN Assessment
+ Create a new IUCN Red List assessment
+
+
+ {/* Species selection dropdown (only if not pre-selected) */}
+ {!searchParams.get('species_id') && (
+
+
+
+ Choose the species for this IUCN assessment
+
+
+
+
+
+
+ )}
+
+ {/* Only show the form if a species is selected */}
+ {selectedSpeciesId ? (
+
+
+
+ {selectedSpecies ? (
+ <>
+ IUCN Assessment for {selectedSpecies.scientific_name}
+ >
+ ) : (
+ 'New IUCN Assessment'
+ )}
+
+
+
+
+
+ ) : (
+ !searchParams.get('species_id') && (
+
+ Please select a species to continue
+
+ )
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/iucn-assessments/edit.tsx b/src/pages/admin/iucn-assessments/edit.tsx
new file mode 100644
index 0000000..f033d5d
--- /dev/null
+++ b/src/pages/admin/iucn-assessments/edit.tsx
@@ -0,0 +1,141 @@
+import { useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { IucnAssessmentForm } from '@/components/admin/IucnAssessmentForm';
+import { iucnAssessmentsApi, speciesApi, IucnAssessment } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { ChevronLeft } from 'lucide-react';
+
+export default function EditIucnAssessment() {
+ const { id } = useParams<{ id: string }>();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ // Fetch the IUCN assessment data
+ const { data: assessment, isLoading: assessmentLoading, error: assessmentError } = useQuery({
+ queryKey: ['admin', 'iucn-assessment', id],
+ queryFn: async () => {
+ // Since there's no direct API to get an assessment by ID in our current API,
+ // we would need to implement this in a real application.
+ // For now, let's simulate by fetching all assessments for the species and finding the right one
+
+ // This is a workaround for demo purposes
+ // In a real app, you would add a getById method to iucnAssessmentsApi
+
+ // First, we need to get all species
+ const { data: allSpecies } = await speciesApi.getAll(1, 100);
+
+ // Then for each species, get its assessments and find the one with matching ID
+ for (const species of allSpecies) {
+ const assessments = await iucnAssessmentsApi.getBySpeciesId(species.id!);
+ const foundAssessment = assessments.find(a => a.id === id);
+
+ if (foundAssessment) {
+ // Attach the species data to the assessment for UI display
+ return {
+ ...foundAssessment,
+ species
+ };
+ }
+ }
+
+ throw new Error('IUCN assessment not found');
+ },
+ enabled: !!id,
+ });
+
+ // Fetch species data to display in the form context
+ const { data: species, isLoading: speciesLoading } = useQuery({
+ queryKey: ['admin', 'species', assessment?.species_id],
+ queryFn: () => assessment?.species_id ? speciesApi.getById(assessment.species_id) : null,
+ enabled: !!assessment?.species_id,
+ });
+
+ // Update IUCN assessment mutation
+ const updateMutation = useMutation({
+ mutationFn: (data: IucnAssessment) => id ? iucnAssessmentsApi.update(id, data) : Promise.reject('Assessment ID is required'),
+ onSuccess: () => {
+ toast({
+ title: 'Success',
+ description: 'IUCN assessment has been updated successfully.',
+ });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'iucn-assessment'] });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'iucn-assessments'] });
+ // navigate('/admin/iucn-assessments');
+ },
+ onError: (error) => {
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: `Failed to update IUCN assessment: ${(error as Error).message}`,
+ });
+ // setIsSubmitting(false); // Will be handled by onSettled
+ },
+ onSettled: () => {
+ setIsSubmitting(false);
+ }
+ });
+
+ const handleSubmit = (data: IucnAssessment) => {
+ setIsSubmitting(true);
+ updateMutation.mutate(data);
+ };
+
+ const isLoading = assessmentLoading || speciesLoading;
+
+ if (isLoading) {
+ return (
+
+ Loading IUCN assessment data...
+
+ );
+ }
+
+ if (assessmentError) {
+ return (
+
+
+ Error loading IUCN assessment: {(assessmentError as Error).message}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Edit IUCN Assessment
+
+ {species && (
+ <>
+ Edit {assessment?.status} assessment for {species.scientific_name}
+ >
+ )}
+
+
+
+
+ {assessment && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/iucn-assessments/list.tsx b/src/pages/admin/iucn-assessments/list.tsx
new file mode 100644
index 0000000..cb9be57
--- /dev/null
+++ b/src/pages/admin/iucn-assessments/list.tsx
@@ -0,0 +1,298 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Plus, Pencil, Trash, Search, ExternalLink } from 'lucide-react';
+import { iucnAssessmentsApi, speciesApi, IucnAssessment, Species } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Badge } from '@/components/ui/badge';
+
+// IUCN Status badges with appropriate styling
+const getStatusBadge = (status: string) => {
+ const statusConfig: Record = {
+ EX: { label: 'Extinct', variant: 'destructive' },
+ EW: { label: 'Extinct in the Wild', variant: 'destructive' },
+ CR: { label: 'Critically Endangered', variant: 'destructive' },
+ EN: { label: 'Endangered', variant: 'destructive' },
+ VU: { label: 'Vulnerable', variant: 'default' },
+ NT: { label: 'Near Threatened', variant: 'default' },
+ LC: { label: 'Least Concern', variant: 'success' },
+ DD: { label: 'Data Deficient', variant: 'outline' },
+ NE: { label: 'Not Evaluated', variant: 'outline' },
+ };
+
+ const config = statusConfig[status] || { label: status, variant: 'outline' };
+
+ return (
+
+ {config.label}
+
+ );
+};
+
+export default function IucnAssessmentsList() {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isSearching, setIsSearching] = useState(false);
+ const [deleteDialog, setDeleteDialog] = useState<{open: boolean, assessment?: IucnAssessment}>({open: false});
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { toast } = useToast();
+
+ // Data fetching for assessments
+ const { data: assessments, isLoading, error } = useQuery({
+ queryKey: ['admin', 'iucn-assessments'],
+ queryFn: async () => {
+ // Fetch all species (limit 100)
+ const speciesData = await speciesApi.getAll(1, 100);
+
+ // For each species, fetch its IUCN assessments
+ const allAssessments: (IucnAssessment & { species?: Species })[] = [];
+
+ await Promise.all(speciesData.data.map(async (species) => {
+ const assessments = await iucnAssessmentsApi.getBySpeciesId(species.id!);
+ // Attach species data to each assessment for display
+ const assessmentsWithSpecies = assessments.map(assessment => ({
+ ...assessment,
+ species,
+ }));
+ allAssessments.push(...assessmentsWithSpecies);
+ }));
+
+ return allAssessments;
+ }
+ });
+
+ // Filter the assessments based on search query
+ const filteredAssessments = assessments && searchQuery
+ ? assessments.filter(assessment =>
+ assessment.species?.scientific_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ assessment.species?.common_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ assessment.status.toLowerCase().includes(searchQuery.toLowerCase())
+ )
+ : assessments;
+
+ // Delete mutation
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => iucnAssessmentsApi.delete(id),
+ onSuccess: () => {
+ toast({
+ title: 'Assessment deleted',
+ description: 'The IUCN assessment has been successfully deleted.',
+ });
+ queryClient.invalidateQueries({queryKey: ['admin', 'iucn-assessments']});
+ setDeleteDialog({open: false});
+ },
+ onError: (err) => {
+ toast({
+ title: 'Error',
+ description: `Failed to delete assessment. ${(err as Error).message}`,
+ variant: 'destructive',
+ });
+ }
+ });
+
+ // Handle search
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSearching(!!searchQuery);
+ };
+
+ // Clear search
+ const clearSearch = () => {
+ setSearchQuery('');
+ setIsSearching(false);
+ };
+
+ // Open delete dialog
+ const confirmDelete = (assessment: IucnAssessment) => {
+ setDeleteDialog({ open: true, assessment });
+ };
+
+ // Handle delete
+ const handleDelete = () => {
+ if (deleteDialog.assessment?.id) {
+ deleteMutation.mutate(deleteDialog.assessment.id);
+ }
+ };
+
+ return (
+
+
+
+ IUCN Assessments
+ Manage IUCN Red List assessments for species
+
+
+
+
+
+ {/* Search bar */}
+
+
+ {/* Assessments table */}
+ {isLoading ? (
+ Loading IUCN assessments...
+ ) : error ? (
+
+ Error loading assessments: {(error as Error).message}
+
+ ) : (
+
+
+
+
+ Species
+ Status
+ Year
+ Flags
+ Actions
+
+
+
+ {filteredAssessments && filteredAssessments.length > 0 ? (
+ filteredAssessments.map((assessment) => (
+
+
+
+ {assessment.species?.scientific_name}
+ {assessment.species?.common_name}
+
+
+
+ {getStatusBadge(assessment.status)}
+ {assessment.is_latest && (
+
+ Latest
+
+ )}
+
+ {assessment.year_published}
+
+
+ {assessment.possibly_extinct && (
+ Possibly Extinct
+ )}
+ {assessment.possibly_extinct_in_wild && (
+ Possibly Extinct in Wild
+ )}
+
+
+
+
+ {assessment.url && (
+
+ )}
+
+
+
+
+
+ ))
+ ) : (
+
+
+ No IUCN assessments found.
+
+
+ )}
+
+
+
+ )}
+
+ {/* Delete confirmation dialog */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/login.tsx b/src/pages/admin/login.tsx
new file mode 100644
index 0000000..a8dcabd
--- /dev/null
+++ b/src/pages/admin/login.tsx
@@ -0,0 +1,22 @@
+import { LoginForm } from '@/components/auth/LoginForm';
+import { useAuth } from '@/contexts/auth/AuthContext';
+import { Navigate } from 'react-router-dom';
+
+export default function AdminLoginPage() {
+ const { user, isAdmin } = useAuth();
+
+ // If user is already logged in and is an admin, redirect to dashboard
+ if (user && isAdmin) {
+ return ;
+ }
+
+ // If user is logged in but not an admin, perhaps redirect to home or show an access denied message
+ // For now, we'll let them see the login form again, or they could be stuck in a redirect loop if AdminRoute also redirects non-admins.
+ // A more robust solution might be needed depending on UX preferences.
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/species/create.tsx b/src/pages/admin/species/create.tsx
new file mode 100644
index 0000000..779b16d
--- /dev/null
+++ b/src/pages/admin/species/create.tsx
@@ -0,0 +1,57 @@
+import { useState } from 'react';
+// import { useNavigate } from 'react-router-dom';
+import { useMutation } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { SpeciesForm } from '@/components/admin/SpeciesForm';
+import { speciesApi, Species } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+
+export default function CreateSpecies() {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ // const navigate = useNavigate();
+ const { toast } = useToast();
+
+ // Create species mutation
+ const createMutation = useMutation({
+ mutationFn: (data: Species) => speciesApi.create(data),
+ onSuccess: (data) => {
+ toast({
+ title: 'Success',
+ description: `Species "${data.scientific_name}" has been created successfully.`,
+ });
+ // navigate('/admin/species');
+ },
+ onError: (error) => {
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: `Failed to create species: ${(error as Error).message}`,
+ });
+ // setIsSubmitting(false); // Will be handled by onSettled
+ },
+ onSettled: () => {
+ setIsSubmitting(false);
+ }
+ });
+
+ const handleSubmit = (data: Species) => {
+ setIsSubmitting(true);
+ createMutation.mutate(data);
+ };
+
+ return (
+
+
+ Add New Species
+ Create a new species record
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/species/edit.tsx b/src/pages/admin/species/edit.tsx
new file mode 100644
index 0000000..bc53713
--- /dev/null
+++ b/src/pages/admin/species/edit.tsx
@@ -0,0 +1,102 @@
+import { useState } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { SpeciesForm } from '@/components/admin/SpeciesForm';
+import { speciesApi, Species } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+import { Button } from '@/components/ui/button';
+import { ChevronLeft } from 'lucide-react';
+
+export default function EditSpecies() {
+ const { id } = useParams<{ id: string }>();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const navigate = useNavigate();
+ const { toast } = useToast();
+ const queryClient = useQueryClient();
+
+ // Fetch species data
+ const { data: species, isLoading, error } = useQuery({
+ queryKey: ['admin', 'species', id],
+ queryFn: () => id ? speciesApi.getById(id) : Promise.reject('Species ID is required'),
+ enabled: !!id,
+ });
+
+ // Update species mutation
+ const updateMutation = useMutation({
+ mutationFn: (data: Species) => id ? speciesApi.update(id, data) : Promise.reject('Species ID is required'),
+ onSuccess: (data) => {
+ toast({
+ title: 'Success',
+ description: `Species "${data.scientific_name}" has been updated successfully.`,
+ });
+ queryClient.invalidateQueries({ queryKey: ['admin', 'species'] });
+ // navigate('/admin/species'); // Removed this line
+ },
+ onError: (error) => {
+ toast({
+ variant: 'destructive',
+ title: 'Error',
+ description: `Failed to update species: ${(error as Error).message}`,
+ });
+ },
+ onSettled: () => {
+ console.log('[EditSpecies] updateMutation onSettled. Setting isSubmitting to false.');
+ setIsSubmitting(false);
+ }
+ });
+
+ const handleSubmit = (data: Species) => {
+ console.log('[EditSpecies] handleSubmit called. Setting isSubmitting to true.');
+ setIsSubmitting(true);
+ updateMutation.mutate(data);
+ };
+
+ if (isLoading) {
+ return (
+
+ Loading species data...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Error loading species data: {(error as Error).message}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Edit Species
+
+ Edit details for {species?.scientific_name}
+
+
+
+
+ {species && (
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/admin/species/list.tsx b/src/pages/admin/species/list.tsx
new file mode 100644
index 0000000..b76149f
--- /dev/null
+++ b/src/pages/admin/species/list.tsx
@@ -0,0 +1,278 @@
+import { useState } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
+import { AdminLayout } from '@/components/layout/AdminLayout';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from '@/components/ui/pagination';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Plus, Search, Pencil, Trash } from 'lucide-react';
+import { speciesApi, Species } from '@/services/adminApi';
+import { useToast } from '@/hooks/use-toast';
+
+export default function SpeciesList() {
+ const [page, setPage] = useState(1);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [isSearching, setIsSearching] = useState(false);
+ const [deleteDialog, setDeleteDialog] = useState<{open: boolean, species?: Species}>({open: false});
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const limit = 10; // Items per page
+ const { toast } = useToast();
+
+ // Data fetching with React Query
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['admin', 'species', page, isSearching ? searchQuery : null],
+ queryFn: async () => {
+ if (isSearching && searchQuery) {
+ const searchData = await speciesApi.search(searchQuery);
+ return { data: searchData, count: searchData.length }; // Assuming search doesn't paginate server-side for this example
+ }
+ return speciesApi.getAll(page, limit);
+ }
+ });
+
+ // Delete mutation
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => speciesApi.delete(id),
+ onSuccess: () => {
+ toast({
+ title: 'Species deleted',
+ description: 'The species has been successfully deleted.',
+ });
+ queryClient.invalidateQueries({queryKey: ['admin', 'species']});
+ setDeleteDialog({open: false});
+ },
+ onError: (err) => {
+ toast({
+ title: 'Error',
+ description: `Failed to delete species. ${(err as Error).message}`,
+ variant: 'destructive',
+ });
+ }
+ });
+
+ // Handle search
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSearching(!!searchQuery);
+ setPage(1); // Reset to first page on new search
+ queryClient.invalidateQueries({queryKey: ['admin', 'species']}); // Refetch with search query
+ };
+
+ // Clear search
+ const clearSearch = () => {
+ setSearchQuery('');
+ setIsSearching(false);
+ setPage(1); // Reset to first page
+ queryClient.invalidateQueries({queryKey: ['admin', 'species']}); // Refetch without search query
+ };
+
+ // Open delete dialog
+ const confirmDelete = (species: Species) => {
+ setDeleteDialog({ open: true, species });
+ };
+
+ // Handle delete
+ const handleDelete = () => {
+ if (deleteDialog.species?.id) {
+ deleteMutation.mutate(deleteDialog.species.id);
+ }
+ };
+
+ // Calculate total pages
+ const totalPages = data?.count ? Math.ceil(data.count / limit) : 1;
+
+ return (
+
+
+
+ Species
+ Manage your species database
+
+
+
+
+
+ {/* Search bar */}
+
+
+ {/* Species table */}
+ {isLoading ? (
+ Loading species...
+ ) : error ? (
+
+ Error loading species: {(error as Error).message}
+
+ ) : (
+ <>
+
+
+
+
+ Scientific Name
+ Common Name
+ Family
+ IUCN Status
+ Actions
+
+
+
+ {data?.data && data.data.length > 0 ? (
+ data.data.map((species) => (
+
+
+ {species.scientific_name}
+
+ {species.common_name}
+ {species.family}
+
+ {/* This would need to be fetched in a real implementation */}
+
+ LC
+
+
+
+
+
+
+
+
+
+ ))
+ ) : (
+
+
+ No species found.
+
+
+ )}
+
+
+
+
+ {/* Pagination */}
+ {!isSearching && data?.count && data.count > 0 && (
+
+
+
+ setPage(p => Math.max(1, p - 1))}
+ // isActive={page > 1} // isActive is not a prop of PaginationPrevious
+ />
+
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map(
+ (pageNumber) => (
+
+ setPage(pageNumber)}
+ >
+ {pageNumber}
+
+
+ )
+ )}
+
+
+ setPage(p => Math.min(totalPages, p + 1))}
+ // isActive={page < totalPages} // isActive is not a prop of PaginationNext
+ />
+
+
+
+ )}
+ >
+ )}
+
+ {/* Delete confirmation dialog */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/home.tsx b/src/pages/home.tsx
index b919dd9..225265d 100644
--- a/src/pages/home.tsx
+++ b/src/pages/home.tsx
@@ -1,19 +1,120 @@
-import { useState, useMemo } from 'react';
+import { useState, useMemo } from 'react'; // Removed useEffect
import { useQuery } from '@tanstack/react-query';
-import { getAllSpecies } from '@/lib/api';
-import { SearchForm } from '@/components/search-form';
+import { getAllSpecies, getSpeciesImages, getTotalTradeCount } from '@/lib/api';
+// Removed unused: import { SearchForm } from '@/components/search-form';
import { ResultsContainer } from '@/components/results-container';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
-import { Search, Globe } from 'lucide-react';
+// Removed Globe, Info, BarChart3
+import { Filter, ArrowRight, AlertTriangle, ImageIcon } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+// Add Tabs imports
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+// Import the new CompareTradeTab component
+import { CompareTradeTab } from '@/components/compare-trade-tab';
+
+// Species Card Component with Image Support
+function SpeciesCard({ species, onClick }: { species: any, onClick: (id: string) => void }) {
+ const { data: imageData, isLoading: isImageLoading } = useQuery({
+ queryKey: ['speciesImage', species.scientific_name],
+ queryFn: () => getSpeciesImages(species.scientific_name),
+ enabled: !!species.scientific_name,
+ });
+
+ // Generate a pseudo-random background color based on species name
+ const getBackgroundColor = (name: string) => {
+ const colors = [
+ 'bg-blue-100 dark:bg-blue-900/50',
+ 'bg-green-100 dark:bg-green-900/50',
+ 'bg-teal-100 dark:bg-teal-900/50',
+ 'bg-cyan-100 dark:bg-cyan-900/50',
+ 'bg-indigo-100 dark:bg-indigo-900/50',
+ 'bg-purple-100 dark:bg-purple-900/50'
+ ];
+ const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
+ return colors[hash % colors.length];
+ };
+
+ return (
+ onClick(species.id)}
+ >
+ {/* Removed inline style from CardTitle */}
+
+ {isImageLoading ? (
+
+ ) : imageData?.url ? (
+ <>
+ 
+
+ {imageData?.attribution && (
+
+ {imageData.attribution}
+
+ )}
+ >
+ ) : (
+ <>
+
+
+
+ No image
+
+ >
+ )}
+
+ {species.latest_assessment?.status && (
+
+ {species.latest_assessment.status === 'CR' && }
+ IUCN: {species.latest_assessment.status}
+
+ )}
+
+
+
+ {/* Removed inline style */}
+
+ {species.scientific_name}
+
+
+
+ {/* Removed inline style */}
+ {species.primary_common_name}
+
+ {/* Removed inline style */}
+
+ {species.family}
+
+
+
+
+
+ );
+}
export function HomePage() {
+ // App state
const [selectedSpeciesId, setSelectedSpeciesId] = useState(null);
- const [showSearch, setShowSearch] = useState(false);
- const { data: allSpecies, isLoading } = useQuery({
+ const { data: allSpecies, isLoading: isLoadingSpecies } = useQuery({
queryKey: ['allSpecies'],
- queryFn: getAllSpecies,
+ queryFn: getAllSpecies
+ });
+
+ // Fetch total trade count
+ const { data: tradeCountData, isLoading: isLoadingTradeCount } = useQuery({
+ queryKey: ['totalTradeCount'],
+ queryFn: getTotalTradeCount
});
// Create a filtered list of unique species by scientific name
@@ -32,111 +133,112 @@ export function HomePage() {
const handleSelectSpecies = (id: string) => {
setSelectedSpeciesId(id);
- setShowSearch(false);
};
const handleBackToSearch = () => {
setSelectedSpeciesId(null);
};
- const handleToggleSearch = () => {
- setShowSearch(!showSearch);
- if (selectedSpeciesId) {
- setSelectedSpeciesId(null);
- }
- };
-
- console.log('All species length:', allSpecies?.length);
- console.log('Unique species length:', uniqueSpecies?.length);
+ // Combine species and trade counts for stats
+ const keyFigures = useMemo(() => {
+ return {
+ totalSpecies: uniqueSpecies.length,
+ totalTrades: tradeCountData?.count ?? 0, // Use nullish coalescing
+ };
+ }, [uniqueSpecies, tradeCountData]);
+
+ if (selectedSpeciesId) {
+ return ;
+ }
return (
-
-
- Arctic Species Tracker
- **Work in Progress!**
-
- A collaborative project between:
-
- School of Humanities and Social Sciences
-
- Thomas Barry
- Dean of School
+
+ {/* Hero Section */}
+
+ {/* Removed arctic-bg.jpg reference causing 404 */}
+ {/* Reduced padding py-16 md:py-20 */}
+
+ {/* Reduced text size text-4xl md:text-5xl, mb-3 */}
+
+ Arctic Species Tracker
+
+ {/* Reduced text size text-lg, mb-8 */}
+
+ Tracking conservation status, CITES listings, and trade data for Arctic species
+
+
+ {/* Key Stats - Increased margin-top mt-8, added flex container */}
+
+ {/* Species Count */}
+
+ {keyFigures.totalSpecies}
+ Total Species {/* Reduced text size */}
-
- Magnus Smari Smarason
- AI Project Manager
+ {/* Trade Count */}
+
+
+ {isLoadingTradeCount ? '...' : keyFigures.totalTrades.toLocaleString()}
+
+ Total Trades {/* Reduced text size */}
- University of Akureyri
-
- Track conservation status, CITES listings, and trade data for Arctic species
-
-
-
-
-
- {/* Search and Results Section */}
-
- {showSearch && !selectedSpeciesId ? (
-
-
-
- ) : selectedSpeciesId ? (
-
- ) : (
-
- {isLoading ? (
-
-
-
- Loading species data...
+ {/* Main Content Area with Tabs */}
+
+
+ {/* Add Tabs component */}
+
+ {/* Added mb-6 for spacing */}
+ Browse Species
+ Compare Trade
+
+
+ {/* Browse Species Tab Content */}
+
+ {isLoadingSpecies ? (
+
+
+
+ Loading species data...
+
-
- ) : uniqueSpecies.length > 0 ? (
- uniqueSpecies.map(species => (
- handleSelectSpecies(species.id)}
- >
-
-
- {species.scientific_name}
-
-
-
- {species.primary_common_name}
-
-
- {species.family}
-
-
-
-
- ))
- ) : (
- No species found
- )}
-
- )}
+ ) : (
+ <>
+
+ {/* Keep h2 and Filter button */}
+ Browse Arctic Species
+
+
+
+
+ {uniqueSpecies.length > 0 ? (
+ uniqueSpecies.map(species => (
+
+ ))
+ ) : (
+ No species found
+ )}
+
+ >
+ )}
+
+
+ {/* Compare Trade Tab Content */}
+
+ {/* Render the CompareTradeTab component */}
+
+
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/services/adminApi.ts b/src/services/adminApi.ts
new file mode 100644
index 0000000..085e608
--- /dev/null
+++ b/src/services/adminApi.ts
@@ -0,0 +1,357 @@
+import { supabase } from '@/lib/supabase'; // Import the shared client
+
+// Types
+export interface Species {
+ id?: string;
+ scientific_name: string;
+ common_name: string;
+ kingdom: string;
+ phylum: string;
+ class: string;
+ order_name: string;
+ family: string;
+ genus: string;
+ species_name: string;
+ authority: string;
+ sis_id?: number;
+ inaturalist_id?: number;
+ default_image_url?: string;
+ description?: string;
+ habitat_description?: string;
+ population_trend?: string;
+ population_size?: number | null; // Changed from string
+ generation_length?: number | null; // Changed from string
+ movement_patterns?: string;
+ use_and_trade?: string;
+ threats_overview?: string;
+ conservation_overview?: string;
+}
+
+export interface CitesListing {
+ id?: string;
+ species_id: string;
+ appendix: string;
+ listing_date: string; // ISO date string
+ notes?: string;
+ is_current: boolean;
+}
+
+export interface IucnAssessment {
+ id?: string;
+ species_id: string;
+ year_published: number;
+ status: string;
+ is_latest: boolean;
+ possibly_extinct: boolean;
+ possibly_extinct_in_wild: boolean;
+ url?: string;
+ assessment_id?: number;
+ scope_code?: string;
+ scope_description?: string;
+}
+
+export interface CommonName {
+ id?: string;
+ species_id: string;
+ name: string;
+ language: string;
+ is_main: boolean;
+}
+
+// Species CRUD
+export const speciesApi = {
+ // Get all species with pagination
+ getAll: async (page = 1, limit = 10) => {
+ const start = (page - 1) * limit;
+ const end = start + limit - 1;
+
+ const { data, error, count } = await supabase
+ .from('species')
+ .select('*', { count: 'exact' })
+ .range(start, end)
+ .order('scientific_name');
+
+ if (error) throw error;
+
+ return { data, count };
+ },
+
+ // Get a single species by ID
+ getById: async (id: string) => {
+ const { data, error } = await supabase
+ .from('species')
+ .select('*')
+ .eq('id', id)
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Create a new species
+ create: async (species: Species) => {
+ const { data, error } = await supabase
+ .from('species')
+ .insert(species)
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Update an existing species
+ update: async (id: string, species: Partial ) => {
+ const { data, error } = await supabase
+ .from('species')
+ .update(species)
+ .eq('id', id)
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Delete a species
+ delete: async (id: string) => {
+ const { error } = await supabase
+ .from('species')
+ .delete()
+ .eq('id', id);
+
+ if (error) throw error;
+
+ return true;
+ },
+
+ // Search species
+ search: async (query: string) => {
+ const { data, error } = await supabase
+ .from('species')
+ .select('*')
+ .or(`scientific_name.ilike.%${query}%,common_name.ilike.%${query}%`)
+ .order('scientific_name');
+
+ if (error) throw error;
+
+ return data;
+ }
+};
+
+// CITES Listings CRUD
+export const citesListingsApi = {
+ // Get all listings for a species
+ getBySpeciesId: async (speciesId: string) => {
+ const { data, error } = await supabase
+ .from('cites_listings')
+ .select('*')
+ .eq('species_id', speciesId)
+ .order('listing_date', { ascending: false });
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Create a new listing
+ create: async (listing: CitesListing) => {
+ const { data, error } = await supabase
+ .from('cites_listings')
+ .insert(listing)
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Update a listing
+ update: async (id: string, listing: Partial) => {
+ const { data, error } = await supabase
+ .from('cites_listings')
+ .update(listing)
+ .eq('id', id)
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Delete a listing
+ delete: async (id: string) => {
+ const { error } = await supabase
+ .from('cites_listings')
+ .delete()
+ .eq('id', id);
+
+ if (error) throw error;
+
+ return true;
+ }
+};
+
+// Similar CRUD operations for IUCN assessments, common names, etc.
+export const iucnAssessmentsApi = {
+ getBySpeciesId: async (speciesId: string) => {
+ const { data, error } = await supabase
+ .from('iucn_assessments')
+ .select('*')
+ .eq('species_id', speciesId)
+ .order('year_published', { ascending: false });
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ create: async (assessment: IucnAssessment) => {
+ const { data, error } = await supabase
+ .from('iucn_assessments')
+ .insert(assessment)
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ update: async (id: string, assessment: Partial) => {
+ const { data, error } = await supabase
+ .from('iucn_assessments')
+ .update(assessment)
+ .eq('id', id)
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ delete: async (id: string) => {
+ const { error } = await supabase
+ .from('iucn_assessments')
+ .delete()
+ .eq('id', id);
+
+ if (error) throw error;
+
+ return true;
+ },
+
+ // For filtering and searching
+ search: async (query: string) => {
+ const { data, error } = await supabase
+ .from('iucn_assessments')
+ .select('*, species:species_id(*)')
+ .or(`status.ilike.%${query}%,species.scientific_name.ilike.%${query}%`)
+ .order('year_published', { ascending: false });
+
+ if (error) throw error;
+
+ return data;
+ }
+};
+
+export const commonNamesApi = {
+ getBySpeciesId: async (speciesId: string) => {
+ const { data, error } = await supabase
+ .from('common_names')
+ .select('*')
+ .eq('species_id', speciesId)
+ .order('is_main', { ascending: false });
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Get all common names
+ getAll: async (page = 1, limit = 10) => {
+ const start = (page - 1) * limit;
+ const end = start + limit - 1;
+
+ const { data, error, count } = await supabase
+ .from('common_names')
+ .select('*, species:species_id(scientific_name, common_name)', { count: 'exact' })
+ .range(start, end)
+ .order('is_main', { ascending: false });
+
+ if (error) throw error;
+
+ return { data, count };
+ },
+
+ // Get a single common name by ID
+ getById: async (id: string) => {
+ const { data, error } = await supabase
+ .from('common_names')
+ .select('*')
+ .eq('id', id)
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Create a new common name
+ create: async (commonName: CommonName) => {
+ const { data, error } = await supabase
+ .from('common_names')
+ .insert(commonName)
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Update an existing common name
+ update: async (id: string, commonName: Partial) => {
+ const { data, error } = await supabase
+ .from('common_names')
+ .update(commonName)
+ .eq('id', id)
+ .select()
+ .single();
+
+ if (error) throw error;
+
+ return data;
+ },
+
+ // Delete a common name
+ delete: async (id: string) => {
+ const { error } = await supabase
+ .from('common_names')
+ .delete()
+ .eq('id', id);
+
+ if (error) throw error;
+
+ return true;
+ },
+
+ // Search common names
+ search: async (query: string) => {
+ const { data, error } = await supabase
+ .from('common_names')
+ .select('*, species:species_id(scientific_name, common_name)')
+ .or(`name.ilike.%${query}%,language.ilike.%${query}%,species.scientific_name.ilike.%${query}%`)
+ .order('is_main', { ascending: false });
+
+ if (error) throw error;
+
+ return data;
+ }
+};
diff --git a/tailwind.config.js b/tailwind.config.js
index 0122a7d..a888735 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -8,69 +8,84 @@ module.exports = {
'./src/**/*.{ts,tsx}',
],
theme: {
- container: {
- center: true,
- padding: "2rem",
- screens: {
- "2xl": "1400px",
- },
- },
- extend: {
- colors: {
- border: "hsl(var(--border))",
- input: "hsl(var(--input))",
- ring: "hsl(var(--ring))",
- background: "hsl(var(--background))",
- foreground: "hsl(var(--foreground))",
- primary: {
- DEFAULT: "hsl(var(--primary))",
- foreground: "hsl(var(--primary-foreground))",
- },
- secondary: {
- DEFAULT: "hsl(var(--secondary))",
- foreground: "hsl(var(--secondary-foreground))",
- },
- destructive: {
- DEFAULT: "hsl(var(--destructive))",
- foreground: "hsl(var(--destructive-foreground))",
- },
- muted: {
- DEFAULT: "hsl(var(--muted))",
- foreground: "hsl(var(--muted-foreground))",
- },
- accent: {
- DEFAULT: "hsl(var(--accent))",
- foreground: "hsl(var(--accent-foreground))",
- },
- popover: {
- DEFAULT: "hsl(var(--popover))",
- foreground: "hsl(var(--popover-foreground))",
- },
- card: {
- DEFAULT: "hsl(var(--card))",
- foreground: "hsl(var(--card-foreground))",
- },
- },
- borderRadius: {
- lg: "var(--radius)",
- md: "calc(var(--radius) - 2px)",
- sm: "calc(var(--radius) - 4px)",
- },
- keyframes: {
- "accordion-down": {
- from: { height: 0 },
- to: { height: "var(--radix-accordion-content-height)" },
- },
- "accordion-up": {
- from: { height: "var(--radix-accordion-content-height)" },
- to: { height: 0 },
- },
- },
- animation: {
- "accordion-down": "accordion-down 0.2s ease-out",
- "accordion-up": "accordion-up 0.2s ease-out",
- },
- },
+ container: {
+ center: true,
+ padding: '2rem',
+ screens: {
+ '2xl': '1400px'
+ }
+ },
+ extend: {
+ colors: {
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))'
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))'
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))'
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))'
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))'
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))'
+ },
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))'
+ },
+ chart: {
+ '1': 'hsl(var(--chart-1))',
+ '2': 'hsl(var(--chart-2))',
+ '3': 'hsl(var(--chart-3))',
+ '4': 'hsl(var(--chart-4))',
+ '5': 'hsl(var(--chart-5))'
+ }
+ },
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)'
+ },
+ keyframes: {
+ 'accordion-down': {
+ from: {
+ height: 0
+ },
+ to: {
+ height: 'var(--radix-accordion-content-height)'
+ }
+ },
+ 'accordion-up': {
+ from: {
+ height: 'var(--radix-accordion-content-height)'
+ },
+ to: {
+ height: 0
+ }
+ }
+ },
+ animation: {
+ 'accordion-down': 'accordion-down 0.2s ease-out',
+ 'accordion-up': 'accordion-up 0.2s ease-out'
+ }
+ }
},
plugins: [require("tailwindcss-animate")],
}
\ No newline at end of file
| |