Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -26,7 +30,15 @@ function App() {
<Route path="login" element={<Login />} />
<Route path="change-password" element={<ChangePassword />} />
<Route path="admin/users/new" element={<RequireAuth><AdminCreateUser /></RequireAuth>} />
<Route path="review" element={<RequireAuth><Review /></RequireAuth>} />
<Route path="admin/registrations" element={<RequireAuth><AdminRegistrations /></RequireAuth>} />
<Route path="register" element={<Register />} />
<Route path="reports" element={<Reports />} />
{/* Same paths as the legacy site, so existing links keep working. */}
<Route path="about" element={<InfoPage page="about" />} />
<Route path="terms-of-use" element={<InfoPage page="terms-of-use" />} />
<Route path="paia-popia" element={<InfoPage page="paia-popia" />} />
<Route path="license" element={<InfoPage page="license" />} />
<Route path="submission/:id" element={<SubmissionDetails />} />
<Route path="submission/new" element={<RequireAuth><SubmissionForm /></RequireAuth>} />
<Route path="submission/edit/:id" element={<RequireAuth><SubmissionForm /></RequireAuth>} />
Expand Down
7 changes: 5 additions & 2 deletions src/components/BulkUploadModal.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { uploadBulkSubmissions } from '../services/api';
import { uploadBulkSubmissions, UPLOAD_TEMPLATE_URL } from '../services/api';
import { Upload, UploadCloud, X, AlertTriangle, CheckCircle2, Activity } from 'lucide-react';

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -97,7 +97,10 @@ const BulkUploadModal = ({ onClose, onSuccess }) => {
Upload a consolidated <strong>.xlsx</strong> workbook with sheets:
&ldquo;General project details&rdquo;, &ldquo;Adaptation details&rdquo; (optional),
&ldquo;Mitigation details&rdquo; (optional). Row 1 must contain column headers.
All rows are validated before any record is saved.
All rows are validated before any record is saved.{' '}
<a href={UPLOAD_TEMPLATE_URL} style={{ color: 'var(--accent-primary)', textDecoration: 'underline' }}>
Download the template
</a>{' '}with dropdown lists and instructions.
</p>

{/* Drop zone */}
Expand Down
51 changes: 51 additions & 0 deletions src/components/DownloadProjectButton.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// ─────────────────────────────────────────────────────────────────────────────
// DownloadProjectButton — downloads one project as an Excel file (same columns
// as the bulk export). `compact` renders an icon button for result cards.
// ─────────────────────────────────────────────────────────────────────────────
import { useState } from 'react';
import { Download } from 'lucide-react';
import { downloadExport } from '../services/api';
import { useCurrentUser } from '../context/CurrentUserContext';
import { singleProjectParams } from '../utils/submissionQuery';

const DownloadProjectButton = ({ submission, compact = false }) => {
const { user, hasPermission } = useCurrentUser();
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);

const download = async () => {
setBusy(true);
setFailed(false);
try {
await downloadExport(singleProjectParams(submission, {
isOwner: !!user && submission.createdby === user.id,
isReviewer: hasPermission('validate-submission'),
}), 'xlsx');
} catch (err) {
console.error(err);
setFailed(true);
} finally {
setBusy(false);
}
};

const label = failed ? 'Download failed, try again' : `Download ${submission.title || 'this project'} as Excel`;
return compact ? (
<button type="button" className="btn btn-outline icon-button" onClick={download} disabled={busy} title={label} aria-label={label}>
<Download size={18} color={failed ? '#b91c1c' : 'var(--accent-primary)'} />
</button>
) : (
<button
type="button"
className="btn btn-outline flex items-center gap-2"
onClick={download}
disabled={busy}
title={failed ? label : 'Download this project as an Excel file'}
style={{ padding: '0.4rem 1rem', fontSize: '0.85rem', fontWeight: 600 }}
>
<Download size={15} /> {busy ? 'Preparing…' : failed ? 'Retry download' : 'Download'}
</button>
);
};

export default DownloadProjectButton;
35 changes: 10 additions & 25 deletions src/components/InterventionBadge.jsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,15 @@
// ─────────────────────────────────────────────────────────────────────────────
// InterventionBadge — coloured pill chip per intervention type (used by Home)
// InterventionBadge — project type in the shared boxed .badge style, with a
// colour dot carrying the type colour (shared with the map and charts,
// utils/typeColors.js); the text keeps the badge's readable colour.
// ─────────────────────────────────────────────────────────────────────────────
import { typeColor } from '../utils/typeColors';

/** Colour tokens for the per-submission intervention type badge. */
const INTERVENTION_BADGE = {
Mitigation: { color: '#fbbf24', bg: 'rgba(251,191,36,0.12)' },
Adaptation: { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
'Cross Cutting': { color: '#818cf8', bg: 'rgba(129,140,248,0.12)' },
};

const InterventionBadge = ({ type }) => {
const style = INTERVENTION_BADGE[type] || { color: 'var(--text-muted)', bg: 'transparent' };
return (
<span style={{
display: 'inline-block',
padding: '0.15rem 0.6rem',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 600,
color: style.color,
background: style.bg,
border: `1px solid ${style.color}40`,
}}>
{type || 'General'}
</span>
);
};
const InterventionBadge = ({ type }) => (
<span className="badge type-badge">
<span className="type-dot" style={{ background: typeColor(type) }} aria-hidden="true" />
{type || 'General'}
</span>
);

export default InterventionBadge;
14 changes: 2 additions & 12 deletions src/components/Layout.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Outlet, useLocation } from 'react-router-dom';
import Navbar from './Navbar';
import SiteFooter from './SiteFooter';

const Layout = () => {
const location = useLocation();
Expand All @@ -11,18 +12,7 @@ const Layout = () => {
<main className="page-wrapper animate-fade-in">
<Outlet />
</main>
<footer style={{
padding: '3rem 0',
background: 'var(--bg-secondary)',
borderTop: '1px solid var(--border-light)',
color: 'var(--text-secondary)'
}}>
<div className="container" style={{ textAlign: 'center' }}>
<p style={{ margin: 0, fontSize: '0.9rem' }}>
&copy; {new Date().getFullYear()} National Climate Change Response Database. Powered by Open Data Platform.
</p>
</div>
</footer>
<SiteFooter />
</div>
);
};
Expand Down
39 changes: 38 additions & 1 deletion src/components/Navbar.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { Link, useNavigate } from 'react-router-dom';
import { Search, UserPlus, Activity, FolderOpen } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Search, UserPlus, Activity, FolderOpen, ClipboardCheck, BarChart3, UserCheck } from 'lucide-react';
import { useCurrentUser } from '../context/CurrentUserContext';
import { getReviewCounts, getRegistrations } from '../services/api';

