Add .env options for hiding tabs, MeshMapper themes, app name and a GitHub link

This commit is contained in:
MrAlders0n committed 2026-07-13 11:14:34 -04:00
1 parent bda7147741
commit 8786ef3d0a
24 files changed
+233 -42

No files matched your search

+3
View File
@@ -7,6 +7,9 @@ ENV VITE_API_BASE=__VITE_API_BASE__
ENV VITE_WS_URL=__VITE_WS_URL__
ENV VITE_MAP_CENTER=__VITE_MAP_CENTER__
ENV VITE_MAP_ZOOM=__VITE_MAP_ZOOM__
ENV VITE_DISABLED_TABS=__VITE_DISABLED_TABS__
ENV VITE_ENABLED_THEMES=__VITE_ENABLED_THEMES__
ENV VITE_APP_NAME=__VITE_APP_NAME__
RUN npm run build
FROM caddy:2-alpine
+8
View File
@@ -6,12 +6,20 @@ WS_URL="${VITE_WS_URL:-ws://localhost:8080/ws}"
# Optional map view — leave empty when unset; the app falls back to a world overview.
MAP_CENTER="${VITE_MAP_CENTER:-}"
MAP_ZOOM="${VITE_MAP_ZOOM:-}"
# Optional branding/customization — empty means default behaviour (all tabs, hidden themes hidden).
DISABLED_TABS="${VITE_DISABLED_TABS:-}"
ENABLED_THEMES="${VITE_ENABLED_THEMES:-}"
# APP_NAME is substituted verbatim; avoid '&' and '|' (sed replacement metachar and delimiter).
APP_NAME="${VITE_APP_NAME:-BEACON}"
find /srv -name '*.js' -exec sed -i \
-e "s|__VITE_API_BASE__|${API_BASE}|g" \
-e "s|__VITE_WS_URL__|${WS_URL}|g" \
-e "s|__VITE_MAP_CENTER__|${MAP_CENTER}|g" \
-e "s|__VITE_MAP_ZOOM__|${MAP_ZOOM}|g" \
-e "s|__VITE_DISABLED_TABS__|${DISABLED_TABS}|g" \
-e "s|__VITE_ENABLED_THEMES__|${ENABLED_THEMES}|g" \
-e "s|__VITE_APP_NAME__|${APP_NAME}|g" \
{} +
exec "$@"
+11
View File
@@ -11,3 +11,14 @@
# selection has no airport coords; otherwise the map fits bounds over the airports. Unset = world.
# VITE_MAP_CENTER=52.5,-96.8
# VITE_MAP_ZOOM=3.2
# Hide tabs completely (comma list, case-insensitive). Unset = show all.
# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics
# VITE_DISABLED_TABS=Map,Traces
# Reveal normally-hidden themes in the picker (comma list of theme ids). Unset = none.
# Options: meshmapper_dark,meshmapper_light
# VITE_ENABLED_THEMES=meshmapper_dark,meshmapper_light
# Custom top-left app name (default: BEACON). Avoid the & and | characters.
# VITE_APP_NAME=Beacon - MeshMapper
+11
View File
@@ -11,3 +11,14 @@ VITE_WS_URL=ws://localhost:8080/ws
# decimal "lat,lon", VITE_MAP_ZOOM is 0-22. The example below is a Canada-wide view.
VITE_MAP_CENTER=52.5,-96.8
VITE_MAP_ZOOM=3.2
# Hide tabs completely (comma list, case-insensitive). Leave empty to show all.
# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics
VITE_DISABLED_TABS=
# Reveal normally-hidden themes in the picker (comma list of theme ids). Leave empty for none.
# Options: meshmapper_dark,meshmapper_light
VITE_ENABLED_THEMES=
# Custom app name shown in the top-left wordmark. Default: BEACON. Avoid the & and | characters.
VITE_APP_NAME=
+4
View File
@@ -20,4 +20,8 @@ services:
# Optional "All" map view; unset = world overview
- VITE_MAP_CENTER=${VITE_MAP_CENTER:-}
- VITE_MAP_ZOOM=${VITE_MAP_ZOOM:-}
# Optional branding/customization; unset = all tabs, hidden themes hidden, name "BEACON"
- VITE_DISABLED_TABS=${VITE_DISABLED_TABS:-}
- VITE_ENABLED_THEMES=${VITE_ENABLED_THEMES:-}
- VITE_APP_NAME=${VITE_APP_NAME:-BEACON}
restart: unless-stopped
+44
View File
@@ -334,5 +334,49 @@
"--palette-text-muted": "#6B8A8A",
"--palette-text-dim": "#4D6A6A"
}
},
{
"id": "meshmapper_dark",
"name": "MeshMapper Dark",
"hidden": true,
"vars": {
"--palette-bg-base": "#121212",
"--palette-bg-surface": "#1E1E1E",
"--palette-bg-raised": "#2A2A2A",
"--palette-border": "#333333",
"--palette-border-subtle": "#242424",
"--palette-primary": "#00D2FF",
"--palette-primary-dim": "#00A5C8",
"--palette-secondary": "#4A9EFF",
"--palette-green": "#28A745",
"--palette-danger": "#DC3545",
"--palette-warn": "#FFC107",
"--palette-text-bright": "#FFFFFF",
"--palette-text-normal": "#E0E0E0",
"--palette-text-muted": "#AAAAAA",
"--palette-text-dim": "#777777"
}
},
{
"id": "meshmapper_light",
"name": "MeshMapper Light",
"hidden": true,
"vars": {
"--palette-bg-base": "#F4F4F9",
"--palette-bg-surface": "#FFFFFF",
"--palette-bg-raised": "#FFFFFF",
"--palette-border": "#DDDDDD",
"--palette-border-subtle": "#EEEEEE",
"--palette-primary": "#007A94",
"--palette-primary-dim": "#005F73",
"--palette-secondary": "#0062CC",
"--palette-green": "#1E7E34",
"--palette-danger": "#C82333",
"--palette-warn": "#9A6A00",
"--palette-text-bright": "#1A1A1A",
"--palette-text-normal": "#333333",
"--palette-text-muted": "#666666",
"--palette-text-dim": "#767676"
}
}
]
+2 -2
View File
@@ -28,7 +28,7 @@ import { ChannelList } from "./features/channels/ChannelList";
import { EmptyState } from "./components/EmptyState";
import { getPacketDetail } from "./api/client";
import { WsManager } from "./api/ws-manager";
import { WS_URL, TABS } from "./lib/constants";
import { WS_URL, ENABLED_TABS } from "./lib/constants";
// Map is the only heavy tab (maplibre-gl is ~1MB), so lazy-load it — its chunk is fetched the
// first time someone opens the Map tab instead of bloating the initial bundle.
@@ -123,7 +123,7 @@ function AppInner() {
// unknown ?tab value falls back to Packets instead of rendering a blank pane.
// "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working.
const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab");
const activeTab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as string) : "Packets";
const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets");
// Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection).
const [initialSelection] = useState(() => computeInitialSelection(searchParams));
+21 -10
View File
@@ -9,7 +9,7 @@ import { Dropdown } from "./Dropdown";
import { BottomNav } from "./BottomNav";
import { BeaconWordmark } from "./BeaconWordmark";
import { getIatas } from "../api/client";
import { TABS } from "../lib/constants";
import { ENABLED_TABS, ENABLED_THEME_IDS, isThemeVisible, APP_NAME, GITHUB_URL } from "../lib/constants";
import type { WsManager } from "../api/ws-manager";
// header widgets: WS status, region picker, theme picker
@@ -131,7 +131,7 @@ function RegionSelector() {
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
isAllRegions(selection)
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-white/3"
: "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => setSelection(ALL_REGIONS)}
>
@@ -151,7 +151,7 @@ function RegionSelector() {
key={r.slug}
type="button"
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3"
checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => toggleRegion(r.slug)}
>
@@ -172,7 +172,7 @@ function RegionSelector() {
key={i.iata}
type="button"
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3"
checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => toggleIata(i.iata)}
>
@@ -206,7 +206,7 @@ function ThemePicker() {
onClick={toggle}
>
<span
className="w-2.5 h-2.5 rounded-full shrink-0 border border-white/10"
className="w-2.5 h-2.5 rounded-full shrink-0 border border-text-normal/20"
style={{ background: current?.vars["--palette-primary"] }}
/>
<span className="text-text-dim text-[11px]">▾</span>
@@ -215,14 +215,14 @@ function ThemePicker() {
>
{(close) => (
<>
{themes.map((t) => (
{themes.filter((t) => isThemeVisible(t, ENABLED_THEME_IDS) || t.id === themeId).map((t) => (
<button
key={t.id}
type="button"
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
t.id === themeId
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-white/3"
: "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => {
setThemeId(t.id);
@@ -230,7 +230,7 @@ function ThemePicker() {
}}
>
<span
className="w-3 h-3 rounded-full shrink-0 border border-white/10"
className="w-3 h-3 rounded-full shrink-0 border border-text-normal/20"
style={{ background: t.vars["--palette-primary"] }}
/>
{t.name}
@@ -260,11 +260,22 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
<RegionSelector />
<ThemePicker />
<LiveBadge wsManager={wsManager} />
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="text-text-muted hover:text-text-normal transition-colors shrink-0"
>
<svg viewBox="0 0 16 16" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
</a>
</div>
</header>
<nav className="hidden md:flex bg-bg-surface border-b border-border px-4 shrink-0" role="tablist">
{TABS.map((tab) => (
{ENABLED_TABS.map((tab) => (
<button
key={tab}
type="button"
@@ -287,7 +298,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
</main>
<footer className="hidden md:flex items-center px-4 py-1.5 bg-bg-surface border-t border-border font-mono text-[11px] text-text-dim shrink-0">
<span>BEACON v{__APP_VERSION__}</span>
<span>{APP_NAME} v{__APP_VERSION__}</span>
</footer>
<BottomNav activeTab={activeTab} onTabChange={onTabChange} />
+4 -3
View File
@@ -1,8 +1,9 @@
// BEACON wordmark: beacon icon + "BEACON" text in Chakra Petch.
// BEACON wordmark: beacon icon + app name (VITE_APP_NAME, default "BEACON") in Chakra Petch.
// `text-primary` on the wrapper drives both the icon (currentColor) and the
// text color, so the whole mark follows the active theme.
import { BeaconLogo } from "./BeaconLogo";
import { APP_NAME } from "../lib/constants";
interface BeaconWordmarkProps {
iconSize?: number;
@@ -21,10 +22,10 @@ export function BeaconWordmark({
<span className={`inline-flex items-center gap-2 text-primary ${className ?? ""}`}>
<BeaconLogo size={iconSize} pulse={pulse} />
<span
className={`font-medium tracking-[0.18em] uppercase leading-none ${textClassName}`}
className={`font-medium tracking-[0.18em] leading-none ${textClassName}`}
style={{ fontFamily: "'Chakra Petch', sans-serif" }}
>
BEACON
{APP_NAME}
</span>
</span>
);
+22 -14
View File
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from "react";
import { BottomSheet } from "./BottomSheet";
import { ENABLED_TABS } from "../lib/constants";
// Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet.
const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const;
@@ -79,17 +80,17 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
}
// Bottom sheet listing the overflow tabs.
function MoreSheet({ activeTab, onPick, onClose }: { activeTab: string; onPick: (tab: string) => void; onClose: () => void }) {
function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activeTab: string; onPick: (tab: string) => void; onClose: () => void }) {
return (
<BottomSheet onClose={onClose} role="menu" label="More tabs">
{OVERFLOW_TABS.map((tab) => (
{tabs.map((tab) => (
<button
key={tab}
type="button"
role="menuitem"
onClick={() => onPick(tab)}
className={`w-full flex items-center gap-3 px-5 py-3 text-left text-sm font-medium cursor-pointer transition-colors ${
activeTab === tab ? "text-primary" : "text-text-normal hover:bg-white/3"
activeTab === tab ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
}`}
>
{tab}
@@ -101,7 +102,10 @@ function MoreSheet({ activeTab, onPick, onClose }: { activeTab: string; onPick:
export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTabChange: (tab: string) => void }) {
const [sheetOpen, setSheetOpen] = useState(false);
const overflowActive = (OVERFLOW_TABS as readonly string[]).includes(activeTab);
const enabled = new Set<string>(ENABLED_TABS);
const primary: string[] = PRIMARY_TABS.filter((t) => enabled.has(t));
const overflow: string[] = OVERFLOW_TABS.filter((t) => enabled.has(t));
const overflowActive = overflow.includes(activeTab);
const pick = (tab: string) => {
onTabChange(tab);
@@ -111,7 +115,7 @@ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTab
return (
<>
<nav className="flex md:hidden shrink-0 bg-bg-surface border-t border-border" role="tablist" aria-label="Primary">
{PRIMARY_TABS.map((tab) => (
{primary.map((tab) => (
<NavButton
key={tab}
label={tab}
@@ -122,17 +126,21 @@ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTab
ariaSelected={activeTab === tab}
/>
))}
<NavButton
label="More"
icon={<Icon name="More" />}
active={overflowActive || sheetOpen}
onClick={() => setSheetOpen((v) => !v)}
ariaHasPopup="menu"
ariaExpanded={sheetOpen}
/>
{overflow.length > 0 && (
<NavButton
label="More"
icon={<Icon name="More" />}
active={overflowActive || sheetOpen}
onClick={() => setSheetOpen((v) => !v)}
ariaHasPopup="menu"
ariaExpanded={sheetOpen}
/>
)}
</nav>
{sheetOpen && <MoreSheet activeTab={activeTab} onPick={pick} onClose={() => setSheetOpen(false)} />}
{sheetOpen && overflow.length > 0 && (
<MoreSheet tabs={overflow} activeTab={activeTab} onPick={pick} onClose={() => setSheetOpen(false)} />
)}
</>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ export function CloseButton({ onClose, label = "Close", className }: {
type="button"
onClick={onClose}
aria-label={label}
className={`flex items-center justify-center w-9 h-9 md:w-7 md:h-7 rounded text-text-muted hover:text-text-bright hover:bg-white/5 cursor-pointer transition-colors ${className ?? ""}`}
className={`flex items-center justify-center w-9 h-9 md:w-7 md:h-7 rounded text-text-muted hover:text-text-bright hover:bg-text-normal/5 cursor-pointer transition-colors ${className ?? ""}`}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
+1 -1
View File
@@ -25,7 +25,7 @@ function MinimizeButton({ collapsed, onToggle }: { collapsed: boolean; onToggle:
type="button"
onClick={onToggle}
aria-label={collapsed ? "Expand detail panel" : "Minimize detail panel"}
className="md:hidden flex items-center justify-center w-9 h-9 rounded text-text-muted hover:text-text-bright hover:bg-white/5 cursor-pointer transition-colors"
className="md:hidden flex items-center justify-center w-9 h-9 rounded text-text-muted hover:text-text-bright hover:bg-text-normal/5 cursor-pointer transition-colors"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d={collapsed ? "M3 10L8 5L13 10" : "M3 6L8 11L13 6"} stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
+1 -1
View File
@@ -132,7 +132,7 @@ export function MultiSelectDropdown({ label, options, selected, onChange, search
className={`w-full flex items-center gap-2 px-2.5 py-1 text-left text-xs font-mono transition-colors ${
isSelected
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-white/3"
: "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => {
if (isSelected) {
+1 -1
View File
@@ -80,7 +80,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange }: Sea
? "text-text-dim/40 cursor-not-allowed"
: f.value === field
? "text-text-bright bg-primary/10"
: "text-text-muted hover:text-text-normal hover:bg-white/3 cursor-pointer"
: "text-text-muted hover:text-text-normal hover:bg-text-normal/3 cursor-pointer"
}`}
onClick={() => {
if (!f.disabled) {
+2 -2
View File
@@ -54,7 +54,7 @@ export function SelectDropdown({ label, options, value, onChange, align = "right
role="option"
aria-selected={!active}
className={`w-full text-left px-2.5 py-1 text-xs font-mono transition-colors ${
!active ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3"
!active ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => { onChange(""); close(); }}
>
@@ -70,7 +70,7 @@ export function SelectDropdown({ label, options, value, onChange, align = "right
role="option"
aria-selected={isSelected}
className={`w-full text-left px-2.5 py-1 text-xs font-mono transition-colors ${
isSelected ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3"
isSelected ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
onClick={() => { onChange(opt.value); close(); }}
>
+1 -1
View File
@@ -146,7 +146,7 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
type="button"
onClick={onBack}
aria-label="Back to channels"
className="self-center flex items-center justify-center w-9 h-9 -ml-1.5 rounded text-text-muted hover:text-text-bright hover:bg-white/5 cursor-pointer transition-colors shrink-0"
className="self-center flex items-center justify-center w-9 h-9 -ml-1.5 rounded text-text-muted hover:text-text-bright hover:bg-text-normal/5 cursor-pointer transition-colors shrink-0"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 4L6 8L10 12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
+1 -1
View File
@@ -28,7 +28,7 @@ export function SegmentedControl({ options, value, onChange, ariaLabel, wrap = f
: "border-border text-text-muted hover:text-text-normal hover:border-text-dim"
}`
: `flex-1 px-2.5 py-1 font-mono text-[11px] transition-colors cursor-pointer ${
active ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3"
active ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`;
return (
+2 -2
View File
@@ -12,7 +12,7 @@ import type { NodeObservation, NodeNeighbor } from "./types";
function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) {
return (
<div
className={`bg-bg-base border border-border rounded px-3 py-2 ${onClick ? "cursor-pointer hover:bg-white/3" : ""}`}
className={`bg-bg-base border border-border rounded px-3 py-2 ${onClick ? "cursor-pointer hover:bg-text-normal/3" : ""}`}
onClick={onClick}
>
<div className="flex items-center gap-2 text-[11px]">
@@ -36,7 +36,7 @@ function NodeObservationRow({ obs, onClick }: { obs: NodeObservation; onClick?:
const level = snrLevel(obs.snr);
return (
<div
className={`bg-bg-base border border-border rounded px-3 py-2 border-l-2 border-l-primary ${onClick ? "cursor-pointer hover:bg-white/3" : ""}`}
className={`bg-bg-base border border-border rounded px-3 py-2 border-l-2 border-l-primary ${onClick ? "cursor-pointer hover:bg-text-normal/3" : ""}`}
onClick={onClick}
>
<div className="flex items-center gap-2 text-[11px] mb-1.5">
@@ -17,7 +17,7 @@ function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick?: (
const level = snrLevel(advert.snr);
return (
<div
className={`bg-bg-base border border-border rounded px-3 py-2 border-l-2 border-l-primary ${onClick ? "cursor-pointer hover:bg-white/3" : ""}`}
className={`bg-bg-base border border-border rounded px-3 py-2 border-l-2 border-l-primary ${onClick ? "cursor-pointer hover:bg-text-normal/3" : ""}`}
onClick={onClick}
>
<div className="flex items-center gap-2 text-[11px] mb-1.5">
+1 -1
View File
@@ -15,7 +15,7 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod
selected
? "border-l-secondary bg-secondary/5"
: "border-l-primary"
} ${onClick ? "cursor-pointer hover:bg-white/3" : ""}`}
} ${onClick ? "cursor-pointer hover:bg-text-normal/3" : ""}`}
onClick={onClick}
>
<div className="flex items-center gap-2 text-[11px] mb-1.5">
+1 -1
View File
@@ -82,7 +82,7 @@ function ObserverList({
type="button"
onClick={() => onSelect(r.id)}
className={`relative overflow-hidden rounded border-l-2 px-2.5 py-1.5 text-left transition-colors cursor-pointer ${
active ? "border-primary bg-primary/10" : "border-transparent hover:bg-white/3"
active ? "border-primary bg-primary/10" : "border-transparent hover:bg-text-normal/3"
}`}
>
{r.count != null && (
+32
View File
@@ -15,3 +15,35 @@ export const WS_RECONNECT_JITTER = 0.25;
// app tab names, in display order; the ?tab URL param is validated against this list
export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const;
// Per-deployment .env config (VITE_* prefix is required so the build can expose it to the browser;
// the Docker entrypoint sed-substitutes each sentinel at container start — see .build/).
// Split a comma-separated env value into trimmed, non-empty entries. An unset var or an
// un-substituted "__VITE_..__" sentinel yields a harmless single entry that matches nothing.
export function parseEnvList(raw: string | undefined): string[] {
return (raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
}
// Tabs left after removing the disabled ones (case-insensitive), keeping display order.
export function filterEnabledTabs(tabs: readonly string[], disabledRaw: string | undefined): string[] {
const disabled = new Set(parseEnvList(disabledRaw).map((t) => t.toLowerCase()));
return tabs.filter((t) => !disabled.has(t.toLowerCase()));
}
// A theme shows in the picker if it isn't hidden, or if its id is in the enabled allowlist.
export function isThemeVisible(theme: { id: string; hidden?: boolean }, enabledIds: Set<string>): boolean {
return !theme.hidden || enabledIds.has(theme.id.toLowerCase());
}
const enabledTabs = filterEnabledTabs(TABS, import.meta.env.VITE_DISABLED_TABS);
// Floor: an all-disabled misconfig would otherwise leave a blank app.
export const ENABLED_TABS = enabledTabs.length > 0 ? enabledTabs : [...TABS];
export const ENABLED_THEME_IDS = new Set(
parseEnvList(import.meta.env.VITE_ENABLED_THEMES).map((s) => s.toLowerCase()),
);
// "||" (not "??") so an empty sed substitution falls back to the default too.
export const APP_NAME = import.meta.env.VITE_APP_NAME || "BEACON";
export const GITHUB_URL = "https://github.com/MeshCore-Beacon";
+2
View File
@@ -4,6 +4,8 @@ export interface Theme {
id: string;
name: string;
vars: Record<string, string>;
// hidden themes are omitted from the picker unless enabled via VITE_ENABLED_THEMES
hidden?: boolean;
}
export const DEFAULT_THEME_ID = "neutral-blue";
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest";
import { TABS, parseEnvList, filterEnabledTabs, isThemeVisible } from "../../src/lib/constants";
describe("parseEnvList", () => {
it("returns [] for undefined or empty", () => {
expect(parseEnvList(undefined)).toEqual([]);
expect(parseEnvList("")).toEqual([]);
});
it("splits, trims, and drops empty entries", () => {
expect(parseEnvList("Map,Nodes")).toEqual(["Map", "Nodes"]);
expect(parseEnvList(" Map , Nodes ,, ")).toEqual(["Map", "Nodes"]);
});
});
describe("filterEnabledTabs", () => {
it("returns all tabs (order preserved) when nothing is disabled", () => {
expect(filterEnabledTabs(TABS, undefined)).toEqual([...TABS]);
expect(filterEnabledTabs(TABS, "")).toEqual([...TABS]);
});
it("excludes named tabs case-insensitively", () => {
expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([
"Packets", "Channels", "Observers", "Routes", "Traces", "Analytics",
]);
expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([
"Packets", "Channels", "Observers", "Routes", "Traces", "Analytics",
]);
});
it("ignores unknown tab names", () => {
expect(filterEnabledTabs(TABS, "bogus")).toEqual([...TABS]);
});
it("can disable every tab", () => {
expect(filterEnabledTabs(TABS, TABS.join(","))).toEqual([]);
});
});
describe("isThemeVisible", () => {
const empty = new Set<string>();
it("always shows a non-hidden theme", () => {
expect(isThemeVisible({ id: "neutral-blue" }, empty)).toBe(true);
expect(isThemeVisible({ id: "neutral-blue", hidden: false }, empty)).toBe(true);
});
it("hides a hidden theme unless it is enabled", () => {
expect(isThemeVisible({ id: "meshmapper_dark", hidden: true }, empty)).toBe(false);
expect(isThemeVisible({ id: "meshmapper_dark", hidden: true }, new Set(["meshmapper_dark"]))).toBe(true);
});
it("matches enabled ids case-insensitively", () => {
expect(isThemeVisible({ id: "MeshMapper_Dark", hidden: true }, new Set(["meshmapper_dark"]))).toBe(true);
});
});