diff --git a/.github/workflows/release_chart.yaml b/.github/workflows/release_chart.yaml
new file mode 100755
index 000000000..3222e1b39
--- /dev/null
+++ b/.github/workflows/release_chart.yaml
@@ -0,0 +1,24 @@
+name: Release Chart
+
+on:
+ # Push includes PR merge
+ push:
+ branches:
+ - develop
+ paths:
+ # Workflow is triggered only if chart dir changes
+ - chart/**
+ # Allow manual trigger
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ publish:
+ uses: hotosm/gh-workflows/.github/workflows/just.yml@3.3.2
+ with:
+ environment: "test"
+ command: "chart publish"
+ secrets: inherit
diff --git a/.gitignore b/.gitignore
index 5df042fa9..7b88b02bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,3 +62,6 @@ fAIr-utilities
data
fair-app-data/*
+
+# helm charts
+fair-*.tgz
diff --git a/Justfile b/Justfile
new file mode 100644
index 000000000..e4c11537a
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,34 @@
+set dotenv-load
+
+# List available commands
+[private]
+default:
+ just help
+
+# List available commands
+help:
+ just --justfile {{justfile()}} --list
+
+# Chart module from https://github.com/hotosm/justfiles
+chart *args:
+ @curl -sS https://raw.githubusercontent.com/hotosm/justfiles/main/chart.just \
+ -o {{justfile_directory()}}/tasks/chart.just;
+ @just --justfile {{justfile_directory()}}/tasks/chart.just --set chart_name "fair" {{args}}
+
+# Echo to terminal with blue colour
+[no-cd]
+_echo-blue text:
+ #!/usr/bin/env sh
+ printf "\033[0;34m%s\033[0m\n" "{{ text }}"
+
+# Echo to terminal with yellow colour
+[no-cd]
+_echo-yellow text:
+ #!/usr/bin/env sh
+ printf "\033[0;33m%s\033[0m\n" "{{ text }}"
+
+# Echo to terminal with red colour
+[no-cd]
+_echo-red text:
+ #!/usr/bin/env sh
+ printf "\033[0;41m%s\033[0m\n" "{{ text }}"
diff --git a/chart/.helmignore b/chart/.helmignore
new file mode 100644
index 000000000..acf3c7144
--- /dev/null
+++ b/chart/.helmignore
@@ -0,0 +1,9 @@
+.DS_Store
+.git
+.gitignore
+.idea
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
diff --git a/chart/Chart.yaml b/chart/Chart.yaml
new file mode 100644
index 000000000..5b3221e73
--- /dev/null
+++ b/chart/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: fair
+description: AI Assisted Mapping Tool
+type: application
+version: 0.1.0
+appVersion: "2.2.19"
diff --git a/chart/README.md b/chart/README.md
new file mode 100644
index 000000000..d1c2d4033
--- /dev/null
+++ b/chart/README.md
@@ -0,0 +1,48 @@
+# fAIr Helm Chart
+
+Deploys the fAIr backend API and Django-Q async worker.
+
+The frontend is deployed separately (S3 + CloudFront via GitHub Actions).
+PostgreSQL is expected to be provided externally (e.g.
+[CloudNativePG](https://cloudnative-pg.io/) or a managed database service).
+
+## Quick start
+
+```bash
+helm install fair oci://ghcr.io/hotosm/charts/fair
+```
+
+## Example values
+
+```yaml
+externalDatabase:
+ host: my-pg-cluster-rw.db.svc
+ database: ai
+ username: fair
+ existingSecret: fair-db-credentials
+ existingSecretKey: password
+
+ingress:
+ enabled: true
+ className: nginx
+ hosts:
+ - host: fair.example.com
+ paths:
+ - path: /api
+ pathType: Prefix
+
+backend:
+ envFrom:
+ - secretRef:
+ name: fair-backend-secrets
+```
+
+## Key values
+
+| Parameter | Description | Default |
+|---|---|---|
+| `externalDatabase.host` | PostgreSQL host | `""` |
+| `externalDatabase.existingSecret` | Secret containing DB password | `""` |
+| `backend.djangoQ.enabled` | Run Django-Q sidecar for async tasks | `true` |
+| `backend.migrate.enabled` | Run migrations on install/upgrade | `true` |
+| `ingress.enabled` | Create Ingress resource | `false` |
diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt
new file mode 100644
index 000000000..bfc7cfcda
--- /dev/null
+++ b/chart/templates/NOTES.txt
@@ -0,0 +1,20 @@
+fAIr has been deployed!
+
+Components:
+ - Backend API: {{ include "fair.backend.fullname" . }}
+{{- if .Values.backend.djangoQ.enabled }}
+ - Django-Q: running as sidecar in backend pod
+{{- end }}
+ - PostgreSQL: external ({{ .Values.externalDatabase.host }})
+
+{{- if .Values.ingress.enabled }}
+
+Access the application at:
+{{- range .Values.ingress.hosts }}
+ http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
+{{- end }}
+{{- else }}
+
+To access the backend API, run:
+ kubectl port-forward svc/{{ include "fair.backend.fullname" . }} 8000:{{ .Values.backend.service.port }}
+{{- end }}
diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl
new file mode 100644
index 000000000..da72ce2e0
--- /dev/null
+++ b/chart/templates/_helpers.tpl
@@ -0,0 +1,90 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "fair.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+*/}}
+{{- define "fair.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "fair.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "fair.labels" -}}
+helm.sh/chart: {{ include "fair.chart" . }}
+{{ include "fair.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "fair.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "fair.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Create the name of the service account to use
+*/}}
+{{- define "fair.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include "fair.fullname" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
+
+{{/*
+Backend fullname
+*/}}
+{{- define "fair.backend.fullname" -}}
+{{- printf "%s-backend" (include "fair.fullname" .) | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Backend selector labels
+*/}}
+{{- define "fair.backend.selectorLabels" -}}
+{{ include "fair.selectorLabels" . }}
+app.kubernetes.io/component: backend
+{{- end }}
+
+{{/*
+Backend image
+*/}}
+{{- define "fair.backend.image" -}}
+{{- $tag := default .Chart.AppVersion .Values.image.backend.tag -}}
+{{- printf "%s:%s" .Values.image.backend.repository $tag }}
+{{- end }}
+
+{{/*
+DATABASE_URL for Django
+*/}}
+{{- define "fair.databaseUrl" -}}
+postgis://$(DATABASE_USER):$(DATABASE_PASSWORD)@{{ .Values.externalDatabase.host }}:{{ .Values.externalDatabase.port | toString }}/{{ .Values.externalDatabase.database }}
+{{- end }}
diff --git a/chart/templates/backend/configmap.yaml b/chart/templates/backend/configmap.yaml
new file mode 100644
index 000000000..e0bd88f45
--- /dev/null
+++ b/chart/templates/backend/configmap.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "fair.backend.fullname" . }}
+ labels:
+ {{- include "fair.labels" . | nindent 4 }}
+ app.kubernetes.io/component: backend
+data:
+ DATABASE_HOST: {{ .Values.externalDatabase.host | quote }}
+ DATABASE_PORT: {{ .Values.externalDatabase.port | toString | quote }}
+ DATABASE_NAME: {{ .Values.externalDatabase.database | quote }}
+ DATABASE_USER: {{ .Values.externalDatabase.username | quote }}
+ {{- range $key, $value := .Values.backend.env }}
+ {{ $key }}: {{ $value | quote }}
+ {{- end }}
diff --git a/chart/templates/backend/deployment.yaml b/chart/templates/backend/deployment.yaml
new file mode 100644
index 000000000..56ca9e03d
--- /dev/null
+++ b/chart/templates/backend/deployment.yaml
@@ -0,0 +1,126 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "fair.backend.fullname" . }}
+ labels:
+ {{- include "fair.labels" . | nindent 4 }}
+ app.kubernetes.io/component: backend
+spec:
+ replicas: {{ .Values.backendReplicaCount }}
+ selector:
+ matchLabels:
+ {{- include "fair.backend.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ annotations:
+ checksum/config: {{ include (print $.Template.BasePath "/backend/configmap.yaml") . | sha256sum }}
+ {{- with .Values.backend.podAnnotations }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ labels:
+ {{- include "fair.backend.selectorLabels" . | nindent 8 }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include "fair.serviceAccountName" . }}
+ {{- with .Values.backend.podSecurityContext }}
+ securityContext:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ containers:
+ - name: api
+ image: {{ include "fair.backend.image" . }}
+ imagePullPolicy: {{ .Values.image.backend.pullPolicy }}
+ {{- with .Values.backend.command }}
+ command:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ ports:
+ - name: http
+ containerPort: {{ .Values.backend.port }}
+ protocol: TCP
+ envFrom:
+ - configMapRef:
+ name: {{ include "fair.backend.fullname" . }}
+ {{- with .Values.backend.envFrom }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ env:
+ - name: DATABASE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ {{- if .Values.externalDatabase.existingSecret }}
+ name: {{ .Values.externalDatabase.existingSecret }}
+ key: {{ .Values.externalDatabase.existingSecretKey }}
+ {{- else }}
+ name: {{ include "fair.backend.fullname" . }}-db
+ key: password
+ {{- end }}
+ - name: DATABASE_URL
+ value: {{ include "fair.databaseUrl" . | quote }}
+ {{- with .Values.backend.livenessProbe }}
+ livenessProbe:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.backend.readinessProbe }}
+ readinessProbe:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.backend.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.backend.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- if .Values.backend.djangoQ.enabled }}
+ - name: django-q
+ image: {{ include "fair.backend.image" . }}
+ imagePullPolicy: {{ .Values.image.backend.pullPolicy }}
+ command:
+ - python
+ - manage.py
+ - qcluster
+ envFrom:
+ - configMapRef:
+ name: {{ include "fair.backend.fullname" . }}
+ {{- with .Values.backend.envFrom }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ env:
+ - name: DATABASE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ {{- if .Values.externalDatabase.existingSecret }}
+ name: {{ .Values.externalDatabase.existingSecret }}
+ key: {{ .Values.externalDatabase.existingSecretKey }}
+ {{- else }}
+ name: {{ include "fair.backend.fullname" . }}-db
+ key: password
+ {{- end }}
+ - name: DATABASE_URL
+ value: {{ include "fair.databaseUrl" . | quote }}
+ {{- with .Values.backend.djangoQ.resources }}
+ resources:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- with .Values.backend.securityContext }}
+ securityContext:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ {{- end }}
+ {{- with .Values.backend.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.backend.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.backend.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/chart/templates/backend/migrate-job.yaml b/chart/templates/backend/migrate-job.yaml
new file mode 100644
index 000000000..0e9e44734
--- /dev/null
+++ b/chart/templates/backend/migrate-job.yaml
@@ -0,0 +1,53 @@
+{{- if .Values.backend.migrate.enabled }}
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ include "fair.backend.fullname" . }}-migrate
+ labels:
+ {{- include "fair.labels" . | nindent 4 }}
+ app.kubernetes.io/component: backend
+ annotations:
+ "helm.sh/hook": post-install,post-upgrade
+ "helm.sh/hook-weight": "-5"
+ "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
+spec:
+ template:
+ metadata:
+ labels:
+ {{- include "fair.backend.selectorLabels" . | nindent 8 }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include "fair.serviceAccountName" . }}
+ restartPolicy: OnFailure
+ containers:
+ - name: migrate
+ image: {{ include "fair.backend.image" . }}
+ imagePullPolicy: {{ .Values.image.backend.pullPolicy }}
+ command:
+ - python
+ - manage.py
+ - migrate
+ - --noinput
+ envFrom:
+ - configMapRef:
+ name: {{ include "fair.backend.fullname" . }}
+ {{- with .Values.backend.envFrom }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ env:
+ - name: DATABASE_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ {{- if .Values.externalDatabase.existingSecret }}
+ name: {{ .Values.externalDatabase.existingSecret }}
+ key: {{ .Values.externalDatabase.existingSecretKey }}
+ {{- else }}
+ name: {{ include "fair.backend.fullname" . }}-db
+ key: password
+ {{- end }}
+ - name: DATABASE_URL
+ value: {{ include "fair.databaseUrl" . | quote }}
+{{- end }}
diff --git a/chart/templates/backend/secret.yaml b/chart/templates/backend/secret.yaml
new file mode 100644
index 000000000..a89635420
--- /dev/null
+++ b/chart/templates/backend/secret.yaml
@@ -0,0 +1,12 @@
+{{- if not .Values.externalDatabase.existingSecret }}
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "fair.backend.fullname" . }}-db
+ labels:
+ {{- include "fair.labels" . | nindent 4 }}
+ app.kubernetes.io/component: backend
+type: Opaque
+data:
+ password: {{ .Values.externalDatabase.password | b64enc | quote }}
+{{- end }}
diff --git a/chart/templates/backend/service.yaml b/chart/templates/backend/service.yaml
new file mode 100644
index 000000000..4bd6d696b
--- /dev/null
+++ b/chart/templates/backend/service.yaml
@@ -0,0 +1,16 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "fair.backend.fullname" . }}
+ labels:
+ {{- include "fair.labels" . | nindent 4 }}
+ app.kubernetes.io/component: backend
+spec:
+ type: {{ .Values.backend.service.type }}
+ ports:
+ - port: {{ .Values.backend.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ {{- include "fair.backend.selectorLabels" . | nindent 4 }}
diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml
new file mode 100644
index 000000000..3ddee9cbb
--- /dev/null
+++ b/chart/templates/ingress.yaml
@@ -0,0 +1,41 @@
+{{- if .Values.ingress.enabled -}}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include "fair.fullname" . }}
+ labels:
+ {{- include "fair.labels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if .Values.ingress.className }}
+ ingressClassName: {{ .Values.ingress.className }}
+ {{- end }}
+ {{- if .Values.ingress.tls }}
+ tls:
+ {{- range .Values.ingress.tls }}
+ - hosts:
+ {{- range .hosts }}
+ - {{ . | quote }}
+ {{- end }}
+ secretName: {{ .secretName }}
+ {{- end }}
+ {{- end }}
+ rules:
+ {{- range .Values.ingress.hosts }}
+ - host: {{ .host | quote }}
+ http:
+ paths:
+ {{- range .paths }}
+ - path: {{ .path }}
+ pathType: {{ .pathType }}
+ backend:
+ service:
+ name: {{ include "fair.backend.fullname" $ }}
+ port:
+ name: http
+ {{- end }}
+ {{- end }}
+{{- end }}
diff --git a/chart/templates/serviceaccount.yaml b/chart/templates/serviceaccount.yaml
new file mode 100644
index 000000000..275a251e6
--- /dev/null
+++ b/chart/templates/serviceaccount.yaml
@@ -0,0 +1,12 @@
+{{- if .Values.serviceAccount.create -}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include "fair.serviceAccountName" . }}
+ labels:
+ {{- include "fair.labels" . | nindent 4 }}
+ {{- with .Values.serviceAccount.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+{{- end }}
diff --git a/chart/values.yaml b/chart/values.yaml
new file mode 100644
index 000000000..c5ec6a8a6
--- /dev/null
+++ b/chart/values.yaml
@@ -0,0 +1,116 @@
+# -- Number of backend replicas
+backendReplicaCount: 1
+
+image:
+ backend:
+ repository: ghcr.io/hotosm/fair-api
+ tag: "" # defaults to appVersion
+ pullPolicy: IfNotPresent
+
+imagePullSecrets: []
+nameOverride: ""
+fullnameOverride: ""
+
+serviceAccount:
+ create: true
+ annotations: {}
+ name: ""
+
+# -- Backend (Django API) configuration
+backend:
+ command:
+ - gunicorn
+ - --bind=0.0.0.0:8000
+ - --workers=3
+ - --timeout=120
+ - fairproject.wsgi:application
+
+ # -- Run Django-Q cluster as a sidecar for lightweight async tasks
+ djangoQ:
+ enabled: true
+ resources:
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ requests:
+ cpu: 250m
+ memory: 512Mi
+
+ port: 8000
+
+ env: {}
+ # DJANGO_SECRET_KEY: ""
+ # DJANGO_ALLOWED_HOSTS: "*"
+ # OSM_CLIENT_ID: ""
+ # OSM_CLIENT_SECRET: ""
+ # OSM_SECRET_KEY: ""
+
+ # -- Reference an existing Secret for sensitive env vars
+ envFrom: []
+ # - secretRef:
+ # name: fair-backend-secrets
+
+ resources:
+ limits:
+ cpu: "2"
+ memory: 2Gi
+ requests:
+ cpu: 500m
+ memory: 512Mi
+
+ livenessProbe:
+ httpGet:
+ path: /api/
+ port: http
+ initialDelaySeconds: 30
+ periodSeconds: 30
+ timeoutSeconds: 5
+
+ readinessProbe:
+ httpGet:
+ path: /api/
+ port: http
+ initialDelaySeconds: 15
+ periodSeconds: 10
+ timeoutSeconds: 5
+
+ nodeSelector: {}
+ tolerations: []
+ affinity: {}
+ podAnnotations: {}
+ podSecurityContext: {}
+ securityContext: {}
+
+ # -- Run Django migrations as a pre-install/upgrade hook
+ migrate:
+ enabled: true
+
+ service:
+ type: ClusterIP
+ port: 8000
+
+# -- Ingress configuration
+ingress:
+ enabled: false
+ className: ""
+ annotations: {}
+ # nginx.ingress.kubernetes.io/proxy-body-size: "50m"
+ hosts:
+ - host: fair.example.com
+ paths:
+ - path: /api
+ pathType: Prefix
+ tls: []
+ # - secretName: fair-tls
+ # hosts:
+ # - fair.example.com
+
+# -- External PostgreSQL connection
+externalDatabase:
+ host: ""
+ port: 5432
+ database: ai
+ username: postgres
+ password: ""
+ existingSecret: ""
+ existingSecretKey: password
diff --git a/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md b/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md
new file mode 100644
index 000000000..3ef5988fd
--- /dev/null
+++ b/docs/decisions/frontend/architecture/adr-choose-markdown-library/adr1.md
@@ -0,0 +1,46 @@
+# Architecture Decision Record 1: Use react-markdown with remark-gfm for Rendering Markdown Content
+
+Date: 05/03/2026
+
+# Context
+
+The base model detail page displays long-form content (overview, use cases, performance, limitations) that was previously rendered using manual paragraph splitting and hardcoded HTML structures. As content grows in complexity — with bold text, inline code, lists, headings, and links — maintaining this as plain strings with custom rendering logic becomes difficult and error-prone.
+
+We need a solution to render rich, structured text from markdown strings so that content authors can express formatting naturally, while the UI consistently applies the project's design system.
+
+## Decision Drivers
+
+- Content flexibility: authors should be able to use headings, bold, lists, code, and links without code changes.
+- Consistency: rendered markdown must match the application's existing design system (colors, typography, spacing).
+- Minimal bundle impact: the chosen library should be lightweight and avoid unnecessary overhead.
+- Existing ecosystem: leverage libraries already present in the project wherever possible.
+- Security: HTML should be sanitised by default to prevent XSS from user-supplied content.
+
+## Considered Options
+
+- **[react-markdown](https://github.com/remarkjs/react-markdown) + [remark-gfm](https://github.com/remarkjs/remark-gfm)** — A lightweight React component that converts markdown to React elements via the unified/remark ecosystem. `remark-gfm` adds GitHub Flavored Markdown support (tables, strikethrough, task lists, autolinks). Does **not** use `dangerouslySetInnerHTML`; it builds a React virtual DOM tree. Already installed as project dependencies.
+- **[markdown-to-jsx](https://www.npmjs.com/package/markdown-to-jsx)** — A single-package alternative that compiles markdown to JSX. Slightly smaller bundle size, but lacks the plugin ecosystem of remark and does not support GFM features without extra work.
+
+- **Custom rendering logic** — Continue splitting strings on `\n\n` and mapping to `
`, `
`, `` elements manually. Does not scale as content grows in richness.
+
+# Decision
+
+We will use **react-markdown** (v9) with the **remark-gfm** plugin to render all long-form content in the frontend starting with base model detail page.
+
+Key implementation details:
+
+1. **Data model simplification**: The separate `overview`, `useCases`, `performance`, and `limitations` fields on `TBaseModelDetail` are consolidated into a single `markdownContent: string` field containing full markdown.
+2. **Styling**: The Tailwind CSS `@tailwindcss/typography` plugin's `prose` class is used as the base, with a scoped `.model-detail-prose` CSS class that overrides defaults to match the application's design tokens (colors, font sizes, spacing).
+3. **Banner isolation**: The existing banner component's `.prose *` white-text override is scoped to `.prose:not(.model-detail-prose)` so the two contexts do not conflict.
+
+# Status
+
+Accepted.
+
+# Consequences
+
+- **Positive**: Content is now authored in standard markdown, making it easier to update and maintain. Markdown supports headings, bold, italic, lists, inline code, links, and tables out of the box.
+- **Positive**: No new dependencies added — `react-markdown`, `remark-gfm`, and `@tailwindcss/typography` were already in `package.json`.
+- **Positive**: Safe by default — `react-markdown` does not use `dangerouslySetInnerHTML` and builds React elements directly.
+- **Trade-off**: Content structure is now implicit in the markdown string rather than explicit in the TypeScript type. If specific sections need to be programmatically accessed separately (e.g., extracting just the overview), parsing the markdown would be required.
+- **Trade-off**: Custom `.model-detail-prose` CSS styles need to be maintained alongside the design system. If design tokens change, these styles must be updated accordingly.
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/.env.sample b/frontend/.env.sample
index 5400d89b5..4865c6ebe 100644
--- a/frontend/.env.sample
+++ b/frontend/.env.sample
@@ -254,4 +254,19 @@ MAPSWIPE_VERIFICATION_NUMBER = 4
# The group size for MapSwipe projects.
# Data type: Positive Integer (e.g., 25).
# Default value: 25.
-MAPSWIPE_GROUP_SIZE = 25
\ No newline at end of file
+MAPSWIPE_GROUP_SIZE = 25
+
+
+# The Hanko authentication token for user authentication. ####### ONLY NEEDED IN DEVELOPMENT ENVIRONMENT. ########
+# Data type: String (e.g., "2mKa-_KJR9ak1").
+# Default value: "2mKa-_KJR9".
+VITE_HANKO_AUTH_TOKEN = "xxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+# The base URL for the FAIR STAC Catalog.
+# Data type: String (e.g., "https://stac.fair.krschap.tech/").
+# Default value: "https://stac.fair.krschap.tech/".
+FAIR_STAC_CATALOG_BASE_URL = "https://stac.fair.krschap.tech/"
+
+# The environment mode for the application.
+# Data type: String (e.g., "development", "production").
+VITE_NODE_ENV = "development"
\ No newline at end of file
diff --git a/frontend/.husky/pre-commit b/frontend/.husky/pre-commit
index 0d6108f45..5ecd813c5 100755
--- a/frontend/.husky/pre-commit
+++ b/frontend/.husky/pre-commit
@@ -1,3 +1,3 @@
cd frontend
pnpm format
-pnpm build
+pnpm build
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
index 5ac2d81c1..ebd54a583 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -34,6 +34,7 @@
"framer-motion": "^12.19.1",
"geojson": "^0.5.0",
"maplibre-gl": "^5.3.1",
+ "nuqs": "^2.8.9",
"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 8e93b70fd..9135a828e 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -69,6 +69,9 @@ importers:
maplibre-gl:
specifier: ^5.3.1
version: 5.3.1
+ nuqs:
+ specifier: ^2.8.9
+ version: 2.8.9(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
@@ -957,6 +960,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:
@@ -2628,6 +2634,27 @@ packages:
resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
engines: {node: '>=0.10.0'}
+ nuqs@2.8.9:
+ resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==}
+ 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==}
@@ -4231,6 +4258,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
@@ -6404,6 +6433,14 @@ snapshots:
normalize-range@0.1.2: {}
+ nuqs@2.8.9(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/providers/auth-provider.tsx b/frontend/src/app/providers/auth-provider.tsx
index eec2de5c7..29e418cf7 100644
--- a/frontend/src/app/providers/auth-provider.tsx
+++ b/frontend/src/app/providers/auth-provider.tsx
@@ -7,6 +7,7 @@ import {
HOT_FAIR_LOCAL_STORAGE_ACCESS_TOKEN_KEY,
HOT_FAIR_LOGIN_SUCCESSFUL_SESSION_KEY,
HOT_FAIR_SESSION_REDIRECT_KEY,
+ IS_DEV,
} from "@/config";
import { showErrorToast, showSuccessToast } from "@/utils";
import { TUser } from "@/types/api";
@@ -49,7 +50,7 @@ export const AuthProvider: React.FC = ({ children }) => {
useSessionStorage();
const [token, setToken] = useState(
- AUTH_PROVIDER === "hanko"
+ AUTH_PROVIDER === "hanko" && !IS_DEV
? "hanko-cookie-auth"
: getValue(HOT_FAIR_LOCAL_STORAGE_ACCESS_TOKEN_KEY),
);
@@ -64,11 +65,12 @@ export const AuthProvider: React.FC = ({ children }) => {
* Set token globally to eliminate the need to rewrite it.
* For Hanko, we use withCredentials instead of header token.
*/
- if (AUTH_PROVIDER === "hanko") {
+
+ if (AUTH_PROVIDER === "hanko" && !IS_DEV) {
apiClient.defaults.withCredentials = true;
} else {
- apiClient.defaults.headers.common["access-token"] = token
- ? `${token}`
+ apiClient.defaults.headers.common["Authorization"] = token
+ ? `Bearer ${token}`
: null;
}
@@ -125,7 +127,7 @@ export const AuthProvider: React.FC = ({ children }) => {
*/
const fetchUserProfile = async () => {
try {
- if (AUTH_PROVIDER === "hanko") {
+ if (AUTH_PROVIDER === "hanko" && !IS_DEV) {
const response = await fetch(`${BASE_API_URL}auth/me/`, {
credentials: "include",
});
@@ -255,7 +257,7 @@ export const AuthProvider: React.FC = ({ children }) => {
*/
useEffect(() => {
const intervalId = setInterval(() => {
- if (AUTH_PROVIDER === "hanko") {
+ if (AUTH_PROVIDER === "hanko" && !IS_DEV) {
fetch(`${BASE_API_URL}auth/me/`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((userData) => {
diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx
index 3f544b69c..48f147498 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([
{
@@ -30,6 +31,20 @@ const router = createBrowserRouter([
/**
* Landing page route ends
*/
+
+ /**
+ * Try fAIr (public demo) route starts.
+ */
+ {
+ path: APPLICATION_ROUTES.TRY_FAIR,
+ lazy: async () => {
+ const { TryFairPage } = await import("@/app/routes/try-fair");
+ return { Component: TryFairPage };
+ },
+ },
+ /**
+ * Try fAIr route ends.
+ */
{
path: APPLICATION_ROUTES.LEARN,
lazy: async () => {
@@ -54,6 +69,36 @@ 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-models/base-models-list"
+ );
+ return {
+ Component: () => ,
+ };
+ },
+ },
+ {
+ path: APPLICATION_ROUTES.BASE_MODEL_DETAILS,
+ lazy: async () => {
+ const { BaseModelDetailPage } = await import(
+ "@/app/routes/base-models/base-model-detail"
+ );
+ return {
+ Component: () => ,
+ };
+ },
+ },
+
+ /**
+ * Base Models routes ends.
+ */
{
path: APPLICATION_ROUTES.MODEL_DETAILS,
lazy: async () => {
@@ -96,6 +141,22 @@ const router = createBrowserRouter([
};
},
},
+
+ /**
+ * AI Predictions route (published predictions).
+ */
+ {
+ path: APPLICATION_ROUTES.AI_PREDICTIONS,
+ lazy: async () => {
+ const { AIPredictionsPage } = await import(
+ "@/app/routes/ai-predictions"
+ );
+ return {
+ Component: () => ,
+ };
+ },
+ },
+
/**
* Models details, list and feedbacks route ends.
*/
@@ -441,5 +502,9 @@ const router = createBrowserRouter([
]);
export const AppRouter = () => {
- return ;
+ return (
+
+
+
+ );
};
diff --git a/frontend/src/app/routes/ai-predictions.tsx b/frontend/src/app/routes/ai-predictions.tsx
new file mode 100644
index 000000000..cb191cf9b
--- /dev/null
+++ b/frontend/src/app/routes/ai-predictions.tsx
@@ -0,0 +1,243 @@
+import { Head } from "@/components/seo";
+import { AIPredictionsFilters } from "@/features/ai-predictions/components/ai-predictions-filters";
+import { AIPredictionsGrid } from "@/features/ai-predictions/components/ai-predictions-grid";
+import { useAIPredictions } from "@/features/ai-predictions/hooks/use-ai-predictions";
+import { PredictionResultDrawer } from "@/features/user-profile/components/offline-predictions/predictions-results-drawer";
+import { FeatureCollection, TOfflinePrediction } from "@/types";
+import { useEffect, useState } from "react";
+import { useDialog } from "@/hooks/use-dialog";
+import { MapSwipeProjectResultMapDrawer } from "@/features/mapswipe/components/project-results-map";
+
+import PageHeader from "@/features/models/components/header";
+import { MapswipeProjectStatusDialog } from "@/features/mapswipe/components/project-status-dialog";
+import {
+ useScrollToElement,
+ useScrollToTop,
+} from "@/hooks/use-scroll-to-element";
+import { LayoutView } from "@/enums";
+import { AIPredictionsListLayout } from "@/features/ai-predictions/components/ai-predictions-table";
+import { Spinner } from "@/components/ui/spinner";
+import { AIPredictionsMap } from "@/features/ai-predictions/components/ai-predictions-map";
+
+export const AIPredictionsPage = () => {
+ const {
+ data,
+ isPending,
+ isError,
+ isPlaceholderData,
+ refetch,
+ search,
+ ordering,
+ layout,
+ query,
+ offset,
+ setMapView,
+ setSearch,
+ setOrdering,
+ setLayout,
+ mapViewIsActive,
+ mapData,
+ isMapDataPending,
+ isMapDataError,
+ goToNextPage,
+ goToPrevPage,
+ setPredictionId,
+ clearAllFilters,
+ } = useAIPredictions();
+ const {
+ isOpened: isMapswipeDialogOpen,
+ openDialog: openMapSwipeProjectStatusDialog,
+ closeDialog: closeMapSwipeProjectStatusDialog,
+ } = useDialog();
+ const [mapSwipeResultsPmtiles, setMapSwipeResultsPmtiles] = useState<
+ string | null
+ >(null);
+
+ const {
+ isOpened: isMapSwipeProjectResultMapOpened,
+ openDialog: openMapSwipeProjectResultMapDialog,
+ closeDialog: closeMapSwipeProjectResultMapDialog,
+ } = useDialog();
+ const handleViewMapswipe = (prediction: TOfflinePrediction) => {
+ setMapSwipeResultsPmtiles(null);
+ setActivePrediction(prediction);
+ openMapSwipeProjectStatusDialog();
+ };
+
+ const handleMapSwipeProjectResultMapModal = (pmtiles: string) => {
+ setMapSwipeResultsPmtiles(pmtiles);
+ closeMapSwipeProjectStatusDialog();
+ openMapSwipeProjectResultMapDialog();
+ };
+
+ const handleCloseMapSwipeProjectResultMapModal = () => {
+ closeMapSwipeProjectResultMapDialog();
+ setMapSwipeResultsPmtiles(null);
+ openMapSwipeProjectStatusDialog();
+ };
+ const [activePrediction, setActivePrediction] =
+ useState(null);
+
+ const {
+ isOpened: isPredictionResultOpened,
+ openDialog: openPredictionResultDialog,
+ closeDialog: closePredictionResultDialog,
+ } = useDialog();
+
+ const mapViewElementId = "published-predictions-map-view";
+ const { scrollToElement } = useScrollToElement(mapViewElementId);
+ const { scrollToTop } = useScrollToTop();
+
+ const isListView = layout === LayoutView.LIST;
+
+ const handleViewResults = (prediction: TOfflinePrediction) => {
+ setActivePrediction(prediction);
+ openPredictionResultDialog();
+ };
+
+ useEffect(() => {
+ if (mapViewIsActive) {
+ scrollToElement();
+ } else {
+ scrollToTop();
+ }
+ }, [mapViewIsActive, scrollToElement, scrollToTop]);
+
+ const renderContent = () => {
+ if (mapViewIsActive) {
+ return (
+
+
+
+ {isMapDataPending ||
+ isMapDataError ||
+ !mapData ||
+ mapData.features.length === 0 ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+ {isListView ? (
+
+ ) : (
+
+ )}
+
+ );
+ };
+ return (
+ <>
+
+
+ {/* Prediction result drawer (reused from existing feature) */}
+ {activePrediction && (
+ {
+ setActivePrediction(null);
+ closePredictionResultDialog();
+ }}
+ />
+ )}
+
+ {/* Detail dialog */}
+
+ {activePrediction && (
+
+ )}
+
+ {activePrediction && mapSwipeResultsPmtiles && (
+
+ )}
+
+
+ {/* Page header */}
+
+
+ {/* Filters */}
+
+
+ {/* Content */}
+ {renderContent()}
+
+ >
+ );
+};
diff --git a/frontend/src/app/routes/base-models/base-model-detail.tsx b/frontend/src/app/routes/base-models/base-model-detail.tsx
new file mode 100644
index 000000000..169f7784e
--- /dev/null
+++ b/frontend/src/app/routes/base-models/base-model-detail.tsx
@@ -0,0 +1,312 @@
+import { Head } from "@/components/seo";
+import MarkdownViewer from "@/components/shared/markdown-render";
+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 AccuracyDisplay from "@/features/models/components/accuracy-display";
+import { useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useBaseModel } from "@/features/base-models/hooks/use-base-models";
+import { TBaseModelVariant } from "@/types";
+
+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 { data: model, isLoading, isError } = useBaseModel(id);
+
+ if (isLoading) {
+ return Loading model...
;
+ }
+
+ if (isError || !model) {
+ return Failed to load model
;
+ }
+
+ 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) {
+ return null;
+ }
+
+ 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 */}
+
+
+ {/* Right Column - Architecture Info */}
+
+
+
+ {architectureRows.map((row) => (
+
+ ))}
+
+
+
+
+
+ {model.architecture.variants.map((variant, i) => (
+
+ ))}
+
+
+
+
+
+ {dataInfoRows.map((row) => (
+
+ ))}
+
+
+
+
+
+ >
+ );
+};
diff --git a/frontend/src/app/routes/base-models/base-models-list.tsx b/frontend/src/app/routes/base-models/base-models-list.tsx
new file mode 100644
index 000000000..13339cf0e
--- /dev/null
+++ b/frontend/src/app/routes/base-models/base-models-list.tsx
@@ -0,0 +1,212 @@
+import { Head } from "@/components/seo";
+import { ButtonWithIcon } from "@/components/ui/button";
+import { AddIcon } from "@/components/ui/icons";
+import { SHARED_CONTENT } from "@/constants";
+import { ButtonVariant, LayoutView } from "@/enums";
+import { useDialog } from "@/hooks/use-dialog";
+import { useMemo } from "react";
+import { parseAsString, useQueryStates } from "nuqs";
+import ContributeModelDialog from "@/features/base-models/components/contribute-model-dialog";
+import {
+ BaseModelsFilters,
+ MobileBaseModelFiltersDialog,
+} from "@/features/base-models/components";
+import {
+ BaseModelGridLayout,
+ BaseModelTableLayout,
+} from "@/features/base-models/layouts";
+import { useBaseModels } from "@/features/base-models/hooks/use-base-models";
+import { TBaseModel } from "@/types";
+import { DATE_SORT_OPTIONS } from "@/features/base-models/utils/common";
+
+export const BaseModelsPage = () => {
+ const { isOpened, openDialog, closeDialog } = useDialog();
+
+ const {
+ isOpened: isMobileFiltersOpen,
+ openDialog: openMobileFilters,
+ closeDialog: closeMobileFilters,
+ } = useDialog();
+
+ const [{ q: search, category, date: dateSort, layout }, setQueryStates] =
+ useQueryStates({
+ q: parseAsString.withDefault(""),
+ category: parseAsString.withDefault("all"),
+ date: parseAsString.withDefault("newest"),
+ layout: parseAsString.withDefault(LayoutView.GRID),
+ });
+
+ const isListView = layout === LayoutView.LIST;
+
+ const { data: models = [], isLoading, isError } = useBaseModels();
+
+ /**
+ * 1. Dynamically derive categories from STAC models
+ */
+ const taskCategories = useMemo(() => {
+ const uniqueTasks = new Set();
+
+ models.forEach((m: TBaseModel) => {
+ if (m.task) uniqueTasks.add(m.task);
+ });
+
+ const list = Array.from(uniqueTasks).sort();
+
+ return [
+ { label: "All", value: "all" },
+ ...list.map((t) => ({
+ label: t,
+ value: t,
+ })),
+ ];
+ }, [models]);
+
+ /**
+ * 2. Filters + sorting
+ */
+ const filteredModels = useMemo(() => {
+ let result = [...models];
+
+ if (search) {
+ const searchLower = search.toLowerCase();
+
+ result = result.filter(
+ (model) =>
+ model.name.toLowerCase().includes(searchLower) ||
+ model.description.toLowerCase().includes(searchLower) ||
+ model.author.toLowerCase().includes(searchLower),
+ );
+ }
+
+ if (category && category !== "all") {
+ result = result.filter((model) => model.task === category);
+ }
+
+ result.sort((a, b) => {
+ const aDate = new Date(a.updatedAt).getTime();
+ const bDate = new Date(b.updatedAt).getTime();
+
+ if (dateSort === "oldest") return aDate - bDate;
+ return bDate - aDate;
+ });
+
+ return result;
+ }, [models, search, category, dateSort]);
+
+ /**
+ * 3. Dropdown items (dynamic category)
+ */
+ const categoryMenuItems = useMemo(() => {
+ return taskCategories.map((cat) => ({
+ value: cat.label,
+ apiValue: cat.value,
+ }));
+ }, [taskCategories]);
+
+ const dateMenuItems = DATE_SORT_OPTIONS.map((opt) => ({
+ value: opt.label,
+ apiValue: opt.value,
+ }));
+
+ const selectedCategoryLabel =
+ taskCategories.find((c) => c.value === category)?.label || "Category";
+
+ const selectedDateLabel =
+ DATE_SORT_OPTIONS.find((d) => d.value === dateSort)?.label || "Date";
+
+ const toggleLayout = () => {
+ setQueryStates({
+ layout: isListView ? LayoutView.GRID : LayoutView.LIST,
+ });
+ };
+
+ const renderContent = () => {
+ if (isLoading) {
+ return Loading models...
;
+ }
+
+ if (isError) {
+ return Failed to load models
;
+ }
+
+ if (filteredModels.length === 0) {
+ return (
+
+
No models found
+
+ Try adjusting your search or filter criteria.
+
+
+ );
+ }
+
+ if (isListView) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
+ };
+
+ return (
+ <>
+
+
+
+
+ setQueryStates({ category: value })}
+ setDateSort={(value) => setQueryStates({ date: value })}
+ />
+
+
+
+
+
+ {SHARED_CONTENT.baseModelsPage.pageHeadingTitle}
+
+
+
+
+
+
+
+
+ {SHARED_CONTENT.baseModelsPage.pageHeadingDescription}
+
+
+
+ setQueryStates({ q: value })}
+ categoryMenuItems={categoryMenuItems}
+ dateMenuItems={dateMenuItems}
+ selectedCategoryLabel={selectedCategoryLabel}
+ selectedDateLabel={selectedDateLabel}
+ setCategory={(value) => setQueryStates({ category: value })}
+ setDateSort={(value) => setQueryStates({ date: value })}
+ filteredModelsCount={filteredModels.length}
+ layout={layout}
+ onToggleLayout={toggleLayout}
+ onOpenMobileFilters={openMobileFilters}
+ />
+
+ {renderContent()}
+
+ >
+ );
+};
diff --git a/frontend/src/app/routes/landing.tsx b/frontend/src/app/routes/landing.tsx
index 1e1de3a67..b592620d9 100644
--- a/frontend/src/app/routes/landing.tsx
+++ b/frontend/src/app/routes/landing.tsx
@@ -10,6 +10,7 @@ import {
CoreFeatures,
WhatIsFAIR,
} from "@/components/landing";
+import { BaseModelCTA } from "@/components/landing/base-model-cta/base-model-cta";
export const LandingPage = () => {
return (
@@ -19,6 +20,7 @@ export const LandingPage = () => {
+
diff --git a/frontend/src/app/routes/not-found.tsx b/frontend/src/app/routes/not-found.tsx
index 63a1d8af6..2483c57d7 100644
--- a/frontend/src/app/routes/not-found.tsx
+++ b/frontend/src/app/routes/not-found.tsx
@@ -5,15 +5,28 @@ import { useLocation, useNavigate } from "react-router-dom";
export const PageNotFound = () => {
const location = useLocation();
+ const fromPath = location.state?.from ?? "";
+ const buttonLabelFromState = location.state?.buttonLabel;
+ const redirectPathFromState = location.state?.redirectPath;
- const modelNotFound = location.state?.from.includes(
- APPLICATION_ROUTES.MODELS,
- );
+ const modelNotFound = fromPath.includes(APPLICATION_ROUTES.MODELS);
- const trainingDatasetNotFound = location.state?.from.includes(
+ const trainingDatasetNotFound = fromPath.includes(
APPLICATION_ROUTES.DATASETS,
);
+ const fallbackRedirectPath = modelNotFound
+ ? APPLICATION_ROUTES.MODELS
+ : trainingDatasetNotFound
+ ? APPLICATION_ROUTES.DATASETS
+ : APPLICATION_ROUTES.HOMEPAGE;
+
+ const fallbackButtonLabel = modelNotFound
+ ? SHARED_CONTENT.pageNotFound.actionButtons.modelNotFound
+ : trainingDatasetNotFound
+ ? SHARED_CONTENT.pageNotFound.actionButtons.trainingDatasetNotFound
+ : SHARED_CONTENT.pageNotFound.actionButtons.pageNotFound;
+
const navigate = useNavigate();
return (
@@ -66,22 +79,11 @@ export const PageNotFound = () => {
>
diff --git a/frontend/src/app/routes/profile/settings.tsx b/frontend/src/app/routes/profile/settings.tsx
index 6f03d3037..70f126c12 100644
--- a/frontend/src/app/routes/profile/settings.tsx
+++ b/frontend/src/app/routes/profile/settings.tsx
@@ -216,7 +216,6 @@ export const UserProfileSettingsPage = () => {