Fixed CRUD operations for CITES listings, common names, and IUCN assessments. Added admin routes and authentication context. Updated UI components and added new pages for admin functionalities.
Some checks failed
Build, Lint, and Deploy Arctic Species Portal / test-and-build (push) Failing after 1m0s
Build, Lint, and Deploy Arctic Species Portal / deploy (push) Has been skipped

This commit is contained in:
Magnus Smari Smarason
2025-05-17 20:58:29 +00:00
parent c32449297a
commit 7c3d65dadf
71 changed files with 9727 additions and 1385 deletions

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,213 @@
# Supabase Database Schema (Arctic Species)
This document outlines the data architecture of the Arctic Species Supabase database.
## Tables
The database contains the following tables:
* `catch_records`
* `cites_listings`
* `cites_trade_records`
* `common_names`
* `conservation_measures`
* `distribution_ranges`
* `iucn_assessments`
* `profiles`
* `species`
* `species_threats`
* `subpopulations`
* `timeline_events`
## Table Structures
Below is a preliminary structure for each table, inferred by querying a single record.
---
### `species`
| Column Name | Data Type (Inferred) | Notes |
| ----------------------- | -------------------- | ----------------------------------------- |
| `id` | UUID | Primary Key |
| `scientific_name` | TEXT | Scientific name of the species |
| `common_name` | TEXT | Common name of the species |
| `kingdom` | TEXT | Taxonomic kingdom |
| `phylum` | TEXT | Taxonomic phylum |
| `class` | TEXT | Taxonomic class |
| `order_name` | TEXT | Taxonomic order |
| `family` | TEXT | Taxonomic family |
| `genus` | TEXT | Taxonomic genus |
| `species_name` | TEXT | Specific epithet |
| `authority` | TEXT | Authority who named the species |
| `sis_id` | INTEGER | Species Information Service ID (IUCN) |
| `created_at` | TIMESTAMP WITH TIME ZONE | Timestamp of record creation |
| `inaturalist_id` | INTEGER | iNaturalist taxon ID (nullable) |
| `default_image_url` | TEXT | URL for a default image (nullable) |
| `description` | TEXT | General description (nullable) |
| `habitat_description` | TEXT | Description of habitat (nullable) |
| `population_trend` | TEXT | Population trend (e.g., decreasing, stable) (nullable) |
| `population_size` | TEXT | Estimated population size (nullable) |
| `generation_length` | TEXT | Generation length in years (nullable) |
| `movement_patterns` | TEXT | Description of movement patterns (nullable) |
| `use_and_trade` | TEXT | Information on use and trade (nullable) |
| `threats_overview` | TEXT | Overview of threats (nullable) |
| `conservation_overview` | TEXT | Overview of conservation efforts (nullable) |
---
### `catch_records`
| Column Name | Data Type (Inferred) | Notes |
| ----------- | -------------------- | ----------------------------------------- |
| `id` | INTEGER | Primary Key |
| `species_id`| UUID | Foreign Key referencing `species.id` |
| `country` | TEXT | Country where the catch was recorded |
| `year` | INTEGER | Year of the catch record |
| `area` | TEXT | Specific area of catch (nullable) |
| `catch_total` | INTEGER | Total catch amount |
| `quota` | INTEGER | Catch quota, if applicable (nullable) |
| `source` | TEXT | Source of the catch data (e.g., NAMMCO) |
| `created_at`| TIMESTAMP WITH TIME ZONE | Timestamp of record creation |
---
### `cites_listings`
| Column Name | Data Type (Inferred) | Notes |
| -------------- | -------------------- | ------------------------------------------- |
| `id` | UUID | Primary Key |
| `species_id` | UUID | Foreign Key referencing `species.id` |
| `appendix` | TEXT | CITES Appendix (e.g., I, II, III) |
| `listing_date` | DATE | Date the species was listed on the appendix |
| `notes` | TEXT | Notes regarding the CITES listing (nullable)|
| `is_current` | BOOLEAN | Indicates if the listing is current |
---
### `cites_trade_records`
| Column Name | Data Type (Inferred) | Notes |
| --------------- | -------------------- | ------------------------------------------- |
| `id` | UUID | Primary Key |
| `species_id` | UUID | Foreign Key referencing `species.id` |
| `record_id` | TEXT | Unique ID for the trade record |
| `year` | INTEGER | Year the trade occurred |
| `appendix` | TEXT | CITES Appendix at the time of trade |
| `taxon` | TEXT | Scientific name of the taxon in trade |
| `class` | TEXT | Taxonomic class |
| `order_name` | TEXT | Taxonomic order |
| `family` | TEXT | Taxonomic family |
| `genus` | TEXT | Taxonomic genus |
| `term` | TEXT | Description of the traded item (e.g., skins)|
| `quantity` | FLOAT | Quantity of the item traded |
| `unit` | TEXT | Unit of measurement for quantity (nullable) |
| `importer` | TEXT | Importing country code (ISO 2-letter) |
| `exporter` | TEXT | Exporting country code (ISO 2-letter) |
| `origin` | TEXT | Country of origin code (nullable) |
| `purpose` | TEXT | Purpose of trade code (e.g., P for Personal)|
| `source` | TEXT | Source of specimen code (e.g., W for Wild) |
| `reporter_type` | TEXT | E for Exporter, I for Importer |
| `import_permit` | TEXT | Import permit number (nullable) |
| `export_permit` | TEXT | Export permit number (nullable) |
| `origin_permit` | TEXT | Origin permit number (nullable) |
---
### `common_names`
| Column Name | Data Type (Inferred) | Notes |
| ----------- | -------------------- | ------------------------------------------- |
| `id` | UUID | Primary Key |
| `species_id`| UUID | Foreign Key referencing `species.id` |
| `name` | TEXT | Common name of the species |
| `language` | TEXT | Language code for the common name (e.g., eng)|
| `is_main` | BOOLEAN | Indicates if this is the primary common name|
---
### `conservation_measures`
Structure for this table could not be determined by querying a single record (the table might be empty or access is restricted in this manner). Further investigation or direct schema inspection is required.
---
### `profiles`
Structure for this table could not be determined by querying a single record (the table might be empty or access is restricted in this manner). Further investigation or direct schema inspection is required.
---
### `distribution_ranges`
Structure for this table could not be determined by querying a single record (the table might be empty or access is restricted in this manner). Further investigation or direct schema inspection is required.
---
### `iucn_assessments`
| Column Name | Data Type (Inferred) | Notes |
| ------------------------ | -------------------- | ------------------------------------------------------------ |
| `id` | UUID | Primary Key |
| `species_id` | UUID | Foreign Key referencing `species.id` |
| `year_published` | INTEGER | Year the IUCN assessment was published |
| `is_latest` | BOOLEAN | Indicates if this is the latest assessment for the species |
| `possibly_extinct` | BOOLEAN | Indicates if the species is possibly extinct |
| `possibly_extinct_in_wild` | BOOLEAN | Indicates if the species is possibly extinct in the wild |
| `status` | TEXT | IUCN Red List status code (e.g., LC, VU, EN) |
| `url` | TEXT | URL to the assessment on the IUCN Red List website |
| `assessment_id` | INTEGER | Unique ID for the assessment from IUCN |
| `scope_code` | TEXT | Code indicating the geographic scope of the assessment |
| `scope_description` | TEXT | Description of the geographic scope (e.g., Europe, Global) |
---
### `species_threats`
Structure for this table could not be determined by querying a single record (the table might be empty or access is restricted in this manner). Further investigation or direct schema inspection is required.
---
### `subpopulations`
| Column Name | Data Type (Inferred) | Notes |
| -------------------- | -------------------- | --------------------------------------------- |
| `id` | UUID | Primary Key |
| `species_id` | UUID | Foreign Key referencing `species.id` |
| `scientific_name` | TEXT | Full scientific name including subpopulation |
| `subpopulation_name` | TEXT | Name of the subpopulation |
| `sis_id` | INTEGER | Species Information Service ID (IUCN) |
| `authority` | TEXT | Authority who named the subpopulation (nullable)|
---
### `timeline_events`
| Column Name | Data Type (Inferred) | Notes |
| ------------- | -------------------- | ------------------------------------------------------------ |
| `id` | UUID | Primary Key |
| `species_id` | UUID | Foreign Key referencing `species.id` |
| `event_date` | DATE | Date of the event |
| `year` | INTEGER | Year of the event (can be derived from event_date) |
| `event_type` | TEXT | Type of event (e.g., iucn_assessment, cites_listing) |
| `title` | TEXT | Title of the event |
| `description` | TEXT | Description of the event (nullable) |
| `status` | TEXT | Status associated with the event (e.g., LC, Appendix II) (nullable) |
| `source_type` | TEXT | Type of the source table (e.g., iucn_assessments) (nullable) |
| `source_id` | UUID | Foreign Key to the source record (e.g., `iucn_assessments.id`) (nullable) |
---
## Authentication (`auth` schema)
Supabase provides a built-in authentication system that operates within its own `auth` schema. This schema is separate from the `public` schema detailed above but is integral to managing user identities, sessions, and access control.
Key tables typically found in the `auth` schema include:
* `users`: Stores user identity information (e.g., email, phone, password hash, user metadata). Each user is assigned a unique UUID.
* `sessions`: Manages active user sessions.
* `instances`: Information about authentication instances.
* `refresh_tokens`: Stores refresh tokens for maintaining sessions.
* `audit_log_entries`: Logs significant authentication events.
While these tables are managed by Supabase, the `id` from `auth.users` is commonly used as a foreign key in `public` schema tables (like `profiles`) to link user-specific data to their authentication record. For example, a `profiles` table would typically have a `user_id` column that references `auth.users.id`.

670
package-lock.json generated
View File