const Navbar = () => {
const navigate = useNavigate();
const { user, hasPermission, isAuthenticated, logout } = useCurrentUser();
const isReviewer = hasPermission('validate-submission');
const [awaitingReview, setAwaitingReview] = useState(null);
const isAdmin = hasPermission('assign-role');
const [pendingRequests, setPendingRequests] = useState(null);

// Admins see how many account requests are waiting.
useEffect(() => {
if (!isAdmin) return;
getRegistrations('pending')
.then((list) => setPendingRequests(list.length))
.catch((err) => console.warn('[NCCRD] Could not load account requests.', err));
}, [isAdmin]);

// Reviewers see how many submissions are waiting.
useEffect(() => {
if (!isReviewer) return;
getReviewCounts()
.then((counts) => setAwaitingReview(counts.awaiting_review))
.catch((err) => console.warn('[NCCRD] Could not load review counts.', err));
}, [isReviewer]);

const handleLogout = () => {
logout();
Expand Down Expand Up @@ -53,6 +75,9 @@ const Navbar = () => {
<a href="#" className="nav-link" onClick={(e) => { e.preventDefault(); handleSearchData(); }}>
<Search size={14} /> SEARCH DATA
</a>
<Link to="/reports" className="nav-link">
<BarChart3 size={14} /> DATA REPORTS
</Link>
{isAuthenticated && (
<a href="/?mine=1" className="nav-link" onClick={handleMySubmissions}>
<FolderOpen size={14} /> MY SUBMISSIONS
Expand All @@ -63,6 +88,18 @@ const Navbar = () => {
<Activity size={14} /> NEW SUBMISSION
</Link>
)}
{isReviewer && (
<Link to="/review" className="nav-link">
<ClipboardCheck size={14} /> REVIEW
{awaitingReview > 0 && <span className="nav-count" aria-label={`${awaitingReview} waiting`}>{awaitingReview}</span>}
</Link>
)}
{isAdmin && (
<Link to="/admin/registrations" className="nav-link">
<UserCheck size={14} /> REQUESTS
{pendingRequests > 0 && <span className="nav-count" aria-label={`${pendingRequests} waiting`}>{pendingRequests}</span>}
</Link>
)}
{hasPermission('assign-role') && (
<Link to="/admin/users/new" className="nav-link">
<UserPlus size={14} /> ADD USER
Expand Down
68 changes: 68 additions & 0 deletions src/components/ProjectMap.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// ─────────────────────────────────────────────────────────────────────────────
// ProjectMap — where the filtered projects are, one dot per project location
// (a project can list several), coloured by type with a white ring so dots
// stay distinct on any basemap and where they overlap. Hover shows the title;
// clicking opens a popup linking to the project.
//
// Points come from GET /report/locations (see services/api.js), which takes
// the same filters as the project search. Tiles: OpenStreetMap, credited as
// its licence requires.
// ─────────────────────────────────────────────────────────────────────────────
import { MapContainer, TileLayer, CircleMarker, Tooltip, Popup } from 'react-leaflet';
import { Link } from 'react-router-dom';
import 'leaflet/dist/leaflet.css';
import { countByType, typeColor } from '../utils/typeColors';

const SOUTH_AFRICA = { center: [-29.0, 24.7], zoom: 5 };

const ProjectMap = ({ projects = [], withoutLocation = 0, height = 440 }) => (
<div className="project-map">
<div className="project-map-frame" style={{ height }}>
<MapContainer
center={SOUTH_AFRICA.center}
zoom={SOUTH_AFRICA.zoom}
minZoom={4}
scrollWheelZoom={false}
preferCanvas
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{projects.flatMap((p) => p.points.map(([lat, lon], i) => (
<CircleMarker
key={`${p.id}-${i}`}
center={[lat, lon]}
radius={5}
pathOptions={{ color: '#ffffff', weight: 1.5, fillColor: typeColor(p.type), fillOpacity: 0.9 }}
>
<Tooltip direction="top" offset={[0, -4]}>{p.title || 'Untitled project'}</Tooltip>
<Popup>
<strong>{p.title || 'Untitled project'}</strong>
<br />
{p.type}
<br />
<Link to={`/submission/${p.id}`}>View project</Link>
</Popup>
</CircleMarker>
)))}
</MapContainer>
</div>
<div className="project-map-legend">
{countByType(projects).map(([type, n]) => (
<span key={type} className="project-map-key">
<span className="type-dot" style={{ background: typeColor(type) }} aria-hidden="true" />
{type} <span className="project-map-count">{n.toLocaleString()}</span>
</span>
))}
{withoutLocation > 0 && (
<span className="report-note" style={{ margin: 0 }}>
{withoutLocation.toLocaleString()} projects have no recorded location and are not shown.
</span>
)}
</div>
</div>
);

export default ProjectMap;
Loading
Loading