{title}
+ {subtitle &&{subtitle}
} + {content ?? INFO_PAGES[page].map((block, i) =>diff --git a/package-lock.json b/package-lock.json
index 3b05523..3036527 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,11 @@
"name": "nccrd-react-ui",
"version": "0.0.0",
"dependencies": {
+ "leaflet": "1.9.4",
"lucide-react": "^1.46.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
+ "react-leaflet": "5.0.0",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
@@ -803,6 +805,17 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
+ "node_modules/@react-leaflet/core": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
+ "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
+ "license": "Hippocratic-2.1",
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
@@ -3526,6 +3539,12 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -4379,6 +4398,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-leaflet": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
+ "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
+ "license": "Hippocratic-2.1",
+ "dependencies": {
+ "@react-leaflet/core": "^3.0.0"
+ },
+ "peerDependencies": {
+ "leaflet": "^1.9.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ }
+ },
"node_modules/react-router": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
diff --git a/package.json b/package.json
index bb954a7..eb9adb0 100644
--- a/package.json
+++ b/package.json
@@ -11,9 +11,11 @@
"test": "vitest run"
},
"dependencies": {
+ "leaflet": "1.9.4",
"lucide-react": "^1.46.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
+ "react-leaflet": "5.0.0",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
diff --git a/src/App.jsx b/src/App.jsx
index 329f5c4..f1f319f 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -15,6 +15,10 @@ const Login = lazy(() => import('./pages/Login'))
const ChangePassword = lazy(() => import('./pages/ChangePassword'))
const AdminCreateUser = lazy(() => import('./pages/AdminCreateUser'))
const Reports = lazy(() => import('./pages/Reports'))
+const Review = lazy(() => import('./pages/Review'))
+const InfoPage = lazy(() => import('./pages/InfoPage'))
+const Register = lazy(() => import('./pages/Register'))
+const AdminRegistrations = lazy(() => import('./pages/AdminRegistrations'))
const NotFound = lazy(() => import('./pages/NotFound'))
function App() {
@@ -26,7 +30,15 @@ function App() {
{NOTES[state]}
+ {submission.submission_comments && ( ++ {submission.submission_comments} + {submission.reviewed_by && } ++ )} +
+ {published + ? 'This project is published. Marking it not accepted removes it from the public site.' + : 'Accepting publishes this project on the public site.'} +
+ +{emptyText}
; const max = Math.max(...rows.map((r) => r.count)); @@ -14,8 +14,10 @@ const BarList = ({ rows = [], total, emptyText = 'No data for this selection.' }| {labelHeader} | {data.series.map((s) =>{s} | )}Total |
|---|---|---|
| {r.label} | + {data.series.map((s) =>{(r.values[s] || 0).toLocaleString()} | )} +{data.series.reduce((n, s) => n + (r.values[s] || 0), 0).toLocaleString()} | +
{total(unspecified, series).toLocaleString()} projects {unspecifiedText}.
+ ); + if (!rows.length) return <>{{emptyText}
}{note}>; + const max = Math.max(...rows.map((r) => total(r, series))); + + return ( +{emptyText}
; + + const first = data.rows[0].label; + const last = data.rows[data.rows.length - 1].label; + const byYear = Object.fromEntries(data.rows.map((r) => [r.label, r.values])); + const years = Array.from({ length: last - first + 1 }, (_, i) => { + const values = byYear[first + i] || {}; + return { year: first + i, values, total: series.reduce((n, s) => n + (values[s] || 0), 0) }; + }); + + const width = Math.max(years.length * 28 + PAD.left + PAD.right, 320); + const plotW = width - PAD.left - PAD.right; + const plotH = HEIGHT - PAD.top - PAD.bottom; + const max = niceMax(Math.max(...years.map((d) => d.total))); + const scale = (v) => (v / max) * plotH; + const base = PAD.top + plotH; + const band = plotW / years.length; + const colW = Math.min(COLUMN_MAX, band - 4); + const peak = years.reduce((a, b) => (b.total > a.total ? b : a)); + const labelEvery = Math.ceil(years.length / 12); + + return ( +No start dates recorded for this selection.
; - - const first = data[0].year; - const last = data[data.length - 1].year; - const byYear = Object.fromEntries(data.map((d) => [d.year, d.count])); - const years = Array.from({ length: last - first + 1 }, (_, i) => first + i) - .map((year) => ({ year, count: byYear[year] || 0 })); - - const width = Math.max(years.length * 28 + PAD.left + PAD.right, 320); - const plotW = width - PAD.left - PAD.right; - const plotH = HEIGHT - PAD.top - PAD.bottom; - const max = niceMax(Math.max(...years.map((d) => d.count))); - const band = plotW / years.length; - const colW = Math.min(COLUMN_MAX, band - 4); - const y = (v) => PAD.top + plotH - (v / max) * plotH; - const ticks = [0, max / 2, max]; - const peak = years.reduce((a, b) => (b.count > a.count ? b : a)); - const labelEvery = Math.ceil(years.length / 12); - - return ( -{unknown.toLocaleString()} projects have no start date and are not shown.
- )} -| Start year | Projects |
|---|---|
| {d.year} | {d.count.toLocaleString()} |
You need the assign-role permission to review account requests.
+People who asked for an account on the sign-up page. Approving gives them the chosen role on this site.
+Loading…
+ ) : requests.length === 0 ? ( +{status === 'pending' ? 'No requests waiting.' : 'None.'}
+{r.registration_note}} +
Loading map…
} +{block.text}
+ : ; + } + const Tag = type; + return+ The NCCRD server (API and database code) is free software, copyright © 2025 South African Environmental + Observation Network (SAEON). You can redistribute it and/or modify it under the terms of the GNU Affero + General Public License as published by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. +
++ It is distributed in the hope that it will be useful, but without any warranty; without even the implied + warranty of merchantability or fitness for a particular purpose. See the GNU Affero General Public License + for more details. +
+Project data published on this website is subject to the Terms of Use.
+ > +); + +const PAGES = { + about: { title: 'About the NCCRD' }, + 'terms-of-use': { title: 'Terms of Use' }, + 'paia-popia': { + title: 'PAIA & POPIA', + subtitle: 'Promotion of Access to Information Act (PAIA) and Protection of Personal Information Act (POPIA)', + extra: ( + <> ++ This website does not use cookies or visitor tracking. When you log in, your session is kept in your + browser’s local storage on your own device, along with any unsaved drafts of a project you are + capturing. Logging out removes the session; a draft stays until you save it or discard it. +
+ > + ), + }, + license: { title: 'Licence', content:{subtitle}
} + {content ?? INFO_PAGES[page].map((block, i) =>- Back to home + No account yet? Request one · Back to home
{done}
+Go to log in
++ You need an account to submit projects. An administrator reviews each request; you can log in once yours is approved. +
+ ++ Already have an account? Log in +
+{subtitle}
} {children} + {csv && ( + + )}{summary.under_way_unknown.toLocaleString()} projects have no start date and are not shown.
+ )} +You need the reviewer permission (validate-submission) to see the review queue.
++ Open a project to read it in full, then accept it to publish it, or mark it not accepted with a reason for the submitter. +
+Loading…
+ ) : items.length === 0 ? ( +Nothing here. {tab === 'awaiting_review' && 'The queue is clear.'}
+You don't have permission to {isEdit ? 'edit' : 'create'} submissions.
+{isEdit ? 'You can only edit your own submissions.' : 'You don’t have permission to create submissions.'}