@ -8,11 +8,15 @@
"name": "arctic-species-tracker",
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.13",
"@react-pdf/renderer": "^4.3.0",
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.24.1",
@ -27,11 +31,13 @@
"lucide-react": "^0.338.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.52.1",
"react-router-dom": "^6.22.1",
"recharts": "^2.12.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.11.20",
@ -913,6 +919,15 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -1147,6 +1162,204 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.1.tgz",
"integrity": "sha512-xTaLKAO+XXMPK/BpVTSaAAhlefmvMSACjIhK9mGsImvX2ljcTDm8VGR1CuS1uYcNdR5J+oiOhoJZc5un6bh3VQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.2",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz",
"integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
@ -1465,6 +1678,101 @@
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.6.tgz",
"integrity": "sha512-QzN9a36nKk2eZKMf9EBCia35x3TT+SOgZuzQBVIHyRrmYYi73VYBRK3zKwdJ6az/F5IZ6QlacGJBg7zfB85liA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz",
"integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
@ -1587,6 +1895,308 @@
}
}
},
"node_modules/@radix-ui/react-toast": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.13.tgz",
"integrity": "sha512-e/e43mQAwgYs8BY4y9l99xTK6ig1bK2uXsFLOMn9IZ16lAgulSTsotcPHVT2ZlSb/ye6Sllq7IgyDB8dGhpeXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.9",
"@radix-ui/react-portal": "1.1.8",
"@radix-ui/react-presence": "1.1.4",
"@radix-ui/react-primitive": "2.1.2",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/primitive": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-collection": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.6.tgz",
"integrity": "sha512-PbhRFK4lIEw9ADonj48tiYWzkllz81TM7KVYyyMMw2cwHO7D5h4XKEblL8NlaRisTK3QTe6tBEhDccFUryxHBQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.2",
"@radix-ui/react-slot": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.9.tgz",
"integrity": "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.2",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-portal": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.8.tgz",
"integrity": "sha512-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-presence": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz",
"integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz",
"integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.2.tgz",
"integrity": "sha512-ORCmRUbNiZIv6uV5mhFrhsIKw4UX/N3syZtyqvry61tbGm4JlgQuSn0hk5TwCARsCjkcnuRkSdCE3xfb+ADHew==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@ -1620,6 +2230,39 @@
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
@ -5495,6 +6138,22 @@
"react": "^18.3.1"
}
},
"node_modules/react-hook-form": {
"version": "7.56.4",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz",
"integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -6839,6 +7498,15 @@
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz",
"integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@ -13,11 +13,15 @@
"deploy": "gh-pages -d dist"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.13",
"@react-pdf/renderer": "^4.3.0",
"@supabase/supabase-js": "^2.39.7",
"@tanstack/react-query": "^5.24.1",
@ -32,11 +36,13 @@
"lucide-react": "^0.338.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.52.1",
"react-router-dom": "^6.22.1",
"recharts": "^2.12.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.11.20",

View File

@ -1,14 +1,76 @@
import { Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './contexts/auth/AuthContext';
import { AdminRoute } from './components/auth/AdminRoute';
// Public pages
import { HomePage } from '@/pages/home';
// import SpeciesDetail from './pages/species-detail'; // This will be created later
// Admin pages
import AdminLogin from '@/pages/admin/login';
import AdminDashboard from '@/pages/admin/dashboard';
import SpeciesList from '@/pages/admin/species/list';
import SpeciesEdit from '@/pages/admin/species/edit';
import SpeciesCreate from '@/pages/admin/species/create';
import CitesListingsList from '@/pages/admin/cites-listings/list';
import CitesListingsEdit from '@/pages/admin/cites-listings/edit';
import CitesListingsCreate from '@/pages/admin/cites-listings/create';
import IucnAssessmentsList from '@/pages/admin/iucn-assessments/list';
import IucnAssessmentsEdit from '@/pages/admin/iucn-assessments/edit';
import IucnAssessmentsCreate from '@/pages/admin/iucn-assessments/create';
import CommonNamesList from '@/pages/admin/common-names/list';
import CommonNamesEdit from '@/pages/admin/common-names/edit';
import CommonNamesCreate from '@/pages/admin/common-names/create';
// ...other admin page imports
const queryClient = new QueryClient();
function App() {
return (
<div className="min-h-screen bg-background">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="*" element={<div className="container p-8 text-center">Page not found</div>} />
</Routes>
</div>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<div className="min-h-screen bg-background">
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
{/* <Route path="/species/:id" element={<SpeciesDetail />} /> */}
{/* Admin authentication */}
<Route path="/admin/login" element={<AdminLogin />} />
{/* Protected admin routes */}
<Route element={<AdminRoute />}>
<Route path="/admin" element={<AdminDashboard />} />
{/* Species management */}
<Route path="/admin/species" element={<SpeciesList />} />
<Route path="/admin/species/create" element={<SpeciesCreate />} />
<Route path="/admin/species/:id/edit" element={<SpeciesEdit />} />
{/* CITES listings management */}
<Route path="/admin/cites-listings" element={<CitesListingsList />} />
<Route path="/admin/cites-listings/create" element={<CitesListingsCreate />} />
<Route path="/admin/cites-listings/:id/edit" element={<CitesListingsEdit />} />
{/* IUCN Assessments management */}
<Route path="/admin/iucn-assessments" element={<IucnAssessmentsList />} />
<Route path="/admin/iucn-assessments/create" element={<IucnAssessmentsCreate />} />
<Route path="/admin/iucn-assessments/:id/edit" element={<IucnAssessmentsEdit />} />
{/* Common Names management */}
<Route path="/admin/common-names" element={<CommonNamesList />} />
<Route path="/admin/common-names/create" element={<CommonNamesCreate />} />
<Route path="/admin/common-names/:id/edit" element={<CommonNamesEdit />} />
{/* Other admin routes... */}
</Route>
<Route path="*" element={<div className="container p-8 text-center">Page not found</div>} />
</Routes>
</div>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,207 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { CitesListing } from '@/services/adminApi';
// Validation schema
const citesListingSchema = z.object({
species_id: z.string().min(1, 'Species ID is required'),
appendix: z.string().min(1, 'Appendix is required'),
listing_date: z.string().min(1, 'Listing date is required'),
notes: z.string().optional(),
is_current: z.boolean().default(false),
});
interface CitesListingFormProps {
initialData?: CitesListing;
onSubmit: (data: CitesListing) => void;
isSubmitting: boolean;
speciesId?: string; // Optional species ID for creating new listings from a species context
}
export function CitesListingForm({
initialData,
onSubmit,
isSubmitting,
speciesId
}: CitesListingFormProps) {
// Prepare default values, using speciesId if provided and no initialData
const defaultValues = {
species_id: initialData?.species_id || speciesId || '',
appendix: initialData?.appendix || '',
listing_date: initialData?.listing_date
? new Date(initialData.listing_date).toISOString().split('T')[0]
: '',
notes: initialData?.notes || '',
is_current: initialData?.is_current || false,
};
// Initialize form with react-hook-form
const form = useForm<z.infer<typeof citesListingSchema>>({
resolver: zodResolver(citesListingSchema),
defaultValues,
});
// Update form values if speciesId changes (useful for adding new listings from species page)
useEffect(() => {
if (speciesId && !initialData) {
form.setValue('species_id', speciesId);
}
}, [speciesId, form, initialData]);
const handleSubmit = (values: z.infer<typeof citesListingSchema>) => {
// Convert form values to the expected data format
const formattedData: CitesListing = {
...values,
// Ensure listing_date is in the correct format
listing_date: values.listing_date,
};
if (initialData?.id) {
formattedData.id = initialData.id;
}
onSubmit(formattedData);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
{/* Hidden species_id field for when provided via context */}
<FormField
control={form.control}
name="species_id"
render={({ field }) => (
<input type="hidden" {...field} />
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="appendix"
render={({ field }) => (
<FormItem>
<FormLabel>CITES Appendix*</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select appendix" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="I">Appendix I</SelectItem>
<SelectItem value="II">Appendix II</SelectItem>
<SelectItem value="III">Appendix III</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The CITES appendix for this listing
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listing_date"
render={({ field }) => (
<FormItem>
<FormLabel>Listing Date*</FormLabel>
<FormControl>
<Input
type="date"
{...field}
/>
</FormControl>
<FormDescription>
The date when this listing came into effect
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="Additional notes about this listing..."
className="min-h-24"
/>
</FormControl>
<FormDescription>
Optional notes about the listing (annotations, exceptions, etc.)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="is_current"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Current Listing
</FormLabel>
<FormDescription>
Check if this is the current active listing for the species
</FormDescription>
</div>
</FormItem>
)}
/>
<div className="flex justify-end space-x-4">
<Button type="button" variant="outline" onClick={() => window.history.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : initialData ? 'Update Listing' : 'Create Listing'}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,197 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { CommonName } from '@/services/adminApi';
// Common languages for dropdown
const commonLanguages = [
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'French' },
{ value: 'es', label: 'Spanish' },
{ value: 'ru', label: 'Russian' },
{ value: 'de', label: 'German' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'inuktitut', label: 'Inuktitut' },
{ value: 'greenlandic', label: 'Greenlandic' },
{ value: 'norwegian', label: 'Norwegian' },
{ value: 'swedish', label: 'Swedish' },
{ value: 'finnish', label: 'Finnish' },
{ value: 'danish', label: 'Danish' },
{ value: 'icelandic', label: 'Icelandic' },
{ value: 'faroese', label: 'Faroese' },
{ value: 'sami', label: 'Sami' },
];
// Validation schema
const commonNameSchema = z.object({
species_id: z.string().min(1, 'Species ID is required'),
name: z.string().min(1, 'Common name is required'),
language: z.string().min(1, 'Language is required'),
is_main: z.boolean().default(false),
});
interface CommonNameFormProps {
initialData?: CommonName;
onSubmit: (data: CommonName) => void;
isSubmitting: boolean;
speciesId?: string; // Optional species ID for creating new common names from a species context
}
export function CommonNameForm({
initialData,
onSubmit,
isSubmitting,
speciesId
}: CommonNameFormProps) {
// Prepare default values, using speciesId if provided and no initialData
const defaultValues = {
species_id: initialData?.species_id || speciesId || '',
name: initialData?.name || '',
language: initialData?.language || 'en',
is_main: initialData?.is_main || false,
};
// Initialize form with react-hook-form
const form = useForm<z.infer<typeof commonNameSchema>>({
resolver: zodResolver(commonNameSchema),
defaultValues,
});
// Update form values if speciesId changes (useful for adding new common names from species page)
useEffect(() => {
if (speciesId && !initialData) {
form.setValue('species_id', speciesId);
}
}, [speciesId, form, initialData]);
const handleSubmit = (values: z.infer<typeof commonNameSchema>) => {
// Convert form values to the expected data format
const formattedData: CommonName = {
...values,
};
if (initialData?.id) {
formattedData.id = initialData.id;
}
onSubmit(formattedData);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
{/* Hidden species_id field for when provided via context */}
<FormField
control={form.control}
name="species_id"
render={({ field }) => (
<input type="hidden" {...field} />
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Common Name*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Polar Bear" />
</FormControl>
<FormDescription>
The common name in the selected language
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Language*</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonLanguages.map(lang => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The language of this common name
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="is_main"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Main Common Name
</FormLabel>
<FormDescription>
Check if this is the main common name for this species in this language
</FormDescription>
</div>
</FormItem>
)}
/>
<div className="flex justify-end space-x-4">
<Button type="button" variant="outline" onClick={() => window.history.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : initialData ? 'Update Common Name' : 'Create Common Name'}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,354 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { IucnAssessment } from '@/services/adminApi';
// Validation schema
const iucnAssessmentSchema = z.object({
species_id: z.string().min(1, 'Species ID is required'),
year_published: z.coerce.number().int().min(1900, 'Year must be at least 1900'),
status: z.string().min(1, 'Status is required'),
is_latest: z.boolean().default(false),
possibly_extinct: z.boolean().default(false),
possibly_extinct_in_wild: z.boolean().default(false),
url: z.string().url().optional().or(z.string().length(0)),
assessment_id: z.coerce.number().int().positive().optional(),
scope_code: z.string().optional(),
scope_description: z.string().optional(),
});
// IUCN Status options with colors
const iucnStatusOptions = [
{ value: 'EX', label: 'Extinct', color: '#000000' },
{ value: 'EW', label: 'Extinct in the Wild', color: '#542344' },
{ value: 'CR', label: 'Critically Endangered', color: '#D81E05' },
{ value: 'EN', label: 'Endangered', color: '#FC7F3F' },
{ value: 'VU', label: 'Vulnerable', color: '#F9E814' },
{ value: 'NT', label: 'Near Threatened', color: '#CCE226' },
{ value: 'LC', label: 'Least Concern', color: '#60C659' },
{ value: 'DD', label: 'Data Deficient', color: '#D1D1C6' },
{ value: 'NE', label: 'Not Evaluated', color: '#FFFFFF' },
];
interface IucnAssessmentFormProps {
initialData?: IucnAssessment;
onSubmit: (data: IucnAssessment) => void;
isSubmitting: boolean;
speciesId?: string; // Optional species ID for creating new assessments from a species context
}
export function IucnAssessmentForm({
initialData,
onSubmit,
isSubmitting,
speciesId
}: IucnAssessmentFormProps) {
// Get current year for max validation
const currentYear = new Date().getFullYear();
// Prepare default values, using speciesId if provided and no initialData
const defaultValues = {
species_id: initialData?.species_id || speciesId || '',
year_published: initialData?.year_published || currentYear,
status: initialData?.status || 'NE',
is_latest: initialData?.is_latest || false,
possibly_extinct: initialData?.possibly_extinct || false,
possibly_extinct_in_wild: initialData?.possibly_extinct_in_wild || false,
url: initialData?.url || '',
assessment_id: initialData?.assessment_id || undefined,
scope_code: initialData?.scope_code || '',
scope_description: initialData?.scope_description || '',
};
// Initialize form with react-hook-form
const form = useForm<z.infer<typeof iucnAssessmentSchema>>({
resolver: zodResolver(iucnAssessmentSchema),
defaultValues,
});
// Update form values if speciesId changes (useful for adding new assessments from species page)
useEffect(() => {
if (speciesId && !initialData) {
form.setValue('species_id', speciesId);
}
}, [speciesId, form, initialData]);
const handleSubmit = (values: z.infer<typeof iucnAssessmentSchema>) => {
// Convert form values to the expected data format
const formattedData: IucnAssessment = {
...values,
year_published: Number(values.year_published),
assessment_id: values.assessment_id ? Number(values.assessment_id) : undefined,
};
if (initialData?.id) {
formattedData.id = initialData.id;
}
onSubmit(formattedData);
};
// Get current status to show extinction checkboxes only when appropriate
const currentStatus = form.watch('status');
const showExtinctionFlags = ['CR', 'EN', 'VU'].includes(currentStatus);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
{/* Hidden species_id field for when provided via context */}
<FormField
control={form.control}
name="species_id"
render={({ field }) => (
<input type="hidden" {...field} />
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>IUCN Status*</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
{iucnStatusOptions.map(status => (
<SelectItem key={status.value} value={status.value}>
<div className="flex items-center">
<span
className="mr-2 inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: status.color }}
/>
{status.label} ({status.value})
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
The IUCN Red List conservation status
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="year_published"
render={({ field }) => (
<FormItem>
<FormLabel>Year Published*</FormLabel>
<FormControl>
<Input
type="number"
min={1900}
max={currentYear}
{...field}
value={field.value || ''}
/>
</FormControl>
<FormDescription>
The year this assessment was published
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="assessment_id"
render={({ field }) => (
<FormItem>
<FormLabel>Assessment ID</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ''}
onChange={e => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
/>
</FormControl>
<FormDescription>
The IUCN's assessment ID (if available)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Assessment URL</FormLabel>
<FormControl>
<Input
type="url"
{...field}
value={field.value || ''}
placeholder="https://www.iucnredlist.org/species/..."
/>
</FormControl>
<FormDescription>
URL to the IUCN Red List assessment
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="scope_code"
render={({ field }) => (
<FormItem>
<FormLabel>Scope Code</FormLabel>
<FormControl>
<Input {...field} value={field.value || ''} />
</FormControl>
<FormDescription>
Assessment scope code (if applicable)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scope_description"
render={({ field }) => (
<FormItem>
<FormLabel>Scope Description</FormLabel>
<FormControl>
<Input {...field} value={field.value || ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="is_latest"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Latest Assessment
</FormLabel>
<FormDescription>
Check if this is the most recent assessment for the species
</FormDescription>
</div>
</FormItem>
)}
/>
{showExtinctionFlags && (
<div className="space-y-4">
<FormField
control={form.control}
name="possibly_extinct"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Possibly Extinct
</FormLabel>
<FormDescription>
Species is possibly already extinct, but confirmation is needed
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="possibly_extinct_in_wild"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Possibly Extinct in the Wild
</FormLabel>
<FormDescription>
Species is possibly already extinct in the wild, but may exist in captivity
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
)}
<div className="flex justify-end space-x-4">
<Button type="button" variant="outline" onClick={() => window.history.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : initialData ? 'Update Assessment' : 'Create Assessment'}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,628 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Species } from '@/services/adminApi';
// Validation schema
const speciesSchema = z.object({
scientific_name: z.string().min(1, 'Scientific name is required'),
common_name: z.string().min(1, 'Common name is required'),
kingdom: z.string().min(1, 'Kingdom is required'),
phylum: z.string().min(1, 'Phylum is required'),
class: z.string().min(1, 'Class is required'),
order_name: z.string().min(1, 'Order is required'),
family: z.string().min(1, 'Family is required'),
genus: z.string().min(1, 'Genus is required'),
species_name: z.string().min(1, 'Species is required'),
authority: z.string(), // No longer optional, allows empty string
sis_id: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().int().positive().optional()),
inaturalist_id: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().int().positive().optional()),
default_image_url: z.string().url().optional().or(z.string().length(0)),
description: z.string().optional(),
habitat_description: z.string().optional(),
population_trend: z.string().optional(),
population_size: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().optional()),
generation_length: z.preprocess((val) => (val === "" || val === null ? undefined : val), z.coerce.number().optional()),
movement_patterns: z.string().optional(),
use_and_trade: z.string().optional(),
threats_overview: z.string().optional(),
conservation_overview: z.string().optional(),
});
interface SpeciesFormProps {
initialData?: Species;
onSubmit: (data: Species) => void;
isSubmitting: boolean;
}
export function SpeciesForm({ initialData, onSubmit, isSubmitting }: SpeciesFormProps) {
// Start with the description tab if the species has a description
const initialTab = initialData?.description ? 'description' : 'basic';
const [activeTab, setActiveTab] = useState(initialTab);
// Prepare default values, converting nulls from initialData to empty strings
// for optional fields to align with Zod's expectation (string | undefined)
const getSafeDefaultValue = (value: string | null | undefined) => value === null ? '' : value;
const getSafeNumericDefaultValue = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === '') {
return undefined;
}
const num = Number(value);
return isNaN(num) ? undefined : num;
};
const form = useForm<z.infer<typeof speciesSchema>>({
resolver: zodResolver(speciesSchema),
defaultValues: initialData
? {
...initialData,
authority: getSafeDefaultValue(initialData.authority),
default_image_url: getSafeDefaultValue(initialData.default_image_url),
description: getSafeDefaultValue(initialData.description),
habitat_description: getSafeDefaultValue(initialData.habitat_description),
population_trend: getSafeDefaultValue(initialData.population_trend),
// For numeric fields, ensure they are numbers or undefined
sis_id: getSafeNumericDefaultValue(initialData.sis_id),
inaturalist_id: getSafeNumericDefaultValue(initialData.inaturalist_id),
population_size: getSafeNumericDefaultValue(initialData.population_size),
generation_length: getSafeNumericDefaultValue(initialData.generation_length),
movement_patterns: getSafeDefaultValue(initialData.movement_patterns),
use_and_trade: getSafeDefaultValue(initialData.use_and_trade),
threats_overview: getSafeDefaultValue(initialData.threats_overview),
conservation_overview: getSafeDefaultValue(initialData.conservation_overview),
}
: {
scientific_name: '',
common_name: '',
kingdom: 'Animalia',
phylum: '',
class: '',
order_name: '',
family: '',
genus: '',
species_name: '',
authority: '',
sis_id: undefined,
inaturalist_id: undefined,
default_image_url: '',
description: '',
habitat_description: '',
population_trend: '', // Default to empty string, Select handles it
population_size: undefined,
generation_length: undefined,
movement_patterns: '',
use_and_trade: '',
threats_overview: '',
conservation_overview: '',
},
});
// If initialData changes (e.g., after a fetch), reset the form with new processed defaults.
// This is important if the component re-renders with new initialData after the first mount.
useEffect(() => {
if (initialData) {
form.reset({
...initialData,
authority: getSafeDefaultValue(initialData.authority),
default_image_url: getSafeDefaultValue(initialData.default_image_url),
description: getSafeDefaultValue(initialData.description),
habitat_description: getSafeDefaultValue(initialData.habitat_description),
population_trend: getSafeDefaultValue(initialData.population_trend),
sis_id: getSafeNumericDefaultValue(initialData.sis_id),
inaturalist_id: getSafeNumericDefaultValue(initialData.inaturalist_id),
population_size: getSafeNumericDefaultValue(initialData.population_size),
generation_length: getSafeNumericDefaultValue(initialData.generation_length),
movement_patterns: getSafeDefaultValue(initialData.movement_patterns),
use_and_trade: getSafeDefaultValue(initialData.use_and_trade),
threats_overview: getSafeDefaultValue(initialData.threats_overview),
conservation_overview: getSafeDefaultValue(initialData.conservation_overview),
});
}
}, [initialData, form.reset]); // form.reset is a stable function reference
const handleSubmit = (values: z.infer<typeof speciesSchema>) => {
// Process values to ensure optional empty strings are sent as empty strings,
// or convert to null if your backend prefers null for empty optional text fields.
// The current Zod schema and Supabase text fields generally handle empty strings fine.
const processedValues = {
...values,
description: values.description || '',
habitat_description: values.habitat_description || '',
population_trend: values.population_trend || '', // Keep as empty string if that's what select gives
movement_patterns: values.movement_patterns || '',
use_and_trade: values.use_and_trade || '',
threats_overview: values.threats_overview || '',
conservation_overview: values.conservation_overview || '',
// Ensure numeric fields are sent as null if undefined, or their numeric value
sis_id: values.sis_id === undefined ? null : values.sis_id,
inaturalist_id: values.inaturalist_id === undefined ? null : values.inaturalist_id,
population_size: values.population_size === undefined ? null : values.population_size,
generation_length: values.generation_length === undefined ? null : values.generation_length,
};
onSubmit(processedValues as Species);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
<TabsTrigger value="basic">Basic Info</TabsTrigger>
<TabsTrigger value="taxonomy">Taxonomy</TabsTrigger>
<TabsTrigger value="description">Description</TabsTrigger>
<TabsTrigger value="conservation">Conservation</TabsTrigger>
</TabsList>
{/* Basic Info Tab */}
<TabsContent value="basic" className="space-y-4 pt-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="scientific_name"
render={({ field }) => (
<FormItem>
<FormLabel>Scientific Name*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Ursus maritimus" />
</FormControl>
<FormDescription>
Full scientific name with genus and species
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="common_name"
render={({ field }) => (
<FormItem>
<FormLabel>Common Name*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Polar Bear" />
</FormControl>
<FormDescription>
Primary English common name
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="sis_id"
render={({ field }) => (
<FormItem>
<FormLabel>IUCN SIS ID</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ''}
onChange={e => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
placeholder="e.g. 22823"
/>
</FormControl>
<FormDescription>
Species Information Service ID from IUCN
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="inaturalist_id"
render={({ field }) => (
<FormItem>
<FormLabel>iNaturalist ID</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value || ''}
onChange={e => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
placeholder="e.g. 42021"
/>
</FormControl>
<FormDescription>
Taxon ID from iNaturalist
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="default_image_url"
render={({ field }) => (
<FormItem>
<FormLabel>Default Image URL</FormLabel>
<FormControl>
<Input {...field} placeholder="https://example.com/image.jpg" value={field.value || ''} />
</FormControl>
<FormDescription>
URL for the main species image
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
{/* Taxonomy Tab */}
<TabsContent value="taxonomy" className="space-y-4 pt-4">
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="kingdom"
render={({ field }) => (
<FormItem>
<FormLabel>Kingdom*</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select kingdom" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Animalia">Animalia</SelectItem>
<SelectItem value="Plantae">Plantae</SelectItem>
<SelectItem value="Fungi">Fungi</SelectItem>
<SelectItem value="Protista">Protista</SelectItem>
<SelectItem value="Chromista">Chromista</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phylum"
render={({ field }) => (
<FormItem>
<FormLabel>Phylum*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Chordata" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="class"
render={({ field }) => (
<FormItem>
<FormLabel>Class*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Mammalia" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="order_name"
render={({ field }) => (
<FormItem>
<FormLabel>Order*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Carnivora" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="family"
render={({ field }) => (
<FormItem>
<FormLabel>Family*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Ursidae" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="genus"
render={({ field }) => (
<FormItem>
<FormLabel>Genus*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Ursus" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="species_name"
render={({ field }) => (
<FormItem>
<FormLabel>Species*</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. maritimus" />
</FormControl>
<FormDescription>
Specific epithet (species part of the binomial name)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authority"
render={({ field }) => (
<FormItem>
<FormLabel>Authority</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Phipps, 1774" value={field.value || ''}/>
</FormControl>
<FormDescription>
Name and year of the authority who described the species
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
{/* Description Tab */}
<TabsContent value="description" className="space-y-4 pt-4">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>General Description</FormLabel>
<FormControl>
<Textarea
{...field}
value={field.value || ''}
placeholder="General description of the species..."
className="min-h-64"
rows={12}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="habitat_description"
render={({ field }) => (
<FormItem>
<FormLabel>Habitat Description</FormLabel>
<FormControl>
<Textarea
{...field}
value={field.value || ''}
placeholder="Description of the species' habitat..."
className="min-h-64"
rows={10}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="population_trend"
render={({ field }) => (
<FormItem>
<FormLabel>Population Trend</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || 'unknown'}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select trend" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="unknown">Unknown</SelectItem>
<SelectItem value="increasing">Increasing</SelectItem>
<SelectItem value="stable">Stable</SelectItem>
<SelectItem value="decreasing">Decreasing</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="population_size"
render={({ field }) => (
<FormItem>
<FormLabel>Population Size</FormLabel>
<FormControl>
<Input
type="number"
{...field}
value={field.value === undefined || field.value === null ? '' : String(field.value)}
onChange={e => field.onChange(e.target.value === '' ? undefined : parseFloat(e.target.value))}
placeholder="e.g. 25000"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="generation_length"
render={({ field }) => (
<FormItem>
<FormLabel>Generation Length</FormLabel>
<FormControl>
<Input
type="number"
step="any" // Allows decimal input
{...field}
value={field.value === undefined || field.value === null ? '' : String(field.value)}
onChange={e => field.onChange(e.target.value === '' ? undefined : parseFloat(e.target.value))}
placeholder="e.g. 12.3"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="movement_patterns"
render={({ field }) => (
<FormItem>
<FormLabel>Movement Patterns</FormLabel>
<FormControl>
<Input {...field} placeholder="e.g. Migratory/Resident" value={field.value || ''}/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</TabsContent>
{/* Conservation Tab */}
<TabsContent value="conservation" className="space-y-4 pt-4">
<FormField
control={form.control}
name="use_and_trade"
render={({ field }) => (
<FormItem>
<FormLabel>Use and Trade</FormLabel>
<FormControl>
<Textarea
{...field}
value={field.value || ''}
placeholder="Information on use and trade of this species..."
className="min-h-64"
rows={10}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="threats_overview"
render={({ field }) => (
<FormItem>
<FormLabel>Threats Overview</FormLabel>
<FormControl>
<Textarea
{...field}
value={field.value || ''}
placeholder="Overview of threats to this species..."
className="min-h-64"
rows={10}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="conservation_overview"
render={({ field }) => (
<FormItem>
<FormLabel>Conservation Overview</FormLabel>
<FormControl>
<Textarea
{...field}
value={field.value || ''}
placeholder="Overview of conservation efforts for this species..."
className="min-h-64"
rows={10}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<div className="flex justify-end space-x-4">
<Button type="button" variant="outline" onClick={() => window.history.back()}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : initialData ? 'Update Species' : 'Create Species'}
</Button>
</div>
</form>
</Form>
);
}

View File

@ -0,0 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '@/contexts/auth/AuthContext';
export function AdminRoute() {
const { user, loading, isAdmin } = useAuth();
if (loading) {
return <div className="flex h-screen items-center justify-center">Loading...</div>;
}
if (!user || !isAdmin) {
return <Navigate to="/admin/login" replace />;
}
return <Outlet />;
}

View File

@ -0,0 +1,70 @@
import { useState } from 'react';
import { useAuth } from '@/contexts/auth/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { signIn } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
await signIn(email, password);
} catch (err) {
setError('Invalid email or password');
} finally {
setIsLoading(false);
}
};
return (
<div className="w-full max-w-md space-y-6 rounded-lg border p-8 shadow-md">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Admin Login</h1>
<p className="text-muted-foreground">Enter your credentials to access the admin panel</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">Email</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">Password</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
</div>
);
}

View File

@ -0,0 +1,281 @@
import { useState, useMemo } from 'react';
import { useQuery, useQueries } from '@tanstack/react-query';
import { getAllSpecies, getCitesTradeRecords } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Loader2 } from 'lucide-react';
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from 'recharts';
// TODO: Implement a better multi-select component (e.g., Shadcn Combobox with multi-select)
// For now, using a simple list with checkboxes as a placeholder concept
// Helper function to format numbers without decimals
const formatNumber = (value: number) => Math.round(value).toLocaleString();
// Helper function to generate distinct colors for chart lines
function generateColor(index: number): string {
const colors = [
"#8884d8", "#82ca9d", "#ffc658", "#ff7300", "#0088FE",
"#00C49F", "#FFBB28", "#FF8042", "#d0ed57", "#ffc0cb"
];
return colors[index % colors.length];
}
export function CompareTradeTab() {
const [selectedSpeciesIds, setSelectedSpeciesIds] = useState<string[]>([]);
// State to trigger loading
const [loadTriggered, setLoadTriggered] = useState<boolean>(false);
// 1. Fetch all species for the selector
const { data: allSpecies, isLoading: isLoadingAllSpecies, error: errorAllSpecies } = useQuery({
queryKey: ['allSpecies'],
queryFn: getAllSpecies,
});
// 2. Fetch trade data for selected species using useQueries
const tradeDataQueries = useQueries({
queries: selectedSpeciesIds.map(id => ({
queryKey: ['citesTradeRecords', id],
queryFn: () => getCitesTradeRecords(id),
// Enable only when >= 2 species selected AND button clicked
enabled: !!id && selectedSpeciesIds.length >= 2 && loadTriggered,
staleTime: 1000 * 60 * 5, // Cache for 5 minutes
})),
});
// Check collective loading/error states
const isLoadingTradeData = loadTriggered && tradeDataQueries.some(query => query.isLoading);
const errorTradeData = loadTriggered ? tradeDataQueries.find(query => query.isError)?.error : null;
// 3. Process fetched data for the chart AND calculate totals
const { chartEntries, speciesTotals } = useMemo(() => {
const result = {
chartEntries: [] as { year: number; [speciesId: string]: number }[],
speciesTotals: {} as Record<string, number>
};
if (!loadTriggered || isLoadingTradeData || errorTradeData || selectedSpeciesIds.length < 2) {
return result;
}
const allDataLoaded = tradeDataQueries.every(q => q.isSuccess && q.data);
if (!allDataLoaded) {
return result;
}
const yearlyData: Record<number, { year: number; [speciesId: string]: number }> = {};
const totals: Record<string, number> = {};
tradeDataQueries.forEach((query, index) => {
const speciesId = selectedSpeciesIds[index];
totals[speciesId] = 0; // Initialize total for this species
if (query.isSuccess && query.data) {
query.data.forEach(record => {
const year = record.year;
const quantity = Number(record.quantity) || 0;
if (!yearlyData[year]) {
yearlyData[year] = { year };
}
if (yearlyData[year][speciesId] === undefined) {
yearlyData[year][speciesId] = 0;
}
yearlyData[year][speciesId] += quantity;
totals[speciesId] += quantity; // Accumulate total
});
}
});
const allYears = Object.keys(yearlyData).map(Number).sort((a,b) => a - b);
result.chartEntries = allYears.map(year => {
const yearEntry = yearlyData[year];
selectedSpeciesIds.forEach(id => {
if(yearEntry[id] === undefined) {
yearEntry[id] = 0;
}
});
return yearEntry;
});
result.speciesTotals = totals;
return result;
}, [tradeDataQueries, selectedSpeciesIds, isLoadingTradeData, errorTradeData, loadTriggered]);
const handleSelectChange = (speciesId: string) => {
setSelectedSpeciesIds(prev =>
prev.includes(speciesId)
? prev.filter(id => id !== speciesId)
: [...prev, speciesId]
);
setLoadTriggered(false); // Reset trigger when selection changes
};
// Handler for Load button
const handleLoadComparison = () => {
if (selectedSpeciesIds.length >= 2) {
setLoadTriggered(true);
}
};
// Handler for Clear button
const handleClearSelections = () => {
setSelectedSpeciesIds([]);
setLoadTriggered(false);
};
return (
<Card>
<CardHeader>
<CardTitle>Compare CITES Trade Data</CardTitle>
<CardDescription>Select two or more species, then click "Load Comparison".</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Species Selector Section */}
<div>
<h3 className="text-lg font-medium mb-2">1. Select Species</h3>
{isLoadingAllSpecies && <Loader2 className="h-5 w-5 animate-spin" />}
{errorAllSpecies && <p className="text-destructive">Error loading species list.</p>}
{allSpecies && (
// Placeholder Selector - Replace with proper multi-select Combobox later
<div className="max-h-60 overflow-y-auto border rounded-md p-4 space-y-2">
{allSpecies.map((species) => (
<div key={species.id} className="flex items-center space-x-2">
<Checkbox
id={`compare-${species.id}`}
checked={selectedSpeciesIds.includes(species.id)}
onCheckedChange={(_checked) => {
handleSelectChange(species.id);
}}
/>
<label htmlFor={`compare-${species.id}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{species.primary_common_name || species.scientific_name} (<span className="italic">{species.scientific_name}</span>)
</label>
</div>
))}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2 mt-4">
<Button
onClick={handleLoadComparison}
disabled={selectedSpeciesIds.length < 2}
>
Load Comparison
</Button>
<Button
variant="outline"
onClick={handleClearSelections}
disabled={selectedSpeciesIds.length === 0}
>
Clear Selections
</Button>
</div>
</div>
{/* Results Area */}
{/* Show loading state only when load is triggered */}
{loadTriggered && isLoadingTradeData && (
<div className="flex items-center justify-center py-10 border-t pt-6">
<Loader2 className="h-8 w-8 animate-spin mr-3" />
<span className="text-lg">Loading comparison data...</span>
</div>
)}
{/* Show error state only when load is triggered */}
{loadTriggered && errorTradeData && (
<div className="text-destructive text-center py-10 border-t pt-6">
Error loading trade data: {(errorTradeData as Error).message}
</div>
)}
{/* Chart Section - Show only if load triggered, not loading, no error, and >= 2 selected */}
{loadTriggered && !isLoadingTradeData && !errorTradeData && selectedSpeciesIds.length >= 2 && (
<div className="border-t pt-6">
<h3 className="text-xl font-semibold mb-1">2. Trade Volume Comparison (Reported Quantity)</h3>
{/* Add Data Source Description */}
<p className="text-sm text-muted-foreground mb-4">Data source: CITES Trade Database (March 2025 release)</p>
{chartEntries.length > 0 ? (
<div className="h-96 mb-6">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartEntries}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={formatNumber} />
<Tooltip
formatter={(value: number | string, name: string) => [
formatNumber(Number(value || 0)),
name || 'Quantity'
]}
labelFormatter={(label) => `Year: ${label}`}
/>
<Legend />
{selectedSpeciesIds.map((id, index) => {
const speciesName = allSpecies?.find(s => s.id === id)?.primary_common_name || allSpecies?.find(s => s.id === id)?.scientific_name || id;
return (
<Line
key={id}
type="monotone"
dataKey={id as string}
stroke={generateColor(index)}
name={speciesName}
dot={false}
strokeWidth={2}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</div>
) : (
<p className="text-muted-foreground text-center pt-8">No comparable trade data found for the selected species combination in the available years.</p>
)}
{/* Summary Section (Show only if chart data exists) */}
{chartEntries.length > 0 && (
<div>
<h4 className="text-lg font-semibold mb-3">Total Reported Quantity (Selected Period)</h4>
<div className="space-y-2">
{selectedSpeciesIds.map((id, index) => {
const speciesName = allSpecies?.find(s => s.id === id)?.primary_common_name || allSpecies?.find(s => s.id === id)?.scientific_name || id;
const total = speciesTotals[id] ?? 0;
return (
<div key={id} className="flex items-center justify-between text-sm p-2 rounded bg-muted/50">
<div className="flex items-center">
<span
className="inline-block w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: generateColor(index) }}
></span>
<span>{speciesName}</span>
</div>
<span className="font-medium">{formatNumber(total)}</span>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* Initial prompt or prompt after clearing selections */}
{!loadTriggered && selectedSpeciesIds.length < 2 && (
<p className="text-muted-foreground text-center pt-8 border-t mt-6">Select two or more species and click "Load Comparison".</p>
)}
{!loadTriggered && selectedSpeciesIds.length >= 2 && (
<p className="text-muted-foreground text-center pt-8 border-t mt-6">Click "Load Comparison" to view the chart.</p>
)}
</CardContent>
</Card>
);
}

View File

@ -17,7 +17,7 @@ export function DebugPanel({ data, title = 'Debug Data' }: DebugPanelProps) {
<Card className="border-dashed border-gray-300">
<CardHeader className="py-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-normal text-muted-foreground">
<CardTitle className="text-base font-normal text-muted-foreground">
{title} {citesListings.length > 0 && `(${citesListings.length} CITES listings)`}
</CardTitle>
<Button variant="outline" size="sm" onClick={() => setIsVisible(!isVisible)}>
@ -29,17 +29,17 @@ export function DebugPanel({ data, title = 'Debug Data' }: DebugPanelProps) {
<CardContent className="pb-3 pt-0">
{citesListings.length > 0 && (
<div className="mb-4 rounded border border-yellow-200 bg-yellow-50 p-2">
<h3 className="mb-2 text-sm font-semibold text-yellow-800">CITES Listings:</h3>
<pre className="overflow-auto text-xs text-yellow-900">
<h3 className="mb-2 text-base font-semibold text-yellow-800">CITES Listings:</h3>
<pre className="overflow-auto text-sm text-yellow-900">
{JSON.stringify(citesListings, null, 2)}
</pre>
</div>
)}
<pre className="max-h-96 overflow-auto rounded bg-slate-50 p-2 text-xs">
<pre className="max-h-96 overflow-auto rounded bg-slate-50 p-2 text-sm">
{JSON.stringify(data, null, 2)}
</pre>
</CardContent>
)}
</Card>
);
}
}

View File

@ -0,0 +1,104 @@
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/auth/AuthContext';
import { Button } from '@/components/ui/button';
import {
BarChart,
BookOpen,
Layers,
ListChecks,
Menu,
Tag,
User,
LogOut,
X
} from 'lucide-react';
import { Toaster } from '@/components/ui/toaster'; // Import Toaster
const sidebarItems = [
{ name: 'Dashboard', path: '/admin', icon: <BarChart size={20} /> },
{ name: 'Species', path: '/admin/species', icon: <BookOpen size={20} /> },
{ name: 'CITES Listings', path: '/admin/cites-listings', icon: <Tag size={20} /> },
{ name: 'IUCN Assessments', path: '/admin/iucn-assessments', icon: <ListChecks size={20} /> },
{ name: 'Common Names', path: '/admin/common-names', icon: <Layers size={20} /> },
// Add other items as needed
];
export function AdminLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { signOut, user } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const handleSignOut = async () => {
await signOut();
navigate('/admin/login');
};
return (
<div className="flex min-h-screen flex-col md:flex-row">
{/* Mobile sidebar toggle */}
<button
className="absolute right-4 top-4 md:hidden"
onClick={() => setSidebarOpen(!sidebarOpen)}
>
{sidebarOpen ? <X size={24} /> : <Menu size={24} />}
</button>
{/* Sidebar */}
<div
className={`w-64 bg-slate-900 text-white transition-all ${
sidebarOpen ? 'fixed inset-y-0 left-0 z-50' : 'hidden md:block'
}`}
>
<div className="flex h-full flex-col p-4">
<div className="mb-8 mt-4 text-center">
<h1 className="text-xl font-bold">Arctic Species Admin</h1>
</div>
<nav className="space-y-2">
{sidebarItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`flex items-center rounded-md p-3 transition-colors ${
location.pathname === item.path
? 'bg-slate-800 text-white'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`}
>
{item.icon}
<span className="ml-3">{item.name}</span>
</Link>
))}
</nav>
<div className="mt-auto space-y-2 pt-6">
<div className="flex items-center space-x-3 rounded-md bg-slate-800 p-3 text-white">
<User size={20} />
<div className="overflow-hidden text-sm">
<p className="truncate font-medium">{user?.email}</p>
<p className="text-xs text-slate-400">Admin</p>
</div>
</div>
<Button
variant="ghost"
className="w-full justify-start text-slate-300 hover:bg-slate-800 hover:text-white"
onClick={handleSignOut}
>
<LogOut size={20} className="mr-3" />
Sign Out
</Button>
</div>
</div>
</div>
{/* Main content */}
<div className="flex-1 overflow-auto">
<main className="container mx-auto p-6">{children}</main>
<Toaster /> {/* Add Toaster here */}
</div>
</div>
);
}

View File

@ -1,113 +1,130 @@
import { useQuery } from '@tanstack/react-query';
import { getSpeciesById } from '@/lib/api';
import { SpeciesTabs } from './species-tabs';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
getSpeciesById,
type SpeciesDetails
} from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { IUCN_STATUS_COLORS, CITES_APPENDIX_COLORS } from '@/lib/utils';
import { Loader2, ChevronLeft } from 'lucide-react';
import { DebugPanel } from './debug-panel';
import { useEffect } from 'react';
import { Loader2, ChevronLeft } from 'lucide-react';
import { useRef } from 'react';
import { SpeciesTabs } from './species-tabs';
type ResultsContainerProps = {
interface ResultsContainerProps {
speciesId: string | null;
onBack: () => void;
};
}
export function ResultsContainer({ speciesId, onBack }: ResultsContainerProps) {
const { data: species, isLoading, error } = useQuery({
const headerRef = useRef<HTMLDivElement>(null);
const { data: species, isLoading: isLoadingSpecies, error: errorSpecies } = useQuery<SpeciesDetails | null, Error>({
queryKey: ['species', speciesId],
queryFn: () => getSpeciesById(speciesId as string),
queryFn: () => speciesId ? getSpeciesById(speciesId) : Promise.resolve(null),
enabled: !!speciesId,
});
// Add this useEffect to see the full species data in console when it changes
useEffect(() => {
if (species) {
console.log('ResultsContainer species data loaded for ID:', species.id);
console.log('CITES listings in ResultsContainer:', species.cites_listings?.length);
if (species.cites_listings?.length > 0) {
console.log('First CITES listing:', species.cites_listings[0]);
if (species.cites_listings.length > 1) {
console.log('Second CITES listing:', species.cites_listings[1]);
}
}
}
}, [species]);
if (!speciesId) {
return null;
}
if (isLoading) {
if (isLoadingSpecies) {
return (
<div className="flex h-64 w-full items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="flex h-screen w-full items-center justify-center">
<Loader2 className="h-16 w-16 animate-spin text-primary" />
</div>
);
}
if (error || !species) {
if (errorSpecies || !species) {
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-destructive">Error Loading Species</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">There was an error loading the species information. Please try again.</p>
<Button onClick={onBack} variant="outline" size="sm">
<ChevronLeft className="mr-2 h-4 w-4" />
Back to Search
</Button>
</CardContent>
</Card>
<div className="container mx-auto p-8">
<Card className="w-full max-w-lg mx-auto">
<CardHeader>
<CardTitle className="text-destructive">
{errorSpecies ? 'Error Loading Species' : 'Species Not Found'}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{errorSpecies
? 'There was an error loading the species information.'
: `Could not find details for species ID: ${speciesId}`}
</p>
{errorSpecies && (
<p className="text-sm text-muted-foreground mb-4">{(errorSpecies as Error)?.message}</p>
)}
<Button onClick={onBack} variant="outline" size="sm">
<ChevronLeft className="mr-2 h-5 w-5" />
Back to Species List
</Button>
</CardContent>
</Card>
</div>
);
}
const getStatusColor = (status: string): string => {
switch (status) {
case 'CR': return 'bg-red-600 text-white';
case 'EN': return 'bg-orange-600 text-white';
case 'VU': return 'bg-yellow-600 text-black';
case 'NT': return 'bg-blue-600 text-white';
case 'LC': return 'bg-green-600 text-white';
default: return 'bg-gray-600 text-white';
}
};
const getAppendixColor = (appendix: string): string => {
switch (appendix) {
case 'I': return 'bg-red-600 text-white';
case 'II': return 'bg-blue-600 text-white';
case 'III': return 'bg-yellow-600 text-black';
default: return 'bg-gray-600 text-white';
}
};
return (
<div className="w-full space-y-6">
<div className="flex items-start justify-between">
<Button onClick={onBack} variant="outline" size="sm">
<ChevronLeft className="mr-2 h-4 w-4" />
Back to Search
</Button>
<div className="flex flex-wrap items-center gap-2">
{species.latest_assessment?.status && (
<Badge className={IUCN_STATUS_COLORS[species.latest_assessment.status]}>
IUCN: {species.latest_assessment.status}
</Badge>
)}
{species.current_cites_listing && (
<Badge className={CITES_APPENDIX_COLORS[species.current_cites_listing.appendix]}>
CITES: Appendix {species.current_cites_listing.appendix}
</Badge>
)}
<div className="w-full bg-white dark:bg-gray-950 min-h-screen">
<div
ref={headerRef}
className="bg-gradient-to-r from-blue-900 to-blue-600 py-10 md:py-14"
>
<div className="container-fluid w-full px-6">
<Button
onClick={onBack}
variant="outline"
size="sm"
className="mb-6 bg-white/10 text-white hover:bg-white/20 backdrop-blur-sm border-white/30"
>
<ChevronLeft className="mr-2 h-5 w-5" />
Back to Species List
</Button>
<div className="text-center text-white">
<h1 className="text-4xl md:text-5xl font-bold italic mb-3">{species.scientific_name}</h1>
<p className="text-xl md:text-2xl text-blue-100 mb-5">
{species.common_names[0]?.name || 'No common name available'}
</p>
<div className="flex flex-wrap justify-center gap-3">
{species.latest_assessment?.status && (
<Badge className={`text-sm md:text-base px-4 md:px-6 py-2 rounded-full ${getStatusColor(species.latest_assessment.status)}`}>
IUCN: {species.latest_assessment.status}
</Badge>
)}
{species.current_cites_listing && (
<Badge className={`text-sm md:text-base px-4 md:px-6 py-2 rounded-full ${getAppendixColor(species.current_cites_listing.appendix)}`}>
CITES: Appendix {species.current_cites_listing.appendix}
</Badge>
)}
</div>
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex flex-wrap items-center gap-2">
<span className="italic">{species.scientific_name}</span>
</CardTitle>
<CardDescription>
{species.common_name}
{species.common_names?.length > 0 && (
<span className="ml-2 text-xs">
(Also known as: {species.common_names.map((cn) => cn.name).join(', ')})
</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
<SpeciesTabs species={species} />
</CardContent>
</Card>
{/* Debug panel to see data */}
<DebugPanel data={species} title="Species Data from Supabase" />
<div className="container-fluid w-full px-6 py-8">
<SpeciesTabs species={species} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,706 @@
import { useQuery } from '@tanstack/react-query';
import {
getSpeciesById,
getSpeciesImages,
getSpeciesDistribution,
getSpeciesThreats,
getConservationMeasures,
getSpeciesExtendedInfo,
getTimelineEvents
} from '@/lib/api';
import { SpeciesTabs } from './species-tabs';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { IUCN_STATUS_COLORS, CITES_APPENDIX_COLORS } from '@/lib/utils';
import { Loader2, ChevronLeft, Image, Globe, ExternalLink, FileText, Share2, Camera, Map, Calendar, FileSpreadsheet } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TradeDataTab } from './species/tabs/TradeDataTab';
import { IucnTab } from './species/tabs/IucnTab';
import { TimelineTab } from './species/tabs/TimelineTab';
import { CitesTab } from './species/tabs/CitesTab';
// Add IUCN status full names mapping
const IUCN_STATUS_FULL_NAMES: Record<string, string> = {
'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'
};
type ResultsContainerProps = {
speciesId: string | null;
onBack: () => void;
};
export function ResultsContainer({ speciesId, onBack }: ResultsContainerProps) {
const [isHeaderSticky, setIsHeaderSticky] = useState(false);
const headerRef = useRef<HTMLDivElement>(null);
const { data: species, isLoading, error } = useQuery({
queryKey: ['species', speciesId],
queryFn: () => getSpeciesById(speciesId as string),
enabled: !!speciesId,
});
const { data: imageData } = useQuery({
queryKey: ['speciesImage', species?.scientific_name],
queryFn: () => getSpeciesImages(species?.scientific_name || ''),
enabled: !!species?.scientific_name,
});
const { data: distributionData } = useQuery({
queryKey: ['speciesDistribution', species?.id],
queryFn: () => getSpeciesDistribution(species?.id || ''),
enabled: !!species?.id,
});
const { data: threatsData } = useQuery({
queryKey: ['speciesThreats', species?.id],
queryFn: () => getSpeciesThreats(species?.id || ''),
enabled: !!species?.id,
});
const { data: conservationMeasuresData } = useQuery({
queryKey: ['speciesConservationMeasures', species?.id],
queryFn: () => getConservationMeasures(species?.id || ''),
enabled: !!species?.id,
});
const { data: extendedInfoData } = useQuery({
queryKey: ['speciesExtendedInfo', species?.id],
queryFn: () => getSpeciesExtendedInfo(species?.id || ''),
enabled: !!species?.id,
});
const { data: timelineEventsData } = useQuery({
queryKey: ['speciesTimelineEvents', species?.id],
queryFn: () => getTimelineEvents(species?.id || ''),
enabled: !!species?.id,
});
// Handle header stickiness
useEffect(() => {
const handleScroll = () => {
if (headerRef.current) {
setIsHeaderSticky(window.scrollY > headerRef.current.offsetHeight / 2);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
if (!speciesId) {
return null;
}
if (isLoading) {
return (
<div className="flex h-64 w-full items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
if (error || !species) {
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-destructive">Error Loading Species</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">There was an error loading the species information. Please try again.</p>
<Button onClick={onBack} variant="outline" size="sm">
<ChevronLeft className="mr-2 h-4 w-4" />
Back to Search
</Button>
</CardContent>
</Card>
);
}
const getStatusColor = (status: string) => {
switch (status) {
case 'CR': return 'bg-red-600 text-white';
case 'EN': return 'bg-orange-600 text-white';
case 'VU': return 'bg-yellow-600 text-black';
case 'NT': return 'bg-blue-600 text-white';
case 'LC': return 'bg-green-600 text-white';
default: return 'bg-gray-600 text-white';
}
};
const getAppendixColor = (appendix: string) => {
switch (appendix) {
case 'I': return 'bg-red-600 text-white';
case 'II': return 'bg-blue-600 text-white';
case 'III': return 'bg-yellow-600 text-black';
default: return 'bg-gray-600 text-white';
}
};
return (
<div className="w-full">
{/* Hero Header Section - Background image */}
<div
ref={headerRef}
className="relative py-12"
style={{
backgroundImage: "url('/assets/images/heroarcit.png')",
backgroundSize: "cover",
backgroundPosition: "center"
}}
>
{/* Dark overlay for better text readability */}
<div className="absolute inset-0 bg-black bg-opacity-50"></div>
<div className="container relative mx-auto px-4 z-10">
<Button
onClick={onBack}
variant="outline"
size="sm"
className="bg-white/10 text-white hover:bg-white/20 backdrop-blur-sm border-white/30"
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to Species List
</Button>
<div className="mt-8 text-center text-white">
<h1 className="text-3xl md:text-5xl font-bold italic mb-3">{species.scientific_name}</h1>
<p className="text-xl md:text-2xl text-blue-100">
{species.common_name || species.primary_common_name}
</p>
<div className="mt-6 flex justify-center gap-3">
{species.latest_assessment?.status && (
<Badge className={`text-sm px-4 py-1.5 rounded-full ${getStatusColor(species.latest_assessment.status)}`}>
IUCN: {species.latest_assessment.status}
</Badge>
)}
{species.current_cites_listing && (
<Badge className={`text-sm px-4 py-1.5 rounded-full ${getAppendixColor(species.current_cites_listing.appendix)}`}>
CITES: Appendix {species.current_cites_listing.appendix}
</Badge>
)}
</div>
</div>
</div>
</div>
{/* Sticky Navigation Header */}
<div
className={`sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 py-2 ${
isHeaderSticky ? 'shadow-md animate-in slide-in-from-top-2 duration-300' : 'opacity-0 pointer-events-none h-0 py-0 overflow-hidden'
}`}
>
<div className="container mx-auto px-4 flex items-center justify-between">
<div className="flex items-center">
<Button
onClick={onBack}
variant="ghost"
size="sm"
className="mr-4"
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back
</Button>
<div>
<h2 className="font-medium italic text-lg">{species.scientific_name}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">{species.common_name || species.primary_common_name}</p>
</div>
</div>
<div className="flex items-center gap-2">
{species.latest_assessment?.status && (
<Badge
className={`mt-1 ${species.latest_assessment?.status ? IUCN_STATUS_COLORS[species.latest_assessment.status] || 'bg-gray-500' : 'bg-gray-500'}`}
variant="default"
>
{species.latest_assessment?.status ? IUCN_STATUS_FULL_NAMES[species.latest_assessment.status] || species.latest_assessment.status : 'Not Assessed'}
</Badge>
)}
<Button variant="outline" size="sm" className="ml-2">
<Share2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Main Content Area */}
<div className="container mx-auto px-4">
{/* Tabs with negative margin to overlap with hero image */}
<div className="mt-[-30px] mb-6 relative z-20">
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3 md:grid-cols-5 lg:grid-cols-6 rounded-lg bg-white dark:bg-gray-800 shadow-md">
<TabsTrigger value="overview" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">Overview</TabsTrigger>
<TabsTrigger value="conservation" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">Conservation</TabsTrigger>
<TabsTrigger value="cites" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">
CITES {species.cites_listings?.length > 0 && <span className="ml-1 text-xs bg-blue-600 text-white px-1 rounded-full">{species.cites_listings.length}</span>}
</TabsTrigger>
<TabsTrigger value="iucn" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">IUCN</TabsTrigger>
<TabsTrigger value="trade" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">Trade Data</TabsTrigger>
<TabsTrigger value="timeline" className="rounded-md py-2 font-medium data-[state=active]:bg-blue-50 data-[state=active]:text-blue-800 dark:data-[state=active]:bg-blue-900/20 dark:data-[state=active]:text-blue-100">Timeline</TabsTrigger>
</TabsList>
<div className="my-4">
<div className="text-xs text-gray-500 mb-4">
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 &lt;www.iucnredlist.org&gt;,
Species+/CITES Checklist API (https://api.speciesplus.net/), and CITES Trade Database extracted March 2025 (https://trade.cites.org/).
</div>
<div className="flex justify-end mb-4">
<Button variant="outline" size="sm" className="flex items-center gap-1">
<FileSpreadsheet className="h-4 w-4" />
Export PDF Report
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left column - Image and quick info */}
<div className="md:col-span-1">
{/* Species Image */}
{imageData?.url ? (
<div className="relative aspect-square rounded-lg overflow-hidden mb-4">
<img
src={imageData.url}
alt={species.scientific_name}
className="object-cover w-full h-full"
/>
<div className="absolute bottom-0 right-0 bg-black/60 px-2 py-1 text-[10px] text-white max-w-full truncate">
{imageData.attribution || "© Photo Attribution"}
</div>
<Button size="sm" variant="outline" className="absolute top-2 right-2 bg-white/80 h-8 w-8 p-0">
<Camera className="h-4 w-4" />
</Button>
</div>
) : (
<div className="bg-gray-100 dark:bg-gray-800 aspect-square flex items-center justify-center rounded-lg mb-4">
<div className="text-center p-6">
<Image className="h-12 w-12 mx-auto text-gray-400 dark:text-gray-600" />
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">No image available</p>
</div>
</div>
)}
{/* Taxonomy Card */}
<Card className="mb-4">
<CardHeader className="pb-2">
<CardTitle className="text-lg">Taxonomy</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<dl className="grid grid-cols-2 gap-1 text-sm">
<dt className="text-gray-500">Kingdom:</dt>
<dd>{species.kingdom || "Not available"}</dd>
<dt className="text-gray-500">Phylum:</dt>
<dd>{species.phylum || "Not available"}</dd>
<dt className="text-gray-500">Class:</dt>
<dd>{species.class || "Not available"}</dd>
<dt className="text-gray-500">Order:</dt>
<dd>{species.order_name || "Not available"}</dd>
<dt className="text-gray-500">Family:</dt>
<dd>{species.family || "Not available"}</dd>
<dt className="text-gray-500">Genus:</dt>
<dd>{species.genus || "Not available"}</dd>
</dl>
</CardContent>
</Card>
{/* Conservation Status Card */}
<Card className="mb-4">
<CardHeader className="pb-2">
<CardTitle className="text-lg">Conservation Status</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-3">
{species.latest_assessment && (
<div>
<h4 className="text-sm font-medium text-gray-500">IUCN Red List:</h4>
<Badge
className={`mt-1 ${species.latest_assessment?.status ? IUCN_STATUS_COLORS[species.latest_assessment.status] || 'bg-gray-500' : 'bg-gray-500'}`}
variant="default"
>
{species.latest_assessment?.status ? IUCN_STATUS_FULL_NAMES[species.latest_assessment.status] || species.latest_assessment.status : 'Not Assessed'}
</Badge>
<p className="text-xs text-gray-500 mt-1">Assessment Year: {species.latest_assessment.year_published}</p>
</div>
)}
{species.current_cites_listing && (
<div>
<h4 className="text-sm font-medium text-gray-500">CITES Listing:</h4>
<Badge className={`mt-1 ${species.current_cites_listing?.appendix ? CITES_APPENDIX_COLORS[species.current_cites_listing.appendix] : 'bg-gray-500'}`}>
Appendix {species.current_cites_listing?.appendix || 'N/A'}
</Badge>
<p className="text-xs text-gray-500 mt-1">Listed since: {new Date(species.current_cites_listing.listing_date).toLocaleDateString()}</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* External Resources */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">External Resources</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
<a
href={`https://www.iucnredlist.org/species/${species.sis_id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<Globe className="mr-2 h-4 w-4" />
IUCN Red List
<ExternalLink className="ml-1 h-3 w-3" />
</a>
{species.current_cites_listing && (
<a
href={`https://checklist.cites.org/#/en/search/scientific_name=${encodeURIComponent(species.scientific_name)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<FileText className="mr-2 h-4 w-4" />
CITES Checklist
<ExternalLink className="ml-1 h-3 w-3" />
</a>
)}
<a
href={`https://www.gbif.org/species/search?q=${encodeURIComponent(species.scientific_name)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
<Map className="mr-2 h-4 w-4" />
GBIF Species
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</div>
</CardContent>
</Card>
</div>
{/* Right column - Tab content */}
<div className="md:col-span-2">
{/* Tab Contents */}
{/* Overview Tab (Combined Overview, Distribution, Threats) */}
<TabsContent value="overview" className="mt-0 space-y-6">
{/* Species Description Section */}
<Card>
<CardHeader>
<CardTitle>Species Overview</CardTitle>
<CardDescription>Basic information and description</CardDescription>
</CardHeader>
<CardContent>
<div className="prose dark:prose-invert max-w-none">
<p>
The {species.common_name || species.scientific_name} ({species.scientific_name}) is a species of
{species.class ? ` ${species.class.toLowerCase()}` : ' animal'} in the family
{species.family ? ` ${species.family}` : ''}.
</p>
<p>
This species is currently assessed as <strong>{species.latest_assessment?.status || 'Least Concern (LC)'}</strong> on
the IUCN Red List and is listed in <strong>CITES Appendix {species.current_cites_listing?.appendix || 'I'}</strong>.
</p>
{/* Extended description from the species data */}
<div className="mt-4">
<h3 className="text-xl font-medium mb-2">Description</h3>
{extendedInfoData?.description ? (
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.description}</p>
) : (
<p className="text-gray-700 dark:text-gray-300">
Extended species description will be added soon. The Arctic Species Tracker is being updated to include comprehensive species descriptions.
</p>
)}
</div>
<div className="mt-4">
<h3 className="text-xl font-medium mb-2">Habitat</h3>
{extendedInfoData?.habitat_description ? (
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.habitat_description}</p>
) : (
<p className="text-gray-700 dark:text-gray-300">
Habitat information will be added soon, detailing the natural environment where this species lives.
</p>
)}
</div>
<div className="mt-4">
<h3 className="text-xl font-medium mb-2">Population</h3>
{extendedInfoData?.population_size ? (
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.population_size}</p>
) : (
<p className="text-gray-700 dark:text-gray-300">
Population information including trends and estimates will be added soon.
</p>
)}
{extendedInfoData?.population_trend && (
<p className="text-gray-700 dark:text-gray-300 mt-2">
<strong>Population Trend:</strong> {extendedInfoData.population_trend}
</p>
)}
{extendedInfoData?.generation_length && (
<p className="text-gray-700 dark:text-gray-300 mt-2">
<strong>Generation Length:</strong> {extendedInfoData.generation_length} years
</p>
)}
</div>
{extendedInfoData?.movement_patterns && (
<div className="mt-4">
<h3 className="text-xl font-medium mb-2">Movement Patterns</h3>
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.movement_patterns}</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Distribution Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Map className="mr-2 h-5 w-5" />
Range & Distribution
</CardTitle>
<CardDescription>Geographic range and distribution patterns</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="bg-gray-50 dark:bg-gray-800/50 border border-dashed border-gray-300 dark:border-gray-700 rounded-md h-56 flex items-center justify-center">
<div className="text-center">
<Map className="h-8 w-8 mx-auto text-gray-400" />
<p className="mt-2 text-sm text-gray-500">Interactive distribution map coming soon</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse table-auto">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800 text-left">
<th className="p-2 border border-gray-200 dark:border-gray-700">Region</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Presence</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Origin</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Seasonality</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Notes</th>
</tr>
</thead>
<tbody>
{!distributionData || distributionData.length === 0 ? (
<tr>
<td className="p-2 border border-gray-200 dark:border-gray-700 text-center" colSpan={5}>
No distribution data available for this species.
</td>
</tr>
) : (
distributionData.map((range) => (
<tr key={range.id} className="border-b">
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.region}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.presence_code}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.origin_code}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.seasonal_code || '-'}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{range.notes || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
{/* Threats Section */}
<Card>
<CardHeader>
<CardTitle>Threats</CardTitle>
<CardDescription>Known threats affecting this species</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="prose dark:prose-invert max-w-none">
{extendedInfoData?.threats_overview ? (
<>
<h3>Overview</h3>
<p>{extendedInfoData.threats_overview}</p>
</>
) : (
<p>This section will display information about threats to this species, including human activities, climate change impacts, habitat loss, and other factors affecting its survival.</p>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse table-auto">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800 text-left">
<th className="p-2 border border-gray-200 dark:border-gray-700">Threat Type</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Severity</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Scope</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Timing</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Description</th>
</tr>
</thead>
<tbody>
{!threatsData || threatsData.length === 0 ? (
<tr>
<td className="p-2 border border-gray-200 dark:border-gray-700 text-center" colSpan={5}>
No threat data available for this species.
</td>
</tr>
) : (
threatsData.map((threat) => (
<tr key={threat.id} className="border-b">
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.threat_type}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.severity || '-'}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.scope || '-'}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.timing || '-'}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{threat.description || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Conservation Tab */}
<TabsContent value="conservation" className="mt-0">
<Card>
<CardHeader>
<CardTitle>Conservation Measures</CardTitle>
<CardDescription>Actions taken to protect this species</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="prose dark:prose-invert max-w-none">
{extendedInfoData?.conservation_overview ? (
<>
<h3>Overview</h3>
<p>{extendedInfoData.conservation_overview}</p>
</>
) : (
<p>This section will display information about conservation measures in place for this species, including protected areas, breeding programs, legal protections, and other efforts to ensure its survival.</p>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse table-auto">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800 text-left">
<th className="p-2 border border-gray-200 dark:border-gray-700">Measure Type</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Status</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Effectiveness</th>
<th className="p-2 border border-gray-200 dark:border-gray-700">Description</th>
</tr>
</thead>
<tbody>
{!conservationMeasuresData || conservationMeasuresData.length === 0 ? (
<tr>
<td className="p-2 border border-gray-200 dark:border-gray-700 text-center" colSpan={4}>
No conservation measure data available for this species.
</td>
</tr>
) : (
conservationMeasuresData.map((measure) => (
<tr key={measure.id} className="border-b">
<td className="p-2 border border-gray-200 dark:border-gray-700">{measure.measure_type}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{measure.status || '-'}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{measure.effectiveness || '-'}</td>
<td className="p-2 border border-gray-200 dark:border-gray-700">{measure.description || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Use and Trade Information */}
<div className="mt-6">
<h3 className="text-lg font-medium mb-2">Use and Trade</h3>
<div className="bg-gray-50 dark:bg-gray-800/50 border border-dashed p-4 rounded-md">
{extendedInfoData?.use_and_trade ? (
<p className="text-gray-700 dark:text-gray-300">{extendedInfoData.use_and_trade}</p>
) : (
<p className="text-gray-700 dark:text-gray-300">
Information about how this species is used and traded will be displayed here. This will include details about commercial exploitation, traditional uses, and regulation of trade.
</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* CITES Tab */}
<TabsContent value="cites" className="mt-0">
<Card>
<CardHeader>
<CardTitle>CITES Listings</CardTitle>
<CardDescription>Convention on International Trade in Endangered Species listings</CardDescription>
</CardHeader>
<CardContent className="p-0">
<CitesTab species={species} />
</CardContent>
</Card>
</TabsContent>
{/* IUCN Tab */}
<TabsContent value="iucn" className="mt-0">
<Card>
<CardHeader>
<CardTitle>IUCN Assessment</CardTitle>
<CardDescription>International Union for Conservation of Nature assessments</CardDescription>
</CardHeader>
<CardContent className="p-0">
<IucnTab species={species} />
</CardContent>
</Card>
</TabsContent>
{/* Trade Data Tab */}
<TabsContent value="trade" className="mt-0">
<Card>
<CardHeader>
<CardTitle>CITES Trade Data</CardTitle>
<CardDescription>International trade records reported to CITES</CardDescription>
</CardHeader>
<CardContent className="p-0">
<TradeDataTab species={species} />
</CardContent>
</Card>
</TabsContent>
{/* Timeline Tab */}
<TabsContent value="timeline" className="mt-0">
<Card>
<CardHeader>
<CardTitle>Conservation Timeline</CardTitle>
<CardDescription>Historical conservation and trade events</CardDescription>
</CardHeader>
<CardContent className="p-0">
<TimelineTab species={species} />
</CardContent>
</Card>
</TabsContent>
</div>
</div>
</Tabs>
</div>
</div>
</div>
);
}

View File

@ -2,94 +2,143 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useQuery } from '@tanstack/react-query';
import { getAllSpecies } from '@/lib/api';
import { Search, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { getAllSpecies, type Species, CommonName } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { IUCN_STATUS_COLORS } from '@/lib/utils';
import { Link } from 'react-router-dom';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
export function SearchForm({ onSelectSpecies }: { onSelectSpecies: (id: string) => void }) {
// Filter categories
const FILTER_CATEGORIES = {
IUCN: ['CR', 'EN', 'VU', 'NT', 'LC', 'DD', 'NE'],
CITES: ['I', 'II', 'III']
} as const;
interface SearchFormProps {
onSpeciesSelect: (species: Species) => void;
}
export function SearchForm({ onSpeciesSelect }: SearchFormProps) {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState('');
// Debounce search term
const handleSearch = (value: string) => {
setSearchTerm(value);
if (value.length >= 3) {
const timeoutId = setTimeout(() => {
setDebouncedTerm(value);
}, 500);
return () => clearTimeout(timeoutId);
} else if (value.length === 0) {
setDebouncedTerm('');
}
};
// Fetch search results
const { data: searchResults, isLoading } = useQuery({
queryKey: ['searchSpecies', debouncedTerm],
queryFn: () => getAllSpecies(),
enabled: debouncedTerm.length >= 3,
const [selectedFilters, setSelectedFilters] = useState<{
IUCN: string[];
CITES: string[];
}>({
IUCN: [],
CITES: []
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (searchTerm.length >= 3) {
setDebouncedTerm(searchTerm);
}
const { data: species, isLoading } = useQuery({
queryKey: ['species'],
queryFn: getAllSpecies
});
const toggleFilter = (category: keyof typeof FILTER_CATEGORIES, value: string) => {
setSelectedFilters(prev => ({
...prev,
[category]: prev[category].includes(value)
? prev[category].filter(v => v !== value)
: [...prev[category], value]
}));
};
// Filter results based on search term
const filteredResults = searchResults?.filter(species =>
species.scientific_name.toLowerCase().includes(debouncedTerm.toLowerCase()) ||
species.common_name?.toLowerCase().includes(debouncedTerm.toLowerCase())
);
const filteredSpecies = species?.filter(s => {
const matchesSearch = s.scientific_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.common_names?.some((commonName: CommonName) => commonName.name.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesIUCN = selectedFilters.IUCN.length === 0 ||
selectedFilters.IUCN.includes(s.latest_assessment?.status || 'NE');
const matchesCITES = selectedFilters.CITES.length === 0 ||
selectedFilters.CITES.includes(s.current_cites_listing?.appendix || 'NL');
return matchesSearch && matchesIUCN && matchesCITES;
});
return (
<div className="w-full max-w-2xl">
<form onSubmit={handleSubmit} className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type="text"
placeholder="Search for a species (scientific or common name)..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="pr-10"
/>
{isLoading ? (
<Loader2 className="absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 animate-spin text-muted-foreground" />
) : (
<Search className="absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
)}
</div>
<Button type="submit" disabled={searchTerm.length < 3}>
Search
</Button>
</div>
</form>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="search">Search Species</Label>
<Input
id="search"
placeholder="Search by scientific or common name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Search Results */}
{filteredResults && filteredResults.length > 0 && (
<Card className="mt-2">
<CardContent className="p-2">
<ul className="divide-y divide-border">
{filteredResults.map((species) => (
<li
key={species.id}
className="cursor-pointer p-2 hover:bg-muted"
onClick={() => onSelectSpecies(species.id)}
>
<div className="font-medium italic">{species.scientific_name}</div>
<div className="text-sm text-muted-foreground">{species.common_name}</div>
</li>
))}
</ul>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>IUCN Status</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{FILTER_CATEGORIES.IUCN.map(status => (
<Badge
key={status}
variant="outline"
className={`cursor-pointer ${selectedFilters.IUCN.includes(status) ? IUCN_STATUS_COLORS[status as keyof typeof IUCN_STATUS_COLORS] : ''}`}
onClick={() => toggleFilter('IUCN', status)}
>
{status}
</Badge>
))}
</CardContent>
</Card>
)}
{filteredResults && filteredResults.length === 0 && debouncedTerm.length >= 3 && (
<p className="mt-2 text-sm text-muted-foreground">No species found matching your search.</p>
)}
<Card>
<CardHeader>
<CardTitle>CITES Appendix</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{FILTER_CATEGORIES.CITES.map(appendix => (
<Badge
key={appendix}
variant="outline"
className={`cursor-pointer ${selectedFilters.CITES.includes(appendix) ? 'bg-primary text-primary-foreground' : ''}`}
onClick={() => toggleFilter('CITES', appendix)}
>
{appendix}
</Badge>
))}
</CardContent>
</Card>
</div>
<ScrollArea className="h-[400px] rounded-md border">
{isLoading ? (
<div className="p-4 text-center">Loading species...</div>
) : filteredSpecies?.length === 0 ? (
<div className="p-4 text-center">No species found</div>
) : (
<div className="space-y-2 p-4">
{filteredSpecies?.map(species => (
<Button
key={species.id}
variant="ghost"
className="w-full justify-start"
asChild
>
<Link
to={`/species/${species.id}`}
onClick={() => onSpeciesSelect(species)}
>
<div className="flex flex-col items-start">
<span className="font-medium">{species.scientific_name}</span>
{species.common_names && species.common_names.length > 0 && (
<span className="text-sm text-muted-foreground">
{species.common_names[0]?.name}
</span>
)}
</div>
</Link>
</Button>
))}
</div>
)}
</ScrollArea>
</div>
);
}
}

View File

@ -3,10 +3,25 @@ import { Button } from '@/components/ui/button';
import { FileText, Loader2 } from 'lucide-react';
import { SpeciesDetails } from '@/lib/api';
import jsPDF from 'jspdf';
import { formatDate } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { getCitesTradeRecords } from '@/lib/api';
// Simple date formatter (Add directly into this file)
function formatDate(dateString: string | null | undefined): string {
if (!dateString) return '-';
try {
// Assuming dateString is in YYYY-MM-DD format
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
console.error("Error formatting date:", dateString, e);
return dateString; // Return original string on error
}
}
interface SpeciesReportProps {
species: SpeciesDetails;
imageUrl?: string | null;
@ -33,7 +48,7 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
let currentY = margin;
// Helper function to check if we need a new page
const checkPageBreak = (neededSpace: number) => {
const checkPageBreak = (neededSpace: number): boolean => {
if (currentY + neededSpace > pageHeight - margin) {
doc.addPage();
currentY = margin;
@ -43,7 +58,7 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
};
// Helper function for wrapped text
const addWrappedText = (text: string, x: number, fontSize: number, maxWidth: number) => {
const addWrappedText = (text: string, x: number, fontSize: number, maxWidth: number): void => {
doc.setFontSize(fontSize);
const lines = doc.splitTextToSize(text, maxWidth);
lines.forEach((line: string) => {
@ -69,10 +84,10 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
doc.setFont('helvetica', 'normal');
// Add common name if available
if (species.common_name) {
if (species.common_names?.[0]?.name) {
checkPageBreak(10);
doc.setFontSize(12);
const wrappedCommonName = doc.splitTextToSize(species.common_name, pageWidth - 2 * margin);
const wrappedCommonName = doc.splitTextToSize(species.common_names[0].name, pageWidth - 2 * margin);
wrappedCommonName.forEach((line: string) => {
doc.text(line, pageWidth / 2, currentY, { align: 'center' });
currentY += 5;
@ -85,8 +100,8 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
try {
const img = new Image();
img.crossOrigin = 'Anonymous';
await new Promise((resolve, reject) => {
img.onload = resolve;
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = imageUrl;
});
@ -129,7 +144,7 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
['Family', species.family],
['Genus', species.genus],
['Species', species.species_name],
];
] as const;
taxonomicInfo.forEach(([label, value]) => {
if (value) {
@ -315,4 +330,4 @@ export function SpeciesReport({ species, imageUrl }: SpeciesReportProps) {
)}
</Button>
);
}
}

View File

@ -1,81 +1,343 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { SpeciesDetails } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { getSpeciesImages } from "@/lib/api";
import { getSpeciesImages, getSpeciesDistribution, getConservationMeasures, getSpeciesExtendedInfo } from "@/lib/api";
import { SpeciesReport } from './species-report';
import { OverviewTab } from "./species/tabs/OverviewTab";
import { CitesTab } from "./species/tabs/CitesTab";
import { IucnTab } from "./species/tabs/IucnTab";
import { TradeDataTab } from "./species/tabs/TradeDataTab";
import { TimelineTab } from "./species/tabs/TimelineTab";
import { SpeciesImage } from "./species/SpeciesImage";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CitesListingsTab } from './species/tabs/CitesListingsTab';
type SpeciesTabsProps = {
interface ExternalLinkIconProps extends React.SVGProps<SVGSVGElement> {
className?: string;
}
const ExternalLinkIcon: React.FC<ExternalLinkIconProps> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" x2="21" y1="14" y2="3" />
</svg>
);
interface SpeciesTabsProps {
species: SpeciesDetails;
};
}
export function SpeciesTabs({ species }: SpeciesTabsProps) {
// Add image fetching query
const { data: imageData } = useQuery({
queryKey: ["speciesImage", species.scientific_name],
queryFn: () => getSpeciesImages(species.scientific_name),
enabled: !!species.scientific_name,
});
// Determine default tab based on available data
const defaultTab = "overview"; // Always start with Overview tab
const { data: distributionData } = useQuery({
queryKey: ["speciesDistribution", species.id],
queryFn: () => getSpeciesDistribution(species.id),
enabled: !!species.id,
});
const { data: conservationData } = useQuery({
queryKey: ["conservationMeasures", species.id],
queryFn: () => getConservationMeasures(species.id),
enabled: !!species.id,
});
const { data: extendedInfo } = useQuery({
queryKey: ["speciesExtendedInfo", species.id],
queryFn: () => getSpeciesExtendedInfo(species.id),
enabled: !!species.id,
});
const defaultTab = "overview";
return (
<div className="w-full flex flex-col">
<div className="flex justify-between items-center mb-4">
<div className="text-xs text-muted-foreground">
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 &lt;www.iucnredlist.org&gt;,
Data sources: IUCN 2024. IUCN Red List of Threatened Species. Version 2024-2 www.iucnredlist.org
Species+/CITES Checklist API (https://api.speciesplus.net/), and CITES Trade Database extracted March 2025 (https://trade.cites.org/).
</div>
<div className="w-48">
<div className="w-48 flex-shrink-0">
<SpeciesReport species={species} imageUrl={imageData?.url} />
</div>
</div>
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="w-full">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="cites" className={species.cites_listings && species.cites_listings.length > 0 ? "font-semibold" : ""}>
CITES
{species.cites_listings && species.cites_listings.length > 0 && (
<span className="ml-1 rounded-full bg-primary text-[10px] text-primary-foreground px-1.5">{species.cites_listings.length}</span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-6">
<div className="md:col-span-1">
<SpeciesImage scientificName={species.scientific_name} imageData={imageData} />
</div>
<div className="md:col-span-2 space-y-4">
<h2 className="text-2xl font-bold italic">{species.scientific_name}</h2>
<p className="text-xl text-muted-foreground">{species.common_names?.[0]?.name || 'No common name available'}</p>
<div className="flex flex-wrap gap-2">
{species.latest_assessment && (
<div className="border rounded-md p-2 text-sm">
<div className="text-xs text-muted-foreground mb-1">IUCN Red List Status</div>
<div className="font-medium">{species.latest_assessment.status || "Not Assessed"}</div>
<div>Year: {species.latest_assessment.year_published}</div>
</div>
)}
</TabsTrigger>
<TabsTrigger value="iucn">IUCN</TabsTrigger>
<TabsTrigger value="trade">Trade Data</TabsTrigger>
<TabsTrigger value="timeline">Timeline</TabsTrigger>
{species.current_cites_listing && (
<div className="border rounded-md p-2 text-sm">
<div className="text-xs text-muted-foreground mb-1">CITES Listing</div>
<div className="font-medium">Appendix {species.current_cites_listing.appendix}</div>
<div>Since: {new Date(species.current_cites_listing.listing_date).toLocaleDateString()}</div>
</div>
)}
</div>
<div className="pt-2">
<h3 className="text-lg font-semibold mb-1">General Information</h3>
<p className="text-base text-muted-foreground">
{extendedInfo?.description
? extendedInfo.description.substring(0, 250) + (extendedInfo.description.length > 250 ? '...' : '')
: "General information about the species, including habitat, distribution range, physical characteristics, and other relevant details will appear here."}
</p>
</div>
<div className="pt-2">
<h3 className="text-lg font-semibold mb-1">External Resources</h3>
<ul className="list-none space-y-1 text-base">
<li><a href="#" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center"><ExternalLinkIcon className="mr-2 h-4 w-4 flex-shrink-0" />IUCN Red List</a></li>
<li><a href="#" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center"><ExternalLinkIcon className="mr-2 h-4 w-4 flex-shrink-0" />CITES Checklist</a></li>
<li><a href="#" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center"><ExternalLinkIcon className="mr-2 h-4 w-4 flex-shrink-0" />GBIF Species</a></li>
</ul>
</div>
<div className="pt-2">
<h3 className="text-lg font-semibold mb-1">Taxonomy</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-base">
<div className="font-medium">Class:</div> <div>{species.class}</div>
<div className="font-medium">Family:</div> <div>{species.family}</div>
</div>
</div>
</div>
</div>
<Tabs defaultValue={defaultTab} className="w-full mt-8 species-detail-tabs">
<TabsList className="w-full mb-4 grid grid-cols-3 sm:grid-cols-5">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="cites">CITES</TabsTrigger>
<TabsTrigger value="cms">CMS</TabsTrigger>
<TabsTrigger value="conservation">Conservation</TabsTrigger>
<TabsTrigger value="trade">Trade</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<OverviewTab species={species} imageData={imageData} />
<TabsContent value="overview" className="pt-6">
<div className="space-y-6 text-base">
<Card>
<CardHeader><CardTitle>Species Description</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">{extendedInfo?.description || "No detailed description available for this species."}</p>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Habitat</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">{extendedInfo?.habitat_description || "No habitat information available for this species."}</p>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Threats Overview</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">{extendedInfo?.threats_overview || "No threats overview available."}</p>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Population & Distribution</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-semibold mb-1">Population Trend</h4>
<p className="text-muted-foreground">{extendedInfo?.population_trend || "No population trend data available."}</p>
</div>
<div>
<h4 className="font-semibold mb-1">Population Size</h4>
<p className="text-muted-foreground">{extendedInfo?.population_size || "No population size data available."}</p>
</div>
<div>
<h4 className="font-semibold mb-1">Distribution Range</h4>
{distributionData && distributionData.length > 0 ? (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-1 px-2 font-medium">Region</th>
<th className="text-left py-1 px-2 font-medium">Presence</th>
<th className="text-left py-1 px-2 font-medium">Origin</th>
<th className="text-left py-1 px-2 font-medium">Seasonality</th>
</tr>
</thead>
<tbody>
{distributionData.map((range) => (
<tr key={range.id} className="border-b">
<td className="py-1 px-2">{range.region}</td>
<td className="py-1 px-2">{range.presence_code}</td>
<td className="py-1 px-2">{range.origin_code}</td>
<td className="py-1 px-2">{range.seasonal_code || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-muted-foreground">No distribution data available.</p>
)}
<div className="bg-muted rounded-md p-4 text-sm mt-4">
<p className="text-muted-foreground text-center">Distribution map visualization coming soon.</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>IUCN Assessment</CardTitle></CardHeader>
<CardContent>
<IucnTab species={species} extendedInfo={extendedInfo} />
</CardContent>
</Card>
{extendedInfo?.movement_patterns && (
<Card>
<CardHeader><CardTitle>Movement Patterns</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">{extendedInfo.movement_patterns}</p>
</CardContent>
</Card>
)}
</div>
</TabsContent>
<TabsContent value="cites" className="pt-6">
<div className="space-y-6 text-base">
<Card>
<CardHeader><CardTitle>CITES Listings</CardTitle></CardHeader>
<CardContent>
<CitesListingsTab species={species} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>CITES Publications</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">Relevant CITES publications will be listed here.</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>Breaking the Ice: International trade in Narwhals... (2015)</li>
<li>Shifting priorities for Narwhal conservation... (2020)</li>
<li>(More publications links/info needed)</li>
</ul>
</CardContent>
</Card>
</div>
</TabsContent>
{/* CITES Tab */}
<TabsContent value="cites">
<CitesTab species={species} />
<TabsContent value="cms" className="pt-6">
<div className="space-y-6 text-base">
<Card>
<CardHeader><CardTitle>CMS Agreements & MOUs</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">Information on relevant CMS Agreements and Memoranda of Understanding will be displayed here. (Requires data source: Species+?)</p>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>CMS Publications</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">Relevant CMS publications will be listed here. (Requires data source)</p>
</CardContent>
</Card>
</div>
</TabsContent>
{/* IUCN Tab */}
<TabsContent value="iucn">
<IucnTab species={species} />
<TabsContent value="conservation" className="pt-6">
<div className="space-y-6 text-base">
<Card>
<CardHeader><CardTitle>Conservation Measures</CardTitle></CardHeader>
<CardContent>
{extendedInfo?.conservation_overview && (
<p className="text-muted-foreground mb-4">{extendedInfo.conservation_overview}</p>
)}
{conservationData && conservationData.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-1 px-2 font-medium">Measure</th>
<th className="text-left py-1 px-2 font-medium">Status</th>
<th className="text-left py-1 px-2 font-medium">Effectiveness</th>
<th className="text-left py-1 px-2 font-medium">Description</th>
</tr>
</thead>
<tbody>
{conservationData.map((measure) => (
<tr key={measure.id} className="border-b">
<td className="py-1 px-2">{measure.measure_type}</td>
<td className="py-1 px-2">{measure.status || '-'}</td>
<td className="py-1 px-2">{measure.effectiveness || '-'}</td>
<td className="py-1 px-2">{measure.description || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-muted-foreground">No specific conservation measures data available.</p>
)}
</CardContent>
</Card>
{extendedInfo?.use_and_trade && (
<Card>
<CardHeader><CardTitle>Use and Trade</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">{extendedInfo.use_and_trade}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader><CardTitle>Conservation Frameworks</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">Conservation frameworks which address this species (e.g., CBMP, NAMMCO) will be detailed here. (Requires data source)</p>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Relevant Reports</CardTitle></CardHeader>
<CardContent>
<p className="text-muted-foreground">Relevant reports (e.g., NAMMCO Publications, CBMP Assessments) will be listed here. (Requires data source)</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>NAMMCO Publications</li>
<li>CBMP Assessments</li>
<li>CBMP Marine Monitoring Plan</li>
<li>(More report links/info needed)</li>
</ul>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Trade Data Tab */}
<TabsContent value="trade">
<TradeDataTab species={species} />
</TabsContent>
{/* Timeline Tab */}
<TabsContent value="timeline">
<TimelineTab species={species} />
<TabsContent value="trade" className="pt-6">
<TradeDataTab species={species} />
</TabsContent>
</Tabs>
<div className="w-full text-center mt-8 py-4 text-sm text-muted-foreground border-t">
<div className="w-full text-center mt-8 py-4 text-base text-muted-foreground border-t">
&copy; Magnus Smari Smarason
</div>
</div>

View File

@ -1,80 +1,89 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ImageIcon, X } from "lucide-react";
// Import Dialog components and Expand icon
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose } from "@/components/ui/dialog";
import { ImageIcon, X, Expand } from "lucide-react";
type SpeciesImageProps = {
interface SpeciesImageProps {
scientificName: string;
imageData?: {
url: string;
attribution: string;
license?: string;
} | null;
};
}
export function SpeciesImage({ scientificName, imageData }: SpeciesImageProps) {
const [isImageDialogOpen, setIsImageDialogOpen] = useState(false);
return (
<>
// Wrap everything in the Dialog component
<Dialog open={isImageDialogOpen} onOpenChange={setIsImageDialogOpen}>
{/* Main container div - Relative positioning needed for the absolute button */}
<div
className="relative aspect-[16/9] w-full overflow-hidden rounded-lg bg-muted mb-4 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => imageData && setIsImageDialogOpen(true)}
className="relative aspect-[16/9] w-full h-60 overflow-hidden rounded-lg bg-muted mb-6"
>
{/* Image rendering logic */}
{imageData ? (
<>
<img
src={imageData.url}
alt={scientificName}
className="object-cover w-full h-full"
/>
<div className="absolute bottom-0 right-0 bg-black/50 p-2 text-xs text-white">
{imageData.attribution}
<>
<img
src={imageData.url}
alt={scientificName}
className="object-cover w-full h-full"
/>
{/* Attribution overlay */}
<div className="absolute bottom-0 right-0 bg-black/50 p-2 text-sm text-white pointer-events-none"> {/* Added pointer-events-none back */}
{imageData.attribution}
</div>
</>
) : (
// Placeholder content if no image data
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
<ImageIcon className="h-12 w-12" />
<span className="ml-2">No image available</span>
</div>
<div className="absolute top-2 right-2 bg-black/50 p-1 rounded-full">
<ImageIcon className="h-4 w-4 text-white" />
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
<ImageIcon className="h-12 w-12" />
<span className="ml-2">No image available</span>
</div>
)}
)}
</div>
{/* Dialog for full-size image */}
<Dialog open={isImageDialogOpen} onOpenChange={setIsImageDialogOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
{/* DialogTrigger moved AFTER image div, NOT using asChild */}
{/* Renders its own button, containing the icon */}
<DialogTrigger
disabled={!imageData}
className="mt-2 inline-flex items-center justify-center 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 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-9 px-3" // Basic button styling
>
<Expand className="h-4 w-4 mr-2" /> Show Full Picture
</DialogTrigger>
{/* Dialog content */}
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>{scientificName}</span>
<Button
variant="ghost"
size="icon"
onClick={() => setIsImageDialogOpen(false)}
>
<X className="h-4 w-4" />
</Button>
{/* Use DialogClose for the close button */}
<DialogClose asChild>
<Button variant="ghost" size="icon">
<X className="h-5 w-5" />
</Button>
</DialogClose>
</DialogTitle>
</DialogHeader>
{imageData && (
<div className="relative">
<img
src={imageData.url.replace('medium', 'original')}
// Attempt to load original, fallback to medium if replace fails (e.g., URL doesn't contain 'medium')
src={imageData.url.includes('medium') ? imageData.url.replace('medium', 'original') : imageData.url}
alt={scientificName}
className="w-full h-auto rounded-lg"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 p-4 text-white rounded-b-lg">
<p className="text-sm">{imageData.attribution}</p>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 p-4 text-white rounded-b-lg pointer-events-none"> {/* Added pointer-events-none back */}
<p className="text-base">{imageData.attribution}</p>
{imageData.license && (
<p className="text-xs mt-1">License: {imageData.license}</p>
<p className="text-sm mt-1">License: {imageData.license}</p>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,71 @@
import { SpeciesDetails, CitesListing } from "@/lib/api";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
interface CitesListingsTabProps {
species: SpeciesDetails;
}
// Helper to sort listings by date, newest first
const sortListingsDesc = (a: CitesListing, b: CitesListing) =>
new Date(b.listing_date).getTime() - new Date(a.listing_date).getTime();
// Simple date formatter (Add directly into this file)
function formatDate(dateString: string | null | undefined): string {
if (!dateString) return '-';
try {
// Assuming dateString is in YYYY-MM-DD format
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
console.error("Error formatting date:", dateString, e);
return dateString; // Return original string on error
}
}
export function CitesListingsTab({ species }: CitesListingsTabProps) {
const sortedListings = species.cites_listings?.sort(sortListingsDesc) || [];
if (!sortedListings || sortedListings.length === 0) {
return <p className="text-muted-foreground">No CITES listing information available for this species.</p>;
}
return (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Appendix</TableHead>
<TableHead>Effective Date</TableHead>
<TableHead>Status</TableHead> {/* Renamed from 'Current' */}
{/* Add other relevant columns if available and needed, e.g., Notes */}
{/* <TableHead>Notes</TableHead> */}
</TableRow>
</TableHeader>
<TableBody>
{sortedListings.map((listing, index) => (
<TableRow key={listing.id}>
<TableCell>
<Badge variant={listing.appendix === 'I' ? 'destructive' : listing.appendix === 'II' ? 'default' : 'secondary'} className="whitespace-nowrap">
Appendix {listing.appendix}
</Badge>
</TableCell>
<TableCell>{formatDate(listing.listing_date)}</TableCell>
<TableCell>
{index === 0 ? (
<Badge variant="default">Active</Badge> // Changed variant from 'success' to 'default'
) : (
<Badge variant="outline">Historical</Badge> // Older listings marked as Historical
)}
</TableCell>
{/* <TableCell>{listing.notes || '-'}</TableCell> */}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@ -1,256 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertCircle, Edit, Loader2, Plus, Save, Trash2 } from "lucide-react";
import { SpeciesDetails, CitesListing } from "@/lib/api";
import { formatDate, CITES_APPENDIX_COLORS } from "@/lib/utils";
import { useCitesListingCrud } from "@/hooks/useCitesListingCrud";
type CitesTabProps = {
species: SpeciesDetails;
};
export function CitesTab({ species }: CitesTabProps) {
const {
isEditingCitesListing,
isAddingCitesListing,
citesListingForm,
operationStatus,
handleCitesListingFormChange,
handleEditCitesListing,
handleAddCitesListing,
handleSaveCitesListing,
handleDeleteCitesListing,
handleCancelCitesListing
} = useCitesListingCrud(species.id);
return (
<Card>
<CardHeader>
<CardTitle>CITES Listings</CardTitle>
<CardDescription>Convention on International Trade in Endangered Species listings</CardDescription>
</CardHeader>
<CardContent>
{species.cites_listings.length > 0 ? (
<div className="space-y-6">
{/* Current CITES status highlight */}
{species.current_cites_listing && (
<div className="rounded-md bg-muted p-4">
<h3 className="mb-2 text-lg font-semibold">Current CITES Status</h3>
<div className="flex items-center gap-2">
<Badge className={`${CITES_APPENDIX_COLORS[species.current_cites_listing.appendix]} text-lg px-3 py-1`}>
Appendix {species.current_cites_listing.appendix}
</Badge>
<span>since {formatDate(species.current_cites_listing.listing_date)}</span>
</div>
{species.current_cites_listing.notes && (
<p className="mt-2 text-sm">{species.current_cites_listing.notes}</p>
)}
</div>
)}
{/* Operation status messages */}
{operationStatus.error && (
<div className="rounded-md bg-red-50 p-4 flex items-start mb-4">
<AlertCircle className="h-5 w-5 text-red-500 mr-2 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-red-800">Error</h4>
<p className="text-sm text-red-700 mt-1">{operationStatus.error}</p>
</div>
</div>
)}
{operationStatus.success && (
<div className="rounded-md bg-green-50 p-4 mb-4">
<p className="text-sm text-green-700">Operation completed successfully.</p>
</div>
)}
{/* CITES listing form */}
{(isAddingCitesListing || isEditingCitesListing) && (
<div className="rounded-md border p-4 mb-6">
<h3 className="text-lg font-semibold mb-4">
{isAddingCitesListing ? "Add New CITES Listing" : "Edit CITES Listing"}
</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="appendix">Appendix</Label>
<Select
value={citesListingForm.appendix}
onValueChange={(value) => handleCitesListingFormChange("appendix", value)}
>
<SelectTrigger id="appendix">
<SelectValue placeholder="Select Appendix" />
</SelectTrigger>
<SelectContent>
<SelectItem value="I">Appendix I</SelectItem>
<SelectItem value="II">Appendix II</SelectItem>
<SelectItem value="III">Appendix III</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="listing-date">Listing Date</Label>
<input
id="listing-date"
type="date"
value={citesListingForm.listing_date}
onChange={(e) => handleCitesListingFormChange("listing_date", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="notes">Notes</Label>
<textarea
id="notes"
value={citesListingForm.notes}
onChange={(e) => handleCitesListingFormChange("notes", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background min-h-[100px]"
placeholder="Optional notes about this listing"
/>
</div>
<div className="flex items-center space-x-2">
<input
id="is-current"
type="checkbox"
checked={citesListingForm.is_current}
onChange={(e) => handleCitesListingFormChange("is_current", e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<Label htmlFor="is-current" className="text-sm font-normal">
Set as current listing
</Label>
</div>
</div>
<div className="mt-6 flex justify-end space-x-2">
<Button
variant="outline"
onClick={handleCancelCitesListing}
>
Cancel
</Button>
<Button
onClick={handleSaveCitesListing}
disabled={operationStatus.loading}
>
{operationStatus.loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save
</>
)}
</Button>
</div>
</div>
)}
{/* CITES listings table */}
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Listing History ({species.cites_listings.length} listings)</h3>
<Button
onClick={handleAddCitesListing}
disabled={isAddingCitesListing || isEditingCitesListing}
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
Add Listing
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full table-auto">
<thead>
<tr className="border-b">
<th className="py-2 text-left">Appendix</th>
<th className="py-2 text-left">Listing Date</th>
<th className="py-2 text-left">Notes</th>
<th className="py-2 text-left">Current</th>
<th className="py-2 text-left">Actions</th>
</tr>
</thead>
<tbody>
{species.cites_listings.length === 0 ? (
<tr>
<td colSpan={5} className="py-4 text-center text-muted-foreground">
No listing history available
</td>
</tr>
) : (
species.cites_listings.map((listing: CitesListing) => (
<tr key={listing.id} className="border-b">
<td className="py-2">
<Badge className={CITES_APPENDIX_COLORS[listing.appendix]}>
Appendix {listing.appendix}
</Badge>
</td>
<td className="py-2">
{formatDate(listing.listing_date || '')}
</td>
<td className="py-2">{listing.notes || 'N/A'}</td>
<td className="py-2">
{listing.is_current && (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
Current
</Badge>
)}
</td>
<td className="py-2">
<div className="flex space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditCitesListing(listing)}
disabled={isAddingCitesListing || isEditingCitesListing}
title="Edit listing"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteCitesListing(listing)}
disabled={isAddingCitesListing || isEditingCitesListing || operationStatus.loading}
title="Delete listing"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* CITES info section */}
<div className="rounded-md bg-blue-50 p-4 text-sm">
<h3 className="mb-2 font-medium text-blue-700">What are CITES Appendices?</h3>
<ul className="list-inside list-disc space-y-1 text-blue-700">
<li><strong>Appendix I</strong>: Species threatened with extinction. Trade permitted only in exceptional circumstances.</li>
<li><strong>Appendix II</strong>: Species not necessarily threatened with extinction, but trade must be controlled to avoid utilization incompatible with their survival.</li>
<li><strong>Appendix III</strong>: Species protected in at least one country, which has asked other CITES Parties for assistance in controlling the trade.</li>
</ul>
</div>
</div>
) : (
<p className="text-muted-foreground">No CITES listings available for this species.</p>
)}
</CardContent>
</Card>
);
}

View File

@ -1,120 +1,88 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { SpeciesDetails } from "@/lib/api";
import { IUCN_STATUS_COLORS, IUCN_STATUS_FULL_NAMES } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { IUCN_STATUS_COLORS, IUCN_STATUS_PROGRESS } from "@/lib/utils";
type IucnTabProps = {
interface IucnTabProps {
species: SpeciesDetails;
};
extendedInfo?: {
description: string | null;
habitat_description: string | null;
population_trend: string | null;
population_size: string | null;
generation_length: number | null;
movement_patterns: string | null;
use_and_trade: string | null;
threats_overview: string | null;
conservation_overview: string | null;
};
}
export function IucnTab({ species, extendedInfo }: IucnTabProps) {
const status = species.latest_assessment?.status || 'NE';
const progressValue = IUCN_STATUS_PROGRESS[status as keyof typeof IUCN_STATUS_PROGRESS];
const statusColor = IUCN_STATUS_COLORS[status as keyof typeof IUCN_STATUS_COLORS];
export function IucnTab({ species }: IucnTabProps) {
return (
<Card>
<CardHeader>
<CardTitle>IUCN Red List Assessments</CardTitle>
<CardDescription>International Union for Conservation of Nature assessments</CardDescription>
</CardHeader>
<CardContent>
{species.iucn_assessments.length > 0 ? (
<div className="space-y-6">
{/* Current Status Highlight */}
{species.latest_assessment && (
<div className="rounded-md bg-muted p-4">
<h3 className="mb-2 text-lg font-semibold">Current Status</h3>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
{species.latest_assessment.status && (
<Badge
className={IUCN_STATUS_COLORS[species.latest_assessment.status] || 'bg-gray-500'}
variant="default"
>
{IUCN_STATUS_FULL_NAMES[species.latest_assessment.status] || species.latest_assessment.status}
</Badge>
)}
<span className="text-sm text-muted-foreground">
({species.latest_assessment.year_published})
</span>
</div>
{species.latest_assessment.possibly_extinct && (
<span className="text-sm text-red-600">
Possibly Extinct
</span>
)}
{species.latest_assessment.possibly_extinct_in_wild && (
<span className="text-sm text-orange-600">
Possibly Extinct in the Wild
</span>
)}
</div>
</div>
)}
{/* Assessment History */}
<div>
<h3 className="mb-4 text-lg font-semibold">Assessment History</h3>
<div className="space-y-4">
{species.iucn_assessments
.sort((a, b) => b.year_published - a.year_published)
.map((assessment) => (
<div key={assessment.id} className="border-b pb-4 last:border-0">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{assessment.year_published}</span>
<span></span>
{assessment.status && (
<Badge
className={IUCN_STATUS_COLORS[assessment.status] || 'bg-gray-500'}
variant="default"
>
{IUCN_STATUS_FULL_NAMES[assessment.status] || assessment.status}
</Badge>
)}
</div>
{assessment.scope_description && (
<p className="text-sm text-muted-foreground mb-2">
{assessment.scope_description}
</p>
)}
{assessment.url && (
<a
href={assessment.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
View Assessment
</a>
)}
</div>
))}
</div>
</div>
{/* IUCN Info Section */}
<div className="rounded-md bg-blue-50 p-4 text-sm">
<h3 className="mb-2 font-medium text-blue-700">About IUCN Red List Assessments</h3>
<p className="text-blue-700">
The IUCN Red List of Threatened Species is the world's most comprehensive information source on the global extinction risk status of animal, fungus and plant species.
</p>
<p className="mt-2 text-blue-700">
Status categories:
</p>
<ul className="list-inside list-disc mt-1 text-blue-700">
<li><strong>EX</strong> - Extinct</li>
<li><strong>EW</strong> - Extinct in the Wild</li>
<li><strong>CR</strong> - Critically Endangered</li>
<li><strong>EN</strong> - Endangered</li>
<li><strong>VU</strong> - Vulnerable</li>
<li><strong>NT</strong> - Near Threatened</li>
<li><strong>LC</strong> - Least Concern</li>
<li><strong>DD</strong> - Data Deficient</li>
<li><strong>NE</strong> - Not Evaluated</li>
</ul>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>IUCN Red List Status</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Badge className={`text-sm px-4 py-2 rounded-full ${statusColor}`}>
{status}
</Badge>
<span className="text-sm text-muted-foreground">
Last assessed: {species.latest_assessment?.year_published || 'Not assessed'}
</span>
</div>
<Progress value={progressValue} className="h-3" />
<p className="text-sm text-muted-foreground">
{species.latest_assessment?.scope_description || 'This species has not been assessed for the IUCN Red List'}
</p>
</div>
) : (
<p className="text-muted-foreground">No IUCN assessments available for this species.</p>
)}
</CardContent>
</Card>
</CardContent>
</Card>
{/* Population Trend */}
<Card>
<CardHeader>
<CardTitle>Population Trend</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{extendedInfo?.population_trend || 'Population trend data not available'}
</p>
</CardContent>
</Card>
{/* Habitat and Ecology */}
<Card>
<CardHeader>
<CardTitle>Habitat and Ecology</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{extendedInfo?.habitat_description || 'Habitat and ecology information not available'}
</p>
</CardContent>
</Card>
{/* Threats */}
<Card>
<CardHeader>
<CardTitle>Threats</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{extendedInfo?.threats_overview || 'Threat information not available'}
</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,11 +1,11 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
// Removed unused: import { Badge } from "@/components/ui/badge";
import { SpeciesDetails } from "@/lib/api";
import { formatDate, IUCN_STATUS_COLORS, IUCN_STATUS_FULL_NAMES, CITES_APPENDIX_COLORS } from "@/lib/utils";
import { SpeciesImage } from "../SpeciesImage";
// Removed unused: import { formatDate, IUCN_STATUS_COLORS, IUCN_STATUS_FULL_NAMES, CITES_APPENDIX_COLORS } from "@/lib/utils";
// Removed unused: import { SpeciesImage } from "../SpeciesImage";
type OverviewTabProps = {
species: SpeciesDetails;
species: SpeciesDetails; // Although unused, keep for type consistency if needed elsewhere
imageData?: {
url: string;
attribution: string;
@ -13,7 +13,15 @@ type OverviewTabProps = {
} | null;
};
export function OverviewTab({ species, imageData }: OverviewTabProps) {
/**
* LEGACY COMPONENT - This version is no longer used
* The content has been moved to the main species-tabs.tsx component
* and split across multiple tabs for better organization.
*
* This component is kept for reference.
*/
export function OverviewTab(_props: OverviewTabProps) {
// Parameters are unused as this is a legacy component
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
@ -23,34 +31,9 @@ export function OverviewTab({ species, imageData }: OverviewTabProps) {
</CardHeader>
<CardContent>
{/* Species image */}
<SpeciesImage scientificName={species.scientific_name} imageData={imageData} />
<dl className="grid grid-cols-2 gap-2">
<dt className="font-semibold">Scientific Name:</dt>
<dd className="italic">{species.scientific_name}</dd>
<dt className="font-semibold">Common Name:</dt>
<dd>{species.common_name}</dd>
<dt className="font-semibold">Kingdom:</dt>
<dd>{species.kingdom}</dd>
<dt className="font-semibold">Phylum:</dt>
<dd>{species.phylum}</dd>
<dt className="font-semibold">Class:</dt>
<dd>{species.class}</dd>
<dt className="font-semibold">Order:</dt>
<dd>{species.order_name}</dd>
<dt className="font-semibold">Family:</dt>
<dd>{species.family}</dd>
<dt className="font-semibold">Genus:</dt>
<dd>{species.genus}</dd>
<dt className="font-semibold">Species:</dt>
<dd>{species.species_name}</dd>
{species.authority && (
<>
<dt className="font-semibold">Authority:</dt>
<dd>{species.authority}</dd>
</>
)}
</dl>
<div className="text-center p-4 bg-muted rounded-md">
<p>This content has been moved to the main view above the tabs.</p>
</div>
</CardContent>
</Card>
@ -60,53 +43,9 @@ export function OverviewTab({ species, imageData }: OverviewTabProps) {
<CardDescription>Current protection and assessment</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h4 className="mb-2 text-sm font-semibold">IUCN Red List Status:</h4>
{species.latest_assessment ? (
<div className="flex flex-col gap-2">
{species.latest_assessment.status && (
<Badge
className={IUCN_STATUS_COLORS[species.latest_assessment.status] || 'bg-gray-500'}
variant="default"
>
{IUCN_STATUS_FULL_NAMES[species.latest_assessment.status] || species.latest_assessment.status}
</Badge>
)}
<span className="text-sm">
Assessment Year: {species.latest_assessment.year_published}
</span>
{species.latest_assessment.possibly_extinct && (
<span className="text-sm text-red-600">
Possibly Extinct
</span>
)}
{species.latest_assessment.possibly_extinct_in_wild && (
<span className="text-sm text-orange-600">
Possibly Extinct in the Wild
</span>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">No assessment data available</span>
)}
</div>
<div>
<h4 className="mb-2 text-sm font-semibold">CITES Listing:</h4>
{species.current_cites_listing ? (
<div className="flex flex-col gap-2">
<Badge className={CITES_APPENDIX_COLORS[species.current_cites_listing.appendix]}>
Appendix {species.current_cites_listing.appendix}
</Badge>
<span className="text-sm">
Listed since: {formatDate(species.current_cites_listing.listing_date)}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground">No CITES listing data available</span>
)}
</div>
<div className="text-center p-4 bg-muted rounded-md">
<p>This content has been moved to other tabs for better organization.</p>
<p className="text-base text-muted-foreground mt-2">Please see the Distribution, Threats, and Conservation tabs.</p>
</div>
</CardContent>
</Card>

View File

@ -1,240 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertCircle, Edit, Loader2, Plus, Save, Trash2 } from "lucide-react";
import { SpeciesDetails, TimelineEvent } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { useTimelineEventCrud } from "@/hooks/useTimelineEventCrud";
import { useQuery } from "@tanstack/react-query";
import { getTimelineEvents } from "@/lib/api";
type TimelineTabProps = {
species: SpeciesDetails;
};
export function TimelineTab({ species }: TimelineTabProps) {
const { data: timelineEvents, isLoading: timelineLoading } = useQuery({
queryKey: ["timelineEvents", species.id],
queryFn: () => getTimelineEvents(species.id),
});
const {
isEditingTimelineEvent,
isAddingTimelineEvent,
timelineEventForm,
operationStatus,
handleTimelineEventFormChange,
handleEditTimelineEvent,
handleAddTimelineEvent,
handleSaveTimelineEvent,
handleDeleteTimelineEvent,
handleCancelTimelineEvent
} = useTimelineEventCrud(species.id);
return (
<Card>
<CardHeader>
<CardTitle>Conservation Timeline</CardTitle>
<CardDescription>Historical conservation and trade events</CardDescription>
</CardHeader>
<CardContent>
{/* Operation status messages */}
{operationStatus.error && (
<div className="rounded-md bg-red-50 p-4 flex items-start mb-4">
<AlertCircle className="h-5 w-5 text-red-500 mr-2 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-red-800">Error</h4>
<p className="text-sm text-red-700 mt-1">{operationStatus.error}</p>
</div>
</div>
)}
{operationStatus.success && (
<div className="rounded-md bg-green-50 p-4 mb-4">
<p className="text-sm text-green-700">Operation completed successfully.</p>
</div>
)}
{/* Timeline event form */}
{(isAddingTimelineEvent || isEditingTimelineEvent) && (
<div className="rounded-md border p-4 mb-6">
<h3 className="text-lg font-semibold mb-4">
{isAddingTimelineEvent ? "Add New Timeline Event" : "Edit Timeline Event"}
</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="event-date">Event Date</Label>
<input
id="event-date"
type="date"
value={timelineEventForm.event_date}
onChange={(e) => {
handleTimelineEventFormChange("event_date", e.target.value);
// Also update the year based on the date
const year = new Date(e.target.value).getFullYear();
handleTimelineEventFormChange("year", year);
}}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-type">Event Type</Label>
<Select
value={timelineEventForm.event_type}
onValueChange={(value) => handleTimelineEventFormChange("event_type", value)}
>
<SelectTrigger id="event-type">
<SelectValue placeholder="Select Event Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cites_listing">CITES Listing</SelectItem>
<SelectItem value="iucn_assessment">IUCN Assessment</SelectItem>
<SelectItem value="conservation_action">Conservation Action</SelectItem>
<SelectItem value="population_trend">Population Trend</SelectItem>
<SelectItem value="legislation">Legislation</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="title">Title</Label>
<input
id="title"
type="text"
value={timelineEventForm.title}
onChange={(e) => handleTimelineEventFormChange("title", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
placeholder="Event title"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="description">Description</Label>
<textarea
id="description"
value={timelineEventForm.description}
onChange={(e) => handleTimelineEventFormChange("description", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background min-h-[100px]"
placeholder="Optional description of this event"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status (Optional)</Label>
<input
id="status"
type="text"
value={timelineEventForm.status}
onChange={(e) => handleTimelineEventFormChange("status", e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background"
placeholder="e.g., Completed, In Progress, etc."
/>
</div>
</div>
<div className="mt-6 flex justify-end space-x-2">
<Button
variant="outline"
onClick={handleCancelTimelineEvent}
>
Cancel
</Button>
<Button
onClick={handleSaveTimelineEvent}
disabled={operationStatus.loading || !timelineEventForm.title}
>
{operationStatus.loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save
</>
)}
</Button>
</div>
</div>
)}
{/* Timeline events list */}
{timelineLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">
Timeline Events ({timelineEvents?.length || 0})
</h3>
<Button
onClick={handleAddTimelineEvent}
disabled={isAddingTimelineEvent || isEditingTimelineEvent}
size="sm"
>
<Plus className="mr-2 h-4 w-4" />
Add Event
</Button>
</div>
{timelineEvents && timelineEvents.length > 0 ? (
<div className="relative ml-4 space-y-6 border-l border-muted pl-6 pt-2">
{timelineEvents.map((event: TimelineEvent) => (
<div key={event.id} className="relative">
<div className="absolute -left-10 top-1 h-4 w-4 rounded-full bg-primary"></div>
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center text-sm">
<span className="font-medium">{formatDate(event.event_date)}</span>
{event.status && (
<Badge
className="ml-2"
variant={event.event_type === 'cites_listing' ? 'default' : 'secondary'}
>
{event.status}
</Badge>
)}
</div>
<div className="flex space-x-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditTimelineEvent(event)}
disabled={isAddingTimelineEvent || isEditingTimelineEvent}
title="Edit event"
className="h-8 w-8"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTimelineEvent(event)}
disabled={isAddingTimelineEvent || isEditingTimelineEvent || operationStatus.loading}
title="Delete event"
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<h4 className="text-base font-medium">{event.title}</h4>
{event.description && <p className="text-sm text-muted-foreground">{event.description}</p>}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No timeline events available for this species.</p>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -1,28 +1,42 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Filter, Loader2 } from "lucide-react";
import { SpeciesDetails, CitesTradeRecord } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { getCitesTradeRecords } from "@/lib/api";
import { useTradeDataVisualization } from "@/hooks/useTradeDataVisualization";
import { useTradeRecordFilters } from "@/hooks/useTradeRecordFilters";
import { TradeCharts } from "../visualizations/TradeCharts";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Loader2 } from 'lucide-react';
import { SpeciesDetails, CitesTradeRecord } from '@/lib/api';
import { useQuery } from '@tanstack/react-query';
import { getCitesTradeRecords } from '@/lib/api';
import { useTradeDataVisualization } from '@/hooks/useTradeDataVisualization';
import { useTradeRecordFilters, CountryOption } from '@/hooks/useTradeRecordFilters';
import { getCountryName } from '@/lib/countries';
import { TradeCharts } from '../visualizations/TradeCharts';
import { getTimelineEvents, TimelineEvent } from '@/lib/api';
import { getCatchRecords, CatchRecord } from '@/lib/api';
import { CatchChart } from '../visualizations/CatchChart';
type TradeDataTabProps = {
interface TradeDataTabProps {
species: SpeciesDetails;
};
}
export function TradeDataTab({ species }: TradeDataTabProps) {
// Fetch trade records
const isNarwhal = species.scientific_name === 'Monodon monoceros';
const { data: tradeRecords, isLoading: tradeLoading, error: tradeError } = useQuery({
queryKey: ["tradeRecords", species.id],
queryFn: () => getCitesTradeRecords(species.id),
});
// Use hooks for filtering
const { data: timelineEvents } = useQuery<TimelineEvent[], Error>({
queryKey: ["timelineEvents", species.id],
queryFn: () => getTimelineEvents(species.id),
});
const { data: catchRecords, isLoading: catchLoading, error: catchError } = useQuery<CatchRecord[], Error>({
queryKey: ["catchRecords", species.id],
queryFn: () => getCatchRecords(species.id),
enabled: isNarwhal,
});
const {
startYearFilter,
setStartYearFilter,
@ -30,12 +44,17 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
setEndYearFilter,
termFilter,
setTermFilter,
importerFilter,
setImporterFilter,
exporterFilter,
setExporterFilter,
filteredRecords,
resetFilters,
yearRange
yearRange,
uniqueImporters,
uniqueExporters
} = useTradeRecordFilters(tradeRecords);
// Use hook for visualization with filtered records
const {
visualizationData,
years,
@ -58,17 +77,16 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
) : tradeError ? (
<div className="rounded-md bg-red-50 p-4">
<p className="text-red-800">Error loading trade records. Please try again later.</p>
<p className="mt-2 text-xs text-red-700">{tradeError.message || 'Unknown error'}</p>
<p className="mt-2 text-sm text-red-700">{(tradeError as Error)?.message || 'Unknown error'}</p>
</div>
) : tradeRecords && tradeRecords.length > 0 ? (
<div className="space-y-6">
{/* Trade Filters */}
<div className="rounded-md border p-4">
<div className="mb-2 flex items-center">
<Filter className="mr-2 h-4 w-4" />
<h3 className="text-sm font-medium">Filter Trade Records</h3>
<Filter className="mr-2 h-5 w-5" />
<h3 className="text-base font-medium">Filter Trade Records</h3>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
<div className="space-y-2">
<Label htmlFor="start-year-filter">Start Year</Label>
<Select
@ -126,10 +144,51 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="importer-filter">Importer</Label>
<Select
value={importerFilter}
onValueChange={setImporterFilter}
>
<SelectTrigger id="importer-filter">
<SelectValue placeholder="All Importers">
{importerFilter === 'all' ? 'All Importers' : getCountryName(importerFilter)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Importers</SelectItem>
{uniqueImporters.map((country: CountryOption) => (
<SelectItem key={`importer-${country.code}`} value={country.code}>
{country.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="exporter-filter">Exporter</Label>
<Select
value={exporterFilter}
onValueChange={setExporterFilter}
>
<SelectTrigger id="exporter-filter">
<SelectValue placeholder="All Exporters">
{exporterFilter === 'all' ? 'All Exporters' : getCountryName(exporterFilter)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Exporters</SelectItem>
{uniqueExporters.map((country: CountryOption) => (
<SelectItem key={`exporter-${country.code}`} value={country.code}>
{country.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Reset Filters Button */}
{(startYearFilter !== "all" || endYearFilter !== "all" || termFilter !== "all") && (
{(startYearFilter !== "all" || endYearFilter !== "all" || termFilter !== "all" || importerFilter !== "all" || exporterFilter !== "all") && (
<div className="mt-4 flex justify-end">
<Button
variant="outline"
@ -142,21 +201,20 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
)}
</div>
{/* Trade summary section */}
<div className="rounded-md bg-muted p-4">
<h3 className="mb-2 text-lg font-semibold">Trade Summary</h3>
<h3 className="mb-2 text-xl font-semibold">Trade Summary</h3>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-md bg-background p-3 shadow-sm">
<h4 className="text-sm font-medium text-muted-foreground">Records</h4>
<h4 className="text-base font-medium text-muted-foreground">Records</h4>
<p className="text-2xl font-bold">{filteredRecords.length}</p>
{filteredRecords.length !== tradeRecords.length && (
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
of {tradeRecords.length} total
</p>
)}
</div>
<div className="rounded-md bg-background p-3 shadow-sm">
<h4 className="text-sm font-medium text-muted-foreground">Year Range</h4>
<h4 className="text-base font-medium text-muted-foreground">Year Range</h4>
<p className="text-2xl font-bold">
{filteredRecords.length > 0
? `${startYearFilter === "all" ? yearRange.min : startYearFilter} - ${endYearFilter === "all" ? yearRange.max : endYearFilter}`
@ -165,7 +223,7 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
</p>
</div>
<div className="rounded-md bg-background p-3 shadow-sm">
<h4 className="text-sm font-medium text-muted-foreground">Total Quantity</h4>
<h4 className="text-base font-medium text-muted-foreground">Total Quantity</h4>
<p className="text-2xl font-bold">
{filteredRecords
.reduce((sum: number, record: CitesTradeRecord) => sum + (Number(record.quantity) || 0), 0)
@ -175,78 +233,39 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
</div>
</div>
{/* Visualizations Section */}
{visualizationData && (
<TradeCharts
visualizationData={visualizationData}
PURPOSE_DESCRIPTIONS={PURPOSE_DESCRIPTIONS}
SOURCE_DESCRIPTIONS={SOURCE_DESCRIPTIONS}
timelineEvents={timelineEvents}
/>
)}
{/* Trade records table */}
<div>
<h3 className="mb-2 text-lg font-semibold">Trade Records</h3>
{filteredRecords.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No records match the selected filters.</p>
<Button
variant="link"
className="mt-2"
onClick={resetFilters}
>
Reset Filters
</Button>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full table-auto">
<thead>
<tr className="border-b">
<th className="py-2 text-left">Year</th>
<th className="py-2 text-left">Appendix</th>
<th className="py-2 text-left">Term</th>
<th className="py-2 text-left">Quantity</th>
<th className="py-2 text-left">Unit</th>
<th className="py-2 text-left">Importer</th>
<th className="py-2 text-left">Exporter</th>
<th className="py-2 text-left">Purpose</th>
<th className="py-2 text-left">Source</th>
</tr>
</thead>
<tbody>
{filteredRecords.slice(0, 20).map((record: CitesTradeRecord) => (
<tr key={record.id} className="border-b">
<td className="py-2">{record.year}</td>
<td className="py-2">
<Badge variant="outline">
{record.appendix}
</Badge>
</td>
<td className="py-2">{record.term}</td>
<td className="py-2 text-right">{record.quantity || 'N/A'}</td>
<td className="py-2">{record.unit || '-'}</td>
<td className="py-2">{record.importer || '-'}</td>
<td className="py-2">{record.exporter || '-'}</td>
<td className="py-2">{record.purpose || '-'}</td>
<td className="py-2">{record.source || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredRecords.length > 20 && (
<div className="mt-4 text-center text-sm text-muted-foreground">
Showing 20 of {filteredRecords.length} records. Use the filters to narrow down results.
</div>
)}
</>
)}
</div>
{isNarwhal && catchRecords && catchRecords.length > 0 && (
<div className="mt-8">
<CatchChart catchRecords={catchRecords} />
</div>
)}
{isNarwhal && catchLoading && (
<div className="mt-8 flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="ml-2">Loading catch data...</p>
</div>
)}
{isNarwhal && catchError && (
<div className="mt-8 rounded-md bg-red-50 p-4">
<p className="text-red-800">Error loading catch records.</p>
<p className="mt-2 text-sm text-red-700">{catchError.message || 'Unknown error'}</p>
</div>
)}
{isNarwhal && !catchLoading && !catchError && catchRecords?.length === 0 && (
<div className="mt-8 rounded-md border border-dashed p-8 text-center">
<p className="text-muted-foreground">No specific catch data found for Narwhal.</p>
</div>
)}
{/* Trade data info section */}
<div className="rounded-md bg-blue-50 p-4 text-sm">
<div className="rounded-md bg-blue-50 p-4 text-base mt-8">
<h3 className="mb-2 font-medium text-blue-700">About CITES Trade Data</h3>
<p className="text-blue-700">
The CITES Trade Database, managed by UNEP-WCMC on behalf of the CITES Secretariat, contains records of trade in wildlife
@ -259,8 +278,7 @@ export function TradeDataTab({ species }: TradeDataTabProps) {
<div className="space-y-4">
<p className="text-muted-foreground">No trade records available for this species.</p>
{/* Add info about what trade records are */}
<div className="rounded-md bg-blue-50 p-4 text-sm">
<div className="rounded-md bg-blue-50 p-4 text-base">
<h3 className="mb-2 font-medium text-blue-700">About CITES Trade Records</h3>
<p className="text-blue-700">
CITES trade records document international trade in wildlife listed in the CITES Appendices.

View File

@ -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 (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center">
<Fish className="mr-2 h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Catch Data Over Time (NAMMCO)</CardTitle>
</div>
<CardDescription>Reported catch totals and quotas by year</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{ top: 5, right: 30, left: 20, bottom: 25 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
angle={-45}
textAnchor="end"
tick={{ fontSize: 12 }}
// domain={['dataMin', 'dataMax']} // Optional: ensure axis covers full range
/>
<YAxis tickFormatter={formatNumber} />
<Tooltip
formatter={(value: number | string, name: string) => [
formatNumber(value),
name // Use default name mapping ('Catch Total', 'Quota')
]}
/>
<Legend />
<Line
type="monotone"
dataKey="catch_total"
name="Catch Total"
stroke="#8884d8"
activeDot={{ r: 8 }}
connectNulls={false} // Do not connect lines across null data points
/>
<Line
type="monotone"
dataKey="quota"
name="Quota"
stroke="#82ca9d"
strokeDasharray="5 5" // Differentiate quota line
activeDot={{ r: 8 }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}

View File

@ -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<string, string>;
SOURCE_DESCRIPTIONS: Record<string, string>;
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 (
<text
x={x}
y={y}
fill="#000000"
textAnchor={x > numCx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={12}
fontWeight="500"
>
{`${(payload as { term: string }).term} (${(percent * 100).toFixed(0)}%)`}
</text>
);
};
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 (
<text
x={x}
y={y}
fill="#000000"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={12}
fontWeight="500"
>
{`${payload.term} (${(percent * 100).toFixed(0)}%)`}
</text>
);
// 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 (
<div className="space-y-8">
<h3 className="text-lg font-semibold">Trade Visualizations</h3>
<h3 className="text-xl font-semibold">Trade Visualizations</h3>
{/* Records Over Time */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center">
<LineChart className="mr-2 h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Records Over Time</CardTitle>
</div>
<CardDescription>Number of trade records by year</CardDescription>
<CardTitle className="text-base">
Records Over Time
</CardTitle>
</div>
<CardDescription>
Number of trade records by year
<br />
<span className="text-xs text-muted-foreground">
Green dashed lines indicate important timeline events. Hover over the year to see event details.
</span>
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
@ -117,8 +175,35 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
textAnchor="end"
tick={{ fontSize: 12 }}
/>
<YAxis />
<Tooltip />
<YAxis tickFormatter={formatNumber} />
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload || payload.length === 0) return null;
return (
<div className="rounded border border-gray-300 bg-white p-2 shadow">
<div><strong>Year:</strong> {label}</div>
{payload.map((entry, idx) => {
const value = Array.isArray(entry.value) ? entry.value[0] : entry.value;
return (
<div key={idx}>
<span className="font-semibold">{entry.name}:</span> {formatNumber(value)}
</div>
);
})}
{timelineEvents
?.filter(e => e.year === label)
.map((event, idx) => (
<div key={idx} className="mt-1 border-t pt-1 text-xs">
<div><strong>Event Type:</strong> {event.event_type}</div>
<div><strong>Title:</strong> {event.title}</div>
{event.description && <div><strong>Description:</strong> {event.description}</div>}
</div>
))}
</div>
);
}}
/>
<Legend />
<Line
type="monotone"
@ -127,6 +212,17 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
stroke="#8884d8"
activeDot={{ r: 8 }}
/>
{timelineEvents && timelineEvents.map(event => (
<ReferenceLine
key={event.id}
x={event.year}
stroke="green"
strokeDasharray="3 3"
>
</ReferenceLine>
))}
</RechartsLineChart>
</ResponsiveContainer>
</div>
@ -134,11 +230,11 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
</Card>
{/* Top Importers and Exporters */}
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-8 md:grid-cols-2">
{/* Top Importers */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center">
<div className="flex items-center">
<BarChart3 className="mr-2 h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Top Importers</CardTitle>
</div>
@ -159,8 +255,13 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
tick={{ fontSize: 12 }}
interval={0}
/>
<YAxis />
<Tooltip />
<YAxis tickFormatter={formatNumber} />
<Tooltip
formatter={(value: number | string, name: string) => [
formatNumber(value),
name || 'Value'
]}
/>
<Bar dataKey="count" name="Records" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
@ -170,8 +271,8 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Top Exporters */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center">
<CardHeader className="pb-2">
<div className="flex items-center">
<BarChart3 className="mr-2 h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Top Exporters</CardTitle>
</div>
@ -192,8 +293,13 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
tick={{ fontSize: 12 }}
interval={0}
/>
<YAxis />
<Tooltip />
<YAxis tickFormatter={formatNumber} />
<Tooltip
formatter={(value: number | string, name: string) => [
formatNumber(value),
name || 'Value'
]}
/>
<Bar dataKey="count" name="Records" fill="#82ca9d" />
</BarChart>
</ResponsiveContainer>
@ -204,8 +310,8 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Terms Traded */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center">
<CardHeader className="pb-2">
<div className="flex items-center">
<PieChart className="mr-2 h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Terms Traded</CardTitle>
</div>
@ -230,7 +336,12 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(value, _, props) => [`${value} records`, props.payload.term]} />
<Tooltip
formatter={(value: number | string, name: string) => [
`${formatNumber(value)} records`,
name || 'Term' // name is the nameKey (term)
]}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
@ -238,11 +349,11 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
</Card>
{/* Trade Purposes and Sources */}
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-8 md:grid-cols-2">
{/* Trade Purposes */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center">
<div className="flex items-center">
<BarChart3 className="mr-2 h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Trade Purposes</CardTitle>
</div>
@ -257,7 +368,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<XAxis type="number" tickFormatter={formatNumber} />
<YAxis
dataKey="purpose"
type="category"
@ -265,10 +376,8 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
tickFormatter={(value) => `${value} - ${PURPOSE_DESCRIPTIONS[value] || 'Unknown'}`}
width={150}
/>
<Tooltip formatter={(value, _, props) => [
`${value} records`,
`${props.payload.purpose} - ${props.payload.description}`
]} />
{/* Revert to using as any due to persistent type issues */}
<Tooltip formatter={barChartDetailFormatter as any} />
<Bar dataKey="count" name="Records" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
@ -279,7 +388,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Trade Sources */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center">
<div className="flex items-center">
<BarChart3 className="mr-2 h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Trade Sources</CardTitle>
</div>
@ -294,7 +403,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
layout="vertical"
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<XAxis type="number" tickFormatter={formatNumber} />
<YAxis
dataKey="source"
type="category"
@ -302,10 +411,8 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
tickFormatter={(value) => `${value} - ${SOURCE_DESCRIPTIONS[value] || 'Unknown'}`}
width={150}
/>
<Tooltip formatter={(value, _, props) => [
`${value} records`,
`${props.payload.source} - ${props.payload.description}`
]} />
{/* Revert to using as any due to persistent type issues */}
<Tooltip formatter={barChartDetailFormatter as any} />
<Bar dataKey="count" name="Records" fill="#82ca9d" />
</BarChart>
</ResponsiveContainer>
@ -317,7 +424,7 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
{/* Quantity of Top Terms Over Time */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center">
<div className="flex items-center">
<LineChart className="mr-2 h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Quantity of Top Terms Over Time</CardTitle>
</div>
@ -337,15 +444,20 @@ export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DE
textAnchor="end"
tick={{ fontSize: 12 }}
/>
<YAxis />
<Tooltip />
<YAxis tickFormatter={formatNumber} />
<Tooltip
formatter={(value: number | string, name: string) => [
formatNumber(value),
name || 'Value' // Use name prop from <Line>
]}
/>
<Legend />
{visualizationData.topTerms.map((term, index) => (
<Line
key={term}
type="monotone"
dataKey={term}
name={term}
name={term} // This name is passed to the tooltip formatter
stroke={CHART_COLORS[index % CHART_COLORS.length]}
activeDot={{ r: 8 }}
/>

View File

@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -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 }
export { Badge, badgeVariants }

View File

@ -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<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button, buttonVariants };

View File

@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
className={cn("flex flex-col space-y-2 p-8", className)}
{...props}
/>
))
@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
"text-3xl font-semibold leading-none tracking-tight",
className
)}
{...props}
@ -50,7 +50,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-base text-muted-foreground", className)}
{...props}
/>
))
@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
<div ref={ref} className={cn("p-8 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
@ -76,4 +76,4 @@ const CardFooter = React.forwardRef<
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -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<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
"text-xl font-semibold leading-none tracking-tight",
className
)}
{...props}
@ -102,7 +102,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-base text-muted-foreground", className)}
{...props}
/>
))
@ -119,4 +119,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
}

176
src/components/ui/form.tsx Normal file
View File

@ -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<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
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 <FormField>")
}
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<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-12 w-full rounded-md border border-input bg-background px-4 py-3 text-lg ring-offset-background file:border-0 file:bg-transparent file:text-lg file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
@ -22,4 +22,4 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
)
Input.displayName = "Input"
export { Input }
export { Input }

View File

@ -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 }
export { Label }

View File

@ -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">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -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<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -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<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -17,14 +17,14 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm 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",
"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}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
<ChevronDown className="h-6 w-6 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
@ -42,7 +42,7 @@ const SelectScrollUpButton = React.forwardRef<
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
<ChevronUp className="h-5 w-5" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
@ -59,7 +59,7 @@ const SelectScrollDownButton = React.forwardRef<
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
<ChevronDown className="h-5 w-5" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
@ -116,14 +116,14 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-2 pl-8 pr-3 text-base outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<Check className="h-5 w-5" />
</SelectPrimitive.ItemIndicator>
</span>
@ -155,4 +155,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
}

120
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -11,7 +11,8 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
// Re-apply flex-wrap and h-auto defaults, remove inline-flex and h-10
"flex flex-wrap h-auto items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
@ -26,7 +27,8 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
// Removed whitespace-nowrap to allow text wrapping within trigger if needed
"inline-flex items-center justify-center rounded-sm px-4 py-2 text-xl font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
@ -49,4 +51,4 @@ const TabsContent = React.forwardRef<
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

127
src/components/ui/toast.tsx Normal file
View File

@ -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<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
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<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -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 (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -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<void>;
signOut: () => Promise<void>;
isAdmin: boolean;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AppUser>(null);
const [sessionUserInfo, setSessionUserInfo] = useState<SessionUserInfo | null>(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<Profile>();
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<Profile>();
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 (
<AuthContext.Provider value={{ user, loading, signIn, signOut, isAdmin }}>
{children}
</AuthContext.Provider>
);
};
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

194
src/hooks/use-toast.ts Normal file
View File

@ -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<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
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<ToasterToast, "id">
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<State>(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 }

View File

@ -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<string>("all");
const [endYearFilter, setEndYearFilter] = useState<string>("all");
const [termFilter, setTermFilter] = useState<string>("all");
const [importerFilter, setImporterFilter] = useState<string>("all");
const [exporterFilter, setExporterFilter] = useState<string>('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<number>();
const importerCodes = new Set<string>();
const exporterCodes = new Set<string>();
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<string>): 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
};
}

View File

@ -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 */
}
}
/* Let Tailwind's padding and height utilities work as intended */
}
/* Removed custom override for species detail tabs wrap */

View File

@ -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<SpeciesDetails | null>
}
}
/**
* 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<CatchRecord[]> {
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
}
}

260
src/lib/countries.ts Normal file
View File

@ -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<string, string> = {
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
}

View File

@ -22,19 +22,6 @@ const client = createClient<Database>(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;
export const supabase = client;
// Removed the self-executing connection test

View File

@ -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<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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) + '...';
}

View File

@ -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<string | null>(
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 (
<AdminLayout>
<div className="mb-6">
<Button
variant="outline"
className="mb-4"
onClick={() => navigate('/admin/cites-listings')}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to CITES Listings
</Button>
<h1 className="text-3xl font-bold">Add New CITES Listing</h1>
<p className="text-muted-foreground">Create a new CITES appendix listing</p>
</div>
{/* Species selection dropdown (only if not pre-selected) */}
{!searchParams.get('species_id') && (
<div className="mb-6 rounded-md border p-6">
<Label htmlFor="species-select" className="text-lg font-medium">
Select Species
</Label>
<p className="mb-4 text-sm text-muted-foreground">
Choose the species for this CITES listing
</p>
<div className="max-w-md">
<Select
value={selectedSpeciesId || ''}
onValueChange={handleSpeciesChange}
disabled={speciesLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a species" />
</SelectTrigger>
<SelectContent>
{species?.map((s: Species) => (
<SelectItem key={s.id} value={s.id!}>
<span className="italic">{s.scientific_name}</span>
{' - '}
<span>{s.common_name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Only show the form if a species is selected */}
{selectedSpeciesId ? (
<div className="rounded-md border p-6">
<div className="mb-4">
<h2 className="text-xl font-semibold">
{selectedSpecies ? (
<>
CITES Listing for <span className="italic">{selectedSpecies.scientific_name}</span>
</>
) : (
'New CITES Listing'
)}
</h2>
</div>
<CitesListingForm
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
speciesId={selectedSpeciesId}
/>
</div>
) : (
!searchParams.get('species_id') && (
<div className="rounded-md border p-6 text-center">
<p className="text-muted-foreground">Please select a species to continue</p>
</div>
)
)}
</AdminLayout>
);
}

View File

@ -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 (
<AdminLayout>
<div className="flex justify-center p-8">Loading CITES listing data...</div>
</AdminLayout>
);
}
if (listingError) {
return (
<AdminLayout>
<div className="rounded-md bg-red-50 p-4 text-red-500">
Error loading CITES listing: {(listingError as Error).message}
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="mb-6">
<Button
variant="outline"
className="mb-4"
onClick={() => navigate('/admin/cites-listings')}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to CITES Listings
</Button>
<h1 className="text-3xl font-bold">Edit CITES Listing</h1>
<p className="text-muted-foreground">
{species && (
<>
Edit CITES Appendix {listing?.appendix} listing for <span className="italic">{species.scientific_name}</span>
</>
)}
</p>
</div>
<div className="rounded-md border p-6">
{listing && (
<CitesListingForm
initialData={listing}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)}
</div>
</AdminLayout>
);
}

View File

@ -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 (
<AdminLayout>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">CITES Listings</h1>
<p className="text-muted-foreground">Manage CITES appendix listings for species</p>
</div>
<Button asChild>
<Link to="/admin/cites-listings/create">
<Plus className="mr-2 h-4 w-4" />
Add Listing
</Link>
</Button>
</div>
{/* Search bar */}
<div className="mb-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search by species name or appendix..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button type="submit">Search</Button>
{isSearching && (
<Button variant="outline" onClick={clearSearch}>
Clear
</Button>
)}
</form>
</div>
{/* Listings table */}
{isLoading ? (
<div className="flex justify-center p-8">Loading CITES listings...</div>
) : error ? (
<div className="rounded-md bg-red-50 p-4 text-red-500">
Error loading listings: {(error as Error).message}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Species</TableHead>
<TableHead>Appendix</TableHead>
<TableHead>Listing Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredListings && filteredListings.length > 0 ? (
filteredListings.map((listing) => (
<TableRow key={listing.id}>
<TableCell>
<div>
<p className="font-medium italic">{listing.species?.scientific_name}</p>
<p className="text-sm text-muted-foreground">{listing.species?.common_name}</p>
</div>
</TableCell>
<TableCell>
<Badge variant={
listing.appendix === 'I' ? 'destructive' :
listing.appendix === 'II' ? 'default' : 'outline'
}>
Appendix {listing.appendix}
</Badge>
</TableCell>
<TableCell>{formatDate(listing.listing_date)}</TableCell>
<TableCell>
{listing.is_current ? (
<Badge variant="success">Current</Badge>
) : (
<Badge variant="outline">Historical</Badge>
)}
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(`/admin/cites-listings/${listing.id}/edit`)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:text-red-700"
onClick={() => confirmDelete(listing)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
No CITES listings found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
{/* Delete confirmation dialog */}
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete CITES Listing</DialogTitle>
<DialogDescription>
Are you sure you want to delete the CITES Appendix {deleteDialog.listing?.appendix} listing for{' '}
<span className="font-medium italic">{
listings?.find(l => l.id === deleteDialog.listing?.id)?.species?.scientific_name
}</span>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialog({ open: false })}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AdminLayout>
);
}

View File

@ -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<string | null>(
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 (
<AdminLayout>
<div className="mb-6">
<Button
variant="outline"
className="mb-4"
onClick={() => navigate('/admin/common-names')}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to Common Names
</Button>
<h1 className="text-3xl font-bold">Add New Common Name</h1>
<p className="text-muted-foreground">Create a new common name for a species</p>
</div>
{/* Species selection dropdown (only if not pre-selected) */}
{!searchParams.get('species_id') && (
<div className="mb-6 rounded-md border p-6">
<Label htmlFor="species-select" className="text-lg font-medium">
Select Species
</Label>
<p className="mb-4 text-sm text-muted-foreground">
Choose the species for this common name
</p>
<div className="max-w-md">
<Select
value={selectedSpeciesId || ''}
onValueChange={handleSpeciesChange}
disabled={speciesLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a species" />
</SelectTrigger>
<SelectContent>
{species?.map((s: Species) => (
<SelectItem key={s.id} value={s.id!}>
<span className="italic">{s.scientific_name}</span>
{' - '}
<span>{s.common_name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Only show the form if a species is selected */}
{selectedSpeciesId ? (
<div className="rounded-md border p-6">
<div className="mb-4">
<h2 className="text-xl font-semibold">
{selectedSpecies ? (
<>
New Common Name for <span className="italic">{selectedSpecies.scientific_name}</span>
</>
) : (
'New Common Name'
)}
</h2>
</div>
<CommonNameForm
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
speciesId={selectedSpeciesId}
/>
</div>
) : (
!searchParams.get('species_id') && (
<div className="rounded-md border p-6 text-center">
<p className="text-muted-foreground">Please select a species to continue</p>
</div>
)
)}
</AdminLayout>
);
}

View File

@ -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 (
<AdminLayout>
<div className="flex justify-center p-8">Loading common name data...</div>
</AdminLayout>
);
}
if (commonNameError) {
return (
<AdminLayout>
<div className="rounded-md bg-red-50 p-4 text-red-500">
Error loading common name: {(commonNameError as Error).message}
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="mb-6">
<Button
variant="outline"
className="mb-4"
onClick={() => navigate('/admin/common-names')}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to Common Names
</Button>
<h1 className="text-3xl font-bold">Edit Common Name</h1>
<p className="text-muted-foreground">
{species && (
<>
Edit common name "{commonName?.name}" for <span className="italic">{species.scientific_name}</span>
</>
)}
</p>
</div>
<div className="rounded-md border p-6">
{commonName && (
<CommonNameForm
initialData={commonName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)}
</div>
</AdminLayout>
);
}

View File

@ -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<string, string> = {
'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 (
<AdminLayout>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Common Names</h1>
<p className="text-muted-foreground">Manage common names for species in different languages</p>
</div>
<Button asChild>
<Link to="/admin/common-names/create">
<Plus className="mr-2 h-4 w-4" />
Add Common Name
</Link>
</Button>
</div>
{/* Search bar */}
<div className="mb-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search by name, language, or species..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button type="submit">Search</Button>
{isSearching && (
<Button variant="outline" onClick={clearSearch}>
Clear
</Button>
)}
</form>
</div>
{/* Common names table */}
{isLoading ? (
<div className="flex justify-center p-8">Loading common names...</div>
) : error ? (
<div className="rounded-md bg-red-50 p-4 text-red-500">
Error loading common names: {(error as Error).message}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Common Name</TableHead>
<TableHead>Language</TableHead>
<TableHead>Species</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCommonNames && filteredCommonNames.length > 0 ? (
filteredCommonNames.map((commonName) => (
<TableRow key={commonName.id}>
<TableCell className="font-medium">
{commonName.name}
</TableCell>
<TableCell>
{getLanguageName(commonName.language)}
</TableCell>
<TableCell>
{commonName.species && (
<div>
<p className="italic">{commonName.species.scientific_name}</p>
<p className="text-sm text-muted-foreground">{commonName.species.common_name}</p>
</div>
)}
</TableCell>
<TableCell>
{commonName.is_main && (
<Badge className="bg-blue-500">
<Flag className="mr-1 h-3 w-3" /> Main
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(`/admin/common-names/${commonName.id}/edit`)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:text-red-700"
onClick={() => confirmDelete(commonName)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
No common names found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
{/* Delete confirmation dialog */}
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Common Name</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{deleteDialog.commonName?.name}" ({getLanguageName(deleteDialog.commonName?.language || '')}) for{' '}
<span className="font-medium italic">{deleteDialog.commonName?.species?.scientific_name}</span>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialog({ open: false })}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AdminLayout>
);
}

View File

@ -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 (
<AdminLayout>
<div className="mb-6">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<p className="text-muted-foreground">Manage your Arctic species database</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Species"
value={loadingStats ? 'Loading...' : stats.totalSpecies}
description="Species in database"
icon={<Layers className="h-6 w-6 text-blue-500" />}
/>
<StatsCard
title="CITES Trade Records"
value={loadingStats ? 'Loading...' : stats.totalTradeRecords}
description="Trade records tracked"
icon={<BarChart className="h-6 w-6 text-green-500" />}
/>
<StatsCard
title="IUCN Assessments"
value={loadingStats ? 'Loading...' : stats.totalIucnAssessments}
description="Conservation status assessments"
icon={<FileCheck className="h-6 w-6 text-red-500" />}
/>
<StatsCard
title="CITES Listings"
value={loadingStats ? 'Loading...' : stats.totalCitesListings}
description="CITES appendix listings"
icon={<GitBranch className="h-6 w-6 text-purple-500" />}
/>
</div>
{/* TODO: Add loading indicator for the whole stats section if loadingStats is true */}
{/* Recent activity, quick links or other dashboard elements */}
</AdminLayout>
);
}
function StatsCard({ title, value, description, icon }: StatsCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}

View File

@ -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<string | null>(
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 (
<AdminLayout>
<div className="mb-6">
<Button
variant="outline"
className="mb-4"
onClick={() => navigate('/admin/iucn-assessments')}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to IUCN Assessments
</Button>
<h1 className="text-3xl font-bold">Add New IUCN Assessment</h1>
<p className="text-muted-foreground">Create a new IUCN Red List assessment</p>
</div>
{/* Species selection dropdown (only if not pre-selected) */}
{!searchParams.get('species_id') && (
<div className="mb-6 rounded-md border p-6">
<Label htmlFor="species-select" className="text-lg font-medium">
Select Species
</Label>
<p className="mb-4 text-sm text-muted-foreground">
Choose the species for this IUCN assessment
</p>
<div className="max-w-md">
<Select
value={selectedSpeciesId || ''}
onValueChange={handleSpeciesChange}
disabled={speciesLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a species" />
</SelectTrigger>
<SelectContent>
{species?.map((s: Species) => (
<SelectItem key={s.id} value={s.id!}>
<span className="italic">{s.scientific_name}</span>
{' - '}
<span>{s.common_name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Only show the form if a species is selected */}
{selectedSpeciesId ? (
<div className="rounded-md border p-6">
<div className="mb-4">
<h2 className="text-xl font-semibold">
{selectedSpecies ? (
<>
IUCN Assessment for <span className="italic">{selectedSpecies.scientific_name}</span>
</>
) : (
'New IUCN Assessment'
)}
</h2>
</div>
<IucnAssessmentForm
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
speciesId={selectedSpeciesId}
/>
</div>
) : (
!searchParams.get('species_id') && (
<div className="rounded-md border p-6 text-center">
<p className="text-muted-foreground">Please select a species to continue</p>
</div>
)
)}
</AdminLayout>
);
}

View File

@ -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 (
<AdminLayout>
<div className="flex justify-center p-8">Loading IUCN assessment data...</div>
</AdminLayout>
);
}
if (assessmentError) {
return (
<AdminLayout>
<div className="rounded-md bg-red-50 p-4 text-red-500">
Error loading IUCN assessment: {(assessmentError as Error).message}
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="mb-6">
<Button
variant="outline"
className="mb-4"
onClick={() => navigate('/admin/iucn-assessments')}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to IUCN Assessments
</Button>
<h1 className="text-3xl font-bold">Edit IUCN Assessment</h1>
<p className="text-muted-foreground">
{species && (
<>
Edit {assessment?.status} assessment for <span className="italic">{species.scientific_name}</span>
</>
)}
</p>
</div>
<div className="rounded-md border p-6">
{assessment && (
<IucnAssessmentForm
initialData={assessment}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)}
</div>
</AdminLayout>
);
}

View File

@ -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<string, { label: string; variant: string }> = {
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 (
<Badge variant={config.variant as any}>
{config.label}
</Badge>
);
};
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 (
<AdminLayout>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">IUCN Assessments</h1>
<p className="text-muted-foreground">Manage IUCN Red List assessments for species</p>
</div>
<Button asChild>
<Link to="/admin/iucn-assessments/create">
<Plus className="mr-2 h-4 w-4" />
Add Assessment
</Link>
</Button>
</div>
{/* Search bar */}
<div className="mb-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search by species name or status..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button type="submit">Search</Button>
{isSearching && (
<Button variant="outline" onClick={clearSearch}>
Clear
</Button>
)}
</form>
</div>
{/* Assessments table */}
{isLoading ? (
<div className="flex justify-center p-8">Loading IUCN assessments...</div>
) : error ? (
<div className="rounded-md bg-red-50 p-4 text-red-500">
Error loading assessments: {(error as Error).message}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Species</TableHead>
<TableHead>Status</TableHead>
<TableHead>Year</TableHead>
<TableHead>Flags</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAssessments && filteredAssessments.length > 0 ? (
filteredAssessments.map((assessment) => (
<TableRow key={assessment.id}>
<TableCell>
<div>
<p className="font-medium italic">{assessment.species?.scientific_name}</p>
<p className="text-sm text-muted-foreground">{assessment.species?.common_name}</p>
</div>
</TableCell>
<TableCell>
{getStatusBadge(assessment.status)}
{assessment.is_latest && (
<span className="ml-2 rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
Latest
</span>
)}
</TableCell>
<TableCell>{assessment.year_published}</TableCell>
<TableCell>
<div className="space-y-1">
{assessment.possibly_extinct && (
<Badge variant="destructive" className="mr-1">Possibly Extinct</Badge>
)}
{assessment.possibly_extinct_in_wild && (
<Badge variant="destructive">Possibly Extinct in Wild</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="flex space-x-2">
{assessment.url && (
<Button
size="sm"
variant="ghost"
asChild
>
<a href={assessment.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => navigate(`/admin/iucn-assessments/${assessment.id}/edit`)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:text-red-700"
onClick={() => confirmDelete(assessment)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
No IUCN assessments found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
{/* Delete confirmation dialog */}
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete IUCN Assessment</DialogTitle>
<DialogDescription>
Are you sure you want to delete the {deleteDialog.assessment?.status} IUCN assessment for{' '}
<span className="font-medium italic">{
assessments?.find(a => a.id === deleteDialog.assessment?.id)?.species?.scientific_name
}</span>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialog({ open: false })}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AdminLayout>
);
}

22
src/pages/admin/login.tsx Normal file
View File

@ -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 <Navigate to="/admin" replace />;
}
// 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 (
<div className="flex min-h-screen flex-col items-center justify-center bg-slate-100 p-4">
<LoginForm />
</div>
);
}

View File

@ -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 (
<AdminLayout>
<div className="mb-6">
<h1 className="text-3xl font-bold">Add New Species</h1>
<p className="text-muted-foreground">Create a new species record</p>
</div>
<div className="rounded-md border p-6">
<SpeciesForm
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</div>
</AdminLayout>
);
}

View File

@ -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 (
<AdminLayout>
<div className="flex justify-center p-8">Loading species data...</div>
</AdminLayout>
);
}
if (error) {
return (
<AdminLayout>
<div className="rounded-md bg-red-50 p-4 text-red-500">
Error loading species data: {(error as Error).message}
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="mb-6">
<Button
variant="outline"
className="mb-4"
onClick={() => navigate('/admin/species')}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back to Species List
</Button>
<h1 className="text-3xl font-bold">Edit Species</h1>
<p className="text-muted-foreground">
Edit details for <span className="italic">{species?.scientific_name}</span>
</p>
</div>
<div className="rounded-md border p-6">
{species && (
<SpeciesForm
initialData={species}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)}
</div>
</AdminLayout>
);
}

View File

@ -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 (
<AdminLayout>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Species</h1>
<p className="text-muted-foreground">Manage your species database</p>
</div>
<Button asChild>
<Link to="/admin/species/create">
<Plus className="mr-2 h-4 w-4" />
Add Species
</Link>
</Button>
</div>
{/* Search bar */}
<div className="mb-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search by scientific or common name..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button type="submit">Search</Button>
{isSearching && (
<Button variant="outline" onClick={clearSearch}>
Clear
</Button>
)}
</form>
</div>
{/* Species table */}
{isLoading ? (
<div className="flex justify-center p-8">Loading species...</div>
) : error ? (
<div className="rounded-md bg-red-50 p-4 text-red-500">
Error loading species: {(error as Error).message}
</div>
) : (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Scientific Name</TableHead>
<TableHead>Common Name</TableHead>
<TableHead>Family</TableHead>
<TableHead>IUCN Status</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data && data.data.length > 0 ? (
data.data.map((species) => (
<TableRow key={species.id}>
<TableCell className="font-medium">
<span className="italic">{species.scientific_name}</span>
</TableCell>
<TableCell>{species.common_name}</TableCell>
<TableCell>{species.family}</TableCell>
<TableCell>
{/* This would need to be fetched in a real implementation */}
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
LC
</span>
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(`/admin/species/${species.id}/edit`)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-500 hover:text-red-700"
onClick={() => confirmDelete(species)}
>
<Trash className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
No species found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{!isSearching && data?.count && data.count > 0 && (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setPage(p => Math.max(1, p - 1))}
// isActive={page > 1} // isActive is not a prop of PaginationPrevious
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(pageNumber) => (
<PaginationItem key={pageNumber}>
<PaginationLink
isActive={page === pageNumber}
onClick={() => setPage(pageNumber)}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
// isActive={page < totalPages} // isActive is not a prop of PaginationNext
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</>
)}
{/* Delete confirmation dialog */}
<Dialog open={deleteDialog.open} onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Species</DialogTitle>
<DialogDescription>
Are you sure you want to delete <span className="font-medium italic">{deleteDialog.species?.scientific_name}</span>?
This action cannot be undone, and all related data will also be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteDialog({ open: false })}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AdminLayout>
);
}

View File

@ -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 (
<Card
key={species.id}
className="group overflow-hidden transition-all hover:shadow-lg hover:border-blue-200 dark:hover:border-blue-800"
onClick={() => onClick(species.id)}
>
{/* Removed inline style from CardTitle */}
<div className={`relative h-72 ${getBackgroundColor(species.scientific_name)} overflow-hidden`}>
{isImageLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-12 w-12 animate-spin rounded-full border-b-2 border-gray-900 dark:border-gray-200"></div>
</div>
) : imageData?.url ? (
<>
<img
src={imageData.url}
alt={species.scientific_name}
className="object-cover w-full h-full"
/>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/50"></div>
{imageData?.attribution && (
<div className="absolute bottom-0 right-0 bg-black/50 px-2 py-1 text-sm text-white max-w-[80%] truncate">
{imageData.attribution}
</div>
)}
</>
) : (
<>
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/30"></div>
<div className="absolute inset-0 flex flex-col items-center justify-center opacity-50">
<ImageIcon className="h-14 w-14" />
<span className="text-base mt-2">No image</span>
</div>
</>
)}
<div className="absolute bottom-3 left-3">
{species.latest_assessment?.status && (
<Badge variant="outline" className="bg-white/80 text-sm font-medium px-3 py-1">
{species.latest_assessment.status === 'CR' && <AlertTriangle className="mr-1 h-4 w-4 text-red-500" />}
IUCN: {species.latest_assessment.status}
</Badge>
)}
</div>
</div>
<CardHeader className="p-4 pb-2">
{/* Removed inline style */}
<CardTitle className="text-2xl md:text-3xl group-hover:text-blue-600 dark:group-hover:text-blue-400">
<span className="italic">{species.scientific_name}</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{/* Removed inline style */}
<p className="text-xl text-gray-600 dark:text-gray-300">{species.primary_common_name}</p>
<div className="mt-3 flex flex-wrap items-center justify-between">
{/* Removed inline style */}
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-lg font-medium text-gray-800 dark:text-gray-200">
{species.family}
</span>
<Button size="sm" variant="ghost" className="p-0 h-9 w-9 rounded-full">
<ArrowRight className="h-6 w-6" />
</Button>
</div>
</CardContent>
</Card>
);
}
export function HomePage() {
// App state
const [selectedSpeciesId, setSelectedSpeciesId] = useState<string | null>(null);
const [showSearch, setShowSearch] = useState<boolean>(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 <ResultsContainer speciesId={selectedSpeciesId} onBack={handleBackToSearch} />;
}
return (
<div className="container mx-auto my-8 space-y-8 px-4">
<div className="text-center max-w-3xl mx-auto">
<h1 className="mb-4 text-5xl font-bold tracking-tight text-gray-900 dark:text-gray-100">Arctic Species Tracker</h1>
<p className="mb-4 text-base font-semibold text-blue-600 dark:text-blue-400">**Work in Progress!**</p>
<div className="mb-6 text-base text-gray-600 dark:text-gray-300 space-y-1">
<p className="font-medium">A collaborative project between:</p>
<div className="mt-3 space-y-2">
<div className="font-medium">School of Humanities and Social Sciences</div>
<div className="space-y-1">
<p>Thomas Barry</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Dean of School</p>
<div className="flex min-h-screen flex-col">
{/* Hero Section */}
<div className="relative bg-gradient-to-r from-blue-900 to-blue-700 text-white">
{/* Removed arctic-bg.jpg reference causing 404 */}
{/* Reduced padding py-16 md:py-20 */}
<div className="container relative mx-auto px-8 py-16 text-center md:py-20">
{/* Reduced text size text-4xl md:text-5xl, mb-3 */}
<h1 className="mb-3 text-4xl font-bold tracking-tight md:text-5xl">
Arctic Species Tracker
</h1>
{/* Reduced text size text-lg, mb-8 */}
<p className="mb-8 mx-auto max-w-2xl text-lg text-blue-100">
Tracking conservation status, CITES listings, and trade data for Arctic species
</p>
{/* Key Stats - Increased margin-top mt-8, added flex container */}
<div className="mt-8 flex justify-center space-x-6">
{/* Species Count */}
<div className="rounded-lg bg-white/10 backdrop-blur-md p-4 text-center">
<div className="text-4xl font-bold">{keyFigures.totalSpecies}</div>
<div className="text-lg text-blue-200">Total Species</div> {/* Reduced text size */}
</div>
<div className="mt-3 space-y-1">
<p>Magnus Smari Smarason</p>
<p className="text-sm text-gray-500 dark:text-gray-400">AI Project Manager</p>
{/* Trade Count */}
<div className="rounded-lg bg-white/10 backdrop-blur-md p-4 text-center">
<div className="text-4xl font-bold">
{isLoadingTradeCount ? '...' : keyFigures.totalTrades.toLocaleString()}
</div>
<div className="text-lg text-blue-200">Total Trades</div> {/* Reduced text size */}
</div>
<div className="mt-4 font-medium">University of Akureyri</div>
</div>
</div>
<p className="mb-8 text-lg text-gray-600 dark:text-gray-300">
Track conservation status, CITES listings, and trade data for Arctic species
</p>
<div className="flex justify-center gap-4">
<Button
onClick={handleToggleSearch}
variant={showSearch ? "default" : "outline"}
className="min-w-[160px] h-11"
>
<Search className="mr-2 h-5 w-5" />
Search Species
</Button>
<Button
onClick={() => setShowSearch(false)}
variant={!showSearch && !selectedSpeciesId ? "default" : "outline"}
className="min-w-[160px] h-11"
>
<Globe className="mr-2 h-5 w-5" />
Browse All Species
</Button>
</div>
</div>
{/* Search and Results Section */}
<div className="mt-12">
{showSearch && !selectedSpeciesId ? (
<div className="mx-auto flex max-w-2xl flex-col items-center space-y-6">
<SearchForm onSelectSpecies={handleSelectSpecies} />
</div>
) : selectedSpeciesId ? (
<ResultsContainer speciesId={selectedSpeciesId} onBack={handleBackToSearch} />
) : (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{isLoading ? (
<div className="col-span-full flex justify-center items-center py-12">
<div className="flex items-center space-x-4 text-gray-500">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<p>Loading species data...</p>
{/* Main Content Area with Tabs */}
<div className="flex-grow bg-gray-50 dark:bg-gray-900 py-8">
<div className="container mx-auto px-6">
{/* Add Tabs component */}
<Tabs defaultValue="browse" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6"> {/* Added mb-6 for spacing */}
<TabsTrigger value="browse">Browse Species</TabsTrigger>
<TabsTrigger value="compare">Compare Trade</TabsTrigger>
</TabsList>
{/* Browse Species Tab Content */}
<TabsContent value="browse">
{isLoadingSpecies ? (
<div className="flex justify-center items-center py-12">
<div className="flex items-center space-x-4 text-gray-500">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<p>Loading species data...</p>
</div>
</div>
</div>
) : uniqueSpecies.length > 0 ? (
uniqueSpecies.map(species => (
<Card
key={species.id}
className="cursor-pointer transition-all hover:shadow-lg hover:border-blue-200 dark:hover:border-blue-800"
onClick={() => handleSelectSpecies(species.id)}
>
<CardHeader className="p-4 pb-2">
<CardTitle className="text-lg">
<span className="italic">{species.scientific_name}</span>
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<p className="text-sm text-gray-600 dark:text-gray-300">{species.primary_common_name}</p>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs font-medium text-gray-800 dark:text-gray-200">
{species.family}
</span>
</div>
</CardContent>
</Card>
))
) : (
<p className="col-span-full text-center text-gray-500 dark:text-gray-400 py-12">No species found</p>
)}
</div>
)}
) : (
<>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
{/* Keep h2 and Filter button */}
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Browse Arctic Species</h2>
<Button variant="outline" className="flex items-center gap-2">
<Filter className="h-5 w-5" /> Filter
</Button>
</div>
<div className="grid gap-10 md:gap-12 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{uniqueSpecies.length > 0 ? (
uniqueSpecies.map(species => (
<SpeciesCard
key={species.id}
species={species}
onClick={handleSelectSpecies}
/>
))
) : (
<p className="col-span-full text-center text-gray-500 dark:text-gray-400 py-12">No species found</p>
)}
</div>
</>
)}
</TabsContent>
{/* Compare Trade Tab Content */}
<TabsContent value="compare">
{/* Render the CompareTradeTab component */}
<CompareTradeTab />
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
}

357
src/services/adminApi.ts Normal file
View File

@ -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<Species>) => {
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<CitesListing>) => {
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<IucnAssessment>) => {
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<CommonName>) => {
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;
}
};

View File

@ -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")],
}