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

This commit is contained in:
Magnus Smari Smarason
2025-03-25 12:28:20 +00:00
parent 436b661acd
commit a5a1e2bdf5
11 changed files with 707 additions and 84 deletions

View File

@ -1,4 +1,13 @@
# Arctic Species 2025 Frontend # Arctic Species 2025 Frontend
**Work in Progress!**
A collaborative project between:
- School of Humanities and Social Sciences
- Thomas Barry
- Dean of School
- Magnus SMari Smarason
- AI Project Manager
University of Akureyri
A comprehensive web application for tracking and analyzing Arctic species data, including CITES trade records, IUCN assessments, and conservation status. This project is part of the Arctic Species 2025 initiative. A comprehensive web application for tracking and analyzing Arctic species data, including CITES trade records, IUCN assessments, and conservation status. This project is part of the Arctic Species 2025 initiative.
@ -10,6 +19,8 @@ A comprehensive web application for tracking and analyzing Arctic species data,
- Multiple common names support - Multiple common names support
- Arctic subpopulation tracking - Arctic subpopulation tracking
- Region-specific conservation status - Region-specific conservation status
- High-quality species images from iNaturalist
- Interactive image viewer with attribution
- **CITES Information** - **CITES Information**
- Complete CITES listing history - Complete CITES listing history
@ -17,12 +28,15 @@ A comprehensive web application for tracking and analyzing Arctic species data,
- Detailed trade records with filtering - Detailed trade records with filtering
- Historical trade data analysis - Historical trade data analysis
- Arctic-specific trade patterns - Arctic-specific trade patterns
- Interactive trade visualizations
- Filterable trade records table
- **IUCN Assessments** - **IUCN Assessments**
- Latest IUCN Red List status - Latest IUCN Red List status
- Historical assessment tracking - Historical assessment tracking
- Population trend analysis - Population trend analysis
- Arctic region-specific assessments - Arctic region-specific assessments
- Color-coded status indicators
- **Timeline View** - **Timeline View**
- Chronological view of species events - Chronological view of species events
@ -31,6 +45,13 @@ A comprehensive web application for tracking and analyzing Arctic species data,
- Trade record history - Trade record history
- Arctic conservation milestones - Arctic conservation milestones
- **Data Visualization**
- Trade records over time
- Top importers and exporters
- Distribution of traded terms
- Trade purposes and sources
- Interactive charts and graphs
## Tech Stack ## Tech Stack
- **Frontend**: React + TypeScript + Vite - **Frontend**: React + TypeScript + Vite
@ -39,6 +60,8 @@ A comprehensive web application for tracking and analyzing Arctic species data,
- **Database**: Supabase (PostgreSQL) - **Database**: Supabase (PostgreSQL)
- **State Management**: React Query - **State Management**: React Query
- **Routing**: React Router - **Routing**: React Router
- **Charts**: Recharts
- **Image Integration**: iNaturalist API
## Getting Started ## Getting Started
@ -86,6 +109,13 @@ The application uses the following main tables:
- `cites_trade_records`: CITES trade data - `cites_trade_records`: CITES trade data
- `timeline_events`: Chronological events - `timeline_events`: Chronological events
## Data Sources
- **Species Images**: iNaturalist API (https://api.inaturalist.org/)
- **CITES Data**: CITES Trade Database (https://trade.cites.org/)
- **IUCN Data**: IUCN Red List (https://www.iucnredlist.org/)
- **Species Information**: Arctic Species Database
## Contributing ## Contributing
1. Fork the repository 1. Fork the repository
@ -102,5 +132,6 @@ This project is licensed under the MIT License - see the LICENSE file for detail
- CITES Secretariat for trade data - CITES Secretariat for trade data
- IUCN Red List for assessment data - IUCN Red List for assessment data
- iNaturalist for species images
- Arctic Council for regional guidance - Arctic Council for regional guidance
- All contributors and maintainers - All contributors and maintainers

334
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "arctic-species-tracker", "name": "arctic-species-tracker",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
@ -38,6 +39,7 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"gh-pages": "^6.3.0",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",
@ -1196,6 +1198,42 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz",
"integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.5",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.2",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-portal": "1.1.4",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"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-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@ -2611,6 +2649,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"dev": true,
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.21", "version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@ -2862,6 +2907,13 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3137,6 +3189,13 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/email-addresses": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz",
"integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==",
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@ -3505,6 +3564,34 @@
"node": "^10.12.0 || >=12.0.0" "node": "^10.12.0 || >=12.0.0"
} }
}, },
"node_modules/filename-reserved-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz",
"integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/filenamify": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz",
"integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"filename-reserved-regex": "^2.0.0",
"strip-outer": "^1.0.1",
"trim-repeated": "^1.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -3517,6 +3604,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/find-cache-dir": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz",
"integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==",
"dev": true,
"license": "MIT",
"dependencies": {
"commondir": "^1.0.1",
"make-dir": "^3.0.2",
"pkg-dir": "^4.1.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/avajs/find-cache-dir?sponsor=1"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@ -3586,6 +3691,21 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/fs-extra": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
"integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/fs.realpath": { "node_modules/fs.realpath": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -3635,6 +3755,39 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/gh-pages": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.3.0.tgz",
"integrity": "sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"async": "^3.2.4",
"commander": "^13.0.0",
"email-addresses": "^5.0.0",
"filenamify": "^4.3.0",
"find-cache-dir": "^3.3.1",
"fs-extra": "^11.1.1",
"globby": "^11.1.0"
},
"bin": {
"gh-pages": "bin/gh-pages.js",
"gh-pages-clean": "bin/gh-pages-clean.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/gh-pages/node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/glob": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@ -3724,6 +3877,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/graphemer": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@ -4011,6 +4171,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -4113,6 +4286,32 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -4306,6 +4505,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -4428,6 +4637,75 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
"integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"find-up": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/pkg-dir/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/pkg-dir/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/pkg-dir/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkg-dir/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.3", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
@ -5157,6 +5435,29 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strip-outer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz",
"integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^1.0.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-outer/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/sucrase": { "node_modules/sucrase": {
"version": "3.35.0", "version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@ -5332,6 +5633,29 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/trim-repeated": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
"integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^1.0.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/trim-repeated/node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
@ -5403,6 +5727,16 @@
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@ -3,13 +3,17 @@
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"homepage": "https://magnussmari.github.io/arctic-species-2025-frontend",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview",
"predeploy": "npm run build",
"deploy": "gh-pages -d dist"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
@ -40,6 +44,7 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"gh-pages": "^6.3.0",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",

View File

@ -1,12 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { searchSpecies } from '@/lib/api';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { useQuery } from '@tanstack/react-query';
import { getAllSpecies } from '@/lib/api';
import { Search, Loader2 } from 'lucide-react'; import { Search, Loader2 } from 'lucide-react';
import { IUCN_STATUS_COLORS } from '@/lib/utils'; import { Card, CardContent } from '@/components/ui/card';
export function SearchForm({ onSelectSpecies }: { onSelectSpecies: (id: string) => void }) { export function SearchForm({ onSelectSpecies }: { onSelectSpecies: (id: string) => void }) {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@ -28,7 +26,7 @@ export function SearchForm({ onSelectSpecies }: { onSelectSpecies: (id: string)
// Fetch search results // Fetch search results
const { data: searchResults, isLoading } = useQuery({ const { data: searchResults, isLoading } = useQuery({
queryKey: ['searchSpecies', debouncedTerm], queryKey: ['searchSpecies', debouncedTerm],
queryFn: () => searchSpecies(debouncedTerm), queryFn: () => getAllSpecies(),
enabled: debouncedTerm.length >= 3, enabled: debouncedTerm.length >= 3,
}); });
@ -39,6 +37,12 @@ export function SearchForm({ onSelectSpecies }: { onSelectSpecies: (id: string)
} }
}; };
// 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())
);
return ( return (
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<form onSubmit={handleSubmit} className="relative"> <form onSubmit={handleSubmit} className="relative">
@ -64,11 +68,11 @@ export function SearchForm({ onSelectSpecies }: { onSelectSpecies: (id: string)
</form> </form>
{/* Search Results */} {/* Search Results */}
{searchResults && searchResults.length > 0 && ( {filteredResults && filteredResults.length > 0 && (
<Card className="mt-2"> <Card className="mt-2">
<CardContent className="p-2"> <CardContent className="p-2">
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
{searchResults.map((species) => ( {filteredResults.map((species) => (
<li <li
key={species.id} key={species.id}
className="cursor-pointer p-2 hover:bg-muted" className="cursor-pointer p-2 hover:bg-muted"
@ -83,7 +87,7 @@ export function SearchForm({ onSelectSpecies }: { onSelectSpecies: (id: string)
</Card> </Card>
)} )}
{searchResults && searchResults.length === 0 && debouncedTerm.length >= 3 && ( {filteredResults && filteredResults.length === 0 && debouncedTerm.length >= 3 && (
<p className="mt-2 text-sm text-muted-foreground">No species found matching your search.</p> <p className="mt-2 text-sm text-muted-foreground">No species found matching your search.</p>
)} )}
</div> </div>

View File

@ -4,10 +4,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { SpeciesDetails, TimelineEvent, CitesTradeRecord } from "@/lib/api"; import { SpeciesDetails, TimelineEvent, CitesTradeRecord } from "@/lib/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getTimelineEvents, getCitesTradeRecords } from "@/lib/api"; import { getTimelineEvents, getCitesTradeRecords, getSpeciesImages } from "@/lib/api";
import { formatDate, IUCN_STATUS_COLORS, IUCN_STATUS_FULL_NAMES, CITES_APPENDIX_COLORS } from "@/lib/utils"; import { formatDate, IUCN_STATUS_COLORS, IUCN_STATUS_FULL_NAMES, CITES_APPENDIX_COLORS } from "@/lib/utils";
import { Loader2, Filter, BarChart3, PieChart, LineChart } from "lucide-react"; import { Loader2, Filter, BarChart3, PieChart, LineChart, ImageIcon, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -26,8 +25,13 @@ import {
PieChart as RechartsPieChart, PieChart as RechartsPieChart,
Pie, Pie,
Cell, Cell,
Sector
} from "recharts"; } from "recharts";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type SpeciesTabsProps = { type SpeciesTabsProps = {
species: SpeciesDetails; species: SpeciesDetails;
@ -122,10 +126,18 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
queryFn: () => getCitesTradeRecords(species.id), queryFn: () => getCitesTradeRecords(species.id),
}); });
// Add image fetching query
const { data: imageData } = useQuery({
queryKey: ["speciesImage", species.scientific_name],
queryFn: () => getSpeciesImages(species.scientific_name),
enabled: !!species.scientific_name,
});
// State for trade record filters - define these at the component level, not inside a conditional // State for trade record filters - define these at the component level, not inside a conditional
const [yearFilter, setYearFilter] = useState<string>("all"); const [yearFilter, setYearFilter] = useState<string>("all");
const [termFilter, setTermFilter] = useState<string>("all"); const [termFilter, setTermFilter] = useState<string>("all");
const [appendixFilter, setAppendixFilter] = useState<string>("all"); const [appendixFilter, setAppendixFilter] = useState<string>("all");
const [isImageDialogOpen, setIsImageDialogOpen] = useState(false);
// Debug: Log CITES listings data // Debug: Log CITES listings data
console.log("CITES listings count:", species.cites_listings?.length); console.log("CITES listings count:", species.cites_listings?.length);
@ -270,7 +282,7 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
}, [tradeRecords]); }, [tradeRecords]);
// Custom tooltip for pie chart // Custom tooltip for pie chart
const renderCustomizedLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent, index, payload }: any) => { const renderCustomizedLabel = ({ cx, cy, midAngle, outerRadius, percent, payload }: any) => {
const RADIAN = Math.PI / 180; const RADIAN = Math.PI / 180;
const radius = outerRadius * 1.1; const radius = outerRadius * 1.1;
const x = cx + radius * Math.cos(-midAngle * RADIAN); const x = cx + radius * Math.cos(-midAngle * RADIAN);
@ -326,10 +338,7 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
}; };
// Determine default tab based on available data // Determine default tab based on available data
const defaultTab = const defaultTab = "overview"; // Always start with Overview tab
species.cites_listings && species.cites_listings.length > 0 ? "cites" :
species.iucn_assessments && species.iucn_assessments.length > 0 ? "iucn" :
"overview";
return ( return (
<div className="w-full flex flex-col"> <div className="w-full flex flex-col">
@ -360,6 +369,66 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
<CardDescription>Taxonomic classification and basic info</CardDescription> <CardDescription>Taxonomic classification and basic info</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Add image display */}
<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)}
>
{imageData ? (
<>
<img
src={imageData.url}
alt={species.scientific_name}
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}
</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>
{/* Add Dialog for full-size image */}
<Dialog open={isImageDialogOpen} onOpenChange={setIsImageDialogOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>{species.scientific_name}</span>
<Button
variant="ghost"
size="icon"
onClick={() => setIsImageDialogOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</DialogTitle>
</DialogHeader>
{imageData && (
<div className="relative">
<img
src={imageData.url.replace('medium', 'original')}
alt={species.scientific_name}
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>
{imageData.license && (
<p className="text-xs mt-1">License: {imageData.license}</p>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
<dl className="grid grid-cols-2 gap-2"> <dl className="grid grid-cols-2 gap-2">
<dt className="font-semibold">Scientific Name:</dt> <dt className="font-semibold">Scientific Name:</dt>
<dd className="italic">{species.scientific_name}</dd> <dd className="italic">{species.scientific_name}</dd>
@ -852,11 +921,11 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
dataKey="count" dataKey="count"
nameKey="term" nameKey="term"
> >
{visualizationData.termsTraded.map((entry, index) => ( {visualizationData.termsTraded.map((_, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} /> <Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))} ))}
</Pie> </Pie>
<Tooltip formatter={(value, name, props) => [`${value} records`, props.payload.term]} /> <Tooltip formatter={(value, _, props) => [`${value} records`, props.payload.term]} />
</RechartsPieChart> </RechartsPieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@ -891,7 +960,7 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
tickFormatter={(value) => `${value} - ${PURPOSE_DESCRIPTIONS[value] || 'Unknown'}`} tickFormatter={(value) => `${value} - ${PURPOSE_DESCRIPTIONS[value] || 'Unknown'}`}
width={150} width={150}
/> />
<Tooltip formatter={(value, name, props) => [ <Tooltip formatter={(value, _, props) => [
`${value} records`, `${value} records`,
`${props.payload.purpose} - ${props.payload.description}` `${props.payload.purpose} - ${props.payload.description}`
]} /> ]} />
@ -928,7 +997,7 @@ export function SpeciesTabs({ species }: SpeciesTabsProps) {
tickFormatter={(value) => `${value} - ${SOURCE_DESCRIPTIONS[value] || 'Unknown'}`} tickFormatter={(value) => `${value} - ${SOURCE_DESCRIPTIONS[value] || 'Unknown'}`}
width={150} width={150}
/> />
<Tooltip formatter={(value, name, props) => [ <Tooltip formatter={(value, _, props) => [
`${value} records`, `${value} records`,
`${props.payload.source} - ${props.payload.description}` `${props.payload.source} - ${props.payload.description}`
]} /> ]} />

View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{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" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -26,19 +26,6 @@ export type SpeciesDetails = Species & {
current_cites_listing?: CitesListing; current_cites_listing?: CitesListing;
}; };
// Debug function to check for duplicate species in database
async function checkForDuplicateSpecies() {
// Direct SQL query using RPC for more complex query
const { data, error } = await supabase.rpc('get_duplicate_species', {});
if (error) {
console.error('Error checking for duplicates:', error);
return null;
}
return data;
}
export async function getAllSpecies() { export async function getAllSpecies() {
try { try {
console.log("Fetching all species from database..."); console.log("Fetching all species from database...");
@ -379,4 +366,42 @@ export async function searchSpecies(query: string) {
} }
return Array.from(speciesMap.values()); return Array.from(speciesMap.values());
}
export async function updateSpeciesImage(speciesId: string, imageUrl: string) {
const { error } = await supabase
.from('species')
.update({ default_image_url: imageUrl })
.eq('id', speciesId);
if (error) {
console.error('Error updating species image:', error);
}
}
export async function getSpeciesImages(scientificName: string) {
try {
console.log('Fetching images for species:', scientificName);
// First try to get from iNaturalist API
const response = await fetch(
`https://api.inaturalist.org/v1/taxa?q=${encodeURIComponent(scientificName)}&order=desc&order_by=observations_count`
);
const data = await response.json();
if (data.results && data.results[0] && data.results[0].default_photo) {
console.log('Found image from iNaturalist:', data.results[0].default_photo);
return {
url: data.results[0].default_photo.medium_url,
attribution: data.results[0].default_photo.attribution,
license: data.results[0].default_photo.license_code
};
}
console.log('No image found for species:', scientificName);
return null;
} catch (error) {
console.error('Error fetching species images:', error);
return null;
}
} }

View File

@ -25,7 +25,7 @@ const client = createClient<Database>(supabaseUrl, supabaseAnonKey, {
// Test the connection // Test the connection
(async () => { (async () => {
try { try {
const { data, error } = await client.from('species').select('id').limit(1); const { error } = await client.from('species').select('id').limit(1);
if (error) { if (error) {
console.error('Failed to connect to Supabase:', error.message); console.error('Failed to connect to Supabase:', error.message);
} else { } else {

View File

@ -10,7 +10,7 @@ import { queryClient } from './lib/query-client.ts'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter basename="/arctic-species-2025-frontend">
<App /> <App />
</BrowserRouter> </BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />

View File

@ -51,60 +51,92 @@ export function HomePage() {
return ( return (
<div className="container mx-auto my-8 space-y-8 px-4"> <div className="container mx-auto my-8 space-y-8 px-4">
<div className="text-center"> <div className="text-center max-w-3xl mx-auto">
<h1 className="mb-2 text-4xl font-bold">Arctic Species Tracker</h1> <h1 className="mb-4 text-5xl font-bold tracking-tight text-gray-900 dark:text-gray-100">Arctic Species Tracker</h1>
<p className="mb-6 text-muted-foreground"> <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>
<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>
</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 Track conservation status, CITES listings, and trade data for Arctic species
</p> </p>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-4">
<Button onClick={handleToggleSearch} variant={showSearch ? "default" : "outline"}> <Button
<Search className="mr-2 h-4 w-4" /> onClick={handleToggleSearch}
variant={showSearch ? "default" : "outline"}
className="min-w-[160px] h-11"
>
<Search className="mr-2 h-5 w-5" />
Search Species Search Species
</Button> </Button>
<Button onClick={() => setShowSearch(false)} variant={!showSearch && !selectedSpeciesId ? "default" : "outline"}> <Button
<Globe className="mr-2 h-4 w-4" /> 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 Browse All Species
</Button> </Button>
</div> </div>
</div> </div>
{showSearch && !selectedSpeciesId ? ( {/* Search and Results Section */}
<div className="mx-auto flex max-w-2xl flex-col items-center space-y-6"> <div className="mt-12">
<SearchForm onSelectSpecies={handleSelectSpecies} /> {showSearch && !selectedSpeciesId ? (
</div> <div className="mx-auto flex max-w-2xl flex-col items-center space-y-6">
) : selectedSpeciesId ? ( <SearchForm onSelectSpecies={handleSelectSpecies} />
<ResultsContainer speciesId={selectedSpeciesId} onBack={handleBackToSearch} /> </div>
) : ( ) : selectedSpeciesId ? (
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <ResultsContainer speciesId={selectedSpeciesId} onBack={handleBackToSearch} />
{isLoading ? ( ) : (
<p className="col-span-full text-center">Loading species data...</p> <div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
) : uniqueSpecies.length > 0 ? ( {isLoading ? (
uniqueSpecies.map(species => ( <div className="col-span-full flex justify-center items-center py-12">
<Card <div className="flex items-center space-x-4 text-gray-500">
key={species.id} <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
className="cursor-pointer transition-all hover:shadow-md" <p>Loading species data...</p>
onClick={() => handleSelectSpecies(species.id)} </div>
> </div>
<CardHeader className="p-4 pb-2"> ) : uniqueSpecies.length > 0 ? (
<CardTitle className="text-lg"> uniqueSpecies.map(species => (
<span className="italic">{species.scientific_name}</span> <Card
</CardTitle> key={species.id}
</CardHeader> className="cursor-pointer transition-all hover:shadow-lg hover:border-blue-200 dark:hover:border-blue-800"
<CardContent className="p-4 pt-0"> onClick={() => handleSelectSpecies(species.id)}
<p className="text-sm text-muted-foreground">{species.primary_common_name}</p> >
<div className="mt-2 flex flex-wrap gap-1"> <CardHeader className="p-4 pb-2">
<span className="rounded-sm bg-muted px-1.5 py-0.5 text-xs"> <CardTitle className="text-lg">
{species.family} <span className="italic">{species.scientific_name}</span>
</span> </CardTitle>
</div> </CardHeader>
</CardContent> <CardContent className="p-4 pt-0">
</Card> <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">
<p className="col-span-full text-center">No species found</p> {species.family}
)} </span>
</div> </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>
</div> </div>
); );
} }

View File

@ -4,6 +4,7 @@ import path from 'path';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: '/arctic-species-2025-frontend/',
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {