diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx
index 2fa187e..9173246 100644
--- a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.test.tsx
@@ -1,16 +1,23 @@
import { render, screen } from "@testing-library/react";
-import type { ReactNode } from "react";
+import type { ComponentType, ReactNode } from "react";
import { describe, expect, it } from "vitest";
import { AppConfigProvider } from "@/context/AppConfigContext";
import { PageHeader } from "@/components/PageHeader";
+import { IconNodes } from "@/components/icons";
import { makeConfig } from "@/test/makeConfig";
import type { AppConfig } from "@/types/config";
-function renderHeader(config: AppConfig = makeConfig(), children?: ReactNode) {
+function renderHeader(
+ config: AppConfig = makeConfig(),
+ children?: ReactNode,
+ icon?: ComponentType<{ className?: string }>,
+) {
return render(
- {children}
+
+ {children}
+
,
);
}
@@ -41,4 +48,21 @@ describe("PageHeader", () => {
expect(screen.getByText("EST")).toBeInTheDocument();
expect(screen.getByText("extra badge")).toBeInTheDocument();
});
+
+ it("renders the icon inside the heading when provided", () => {
+ renderHeader(makeConfig(), undefined, IconNodes);
+ const svg = screen
+ .getByRole("heading", { name: "Nodes" })
+ .querySelector("svg");
+ expect(svg).not.toBeNull();
+ expect(svg?.getAttribute("class")).toContain("h-8");
+ expect(svg?.getAttribute("class")).toContain("w-8");
+ });
+
+ it("renders no icon when omitted", () => {
+ renderHeader();
+ expect(
+ screen.getByRole("heading", { name: "Nodes" }).querySelector("svg"),
+ ).toBeNull();
+ });
});
diff --git a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx
index 68f376c..721917d 100644
--- a/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/components/PageHeader.tsx
@@ -1,18 +1,23 @@
-import type { ReactNode } from "react";
+import type { ComponentType, ReactNode } from "react";
import { useAppConfig } from "@/context/AppConfigContext";
export function PageHeader({
title,
+ icon: Icon,
children,
}: {
title: ReactNode;
+ icon?: ComponentType<{ className?: string }>;
children?: ReactNode;
}) {
const config = useAppConfig();
const tz = config.timezone || "";
return (
-
{title}
+
+ {Icon && }
+ {title}
+
{tz && tz !== "UTC" && (
{tz}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx
index bd27821..ed3382e 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.test.tsx
@@ -40,6 +40,11 @@ describe("Advertisements", () => {
await waitFor(() => {
expect(screen.getAllByText("AdNode").length).toBeGreaterThanOrEqual(1);
});
+ expect(
+ screen
+ .getByRole("heading", { name: "entities.advertisements" })
+ .querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx
index 85d03d5..b165934 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Advertisements.tsx
@@ -8,6 +8,7 @@ import { qk } from "@/utils/queryKeys";
import { useFormatDateTime } from "@/utils/format";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
+import { IconAdvertisements } from "@/components/icons";
import { Pagination } from "@/components/Pagination";
import {
FilterForm,
@@ -235,7 +236,7 @@ export function Advertisements() {
return (
<>
-
+
{
expect(screen.getByText("Public")).toBeInTheDocument();
expect(screen.getByText("Ops")).toBeInTheDocument();
});
+ expect(
+ screen.getByRole("heading", { name: "entities.channels" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx
index 679e901..1e1132c 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Channels.tsx
@@ -283,7 +283,7 @@ export function Channels() {
const config = useAppConfig();
const oidcEnabled = config.oidc_enabled;
const isAdmin = hasRole("admin");
- usePageTitle("channels.title");
+ usePageTitle("entities.channels");
const queryClient = useQueryClient();
@@ -364,14 +364,7 @@ export function Channels() {
return (
-
-
- {t("channels.title")}
-
- }
- />
+
{error && }
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx
index d5c5f3f..f24b4c8 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.test.tsx
@@ -45,6 +45,9 @@ describe("Dashboard", () => {
await waitFor(() => {
expect(document.querySelector(".loading-spinner")).toBeNull();
});
+ expect(
+ screen.getByRole("heading", { name: "entities.dashboard" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx
index 01da959..b107e94 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Dashboard.tsx
@@ -15,6 +15,7 @@ import { RouteTypeBadge } from "@/components/RouteTypeBadge";
import {
IconAdvertisements,
IconChannel,
+ IconDashboard,
IconMessages,
IconNodes,
IconPackets,
@@ -428,7 +429,7 @@ export function DashboardPage() {
return (
<>
-
+
{visibleChartCount > 0 && (
<>
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx
index 9a06850..6d4c44b 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.test.tsx
@@ -55,6 +55,9 @@ describe("MapPage", () => {
await waitFor(() => {
expect(screen.getByTestId("mock-map")).toBeInTheDocument();
});
+ expect(
+ screen.getByRole("heading", { name: "entities.map" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx
index 6d8895c..5140758 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/MapPage.tsx
@@ -26,6 +26,7 @@ import {
import { FilterToggle, OperatorSelect } from "@/components/FilterForm";
import { ErrorAlert, Loading } from "@/components/Alerts";
import { PageHeader } from "@/components/PageHeader";
+import { IconMap } from "@/components/icons";
const MAX_BOUNDS_RADIUS_KM = 20;
@@ -386,7 +387,7 @@ export function MapPage() {
return (
-
+
{countBadgeText}
{showFilteredBadge && (
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx
index 1336ff8..d30d379 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.test.tsx
@@ -32,6 +32,9 @@ describe("Members", () => {
});
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.queryByText("TestUser")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "entities.members" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an empty state when no visible profiles exist", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx
index f858125..077ded4 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Members.tsx
@@ -14,7 +14,7 @@ import { Loading, ErrorAlert } from "@/components/Alerts";
import { CallsignBadge, RoleBadge } from "@/components/Badges";
import { EmptyState } from "@/components/EmptyState";
import { PageHeader } from "@/components/PageHeader";
-import { IconAntenna, IconUsers } from "@/components/icons";
+import { IconAntenna, IconMembers, IconUsers } from "@/components/icons";
import { usePageTitle } from "@/hooks/usePageTitle";
interface MemberNode {
@@ -184,7 +184,7 @@ export function Members() {
if (visible.length === 0) {
return (
<>
-
+
{t("members_page.empty_state")}
{t("members_page.empty_description")}
@@ -207,7 +207,7 @@ export function Members() {
return (
<>
-
+
{t("common.count_entity", {
count: formatNumber(operators.length + members.length),
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx
index 627f5f5..509b0f8 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.test.tsx
@@ -40,6 +40,9 @@ describe("Messages", () => {
await waitFor(() => {
expect(screen.getAllByText("Hello world").length).toBeGreaterThanOrEqual(1);
});
+ expect(
+ screen.getByRole("heading", { name: "entities.messages" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx
index 8121532..0d40e7b 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Messages.tsx
@@ -18,6 +18,7 @@ import {
} from "@/utils/messageHelpers";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
+import { IconMessages } from "@/components/icons";
import { Pagination } from "@/components/Pagination";
import {
FilterForm,
@@ -278,7 +279,7 @@ export function Messages() {
return (
<>
-
+
{
await waitFor(() => {
expect(document.querySelector(".loading-spinner")).toBeNull();
});
+ expect(
+ screen.getByRole("heading", { name: "DetailNode" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx
index fdda2d4..5d50ba7 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/NodeDetail.tsx
@@ -15,7 +15,7 @@ import { ErrorAlert, Loading, SuccessAlert } from "@/components/Alerts";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { CopyableValue } from "@/components/CopyableValue";
-import { IconEdit, IconPlus, IconTrash } from "@/components/icons";
+import { IconEdit, IconNodes, IconPlus, IconTrash } from "@/components/icons";
import { MeshQrCode } from "@/components/MeshQrCode";
import { Modal } from "@/components/Modal";
import { NotFoundState } from "@/components/NotFoundState";
@@ -606,7 +606,10 @@ export function NodeDetailPage() {
{emoji}
-
{displayName}
+
+
+ {displayName}
+
{tagDescription && (
{tagDescription}
)}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx
index fddfed3..b714474 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.test.tsx
@@ -43,6 +43,9 @@ describe("Nodes", () => {
await waitFor(() => {
expect(screen.getAllByText("TestNode").length).toBeGreaterThanOrEqual(1);
});
+ expect(
+ screen.getByRole("heading", { name: "entities.nodes" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx
index 9cc97b4..f68bb9e 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Nodes.tsx
@@ -10,6 +10,7 @@ import { useFormatDateTime } from "@/utils/format";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
import { Pagination } from "@/components/Pagination";
+import { IconNodes } from "@/components/icons";
import {
FilterForm,
FilterField,
@@ -249,7 +250,7 @@ export function Nodes() {
return (
-
+
{
expect(screen.getAllByText("abc123").length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText("Observer1")).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "abc123" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows not-found state on a 404 error", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx
index 717e299..eb45e65 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketDetail.tsx
@@ -8,6 +8,7 @@ import { useFormatDateTime } from "@/utils/format";
import { Loading, WarningBadge } from "@/components/Alerts";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { NotFoundState } from "@/components/NotFoundState";
+import { IconPackets } from "@/components/icons";
import { DefinitionGrid } from "@/components/Definition";
import {
buildChannelNames,
@@ -90,6 +91,11 @@ export function PacketDetail() {
]}
/>
+
+
+ {leaf || t("packets.detail_title")}
+
+
{notFound && (
{
await waitFor(() => {
expect(screen.getAllByText("grouphash").length).toBeGreaterThanOrEqual(1);
});
+ expect(
+ screen.getByRole("heading", { name: "grouphash" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx
index dec9de0..de4a727 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/PacketGroupDetail.tsx
@@ -21,7 +21,7 @@ import {
import { groupByObserver } from "@/utils/packetGroupHelpers";
import { Loading, WarningBadge } from "@/components/Alerts";
import { Breadcrumbs } from "@/components/Breadcrumbs";
-import { IconSatelliteDish } from "@/components/icons";
+import { IconPackets, IconSatelliteDish } from "@/components/icons";
import { NotFoundState } from "@/components/NotFoundState";
import { TimeAgo } from "@/components/TimeAgo";
import { DefinitionGrid } from "@/components/Definition";
@@ -356,6 +356,11 @@ export function PacketGroupDetail() {
]}
/>
+
+
+ {leaf || t("packets.detail_title")}
+
+
{notFound && (
)}
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx
index 7124443..dcbcf42 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.test.tsx
@@ -43,6 +43,9 @@ describe("Packets", () => {
await waitFor(() => {
expect(screen.getByText("hash1")).toBeInTheDocument();
});
+ expect(
+ screen.getByRole("heading", { name: "entities.packets" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx
index 42a3518..5b2da54 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Packets.tsx
@@ -23,7 +23,7 @@ import { Loading } from "@/components/Alerts";
import { ListToolbar } from "@/components/ListToolbar";
import { PageHeader } from "@/components/PageHeader";
import { EmptyState, EmptyRow } from "@/components/EmptyState";
-import { IconPath, IconRuler, IconSatelliteDish } from "@/components/icons";
+import { IconPath, IconPackets, IconRuler, IconSatelliteDish } from "@/components/icons";
const EVENT_TYPES = [
"advertisement",
@@ -251,7 +251,7 @@ export function Packets() {
return (
-
+
{
expect(screen.getAllByText("Jane Operator").length).toBeGreaterThanOrEqual(1);
});
expect(screen.getAllByText("AB1CDE").length).toBeGreaterThanOrEqual(1);
+ expect(
+ screen.getByRole("heading", { name: "user_profile.title" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error alert on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx
index 7906d93..7ba2d18 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Profile.tsx
@@ -11,6 +11,7 @@ import { Loading, ErrorAlert, SuccessAlert } from "@/components/Alerts";
import { CallsignBadge, RoleBadge } from "@/components/Badges";
import { Breadcrumbs } from "@/components/Breadcrumbs";
import { PageHeader } from "@/components/PageHeader";
+import { IconUser } from "@/components/icons";
import { TimeAgo } from "@/components/TimeAgo";
import { usePageTitle } from "@/hooks/usePageTitle";
@@ -253,7 +254,7 @@ function PublicProfileView({ id }: { id: string }) {
{ label: profile.name || t("common.unnamed") },
]}
/>
-
+
{isOwner && (
{t("user_profile.edit_profile")}
@@ -374,7 +375,7 @@ function OwnProfileView() {
{ label: t("user_profile.title") },
]}
/>
-
+
{flashMessage ? (
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
index 85892b1..0c171da 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.test.tsx
@@ -69,6 +69,9 @@ describe("Routes", () => {
await waitFor(() => {
expect(screen.getAllByText("NodeA").length).toBeGreaterThanOrEqual(1);
});
+ expect(
+ screen.getByRole("heading", { name: "entities.routes" }).querySelector("svg"),
+ ).not.toBeNull();
});
it("shows an error on fetch failure", async () => {
diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx
index 921120e..7098792 100644
--- a/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx
+++ b/src/meshcore_hub/web/static/js/spa-react/pages/Routes.tsx
@@ -1192,7 +1192,7 @@ export function RoutesPage() {
isAdmin || (!!r.created_by && r.created_by === currentUserId);
const mine = searchParams.get("mine") === "true";
const [filterOpen, setFilterOpen] = useState(false);
- usePageTitle("routes.title");
+ usePageTitle("entities.routes");
const queryClient = useQueryClient();
@@ -1501,14 +1501,7 @@ export function RoutesPage() {
return (
-
-
- {t("routes.title")}
-
- }
- />
+
diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json
index 5c6495b..014b915 100644
--- a/src/meshcore_hub/web/static/locales/nl.json
+++ b/src/meshcore_hub/web/static/locales/nl.json
@@ -19,6 +19,7 @@
"admin": "Beheer",
"tags": "Labels",
"tag": "Label",
+ "channels": "Kanalen",
"routes": "Routes"
},
"common": {