From 04d9d4a806e960a827f1d593d9abd242fadcf4b3 Mon Sep 17 00:00:00 2001
From: shittu adewale
Date: Mon, 2 Mar 2026 17:18:55 +0100
Subject: [PATCH 01/62] feat: completed base models
---
.../adr-choose-url-state-library/adr1.md | 40 ++
frontend/.husky/pre-commit | 11 +-
frontend/package.json | 1 +
frontend/pnpm-lock.yaml | 37 ++
frontend/src/app/router.tsx | 33 +-
.../routes/base-model/base-model-detail.tsx | 386 ++++++++++++++++++
.../src/app/routes/base-model/base-models.tsx | 139 +++++++
frontend/src/app/routes/landing.tsx | 2 +
.../assets/images/base_model_cta_image.png | Bin 0 -> 60747 bytes
frontend/src/assets/images/index.ts | 2 +
.../base-model-cta/base-model-cta.module.css | 147 +++++++
.../landing/base-model-cta/base-model-cta.tsx | 39 ++
.../src/components/ui/icons/download-icon.tsx | 16 +
frontend/src/constants/routes.ts | 5 +
.../constants/ui-contents/shared-content.ts | 113 +++++
.../components/base-model-card.tsx | 48 +++
.../components/base-models-filters.tsx | 129 ++++++
.../components/contribute-model-dialog.tsx | 182 +++++++++
frontend/src/styles/index.css | 4 +
frontend/src/types/ui-contents.ts | 42 ++
frontend/tailwind.config.js | 6 +-
21 files changed, 1377 insertions(+), 5 deletions(-)
create mode 100644 docs/decisions/frontend/architecture/adr-choose-url-state-library/adr1.md
create mode 100644 frontend/src/app/routes/base-model/base-model-detail.tsx
create mode 100644 frontend/src/app/routes/base-model/base-models.tsx
create mode 100644 frontend/src/assets/images/base_model_cta_image.png
create mode 100644 frontend/src/components/landing/base-model-cta/base-model-cta.module.css
create mode 100644 frontend/src/components/landing/base-model-cta/base-model-cta.tsx
create mode 100644 frontend/src/features/base-models/components/base-model-card.tsx
create mode 100644 frontend/src/features/base-models/components/base-models-filters.tsx
create mode 100644 frontend/src/features/base-models/components/contribute-model-dialog.tsx
diff --git a/docs/decisions/frontend/architecture/adr-choose-url-state-library/adr1.md b/docs/decisions/frontend/architecture/adr-choose-url-state-library/adr1.md
new file mode 100644
index 000000000..d6a7ab4b3
--- /dev/null
+++ b/docs/decisions/frontend/architecture/adr-choose-url-state-library/adr1.md
@@ -0,0 +1,40 @@
+# Architecture Decision Record 1: Use nuqs for URL-based UI State Management
+
+Date: 02/03/2026
+
+# Context
+
+The frontend currently has multiple pages with search and filter controls that are reflected in URL query parameters. Historically, some pages managed this with ad-hoc utilities and manual synchronization between component state and `useSearchParams`, which increased complexity and inconsistency.
+
+We have validated `nuqs` in the start mapping flow and found it to be a performant and ergonomic approach for query-string state handling. As more pages require URL-based state, we need a consistent, typed pattern across the frontend.
+
+## Decision Drivers
+
+- Consistent URL-state behavior across routes with search and filters.
+- Better type safety and parsing for query params than manual string handling.
+- Simpler implementation and maintenance compared to custom synchronization utilities.
+- Good performance for frequent UI state updates tied to query parameters.
+- Better developer experience and readability for future feature work.
+
+## Considered Options
+
+- Continue using `react-router-dom` `useSearchParams` with custom helper utilities.
+- Use [`nuqs`](https://nuqs.dev/) as the standard query-state library.
+- Keep filter/search state only in component/global state and avoid URL synchronization.
+
+# Decision
+
+We will standardize on `nuqs` for managing URL-based state in frontend routes that need query-parameter-backed UI state (for example: search text, filters, sorting, map/list toggles, pagination, and similar controls).
+
+`react-router-dom` `useSearchParams` may still be used for simple one-off cases, but all new or refactored complex query-state flows should use `nuqs` as the default pattern.
+
+# Status
+
+Accepted.
+
+# Consequences
+
+- Query-state logic becomes more consistent and easier to reason about across pages.
+- We reduce repeated boilerplate for parsing/serializing query parameters.
+- Existing pages that use custom URL-state utilities may need incremental migration to align with this decision.
+- Team members should follow the `nuqs` pattern used in the start mapping implementation as the reference approach.
diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit
index 0d6108f45..8623572aa 100755
--- a/frontend/.husky/pre-commit
+++ b/frontend/.husky/pre-commit
@@ -1,3 +1,8 @@
-cd frontend
-pnpm format
-pnpm build
+#!/bin/sh
+echo "===== Navigating to project directory ====="
+cd frontend || exit 1
+
+echo "===== Running precommit script ====="
+pnpm precommit
+
+echo "===== Pre-commit hook finished ====="
diff --git a/frontend/package.json b/frontend/package.json
index f01b4cb4c..c79fae168 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -32,6 +32,7 @@
"framer-motion": "^12.19.1",
"geojson": "^0.5.0",
"maplibre-gl": "^5.3.1",
+ "nuqs": "^2.8.8",
"pmtiles": "^4.3.0",
"react": "19.1.0",
"react-confetti-explosion": "^3.0.3",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 22a863e89..941752d8e 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -63,6 +63,9 @@ importers:
maplibre-gl:
specifier: ^5.3.1
version: 5.3.1
+ nuqs:
+ specifier: ^2.8.8
+ version: 2.8.8(react-router-dom@6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@6.26.2(react@19.1.0))(react@19.1.0)
pmtiles:
specifier: ^4.3.0
version: 4.3.0
@@ -933,6 +936,9 @@ packages:
resolution: {integrity: sha512-fB9+bPHLg5zVwPbBKEqY3ghyttkJq9RuUzFMTZKweKrNKKDMUACtI8DlMYUqNwpdZMJhf7a0xeak6vFVBSxcbQ==}
engines: {node: '>=14.17.0'}
+ '@standard-schema/spec@1.0.0':
+ resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
+
'@tailwindcss/typography@0.5.15':
resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==}
peerDependencies:
@@ -2578,6 +2584,27 @@ packages:
resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
engines: {node: '>=0.10.0'}
+ nuqs@2.8.8:
+ resolution: {integrity: sha512-LF5sw9nWpHyPWzMMu9oho3r9C5DvkpmBIg4LQN78sexIzGaeRx8DWr0uy3YiFx5i2QGZN1Qqcb+OAtEVRa2bnA==}
+ peerDependencies:
+ '@remix-run/react': '>=2'
+ '@tanstack/react-router': ^1
+ next: '>=14.2.0'
+ react: '>=18.2.0 || ^19.0.0-0'
+ react-router: ^5 || ^6 || ^7
+ react-router-dom: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ '@remix-run/react':
+ optional: true
+ '@tanstack/react-router':
+ optional: true
+ next:
+ optional: true
+ react-router:
+ optional: true
+ react-router-dom:
+ optional: true
+
nwsapi@2.2.16:
resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==}
@@ -4161,6 +4188,8 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
+ '@standard-schema/spec@1.0.0': {}
+
'@tailwindcss/typography@0.5.15(tailwindcss@3.4.13)':
dependencies:
lodash.castarray: 4.4.0
@@ -6298,6 +6327,14 @@ snapshots:
normalize-range@0.1.2: {}
+ nuqs@2.8.8(react-router-dom@6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-router@6.26.2(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@standard-schema/spec': 1.0.0
+ react: 19.1.0
+ optionalDependencies:
+ react-router: 6.26.2(react@19.1.0)
+ react-router-dom: 6.26.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+
nwsapi@2.2.16: {}
object-assign@4.1.1: {}
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx
index 3f544b69c..ccc883439 100644
--- a/frontend/src/app/router.tsx
+++ b/frontend/src/app/router.tsx
@@ -12,6 +12,7 @@ import {
createBrowserRouter,
} from "react-router-dom";
import { ModelsProvider } from "@/app/providers/models-provider";
+import { NuqsAdapter } from "nuqs/adapters/react-router/v6";
const router = createBrowserRouter([
{
@@ -53,6 +54,32 @@ const router = createBrowserRouter([
// },
/**
* Models details, list and feedbacks route starts.
+ */
+
+ /**
+ * Base Models routes.
+ */
+ {
+ path: APPLICATION_ROUTES.BASE_MODELS_HOME,
+ lazy: async () => {
+ const { BaseModelsPage } = await import("@/app/routes/base-model/base-models");
+ return {
+ Component: () => ,
+ };
+ },
+ },
+ {
+ path: APPLICATION_ROUTES.BASE_MODEL_DETAILS_PAGE,
+ lazy: async () => {
+ const { BaseModelDetailPage } = await import("@/app/routes/base-model/base-model-detail");
+ return {
+ Component: () => ,
+ };
+ },
+ },
+
+ /**
+ * Base Models routes ends.
*/
{
path: APPLICATION_ROUTES.MODEL_DETAILS,
@@ -441,5 +468,9 @@ const router = createBrowserRouter([
]);
export const AppRouter = () => {
- return ;
+ return (
+
+
+
+ );
};
diff --git a/frontend/src/app/routes/base-model/base-model-detail.tsx b/frontend/src/app/routes/base-model/base-model-detail.tsx
new file mode 100644
index 000000000..9ec2d4207
--- /dev/null
+++ b/frontend/src/app/routes/base-model/base-model-detail.tsx
@@ -0,0 +1,386 @@
+import { Head } from "@/components/seo";
+import { BackButton, ButtonWithIcon } from "@/components/ui/button";
+import { ChevronDownIcon, InfoIcon } from "@/components/ui/icons";
+import { DownloadIconNew } from "@/components/ui/icons/download-icon";
+import { ToolTip } from "@/components/ui/tooltip";
+import { APPLICATION_ROUTES } from "@/constants";
+import { ButtonVariant } from "@/enums";
+import {
+ BASE_MODELS_DETAIL_DATA,
+ TBaseModelDetail,
+ TBaseModelVariant,
+} from "@/features/base-models/data/base-model-data";
+import AccuracyDisplay from "@/features/models/components/accuracy-display";
+import { useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+
+type TInfoRowConfig = {
+ label: string;
+ value: string;
+ tooltip?: string;
+};
+
+type TMetadataItemProps = {
+ label: string;
+ value: React.ReactNode;
+ tooltip?: string;
+};
+
+/**
+ * Collapsible section component for the right sidebar.
+ */
+const CollapsibleSection = ({
+ title,
+ children,
+ defaultOpen = true,
+}: {
+ title: string;
+ children: React.ReactNode;
+ defaultOpen?: boolean;
+}) => {
+ const [isOpen, setIsOpen] = useState(defaultOpen);
+
+ return (
+
+
+ {isOpen &&
{children}
}
+
+ );
+};
+
+const MetadataItem = ({ label, value, tooltip }: TMetadataItemProps) => (
+
+ {label}:
+ {value}
+ {tooltip && (
+
+
+
+ )}
+
+);
+
+/**
+ * Info row for displaying a label/value pair with an optional info tooltip.
+ */
+const InfoRow = ({
+ label,
+ value,
+ tooltip,
+}: {
+ label: string;
+ value: string;
+ tooltip?: string;
+}) => (
+
+
+ {label}
+ {tooltip && (
+
+
+
+ )}
+
+
{value}
+
+);
+
+/**
+ * Variant display component.
+ */
+const VariantCard = ({ variant }: { variant: TBaseModelVariant }) => (
+
+
+ Name:
+ {variant.name}
+
+
+ Classes:
+
+ {variant.classes}
+
+
+
+ Notes:
+ {variant.notes}
+
+
+);
+
+export const BaseModelDetailPage = () => {
+ const { id } = useParams();
+ const navigate = useNavigate();
+
+ const model: TBaseModelDetail | undefined = useMemo(() => {
+ return BASE_MODELS_DETAIL_DATA.find((m) => String(m.id) === id);
+ }, [id]);
+
+ const architectureRows: TInfoRowConfig[] = model
+ ? [
+ { label: "Base Model", value: model.architecture.baseModel },
+ { label: "Head", value: model.architecture.head },
+ {
+ label: "Input",
+ value: model.architecture.input,
+ tooltip: "Input format used by the model",
+ },
+ { label: "Tile Size px", value: model.architecture.tileSizePx },
+ {
+ label: "Processing",
+ value: model.architecture.processing,
+ tooltip: "Pre-processing steps applied",
+ },
+ {
+ label: "Resize",
+ value: model.architecture.resize,
+ tooltip: "How images are resized before inference",
+ },
+ {
+ label: "Scaling",
+ value: model.architecture.scaling,
+ tooltip: "Pixel value normalization method",
+ },
+ {
+ label: "Output",
+ value: model.architecture.output,
+ tooltip: "Model output format",
+ },
+ {
+ label: "Description",
+ value: model.architecture.outputDescription,
+ tooltip: "Description of the model output",
+ },
+ ]
+ : [];
+
+ const dataInfoRows: TInfoRowConfig[] = model
+ ? [
+ {
+ label: "Sensor",
+ value: model.dataInfo.sensor,
+ tooltip: "Type of sensor used to capture imagery",
+ },
+ {
+ label: "CRS",
+ value: model.dataInfo.crs,
+ tooltip: "Coordinate Reference System",
+ },
+ {
+ label: "Spatial Extent",
+ value: model.dataInfo.spatialExtent,
+ tooltip: "Geographic coverage of training data",
+ },
+ {
+ label: "Temporal Extent",
+ value: model.dataInfo.temporalExtent,
+ tooltip: "Time period of training data",
+ },
+ ]
+ : [];
+
+
+
+ // If model not found, redirect to 404
+ if (!model) {
+ return (
+
+
+ Model Not Found
+
+
+ The base model you are looking for does not exist.
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {/* Title + Start Mapping */}
+
+
+
+ {model.fullTitle}
+
+
Model ID: {model.dataId}
+
+
+
+ navigate(`${APPLICATION_ROUTES.START_MAPPING_BASE}${model.id}`)
+ }
+ variant={ButtonVariant.PRIMARY}
+ label="Start Mapping"
+ />
+
+
+
+ {/* Metadata Row */}
+
+
+ {/* Download Metadata Link */}
+
+
+
+
+ {/* Main Content: Two Column Layout */}
+
+ {/* Left Column - Overview */}
+
+ {/* Overview */}
+
+
+ Overview
+
+ {model.overview.split("\n\n").map((paragraph, i) => (
+
+ {paragraph}
+
+ ))}
+
+
+ {/* Use Cases */}
+
+
+ Use Cases
+
+
+
+
+ Suitable for:
+
+
+ {model.useCases.suitable.map((item, i) => (
+ - {item}
+ ))}
+
+
+
+
+ Not suitable for:
+
+
+ {model.useCases.notSuitable.map((item, i) => (
+ - {item}
+ ))}
+
+
+
+
+
+ {/* Performance */}
+
+
+ Performance
+
+ {model.performance}
+
+
+ {/* Limitations */}
+
+
+ Limitations
+
+
+ {model.limitations.map((item, i) => (
+ - {item}
+ ))}
+
+
+
+
+ {/* Right Column - Architecture Info */}
+
+
+
+ {architectureRows.map((row) => (
+
+ ))}
+
+
+
+
+
+ {model.architecture.variants.map((variant, i) => (
+
+ ))}
+
+
+
+
+
+ {dataInfoRows.map((row) => (
+
+ ))}
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/app/routes/base-model/base-models.tsx b/frontend/src/app/routes/base-model/base-models.tsx
new file mode 100644
index 000000000..f38668d9f
--- /dev/null
+++ b/frontend/src/app/routes/base-model/base-models.tsx
@@ -0,0 +1,139 @@
+import { Head } from "@/components/seo";
+import { ButtonWithIcon } from "@/components/ui/button";
+import { AddIcon } from "@/components/ui/icons";
+import { SHARED_CONTENT } from "@/constants";
+import { ButtonVariant } from "@/enums";
+import {
+ BASE_MODELS_DATA,
+ TASK_CATEGORIES,
+ DATE_SORT_OPTIONS,
+} from "@/features/base-models/data/base-model-data";
+import { useDialog } from "@/hooks/use-dialog";
+import { useMemo } from "react";
+import { useQueryState, parseAsString } from "nuqs";
+import BaseModelCard from "@/features/base-models/components/base-model-card";
+import ContributeModelDialog from "@/features/base-models/components/contribute-model-dialog";
+import BaseModelsFilters from "@/features/base-models/components/base-models-filters";
+
+export const BaseModelsPage = () => {
+ const { isOpened, openDialog, closeDialog } = useDialog();
+ // nuqs-powered search params state
+ const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
+ const [category, setCategory] = useQueryState(
+ "category",
+ parseAsString.withDefault("all"),
+ );
+ const [dateSort, setDateSort] = useQueryState(
+ "date",
+ parseAsString.withDefault("newest"),
+ );
+ const [mapView, setMapView] = useQueryState(
+ "map",
+ parseAsString.withDefault("false"),
+ );
+
+ const isMapViewActive = mapView === "true";
+
+ // Filter and sort models
+ const filteredModels = useMemo(() => {
+ let models = [...BASE_MODELS_DATA];
+
+ // Search filter
+ if (search) {
+ const searchLower = search.toLowerCase();
+ models = models.filter(
+ (model) =>
+ model.name.toLowerCase().includes(searchLower) ||
+ model.description.toLowerCase().includes(searchLower) ||
+ model.author.toLowerCase().includes(searchLower),
+ );
+ }
+
+ // Category filter
+ if (category && category !== "all") {
+ models = models.filter((model) => model.task === category);
+ }
+
+ // Date sorting
+ if (dateSort === "oldest") {
+ models.reverse();
+ }
+
+ return models;
+ }, [search, category, dateSort]);
+
+ // Category dropdown items
+ const categoryMenuItems = TASK_CATEGORIES.map((cat) => ({
+ value: cat.label,
+ apiValue: cat.value,
+ }));
+
+ // Date dropdown items
+ const dateMenuItems = DATE_SORT_OPTIONS.map((opt) => ({
+ value: opt.label,
+ apiValue: opt.value,
+ }));
+
+ const selectedCategoryLabel =
+ TASK_CATEGORIES.find((c) => c.value === category)?.label || "Category";
+
+ const selectedDateLabel =
+ DATE_SORT_OPTIONS.find((d) => d.value === dateSort)?.label || "Date";
+ return (
+ <>
+