mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 06:22:51 +00:00
Add .env options for hiding tabs, MeshMapper themes, app name and a GitHub link
This commit is contained in:
1 parent
bda7147741
commit
8786ef3d0a
24 files changed
+233
-42
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,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,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=
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(); }}
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user