From ef542c198f8506a06a7a83a4453041bec365fd8d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 1 Oct 2026 08:57:29 -0400 Subject: [PATCH] Add an optional instance banner above the header --- .build/Dockerfile | 1 + .build/docker-entrypoint.sh | 3 +++ .env.example | 3 +++ docker/.env.example | 3 +++ docker/docker-compose.yml | 2 ++ src/components/AppShell.tsx | 4 +++- src/components/InstanceBanner.tsx | 22 ++++++++++++++++++++++ src/lib/constants.ts | 3 +++ tests/components/InstanceBanner.test.tsx | 22 ++++++++++++++++++++++ 9 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/components/InstanceBanner.tsx create mode 100644 tests/components/InstanceBanner.test.tsx diff --git a/.build/Dockerfile b/.build/Dockerfile index 2b83f382..dcc8f87b 100644 --- a/.build/Dockerfile +++ b/.build/Dockerfile @@ -11,6 +11,7 @@ ENV VITE_DISABLED_TABS=__VITE_DISABLED_TABS__ ENV VITE_ENABLED_THEMES=__VITE_ENABLED_THEMES__ ENV VITE_APP_NAME=__VITE_APP_NAME__ ENV VITE_SKIP_SPLASH=__VITE_SKIP_SPLASH__ +ENV VITE_BANNER=__VITE_BANNER__ RUN npm run build FROM caddy:2-alpine diff --git a/.build/docker-entrypoint.sh b/.build/docker-entrypoint.sh index a08b8e56..9606ed77 100644 --- a/.build/docker-entrypoint.sh +++ b/.build/docker-entrypoint.sh @@ -12,6 +12,8 @@ ENABLED_THEMES="${VITE_ENABLED_THEMES:-}" SKIP_SPLASH="${VITE_SKIP_SPLASH:-}" # APP_NAME is substituted verbatim; avoid '&' and '|' (sed replacement metachar and delimiter). APP_NAME="${VITE_APP_NAME:-BEACON}" +# Same caveat as APP_NAME, plus no quotes (it lands inside a JS string). +BANNER="${VITE_BANNER:-}" find /srv -name '*.js' -exec sed -i \ -e "s|__VITE_API_BASE__|${API_BASE}|g" \ @@ -22,6 +24,7 @@ find /srv -name '*.js' -exec sed -i \ -e "s|__VITE_ENABLED_THEMES__|${ENABLED_THEMES}|g" \ -e "s|__VITE_APP_NAME__|${APP_NAME}|g" \ -e "s|__VITE_SKIP_SPLASH__|${SKIP_SPLASH}|g" \ + -e "s|__VITE_BANNER__|${BANNER}|g" \ {} + exec "$@" diff --git a/.env.example b/.env.example index 91b7213c..800e481b 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,6 @@ # Skip the once-per-session load splash. Unset = shown. # VITE_SKIP_SPLASH=true + +# Notice shown above the header on every page; [label](url) and bare URLs become links. Unset = none. Avoid &, | and quotes. +# VITE_BANNER=This is the Beacon development instance and may be unstable. For everyday use, visit [live.meshcore.ca](https://live.meshcore.ca). diff --git a/docker/.env.example b/docker/.env.example index 176755e2..87a9ec7e 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -28,3 +28,6 @@ VITE_APP_NAME= # Skip the once-per-session load splash. Set to true to skip; leave empty to show it. VITE_SKIP_SPLASH= + +# Notice above the header (e.g. on a dev instance); [label](url) and bare URLs become links. Blank = none. +VITE_BANNER= diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ac9043ab..bf9a0132 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -26,4 +26,6 @@ services: - VITE_APP_NAME=${VITE_APP_NAME:-BEACON} # Skip the once-per-session load splash; unset = shown - VITE_SKIP_SPLASH=${VITE_SKIP_SPLASH:-} + # Notice above the header, e.g. on a dev instance; unset = none + - VITE_BANNER=${VITE_BANNER:-} restart: unless-stopped diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 53dea428..549c361e 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -14,7 +14,8 @@ import { LanguageOptions, LanguagePicker } from "./LanguagePicker"; import { useIsMobile } from "../hooks/useMediaQuery"; import { BeaconWordmark } from "./BeaconWordmark"; import { getIatas } from "../api/client"; -import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL } from "../lib/constants"; +import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL, BANNER } from "../lib/constants"; +import { InstanceBanner } from "./InstanceBanner"; import type { WsManager } from "../api/ws-manager"; // header widgets: WS status, region picker, theme picker @@ -430,6 +431,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe const isMobile = useIsMobile(); return (
+ {isMobile ? (
diff --git a/src/components/InstanceBanner.tsx b/src/components/InstanceBanner.tsx new file mode 100644 index 00000000..b0942464 --- /dev/null +++ b/src/components/InstanceBanner.tsx @@ -0,0 +1,22 @@ +// [label](url) or a bare URL; trailing punctuation and closing parens stay outside a bare link +const LINK_PATTERN = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s()]*[^\s().,;:!?])/g; + +// Deployment-wide notice set by VITE_BANNER, e.g. flagging the dev instance. +export function InstanceBanner({ text }: { text: string }) { + const trimmed = text.trim(); + if (!trimmed) return null; + const parts = []; + let last = 0; + for (const m of trimmed.matchAll(LINK_PATTERN)) { + parts.push(trimmed.slice(last, m.index)); + const href = m[2] ?? m[3]; + parts.push({m[1] ?? href}); + last = m.index + m[0].length; + } + parts.push(trimmed.slice(last)); + return ( +
+ {parts} +
+ ); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 97837492..6701ea6b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -70,5 +70,8 @@ export const ENABLED_THEME_IDS = new Set( export const APP_NAME = import.meta.env.VITE_APP_NAME || "BEACON"; export const GITHUB_URL = "https://github.com/MeshCore-Beacon"; +// Notice shown above the header on every page; unset = no banner. +export const BANNER = import.meta.env.VITE_BANNER ?? ""; + // Skip the once-per-session load splash entirely (e.g. an embedded/branded deployment). export const SKIP_SPLASH = parseEnvBool(import.meta.env.VITE_SKIP_SPLASH); diff --git a/tests/components/InstanceBanner.test.tsx b/tests/components/InstanceBanner.test.tsx new file mode 100644 index 00000000..d64956ec --- /dev/null +++ b/tests/components/InstanceBanner.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { InstanceBanner } from "../../src/components/InstanceBanner"; + +describe("InstanceBanner", () => { + it("renders nothing without text", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + it("shows the text and links any URL in it", () => { + render(); + const banner = screen.getByRole("note"); + expect(banner).toHaveTextContent("This is the development instance of Beacon, please use live (https://live.meshcore.ca)"); + const link = screen.getByRole("link", { name: "https://live.meshcore.ca" }); + expect(link).toHaveAttribute("href", "https://live.meshcore.ca"); + }); + it("turns [label](url) into a labelled link", () => { + render(); + expect(screen.getByRole("note")).toHaveTextContent("This is the Beacon development instance and may be unstable. For everyday use, visit live.meshcore.ca."); + expect(screen.getByRole("link", { name: "live.meshcore.ca" })).toHaveAttribute("href", "https://live.meshcore.ca"); + }); +});