Browse Source

more refactoring

pull/872/head
Dan Ditomaso 9 months ago
parent
commit
6b5799e0be
  1. 8
      packages/web/package.json
  2. 79
      packages/web/src/App.tsx
  3. 406
      packages/web/src/components/AppSidebar/app-sidebar.tsx
  4. 47
      packages/web/src/components/AppSidebar/sidebar-footer.tsx
  5. 33
      packages/web/src/components/AppSidebar/sidebar-header.tsx
  6. 5
      packages/web/src/components/CommandPalette/index.tsx
  7. 2
      packages/web/src/components/DeviceInfoPanel.tsx
  8. 41
      packages/web/src/components/Header/HeaderActions.tsx
  9. 2
      packages/web/src/components/Header/index.ts
  10. 25
      packages/web/src/components/Header/useHeaderActions.ts
  11. 107
      packages/web/src/components/MeshAvatar.tsx
  12. 0
      packages/web/src/components/MeshMap.tsx
  13. 173
      packages/web/src/components/NodeSidebar/node-sidebar.tsx
  14. 5
      packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx
  15. 7
      packages/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx
  16. 7
      packages/web/src/components/PageComponents/Messages/MessageInput.tsx
  17. 5
      packages/web/src/components/PageComponents/Messages/MessageItem.tsx
  18. 84
      packages/web/src/components/PageLayout.tsx
  19. 220
      packages/web/src/components/Sidebar.tsx
  20. 127
      packages/web/src/components/UI/Avatar.tsx
  21. 81
      packages/web/src/components/UI/Footer.tsx
  22. 21
      packages/web/src/components/UI/Sidebar/SidebarButton.tsx
  23. 7
      packages/web/src/components/UI/Sidebar/SidebarSection.tsx
  24. 106
      packages/web/src/components/UI/Tabs.tsx
  25. 7
      packages/web/src/components/UI/Typography/Heading.tsx
  26. 31
      packages/web/src/components/UI/collapsible.tsx
  27. 56
      packages/web/src/components/UI/scroll-area.tsx
  28. 2
      packages/web/src/components/UI/sidebar.tsx
  29. 1
      packages/web/src/core/hooks/index.ts
  30. 142
      packages/web/src/core/stores/headerStore/headerStore.test.ts
  31. 30
      packages/web/src/core/stores/headerStore/index.ts
  32. 5
      packages/web/src/core/stores/index.ts
  33. 5
      packages/web/src/core/stores/nodeDBStore/index.ts
  34. 46
      packages/web/src/core/stores/sidebarStore/index.tsx
  35. 297
      packages/web/src/index.css
  36. 79
      packages/web/src/pages/Config/index.tsx
  37. 12
      packages/web/src/pages/Map/index.tsx
  38. 367
      packages/web/src/pages/Messages.tsx
  39. 245
      packages/web/src/pages/Messages/index.tsx
  40. 9
      packages/web/src/pages/Nodes/index.tsx
  41. 2
      packages/web/src/routes.tsx
  42. 258
      pnpm-lock.yaml

8
packages/web/package.json

@ -35,7 +35,9 @@
"@meshtastic/transport-web-serial": "workspace:*",
"@noble/curves": "^1.9.2",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@ -81,7 +83,7 @@
"react-map-gl": "8.0.4",
"react-qrcode-logo": "^3.0.0",
"rfc4648": "^1.5.4",
"vite": "^7.1.5",
"vite": "^7.1.6",
"vite-plugin-html": "^3.2.2",
"vite-plugin-pwa": "^1.0.3",
"zod": "^4.0.5",
@ -99,13 +101,13 @@
"@types/react-dom": "^19.1.6",
"@types/serviceworker": "^0.0.142",
"@types/w3c-web-serial": "^1.0.8",
"@vitejs/plugin-react": "^4.6.0",
"@vitejs/plugin-react": "^5.0.3",
"autoprefixer": "^10.4.21",
"gzipper": "^8.2.1",
"happy-dom": "^18.0.1",
"simple-git-hooks": "^2.13.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss": "^4.1.13",
"tailwindcss-animate": "^1.0.7",
"tar": "^7.4.3",
"testing-library": "^0.0.2",

79
packages/web/src/App.tsx

@ -6,18 +6,27 @@ import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx";
import { Toaster } from "@components/Toaster.tsx";
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
import { SidebarProvider, useAppStore, useDeviceStore } from "@core/stores";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@components/UI/sidebar.tsx";
import { useAppStore, useDeviceStore, useHeaderStore } from "@core/stores";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ErrorBoundary } from "react-error-boundary";
import { MapProvider } from "react-map-gl/maplibre";
import { AppSidebar } from "./components/AppSidebar/app-sidebar.tsx";
import { HeaderActions } from "./components/Header/index.ts";
import { ThemeProvider } from "./components/theme-provider.tsx";
import { NodeSidebar } from "./components/NodeSidebar/node-sidebar.tsx";
export function App() {
const { getDevice } = useDeviceStore();
const { selectedDeviceId, setConnectDialogOpen, connectDialogOpen } =
useAppStore();
const { title, actions } = useHeaderStore();
const device = getDevice(selectedDeviceId);
@ -26,36 +35,56 @@ export function App() {
<ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog
open={connectDialogOpen}
onOpenChange={(open) => {
setConnectDialogOpen(open);
}}
onOpenChange={setConnectDialogOpen}
/>
<Toaster />
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper deviceId={selectedDeviceId}>
<div
className="flex h-screen flex-col"
style={{ scrollbarWidth: "thin" }}
>
<SidebarProvider>
<div className="h-full flex flex-col">
{device ? (
<div className="h-full flex w-full">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<MapProvider>
<div className="flex h-[100dvh] overflow-hidden">
{device ? (
<SidebarProvider defaultOpen>
<AppSidebar />
<SidebarInset>
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger />
{/* Breadcrumbs slot (optional / future) */}
<nav
aria-label="Breadcrumb"
className="hidden sm:flex items-center gap-2"
>
{/* <Breadcrumbs /> */}
</nav>
<div className="ml-2 min-w-0 truncate font-medium">
{title || "—"}
</div>
<div className="ml-auto" />
<HeaderActions actions={actions} />
</header>
<MapProvider>
<main className="flex-1 min-h-0 overflow-hidden">
<Outlet />
</MapProvider>
</div>
) : (
<>
<Dashboard />
<Footer />
</>
)}
</main>
</MapProvider>
<Footer hotkeyLabel="Ctrl+K or Cmd+K" />
</SidebarInset>
<NodeSidebar />
</SidebarProvider>
) : (
<div className="flex h-full w-full flex-col">
<main className="flex-1 min-h-0 overflow-auto">
<Dashboard />
</main>
<Footer />
</div>
</SidebarProvider>
)}
</div>
</DeviceWrapper>
</ErrorBoundary>

406
packages/web/src/components/AppSidebar/app-sidebar.tsx

@ -0,0 +1,406 @@
// "use client";
// import { Button } from "@components/UI/button.tsx";
// import {
// Collapsible,
// CollapsibleContent,
// CollapsibleTrigger,
// } from "@components/UI/collapsible.tsx";
// import { Input } from "@components/UI/input.tsx";
// import {
// Sidebar,
// SidebarContent,
// SidebarFooter,
// SidebarGroup,
// SidebarGroupContent,
// SidebarGroupLabel,
// SidebarInset,
// SidebarMenu,
// SidebarMenuBadge,
// SidebarMenuButton,
// SidebarMenuItem,
// SidebarProvider,
// SidebarSeparator,
// } from "@components/UI/sidebar.tsx";
// import { Outlet } from "@tanstack/react-router";
// import {
// ChevronDown,
// Hash,
// Layers,
// MapIcon,
// MessageCircle,
// Radio,
// Send,
// Settings,
// Users,
// } from "lucide-react";
// import { SidebarHeader } from "./sidebar-header.tsx";
// import { MeshAvatar } from "../MeshAvatar.tsx";
// export function AppSidebar() {
// return (
// <Sidebar className="border-r-0">
// <SidebarHeader />
// <SidebarContent>
// <SidebarGroup>
// <SidebarGroupLabel className="text-xs font-medium text-sidebar-foreground/70 uppercase tracking-wider">
// Navigation
// </SidebarGroupLabel>
// <SidebarGroupContent>
// <SidebarMenu>
// <SidebarMenuItem>
// <SidebarMenuButton isActive={true}>
// <MessageCircle className="h-4 w-4" />
// <span>Messages</span>
// </SidebarMenuButton>
// </SidebarMenuItem>
// <SidebarMenuItem>
// <SidebarMenuButton>
// <MapIcon className="h-4 w-4" />
// <span>Map</span>
// </SidebarMenuButton>
// </SidebarMenuItem>
// <SidebarMenuItem>
// <Collapsible defaultOpen={false}>
// <CollapsibleTrigger asChild>
// <SidebarMenuButton className="w-full justify-between">
// <div className="flex items-center gap-2">
// <Settings className="h-4 w-4" />
// <span>Config</span>
// </div>
// <ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
// </SidebarMenuButton>
// </CollapsibleTrigger>
// <CollapsibleContent className="ml-6 mt-1 space-y-1">
// <SidebarMenuButton size="sm">
// <Radio className="h-3 w-3" />
// <span className="text-xs">Radio Config</span>
// </SidebarMenuButton>
// <SidebarMenuButton size="sm">
// <Layers className="h-3 w-3" />
// <span className="text-xs">Module Config</span>
// </SidebarMenuButton>
// <SidebarMenuButton size="sm">
// <Hash className="h-3 w-3" />
// <span className="text-xs">Channel Config</span>
// </SidebarMenuButton>
// </CollapsibleContent>
// </Collapsible>
// </SidebarMenuItem>
// <SidebarMenuItem>
// <SidebarMenuButton>
// <Users className="h-4 w-4" />
// <span>Nodes</span>
// <SidebarMenuBadge>203</SidebarMenuBadge>
// </SidebarMenuButton>
// </SidebarMenuItem>
// </SidebarMenu>
// </SidebarGroupContent>
// </SidebarGroup>
// <SidebarSeparator />
// <SidebarGroup>
// <SidebarGroupLabel className="text-xs font-medium text-sidebar-foreground/70 uppercase tracking-wider">
// Channels
// </SidebarGroupLabel>
// <SidebarGroupContent>
// <SidebarMenu>
// <SidebarMenuItem>
// <SidebarMenuButton
// isActive={true}
// className="bg-sidebar-accent"
// >
// <div className="flex h-2 w-2 rounded-full bg-blue-500" />
// <span>Primary</span>
// </SidebarMenuButton>
// </SidebarMenuItem>
// </SidebarMenu>
// </SidebarGroupContent>
// </SidebarGroup>
// <div className="mt-auto p-4 space-y-2 text-xs text-sidebar-foreground/60">
// <div>Plugged in</div>
// <div>Version: 4.3b.V</div>
// <div>Firmware: 2.7.6.834c365</div>
// <div className="pt-2 border-t border-sidebar-border">
// <button className="text-sidebar-foreground/70 hover:text-sidebar-foreground" type="button">
// Change Color Scheme
// </button>
// </div>
// <div>
// <button className="text-sidebar-foreground/70 hover:text-sidebar-foreground" type="button">
// Change Device Name
// </button>
// </div>
// <div>
// <button className="text-sidebar-foreground/70 hover:text-sidebar-foreground" type="button">
// Command Menu
// </button>
// </div>
// <div>
// <button className="text-sidebar-foreground/70 hover:text-sidebar-foreground" type="button">
// Change Language: English
// </button>
// </div>
// </div>
// </SidebarContent>
// <SidebarFooter>
// <SidebarMenu>
// <SidebarMenuItem>
// <SidebarMenuButton>
// <MeshAvatar text="EAO1" size="sm" />
// </SidebarMenuButton>
// </SidebarMenuItem>
// </SidebarMenu>
// </SidebarFooter>
// </Sidebar>
// );
// }
import { Button } from "@components/UI/button.tsx";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@components/UI/collapsible.tsx";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
SidebarHeader as UISidebarHeader,
} from "@components/UI/sidebar.tsx";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@components/UI/tooltip.tsx";
import {
ChevronDown,
Hash,
Layers,
MapIcon,
MessageCircle,
Radio,
Settings,
Users,
} from "lucide-react";
import { MeshAvatar } from "../MeshAvatar.tsx";
import { SidebarHeader } from "./sidebar-header.tsx";
/**
* Utility: when the sidebar is collapsed (rail), hide text but keep icons visible.
* We rely on `data-collapsed` set by the Sidebar primitive when `collapsible="icon"`.
* Any `.sidebar-label` will hide in collapsed mode.
*/
export const labelClass =
"sidebar-label group-data-[collapsible=icon]:data-[state=collapsed]:hidden";
export function AppSidebar() {
return (
<Sidebar collapsible="icon" className="border-r-0">
{/* Header */}
<SidebarMenuItem>
<SidebarHeader />
</SidebarMenuItem>
{/* Optional: a thin rail you can grab/hover on (like ChatGPT) */}
<SidebarRail />
<SidebarContent>
<TooltipProvider delayDuration={200}>
{/* -------- Primary Nav -------- */}
<SidebarGroup>
<SidebarGroupLabel className="text-xs font-medium text-sidebar-foreground/70 uppercase tracking-wider">
<span className={labelClass}>Navigation</span>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{/* Messages */}
<SidebarMenuItem>
<TooltipWrapper label="Messages">
<SidebarMenuButton isActive>
<MessageCircle className="h-4 w-4" />
<span className={labelClass}>Messages</span>
</SidebarMenuButton>
</TooltipWrapper>
</SidebarMenuItem>
{/* Map */}
<SidebarMenuItem>
<TooltipWrapper label="Map">
<SidebarMenuButton>
<MapIcon className="h-4 w-4" />
<span className={labelClass}>Map</span>
</SidebarMenuButton>
</TooltipWrapper>
</SidebarMenuItem>
{/* Config */}
<SidebarMenuItem>
<Collapsible
defaultOpen={false}
className="group/collapsible"
>
<CollapsibleTrigger asChild>
<TooltipWrapper label="Config">
<SidebarMenuButton className="w-full justify-between">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4" />
<span className={labelClass}>Config</span>
</div>
{/* Keep chevron visible in expanded mode; hidden in rail */}
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180 group-data-[collapsible=icon]:data-[state=collapsed]:hidden" />
</SidebarMenuButton>
</TooltipWrapper>
</CollapsibleTrigger>
<CollapsibleContent className="ml-6 mt-1 space-y-1 group-data-[collapsible=icon]:data-[state=collapsed]:hidden">
<SidebarMenuButton size="sm">
<Radio className="h-3 w-3" />
<span className="text-xs">Radio Config</span>
</SidebarMenuButton>
<SidebarMenuButton size="sm">
<Layers className="h-3 w-3" />
<span className="text-xs">Module Config</span>
</SidebarMenuButton>
<SidebarMenuButton size="sm">
<Hash className="h-3 w-3" />
<span className="text-xs">Channel Config</span>
</SidebarMenuButton>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
{/* Nodes */}
<SidebarMenuItem>
<TooltipWrapper label="Nodes">
<SidebarMenuButton>
<Users className="h-4 w-4" />
<span className={labelClass}>Nodes</span>
{/* Keep badge, it’ll sit beside the icon in rail mode */}
<SidebarMenuBadge>203</SidebarMenuBadge>
</SidebarMenuButton>
</TooltipWrapper>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* -------- Channels -------- */}
<SidebarGroup>
<SidebarGroupLabel className="text-xs font-medium text-sidebar-foreground/70 uppercase tracking-wider">
<span className={labelClass}>Channels</span>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<TooltipWrapper label="Primary">
<SidebarMenuButton isActive className="bg-sidebar-accent">
<div className="flex h-2 w-2 rounded-full bg-blue-500" />
<span className={labelClass}>Primary</span>
</SidebarMenuButton>
</TooltipWrapper>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* -------- Footer Info (auto-hides text when collapsed) -------- */}
<div className="mt-auto p-4 space-y-2 text-xs text-sidebar-foreground/60 group-data-[collapsible=icon]:data-[state=collapsed]:hidden">
<div>Plugged in</div>
<div>Version: 4.3b.V</div>
<div>Firmware: 2.7.6.834c365</div>
<div className="pt-2 border-t border-sidebar-border">
<button
className="text-sidebar-foreground/70 hover:text-sidebar-foreground"
type="button"
>
Change Color Scheme
</button>
</div>
<div>
<button
className="text-sidebar-foreground/70 hover:text-sidebar-foreground"
type="button"
>
Change Device Name
</button>
</div>
<div>
<button
className="text-sidebar-foreground/70 hover:text-sidebar-foreground"
type="button"
>
Command Menu
</button>
</div>
<div>
<button
className="text-sidebar-foreground/70 hover:text-sidebar-foreground"
type="button"
>
Change Language: English
</button>
</div>
</div>
</TooltipProvider>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<TooltipWrapper label="Account">
<SidebarMenuButton>
<MeshAvatar text="EAO1" size="sm" />
<span className={labelClass}>Account</span>
</SidebarMenuButton>
</TooltipWrapper>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
/** Shows a tooltip only when the sidebar is in collapsed (icon) mode. */
function TooltipWrapper({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
{/* keep button focus/hover behavior intact */}
<div className="w-full">{children}</div>
</TooltipTrigger>
{/* Hidden when expanded; visible when collapsed */}
<TooltipContent
side="right"
className="group-data-[collapsible=icon]:data-[state=collapsed]:block hidden"
>
{label}
</TooltipContent>
</Tooltip>
);
}

47
packages/web/src/components/AppSidebar/sidebar-footer.tsx

@ -0,0 +1,47 @@
import {
SidebarFooter as ShadcnSidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@components/UI/sidebar.tsx";
import { ChevronUp } from "lucide-react";
import { MeshAvatar } from "../MeshAvatar.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../UI/DropdownMenu.tsx";
export function SidebarFooter() {
return (
<ShadcnSidebarFooter className="py-4">
<SidebarMenu>
<SidebarMenuItem className="px-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton>
<MeshAvatar text={"EAO"} /> Username
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
className="w-[--radix-popper-anchor-width]"
>
<DropdownMenuItem>
<span>Account</span>
</DropdownMenuItem>
<DropdownMenuItem>
<span>Billing</span>
</DropdownMenuItem>
<DropdownMenuItem>
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</ShadcnSidebarFooter>
);
}

33
packages/web/src/components/AppSidebar/sidebar-header.tsx

@ -0,0 +1,33 @@
import {
SidebarTrigger,
SidebarHeader as UISidebarHeader,
} from "@components/UI/sidebar.tsx";
import { Heading } from "../UI/Typography/Heading.tsx";
export function SidebarHeader() {
return (
<UISidebarHeader className="group/logo relative flex h-12 px-4 transition-all duration-200 hover:bg-sidebar-accent/50">
{/* Logo container - always visible */}
<div className="flex size-8 shrink-0 items-center justify-center rounded-md">
<img src="/logo.svg" alt="Meshtastic logo" className="" />
</div>
<Heading
as="h1"
className="ml-2 text-sm truncate font-semibold tracking-wide transition-all duration-200 group-data-[collapsible=icon]:data-[state=collapsed]:hidden"
>
Meshtastic
</Heading>
<SidebarTrigger
aria-label="Expand sidebar"
className="
absolute right-2 hidden opacity-0 transition-all duration-200 ease-in-out
hover:bg-sidebar-accent hover:scale-110
group-hover/logo:opacity-100
group-data-[collapsible=icon]:data-[state=expanded]:flex
"
/>
</UISidebarHeader>
);
}

5
packages/web/src/components/CommandPalette/index.tsx

@ -1,4 +1,4 @@
import { Avatar } from "@components/UI/Avatar.tsx";
import { Avatar } from "@app/components/UI/avatar";
import {
CommandDialog,
CommandEmpty,
@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { MeshAvatar } from "../MeshAvatar.tsx";
export interface Group {
id: string;
@ -127,7 +128,7 @@ export const CommandPalette = () => {
getNode(device.hardware.myNodeNum)?.user?.longName ??
t("unknown.shortName"),
icon: (
<Avatar
<MeshAvatar
text={
getNode(device.hardware.myNodeNum)?.user?.shortName ??
t("unknown.shortName")

2
packages/web/src/components/DeviceInfoPanel.tsx

@ -17,7 +17,7 @@ import BatteryStatus from "./BatteryStatus.tsx";
import LanguageSwitcher from "./LanguageSwitcher.tsx";
import ThemeSwitcher from "./ThemeSwitcher.tsx";
import { ThemeModeToggle } from "./theme-mode-toggle.tsx";
import { Avatar } from "./UI/Avatar.tsx";
import { Avatar } from "./UI/avatar.tsx";
import { Button } from "./UI/Button.tsx";
import { Separator } from "./UI/Separator.tsx";
import { Subtle } from "./UI/Typography/Subtle.tsx";

41
packages/web/src/components/Header/HeaderActions.tsx

@ -0,0 +1,41 @@
import { Spinner } from "@components/UI/Spinner.tsx";
import { cn } from "@core/utils/cn.ts";
import type { ActionItem } from "@stores/headerStore.tsx";
export default function HeaderActions({ actions }: { actions: ActionItem[] }) {
if (!actions?.length) {
return null;
}
return (
<div className="flex items-center space-x-1 md:space-x-2 shrink-0">
{actions.map((action) => (
<button
key={action.key}
type="button"
disabled={action.disabled || action.isLoading}
className={cn(
"flex items-center space-x-2 py-2 px-3 rounded-md",
"text-foreground transition-colors duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed",
action.className,
)}
onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled}
aria-busy={action.isLoading}
>
{action.icon &&
(action.isLoading ? (
<Spinner size="md" />
) : (
<action.icon className={cn("h-5 w-5", action.iconClasses)} />
))}
{action.label && (
<span className="text-sm px-1 pt-0.5">{action.label}</span>
)}
</button>
))}
</div>
);
}

2
packages/web/src/components/Header/index.ts

@ -0,0 +1,2 @@
export { default as HeaderActions } from "./HeaderActions.tsx";
export { default as usePageHeader } from "./useHeaderActions.ts";

25
packages/web/src/components/Header/useHeaderActions.ts

@ -0,0 +1,25 @@
import type { ActionItem } from "@core/stores/";
import { useHeaderStore } from "@core/stores/";
import { useEffect } from "react";
/**
* Pages call this hook to publish their header title/actions into the App header.
* On unmount, we reset to avoid stale UI.
*/
export default function usePageHeader(opts: {
title?: string;
actions?: ActionItem[];
}) {
const { setTitle, setActions, reset } = useHeaderStore();
// biome-ignore lint/correctness/useExhaustiveDependencies: because we want to reset on unmount
useEffect(() => {
if (opts.title !== undefined) {
setTitle(opts.title);
}
if (opts.actions !== undefined) {
setActions(opts.actions);
}
return () => reset();
}, [opts.title, JSON.stringify(opts.actions)]);
}

107
packages/web/src/components/MeshAvatar.tsx

@ -0,0 +1,107 @@
import {
AvatarFallback,
Avatar as ShadcnAvatar,
} from "@app/components/UI/avatar";
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@app/components/UI/tooltip.tsx";
import { getColorFromText, isLightColor } from "@app/core/utils/color";
import { cn } from "@core/utils/cn";
import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
interface MeshAvatarProps {
text: string | number;
size?: "sm";
className?: string;
showError?: boolean;
showFavorite?: boolean;
}
export function MeshAvatar({
text,
size = "sm",
className,
showError = false,
showFavorite = false,
}: MeshAvatarProps) {
const { t } = useTranslation();
const sizes = {
sm: "size-11 font-normal text-xs",
};
const safeText = text?.toString().toUpperCase();
const bgColor = getColorFromText(safeText);
const isLight = isLightColor(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = safeText?.slice(0, 4) ?? t("unknown.shortName");
return (
<div className="relative inline-flex">
<ShadcnAvatar
className={cn(
sizes[size],
"bg-[rgb(var(--bg-r),var(--bg-g),var(--bg-b))]",
"flex items-center justify-cente rounded-3xl text-white",
className,
)}
style={
{
"--bg-r": bgColor.r,
"--bg-g": bgColor.g,
"--bg-b": bgColor.b,
color: textColor,
} as React.CSSProperties
}
>
<AvatarFallback className="bg-transparent p-2">
{initials}
</AvatarFallback>
</ShadcnAvatar>
{/* Favorite badge */}
{showFavorite && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<StarIcon
className="absolute -top-0.5 -right-0.5 z-10 size-4 stroke-1 fill-yellow-400"
aria-hidden="true"
style={{
color: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
}}
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-3 py-1 rounded text-xs">
{t("nodeDetail.favorite.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Error badge */}
{showError && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<LockKeyholeOpenIcon
className="absolute -bottom-0.5 -right-0.5 z-10 size-4 text-red-500 stroke-3"
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-3 py-1 rounded text-xs">
{t("nodeDetail.error.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
}

0
packages/web/src/components/Map.tsx → packages/web/src/components/MeshMap.tsx

173
packages/web/src/components/NodeSidebar/node-sidebar.tsx

@ -0,0 +1,173 @@
import { useDevice, useNodeDB } from "@app/core/stores";
import { MeshAvatar } from "@components/MeshAvatar.tsx";
import { Input } from "@components/UI/input.tsx";
import { cn } from "@core/utils/cn.ts";
import { Users } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from "../UI/sidebar.tsx";
type NodeUser = {
shortName?: string | null;
longName?: string | null;
};
export type NodeListItem = {
num: number;
user?: NodeUser | null;
isFavorite?: boolean;
unreadCount?: number;
};
type NavigateType = "direct" | "channel";
type NodeSidebarProps = {
/** List of nodes to render */
nodes: NodeListItem[];
/** If the current view is a direct-message thread */
isDirect?: boolean;
/** Currently active node number (for highlighting) */
activeNodeNum?: number | null;
/** Called when a node row is clicked */
onNavigate?: (type: NavigateType, id: string) => void;
/** Clear unread count for a node */
onResetUnread?: (nodeNum: number) => void;
/** Whether the node currently has an error */
hasNodeError?: (nodeNum: number) => boolean;
/** Optional additional className for the outer Sidebar */
className?: string;
};
/* --------------------------- Component --------------------------- */
export function NodeSidebar({
// nodes,
// isDirect = true,
// activeNodeNum = null,
// onNavigate = () => {},
// onResetUnread = () => {},
// hasNodeError = () => false,
className,
}: NodeSidebarProps) {
const [searchTerm, setSearchTerm] = useState("");
const { t } = useTranslation();
const { getNode, getMyNode, getNodes } = useNodeDB();
const nodes = getNodes();
const filteredNodes = useMemo(() => {
const q = searchTerm.trim().toLowerCase();
if (!q) {
return nodes;
}
return nodes.filter((n) => {
const shortName = n.user?.shortName?.toLowerCase() ?? "";
const longName = n.user?.longName?.toLowerCase() ?? "";
const id = String(n.num);
return shortName.includes(q) || longName.includes(q) || id.includes(q);
});
}, [nodes, searchTerm]);
return (
<Sidebar className={cn("border-r", className)} variant="inset" side="right">
<SidebarContent>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupLabel className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/70">
<Users className="h-3.5 w-3.5" />
{t("messages:nodes", { defaultValue: "Nodes" })}
</SidebarGroupLabel>
<div className="px-2 py-2">
<label htmlFor="nodeSearch" className="sr-only">
{t("search.nodes", { defaultValue: "Search nodes…" })}
</label>
{/** biome-ignore lint/correctness/useUniqueElementIds: <explanation> */}
<Input
id="nodeSearch"
name="nodeSearch"
placeholder={t("search.nodes", { defaultValue: "Search nodes…" })}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoComplete="off"
/>
</div>
<SidebarGroupContent>
<SidebarMenu>
{filteredNodes.map((node) => {
const active = Boolean(isDirect && activeNodeNum === node.num);
const hasError = hasNodeError(node.num);
return (
<SidebarMenuItem key={node.num}>
<SidebarMenuButton
isActive={active}
className={cn(
"justify-between",
active && "bg-sidebar-accent",
)}
onClick={() => {
onNavigate("direct", node.num.toString());
onResetUnread(node.num);
}}
aria-current={active ? "page" : undefined}
>
<div className="flex items-center gap-2">
<MeshAvatar
text={
node.user?.shortName ??
t("messages:unknown.shortName", {
defaultValue: "Unknown",
})
}
className={cn(hasError && "text-red-500")}
showError={hasError}
showFavorite={Boolean(node.isFavorite)}
size="sm"
/>
<span className="max-w-[9rem] truncate">
{node.user?.longName ??
t("messages:unknown.shortName", {
defaultValue: "Unknown",
})}
</span>
</div>
{!!node.unreadCount && node.unreadCount > 0 && (
<SidebarMenuBadge
aria-label={t("messages:unread", {
defaultValue: "Unread",
})}
>
{node.unreadCount}
</SidebarMenuBadge>
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}

5
packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx

@ -1,6 +1,7 @@
import { MeshAvatar } from "@app/components/MeshAvatar";
import { Avatar } from "@app/components/UI/avatar";
import { cn } from "@app/core/utils/cn";
import type { PxOffset } from "@components/PageComponents/Map/cluster.ts";
import { Avatar } from "@components/UI/Avatar.tsx";
import {
Tooltip,
TooltipArrow,
@ -67,7 +68,7 @@ export const NodeMarker = memo(function NodeMarker({
style={style}
onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })}
>
<Avatar
<MeshAvatar
text={label}
className={cn(
"border-[1.5px] border-slate-600 shadow-m shadow-slate-600",

7
packages/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx

@ -1,7 +1,8 @@
import { MeshAvatar } from "@app/components/MeshAvatar";
import { Avatar } from "@app/components/UI/avatar";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { Mono } from "@components/generic/Mono.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Separator } from "@components/UI/Separator.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
@ -52,7 +53,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
<div className="p-1 text-slate-900">
<div className="flex gap-2">
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
<Avatar text={shortName} size="sm" />
<MeshAvatar text={shortName} size="sm" />
<div
onFocusCapture={(e) => {
@ -113,7 +114,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
</div>
<div>
<Heading as="h5">{name}</Heading>
<Heading as="h4">{name}</Heading>
{hardwareType !== t("unset") && <Subtle>{hardwareType}</Subtle>}
{!!node.deviceMetrics?.batteryLevel && (

7
packages/web/src/components/PageComponents/Messages/MessageInput.tsx

@ -53,9 +53,10 @@ export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
return (
<div className="flex gap-2">
<form className="w-full" name="messageInput" onSubmit={handleSubmit}>
<div className="flex grow gap-1">
<div className="flex grow gap-1 flex-1">
<label className="w-full" htmlFor="messageInput">
<Input
className="bg-background flex"
minLength={1}
name="messageInput"
placeholder={t("sendMessage.placeholder")}
@ -73,8 +74,8 @@ export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => {
{messageBytes}/{maxBytes}
</label>
<Button type="submit" variant="default">
<SendIcon size={16} />
<Button type="submit" size="icon" className="shrink-0">
<SendIcon className="h-4 w-4" />
<span className="sr-only">Send message</span>
</Button>
</div>

5
packages/web/src/components/PageComponents/Messages/MessageItem.tsx

@ -1,3 +1,5 @@
import { MeshAvatar } from "@app/components/MeshAvatar";
import { Avatar } from "@app/components/UI/avatar";
import {
Tooltip,
TooltipArrow,
@ -5,7 +7,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@app/components/UI/tooltip";
import { Avatar } from "@components/UI/Avatar.tsx";
import { MessageState, useDevice, useNodeDB } from "@core/stores";
import type { Message } from "@core/stores/messageStore/types.ts";
import { cn } from "@core/utils/cn.ts";
@ -157,7 +158,7 @@ export const MessageItem = ({ message }: MessageItemProps) => {
return (
<li className={messageItemWrapperClass}>
<div className="grid grid-cols-[auto_1fr] gap-x-2">
<Avatar
<MeshAvatar
size="sm"
text={shortName}
className="pt-0.5"

84
packages/web/src/components/PageLayout.tsx

@ -1,6 +1,4 @@
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
import Footer from "@components/UI/Footer.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { cn } from "@core/utils/cn.ts";
import type { LucideIcon } from "lucide-react";
import type React from "react";
@ -32,83 +30,14 @@ export interface PageLayoutProps {
}
export const PageLayout = ({
label,
actions,
children,
leftBar,
rightBar,
noPadding,
leftBarClassName,
rightBarClassName,
topBarClassName,
contentClassName,
}: PageLayoutProps) => {
return (
<ErrorBoundary FallbackComponent={ErrorPage}>
<div className="flex flex-1 text-foreground overflow-hidden">
{/* Left Sidebar */}
{leftBar && (
<aside
className={cn(
"px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 ",
leftBarClassName,
)}
>
{leftBar}
</aside>
)}
<div className="flex flex-1 flex-col min-w-0">
{/* Header */}
<header
className={cn(
"flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700",
topBarClassName,
)}
>
{/* Header Content */}
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="text-lg font-medium text-foreground truncate px-2">
{label}
</span>
<div className="flex items-center space-x-1 md:space-x-2 shrink-0 pr-6">
{actions?.map((action) => {
return (
<button
key={action.key}
type="button"
disabled={action.disabled || action.isLoading}
className={cn(
"flex items-center space-x-2 py-2 px-3 rounded-md",
"text-foreground transition-colors duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed",
action.className,
)}
onClick={action.onClick}
aria-label={action.ariaLabel || `Action ${action.key}`}
aria-disabled={action.disabled}
aria-busy={action.isLoading}
>
{action.icon &&
(action.isLoading ? (
<Spinner size="md" />
) : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
))}
{action.label && (
<span className="text-sm px-1 pt-0.5">
{action.label}
</span>
)}
</button>
);
})}
</div>
</div>
</header>
<main
className={cn(
"flex-1 flex flex-col",
@ -119,20 +48,7 @@ export const PageLayout = ({
>
{children}
</main>
<Footer />
</div>
{/* Right Sidebar */}
{rightBar && (
<aside
className={cn(
"w-56 lg:w-[270px] text-balance shrink-0 border-l border-slate-300 px-2 overflow-hidden",
rightBarClassName,
)}
>
{rightBar}
</aside>
)}
</div>
</ErrorBoundary>
);

220
packages/web/src/components/Sidebar.tsx

@ -1,220 +0,0 @@
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import {
type Page,
useAppStore,
useDevice,
useNodeDB,
useSidebar,
} from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { useLocation, useNavigate } from "@tanstack/react-router";
import {
CircleChevronLeft,
type LucideIcon,
MapIcon,
MessageSquareIcon,
SettingsIcon,
UsersIcon,
} from "lucide-react";
import type React from "react";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx";
export interface SidebarProps {
children?: React.ReactNode;
}
interface NavLink {
name: string;
icon: LucideIcon;
page: Page;
count?: number;
}
const CollapseToggleButton = () => {
const { isCollapsed, toggleSidebar } = useSidebar();
const { t } = useTranslation("ui");
const buttonLabel = isCollapsed
? t("sidebar.collapseToggle.button.open")
: t("sidebar.collapseToggle.button.close");
return (
<button
type="button"
aria-label={buttonLabel}
onClick={toggleSidebar}
className={cn(
"absolute top-20 right-0 z-10 p-0.5 rounded-full transform translate-x-1/2",
"transition-colors duration-300 ease-in-out",
"border border-slate-300 dark:border-slate-200",
"text-slate-500 dark:text-slate-200 hover:text-slate-400 dark:hover:text-slate-400",
"focus:outline-none focus:ring-2 focus:ring-accent transition-transform bg-background-primary",
)}
>
<CircleChevronLeft
size={24}
className={cn(
"transition-transform duration-300 ease-in-out",
isCollapsed && "rotate-180",
)}
/>
</button>
);
};
export const Sidebar = ({ children }: SidebarProps) => {
const { hardware, metadata, unreadCounts, setDialogOpen, status } =
useDevice();
const { getNode, getNodesLength } = useNodeDB();
const { setCommandPaletteOpen } = useAppStore();
const myNode = getNode(hardware.myNodeNum);
const { isCollapsed } = useSidebar();
const { t } = useTranslation("ui");
const navigate = useNavigate({ from: "/" });
const pathname = useLocation({
select: (location) => location.pathname.replace(/^\//, ""),
});
const myMetadata = metadata.get(0);
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
const [displayedNodeCount, setDisplayedNodeCount] = useState(() =>
Math.max(getNodesLength() - 1, 0),
);
const [_, startNodeCountTransition] = useTransition();
const currentNodeCountValue = Math.max(getNodesLength() - 1, 0);
useEffect(() => {
if (currentNodeCountValue !== displayedNodeCount) {
startNodeCountTransition(() => {
setDisplayedNodeCount(currentNodeCountValue);
});
}
}, [currentNodeCountValue, displayedNodeCount]);
const pages: NavLink[] = [
{
name: t("navigation.messages"),
icon: MessageSquareIcon,
page: "messages",
count: numUnread ? numUnread : undefined,
},
{ name: t("navigation.map"), icon: MapIcon, page: "map" },
{
name: t("navigation.config"),
icon: SettingsIcon,
page: "config",
},
{
name: `${t("navigation.nodes")} (${displayedNodeCount})`,
icon: UsersIcon,
page: "nodes",
},
];
return (
<div
className={cn(
"relative border-slate-300 dark:border-slate-700",
"transition-all duration-300 ease-in-out flex-shrink-0",
isCollapsed ? "w-24" : "w-52 lg:w-64",
)}
>
<CollapseToggleButton />
<div
className={cn(
"h-14 flex mt-2 gap-2 items-center flex-shrink-0 transition-all duration-300 ease-in-out",
"border-b-[0.5px] border-slate-300 dark:border-slate-700",
isCollapsed && "justify-center px-0",
)}
>
<img
src="/logo.svg"
alt={t("app.logo")}
className="size-10 flex-shrink-0 rounded-xl"
/>
<h2
className={cn(
"text-xl font-semibold text-gray-800 dark:text-gray-100 whitespace-nowrap",
"transition-all duration-300 ease-in-out",
isCollapsed
? "opacity-0 max-w-0 invisible ml-0"
: "opacity-100 max-w-xs visible ml-2",
)}
>
{t("app.title")}
</h2>
</div>
<SidebarSection label={t("navigation.title")} className="mt-4 px-0">
{pages.map((link) => {
return (
<SidebarButton
key={link.name}
count={link.count}
label={link.name}
Icon={link.icon}
onClick={() => {
if (myNode !== undefined) {
navigate({ to: `/${link.page}` });
}
}}
active={link.page === pathname}
disabled={myNode === undefined}
/>
);
})}
</SidebarSection>
<div className={cn("flex-1 min-h-0", isCollapsed && "overflow-hidden")}>
{children}
</div>
<div className=" pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 h-full flex-1">
{myNode === undefined ? (
<div className="flex flex-col items-center justify-center py-6">
<Spinner />
<Subtle
className={cn(
"mt-4 transition-opacity duration-300",
isCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
)}
>
{t("loading")}
</Subtle>
</div>
) : (
<DeviceInfoPanel
isCollapsed={isCollapsed}
setCommandPaletteOpen={() => setCommandPaletteOpen(true)}
setDialogOpen={() => setDialogOpen("deviceName", true)}
user={{
longName: myNode?.user?.longName ?? t("unknown.longName"),
shortName: myNode?.user?.shortName ?? t("unknown.shortName"),
}}
firmwareVersion={
myMetadata?.firmwareVersion ?? t("unknown.notAvailable")
}
deviceMetrics={{
connectionStatus: status,
batteryLevel: myNode.deviceMetrics?.batteryLevel,
voltage:
typeof myNode.deviceMetrics?.voltage === "number"
? Math.abs(myNode.deviceMetrics?.voltage)
: undefined,
}}
/>
)}
</div>
</div>
);
};

127
packages/web/src/components/UI/Avatar.tsx

@ -1,97 +1,50 @@
import {
Tooltip,
TooltipArrow,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@app/components/UI/tooltip";
import { getColorFromText, isLightColor } from "@app/core/utils/color";
import { cn } from "@core/utils/cn.ts";
import { LockKeyholeOpenIcon, StarIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@app/lib/utils";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import type * as React from "react";
interface AvatarProps {
text: string | number;
size?: "sm" | "lg";
className?: string;
showError?: boolean;
showFavorite?: boolean;
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
export const Avatar = ({
text,
size = "sm",
showError = false,
showFavorite = false,
function AvatarImage({
className,
}: AvatarProps) => {
const { t } = useTranslation();
const sizes = {
sm: "size-10 text-xs font-light",
lg: "size-16 text-lg",
};
const safeText = text?.toString().toUpperCase();
const bgColor = getColorFromText(safeText);
const isLight = isLightColor(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = safeText?.slice(0, 4) ?? t("unknown.shortName");
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<div
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
`relative flex items-center justify-center rounded-full font-semibold
`,
sizes[size],
"bg-[rgb(var(--bg-r),var(--bg-g),var(--bg-b))]", // allow override with className
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
style={
{
"--bg-r": bgColor.r,
"--bg-g": bgColor.g,
"--bg-b": bgColor.b,
color: textColor,
} as React.CSSProperties
}
>
{showFavorite ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<StarIcon
className="absolute -top-0.5 -right-0.5 z-10 size-4 stroke-1 fill-yellow-400"
aria-hidden="true"
style={{
color: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`,
}}
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetail.favorite.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{showError ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<LockKeyholeOpenIcon
className="absolute -bottom-0.5 -right-0.5 z-10 size-4 text-red-500 stroke-3"
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{t("nodeDetail.error.label", { ns: "nodes" })}
<TooltipArrow className="fill-slate-800 dark:fill-slate-600" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
<p className="p-1 text-nowrap">{initials}</p>
</div>
{...props}
/>
);
};
}
export { Avatar, AvatarImage, AvatarFallback };

81
packages/web/src/components/UI/Footer.tsx

@ -1,9 +1,13 @@
import { cn } from "@core/utils/cn.ts";
import React from "react";
import * as React from "react";
import { Trans } from "react-i18next";
type FooterProps = {
className?: string;
/** Show the "Ctrl+K / Cmd+K" hint */
showHotkeyHint?: boolean;
/** Custom label for the hotkey hint */
hotkeyLabel?: string;
};
const Footer = ({ className, ...props }: FooterProps) => {
@ -15,53 +19,46 @@ const Footer = ({ className, ...props }: FooterProps) => {
() =>
String(import.meta.env.VITE_COMMIT_HASH)
?.toUpperCase()
.slice(0, 7) || "unk",
.slice(0, 7) || "UNK",
[],
);
return (
<footer
className={cn(
"flex mt-auto justify-center py-2 px-4 text-sm lg:text-md",
className,
)}
{...props}
>
<div className="px-2">
<span className="font-semibold text-gray-500/40 dark:text-gray-100">
{version}
</span>
<span className="font-semibold text-gray-500/40 dark:text-gray-100 mx-2">
-
</span>
<span className="font-semibold text-gray-500/40 dark:text-gray-100">
{`#${commitHash}`}
<footer className={cn("border-t border-border p-4", className)} {...props}>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="whitespace-pre-wrap">
<Trans
i18nKey="footer.text"
components={[
<a
title="Vercel sponsors Meshtastic Web Hosting"
key="vercel"
rel="noopener noreferrer"
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline text-link"
>
<span className="sr-only">Vercel homepage</span>
</a>,
<a
key="legal"
rel="noopener noreferrer"
href="https://meshtastic.org/docs/legal"
className="hover:underline text-link"
>
<span className="sr-only">Meshtastic terms and conditions</span>
</a>,
]}
/>
</span>
</div>
<p className="ml-auto mr-auto text-gray-500 dark:text-gray-400">
<Trans
i18nKey="footer.text"
components={[
<a
title="Vercel sponsors Meshtastic Web Hosting"
key="vercel"
rel="noopener noreferrer"
href="https://vercel.com/?utm_source=meshtastic&utm_campaign=oss"
className="hover:underline text-link"
>
<span className="sr-only">Vercel homepage</span>
</a>,
<a
key="legal"
rel="noopener noreferrer"
href="https://meshtastic.org/docs/legal"
className="hover:underline text-link"
>
<span className="sr-only">Meshtastic terms and conditions</span>
</a>,
]}
/>
</p>
{(version || commitHash) && (
<div className="mt-2 text-[11px] leading-none text-muted-foreground">
<span className="font-semibold">{version}</span>
<span className="mx-2"></span>
<span className="font-semibold">{`#${commitHash}`}</span>
</div>
)}
</footer>
);
};

21
packages/web/src/components/UI/Sidebar/SidebarButton.tsx

@ -1,5 +1,4 @@
import { Button } from "@components/UI/Button.tsx";
import { useSidebar } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import type { LucideIcon } from "lucide-react";
import type React from "react";
@ -24,28 +23,19 @@ export const SidebarButton = ({
children,
onClick,
disabled = false,
preventCollapse = false,
isDirty,
}: SidebarButtonProps) => {
const { isCollapsed: isSidebarCollapsed } = useSidebar();
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
return (
<Button
onClick={onClick}
variant={active ? "subtle" : "ghost"}
size="sm"
className={cn(
"flex w-full items-center text-wrap",
isButtonCollapsed
? "justify-center gap-0 px-2 h-9"
: "justify-start gap-2 min-h-9",
"justify-start gap-2 min-h-9",
)}
disabled={disabled}
>
{Icon && (
<Icon size={isButtonCollapsed ? 20 : 18} className="flex-shrink-0" />
)}
{Icon && <Icon size={18} className="flex-shrink-0" />}
{children}
@ -55,21 +45,18 @@ export const SidebarButton = ({
"min-w-0",
"px-1",
"transition-all duration-300 ease-in-out",
isButtonCollapsed
? "opacity-0 max-w-0 invisible w-0 overflow-hidden"
: "opacity-100 max-w-full visible whitespace-normal",
"opacity-100 max-w-full visible whitespace-normal",
)}
>
{label}
</span>
{!isButtonCollapsed && ((!active && count && count > 0) || isDirty) && (
{((!active && count && count > 0) || isDirty) && (
<div
className={cn(
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5",
"flex-shrink-0",
"transition-opacity duration-300 ease-in-out",
isButtonCollapsed ? "opacity-0 invisible" : "opacity-100 visible",
isDirty ? "bg-sky-500" : "bg-red-600",
)}
>

7
packages/web/src/components/UI/Sidebar/SidebarSection.tsx

@ -1,5 +1,4 @@
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { useSidebar } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import type React from "react";
@ -14,9 +13,8 @@ export const SidebarSection = ({
children,
className,
}: SidebarSectionProps) => {
const { isCollapsed } = useSidebar();
return (
<div className={cn("py-2", isCollapsed ? "px-0" : "px-4", className)}>
<div className={cn("py-2", "px-4", className)}>
<Heading
as="h3"
className={cn(
@ -24,9 +22,6 @@ export const SidebarSection = ({
"uppercase tracking-wider text-md",
"transition-all duration-300 ease-in-out",
"whitespace-nowrap overflow-hidden",
isCollapsed
? "opacity-0 max-w-0 h-0 invisible px-0 mb-0"
: "opacity-100 max-w-xs h-auto visible px-1 mb-1",
)}
>
{label}

106
packages/web/src/components/UI/Tabs.tsx

@ -1,52 +1,64 @@
import { cn } from "@core/utils/cn.ts";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
const Tabs = TabsPrimitive.Root;
import { cn } from "@app/lib/utils"
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex flex-wrap items-center rounded-md p-1 mt-2 bg-slate-200 dark:bg-slate-100",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
className={cn(
"inline-flex min-w-[100px] items-center justify-center rounded-[0.185rem] px-3 py-1.5 text-sm font-medium text-slate-900 transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-xs cursor-pointer",
className,
)}
{...props}
ref={ref}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
className={cn(
"mt-2 rounded-md border border-slate-200 p-6 dark:border-slate-700",
className,
)}
{...props}
ref={ref}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
export { Tabs, TabsContent, TabsList, TabsTrigger };
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

7
packages/web/src/components/UI/Typography/Heading.tsx

@ -1,15 +1,14 @@
import type React from "react";
const headingStyles = {
h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
h1: "scroll-m-20 text-center text-4xl font-extrabold tracking-tight text-balance",
h2: "scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700",
h3: "scroll-m-20 text-lg font-semibold tracking-tight",
h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
h4: "scroll-m-20 text-xl font-semibold tracking-tight",
h5: "scroll-m-20 text-lg font-medium tracking-tight",
};
interface HeadingProps {
as?: "h1" | "h2" | "h3" | "h4" | "h5";
as?: "h1" | "h2" | "h3" | "h4";
children: React.ReactNode;
className?: string;
}

31
packages/web/src/components/UI/collapsible.tsx

@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

56
packages/web/src/components/UI/scroll-area.tsx

@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@app/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

2
packages/web/src/components/UI/sidebar.tsx

@ -90,7 +90,7 @@ function SidebarProvider({
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
}, [isMobile, setOpen]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {

1
packages/web/src/core/hooks/index.ts

@ -0,0 +1 @@
export { useSidebar } from "@components/UI/sidebar.tsx";

142
packages/web/src/core/stores/headerStore/headerStore.test.ts

@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
async function freshStore() {
vi.resetModules();
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
const mod = await import("./index.ts");
return mod;
}
describe("headerStore", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should have correct initial state", async () => {
const { useHeaderStore } = await freshStore();
const state = useHeaderStore.getState();
expect(state.title).toBe("");
expect(Array.isArray(state.actions)).toBe(true);
expect(state.actions).toHaveLength(0);
expect(typeof state.setTitle).toBe("function");
expect(typeof state.setActions).toBe("function");
expect(typeof state.reset).toBe("function");
});
it("setTitle should update title", async () => {
const { useHeaderStore } = await freshStore();
const store = useHeaderStore.getState();
store.setTitle("Messages");
expect(useHeaderStore.getState().title).toBe("Messages");
store.setTitle("Nodes");
expect(useHeaderStore.getState().title).toBe("Nodes");
});
it("setActions should replace actions array (by reference) and values", async () => {
const { useHeaderStore } = await freshStore();
const store = useHeaderStore.getState();
const a1 = [
{
key: "refresh",
label: "Refresh",
onClick: () => {},
},
];
store.setActions(a1);
const afterA1 = useHeaderStore.getState().actions;
// Values and reference both as expected
expect(afterA1).toEqual(a1);
expect(afterA1).toBe(a1);
// Replace with a different array
const a2 = [
{
key: "encrypt",
label: "Encrypt",
onClick: () => {},
iconClasses: "text-green-600",
},
{
key: "settings",
label: "Settings",
onClick: () => {},
},
];
store.setActions(a2);
const afterA2 = useHeaderStore.getState().actions;
expect(afterA2).toEqual(a2);
expect(afterA2).toBe(a2);
expect(afterA2).not.toBe(afterA1);
expect(afterA2).toHaveLength(2);
});
it("reset should clear title and actions", async () => {
const { useHeaderStore } = await freshStore();
const store = useHeaderStore.getState();
store.setTitle("Radio Config");
store.setActions([
{ key: "save", label: "Save", onClick: () => {} },
{ key: "cancel", label: "Cancel", onClick: () => {} },
]);
expect(useHeaderStore.getState().title).toBe("Radio Config");
expect(useHeaderStore.getState().actions).toHaveLength(2);
store.reset();
const after = useHeaderStore.getState();
expect(after.title).toBe("");
expect(after.actions).toEqual([]);
});
it("can be updated multiple times without leaking state between calls", async () => {
const { useHeaderStore } = await freshStore();
const s1 = useHeaderStore.getState();
s1.setTitle("A");
s1.setActions([{ key: "a", label: "A", onClick: () => {} }]);
expect(useHeaderStore.getState().title).toBe("A");
expect(useHeaderStore.getState().actions).toHaveLength(1);
s1.setTitle("B");
s1.setActions([
{ key: "b1", label: "B1", onClick: () => {} },
{ key: "b2", label: "B2", onClick: () => {} },
]);
expect(useHeaderStore.getState().title).toBe("B");
expect(useHeaderStore.getState().actions).toHaveLength(2);
s1.reset();
expect(useHeaderStore.getState().title).toBe("");
expect(useHeaderStore.getState().actions).toHaveLength(0);
});
it("actions array objects are not auto-mutated by the store", async () => {
const { useHeaderStore } = await freshStore();
const store = useHeaderStore.getState();
const external = [{ key: "x", label: "X", onClick: () => {} }];
store.setActions(external);
// Mutate the original array after setting to store
external.push({ key: "y", label: "Y", onClick: () => {} });
// The store should still hold the original reference we passed,
// so length will reflect the push IF you pass the same reference.
// This test documents behavior; if you want immutability, clone in setActions.
const inStore = useHeaderStore.getState().actions;
expect(inStore).toBe(external);
expect(inStore).toHaveLength(2);
});
});

30
packages/web/src/core/stores/headerStore/index.ts

@ -0,0 +1,30 @@
import type { LucideIcon } from "lucide-react";
import { create } from "zustand";
export type ActionItem = {
key: string;
icon?: LucideIcon;
iconClasses?: string;
onClick: () => void;
disabled?: boolean;
isLoading?: boolean;
ariaLabel?: string;
label?: string;
className?: string;
};
type HeaderState = {
title: string;
actions: ActionItem[];
setTitle: (title: string) => void;
setActions: (actions: ActionItem[]) => void;
reset: () => void;
};
export const useHeaderStore = create<HeaderState>((set) => ({
title: "",
actions: [],
setTitle: (title) => set({ title }),
setActions: (actions) => set({ actions }),
reset: () => set({ title: "", actions: [] }),
}));

5
packages/web/src/core/stores/index.ts

@ -18,6 +18,7 @@ export {
type ValidModuleConfigType,
type WaypointWithMetadata,
} from "@core/stores/deviceStore";
export { type ActionItem, useHeaderStore } from "@core/stores/headerStore";
export {
MessageState,
type MessageStore,
@ -26,10 +27,6 @@ export {
} from "@core/stores/messageStore";
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore";
export type { NodeErrorType } from "@core/stores/nodeDBStore/types";
export {
SidebarProvider,
useSidebar, // TODO: Bring hook into this file
} from "@core/stores/sidebarStore";
// Re-export idb-keyval functions for clearing all stores, expand this if we add more local storage types
export { clear as clearAllStores } from "idb-keyval";

5
packages/web/src/core/stores/nodeDBStore/index.ts

@ -41,7 +41,7 @@ export interface NodeDB {
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
includeSelf?: boolean,
) => Protobuf.Mesh.NodeInfo[];
getMyNode: () => Protobuf.Mesh.NodeInfo | undefined;
getMyNode: () => Protobuf.Mesh.NodeInfo;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean;
@ -376,7 +376,8 @@ function nodeDBFactory(
getMyNode: () => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
console.error(`No nodeDB found (id: ${id})`);
return;
}
if (nodeDB.myNodeNum) {
return (

46
packages/web/src/core/stores/sidebarStore/index.tsx

@ -1,46 +0,0 @@
import type React from "react";
import { createContext, useContext, useMemo, useState } from "react";
interface SidebarContextProps {
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
toggleSidebar: () => void;
}
const SidebarContext = createContext<SidebarContextProps | undefined>(
undefined,
);
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
const toggleSidebar = useMemo(
() => () => {
setIsCollapsed((prev) => !prev);
},
[],
);
const value = useMemo(
() => ({
isCollapsed,
setIsCollapsed,
toggleSidebar,
}),
[isCollapsed, toggleSidebar],
);
return (
<SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>
);
};
export const useSidebar = (): SidebarContextProps => {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within a SidebarProvider");
}
return context;
};

297
packages/web/src/index.css

@ -1,146 +1,83 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@view-transition {
navigation: auto;
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@theme {
--font-mono:
Cascadia Code, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
--font-sans:
Inter var, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
/* --color-background-primary: var(--backgroundPrimary);
--color-background-secondary: var(--backgroundSecondary);
--color-accent: var(--accent);
--color-accent-muted: var(--accentMuted);
--color-text-primary: var(--textPrimary);
--color-text-secondary: var(--textSecondary);
--color-link: var(--link);
--brightness-hover: var(--brightnessHover);
--brightness-press: var(--brightnessPress);
--brightness-disabled: var(--brightnessDisabled);
--sidebar-width: @apply w-50 lg:w-64; */
}
/* [data-theme="light"] {
--backgroundPrimary: #ffffff;
--textPrimary: #111132;
--textSecondary: #64748b;
--link: #0b69bf;
--brightnessHover: 0.95;
--brightnessPress: 1.05;
--brightnessDisabled: 0.75;
} */
/* [data-theme="dark"] {
--backgroundPrimary: #0f172a;
--textPrimary: #ebebeb;
--textSecondary: #bdbdbd;
--link: #8ec9ff;
--brightnessHover: 1.1;
--brightnessPress: 0.9;
--brightnessDisabled: 0.75;
} */
/* Accordion Animations */
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
.animate-fan-out {
transform: translate(var(--dx), var(--dy));
transition: transform 200ms ease-out; /* expand AND collapse */
}
html {
overflow: hidden;
}
html,
body {
height: 100%;
}
body {
font-family: var(--font-sans);
}
.app-container {
height: 100%;
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-slate-200, currentColor);
}
}
@layer components {
.maplibregl-popup-close-button {
padding: 4px 10px 8px 0;
font-size: 1.2rem;
color: #000;
}
.maplibregl-popup-close-button:hover {
background-color: transparent !important;
}
}
/* Prevent image dragging */
img {
-webkit-user-drag: none;
}
@keyframes spin-slower {
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slower 2s linear infinite;
.dark {
/* Updated dark theme colors to match Meshtastic's navy blue design */
--background: oklch(0.12 0.02 240);
--foreground: oklch(0.95 0.01 240);
--card: oklch(0.15 0.02 240);
--card-foreground: oklch(0.95 0.01 240);
--popover: oklch(0.15 0.02 240);
--popover-foreground: oklch(0.95 0.01 240);
--primary: oklch(0.6 0.15 240);
--primary-foreground: oklch(0.95 0.01 240);
--secondary: oklch(0.2 0.02 240);
--secondary-foreground: oklch(0.95 0.01 240);
--muted: oklch(0.18 0.02 240);
--muted-foreground: oklch(0.65 0.01 240);
--accent: oklch(0.22 0.02 240);
--accent-foreground: oklch(0.95 0.01 240);
--destructive: oklch(0.55 0.2 15);
--destructive-foreground: oklch(0.95 0.01 240);
--border: oklch(0.25 0.02 240);
--input: oklch(0.18 0.02 240);
--ring: oklch(0.6 0.15 240);
--chart-1: oklch(0.6 0.15 240);
--chart-2: oklch(0.7 0.12 180);
--chart-3: oklch(0.8 0.15 120);
--chart-4: oklch(0.65 0.18 60);
--chart-5: oklch(0.75 0.15 300);
--sidebar: oklch(0.15 0.02 240);
--sidebar-foreground: oklch(0.9 0.01 240);
--sidebar-primary: oklch(0.6 0.15 240);
--sidebar-primary-foreground: oklch(0.95 0.01 240);
--sidebar-accent: oklch(0.22 0.02 240);
--sidebar-accent-foreground: oklch(0.95 0.01 240);
--sidebar-border: oklch(0.25 0.02 240);
--sidebar-ring: oklch(0.6 0.15 240);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -156,6 +93,7 @@ img {
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@ -164,6 +102,10 @@ img {
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
@ -174,7 +116,6 @@ img {
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
@ -183,90 +124,4 @@ img {
body {
@apply bg-background text-foreground;
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
}

79
packages/web/src/pages/Config/index.tsx

@ -1,5 +1,4 @@
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
@ -189,45 +188,45 @@ const ConfigPage = () => {
removeWorkingChannelConfig,
]);
const leftSidebar = useMemo(
() => (
<Sidebar>
<SidebarSection label={t("sidebar.label")} className="py-2 px-0">
<SidebarButton
label={t("navigation.radioConfig")}
active={activeConfigSection === "device"}
onClick={() => setActiveConfigSection("device")}
Icon={SettingsIcon}
isDirty={workingConfig.length > 0}
count={workingConfig.length}
/>
<SidebarButton
label={t("navigation.moduleConfig")}
active={activeConfigSection === "module"}
onClick={() => setActiveConfigSection("module")}
Icon={BoxesIcon}
isDirty={workingModuleConfig.length > 0}
count={workingModuleConfig.length}
/>
<SidebarButton
label={t("navigation.channelConfig")}
active={activeConfigSection === "channel"}
onClick={() => setActiveConfigSection("channel")}
Icon={LayersIcon}
isDirty={workingChannelConfig.length > 0}
count={workingChannelConfig.length}
/>
</SidebarSection>
</Sidebar>
),
[
activeConfigSection,
workingConfig,
workingModuleConfig,
workingChannelConfig,
t,
],
);
// const leftSidebar = useMemo(
// () => (
// <div>
// <SidebarSection label={t("sidebar.label")} className="py-2 px-0">
// <SidebarButton
// label={t("navigation.radioConfig")}
// active={activeConfigSection === "device"}
// onClick={() => setActiveConfigSection("device")}
// Icon={SettingsIcon}
// isDirty={workingConfig.length > 0}
// count={workingConfig.length}
// />
// <SidebarButton
// label={t("navigation.moduleConfig")}
// active={activeConfigSection === "module"}
// onClick={() => setActiveConfigSection("module")}
// Icon={BoxesIcon}
// isDirty={workingModuleConfig.length > 0}
// count={workingModuleConfig.length}
// />
// <SidebarButton
// label={t("navigation.channelConfig")}
// active={activeConfigSection === "channel"}
// onClick={() => setActiveConfigSection("channel")}
// Icon={LayersIcon}
// isDirty={workingChannelConfig.length > 0}
// count={workingChannelConfig.length}
// />
// </SidebarSection>
// </div>
// ),
// [
// activeConfigSection,
// workingConfig,
// workingModuleConfig,
// workingChannelConfig,
// t,
// ],
// );
const hasDrafts =
workingConfig.length > 0 ||

12
packages/web/src/pages/Map/index.tsx

@ -8,7 +8,7 @@ import {
type FilterState,
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { BaseMap } from "@components/Map.tsx";
import { MeshMap } from "@app/components/MeshMap";
import { NodesLayer } from "@components/PageComponents/Map/Layers/NodesLayer.tsx";
import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
import {
@ -19,7 +19,6 @@ import {
import { WaypointLayer } from "@components/PageComponents/Map/Layers/WaypointLayer.tsx";
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { useMapFitting } from "@core/hooks/useMapFitting.ts";
import { useNodeDB } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
@ -194,8 +193,9 @@ const MapPage = () => {
);
return (
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<BaseMap
// <PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<>
<MeshMap
onLoad={getMapBounds}
onMouseMove={onMouseMove}
onClick={onMapBackgroundClick}
@ -214,7 +214,7 @@ const MapPage = () => {
to={snrHover.to}
/>
)}
</BaseMap>
</MeshMap>
<div className="flex flex-col space-y-1 fixed top-35 right-2.5">
{myNode && hasPos(myNode?.position) && (
<button
@ -262,7 +262,7 @@ const MapPage = () => {
setVisibilityState={setVisibilityState}
/>
</div>
</PageLayout>
</>
);
};

367
packages/web/src/pages/Messages.tsx

@ -1,367 +0,0 @@
import { messagesWithParamsRoute } from "@app/routes.tsx";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Input } from "@components/UI/Input.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import {
MessageState,
MessageType,
useDevice,
useMessages,
useNodeDB,
useSidebar,
} from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { randId } from "@core/utils/randId.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Config/ChannelConfig.tsx";
import { useNavigate, useParams } from "@tanstack/react-router";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
function SelectMessageChat() {
const { t } = useTranslation("messages");
return (
<div className="flex-1 flex items-center justify-center text-slate-500 p-4">
{t("selectChatPrompt.text", { ns: "messages" })}
</div>
);
}
export const MessagesPage = () => {
const { channels, getUnreadCount, resetUnread, connection } = useDevice();
const { getNodes, getNode, getMyNode, hasNodeError } = useNodeDB();
const { getMessages, setMessageState } = useMessages();
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
const navigate = useNavigate();
const { toast } = useToast();
const { isCollapsed } = useSidebar();
const [searchTerm, setSearchTerm] = useState<string>("");
const { t } = useTranslation(["messages", "channels", "ui"]);
const deferredSearch = useDeferredValue(searchTerm);
const navigateToChat = useCallback(
(type: MessageType, id: string) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${id}` });
},
[navigate],
);
const chatType =
type === "direct" ? MessageType.Direct : MessageType.Broadcast;
const numericChatId = Number(chatId);
const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
useEffect(() => {
if (!type && !chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[0];
navigateToChat(
MessageType.Broadcast,
defaultChannel?.index.toString() ?? "0",
);
}
}, [type, chatId, filteredChannels, navigateToChat]);
const currentChannel = channels.get(numericChatId);
const otherNode = getNode(numericChatId);
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
const filteredNodes = useCallback((): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
return getNodes((node: Protobuf.Mesh.NodeInfo) => {
const longName = node.user?.longName?.toLowerCase() ?? "";
const shortName = node.user?.shortName?.toLowerCase() ?? "";
return (
longName.includes(lowerCaseSearchTerm) ||
shortName.includes(lowerCaseSearchTerm)
);
})
.map((node: Protobuf.Mesh.NodeInfo) => ({
...node,
unreadCount: getUnreadCount(node.num) ?? 0,
}))
.sort((a: NodeInfoWithUnread, b: NodeInfoWithUnread) => {
const diff = b.unreadCount - a.unreadCount;
if (diff !== 0) {
return diff;
}
return Number(b.isFavorite) - Number(a.isFavorite);
});
}, [deferredSearch, getNodes, getUnreadCount]);
const sendText = useCallback(
async (message: string) => {
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const channelValue = isDirect
? Types.ChannelNumber.Primary
: numericChatId;
let messageId: number | undefined;
try {
messageId = await connection?.sendText(
message,
toValue,
true,
channelValue,
);
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId,
newState: MessageState.Ack,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNode().num,
nodeB: numericChatId,
messageId,
newState: MessageState.Ack,
});
}
} else {
console.warn("sendText completed but messageId is undefined");
}
} catch (e: unknown) {
console.error("Failed to send message:", e);
const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId: failedId,
newState: MessageState.Failed,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNode().num,
nodeB: numericChatId,
messageId: failedId,
newState: MessageState.Failed,
});
}
}
},
[numericChatId, chatType, connection, getMyNode, setMessageState, isDirect],
);
const renderChatContent = () => {
switch (chatType) {
case MessageType.Broadcast:
return (
<ChannelChat
messages={getMessages({
type: MessageType.Broadcast,
channelId: numericChatId,
}).reverse()}
/>
);
case MessageType.Direct:
return (
<ChannelChat
messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNode().num,
nodeB: numericChatId,
}).reverse()}
/>
);
default:
return <SelectMessageChat />;
}
};
const leftSidebar = useMemo(
() => (
<Sidebar>
<SidebarSection label={t("navigation.channels")} className="py-2 px-0">
{filteredChannels?.map((channel) => (
<SidebarButton
key={channel.index}
count={getUnreadCount(channel.index)}
label={
channel.settings?.name ||
(channel.index === 0
? t("page.broadcastLabel", { ns: "channels" })
: t("page.channelLabel", {
index: channel.index,
ns: "channels",
}))
}
active={
numericChatId === channel.index &&
chatType === MessageType.Broadcast
}
onClick={() => {
navigateToChat(MessageType.Broadcast, channel.index.toString());
resetUnread(channel.index);
}}
>
<HashIcon
size={16}
className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")}
/>
</SidebarButton>
))}
</SidebarSection>
</Sidebar>
),
[
filteredChannels,
numericChatId,
chatType,
isCollapsed,
getUnreadCount,
navigateToChat,
resetUnread,
t,
],
);
const rightSidebar = useMemo(
() => (
<SidebarSection
label=""
className="px-0 flex flex-col h-full overflow-y-auto"
>
<label className="p-2 block" htmlFor="nodeSearch">
<Input
type="text"
name="nodeSearch"
placeholder={t("search.nodes")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
showClearButton={!!searchTerm}
/>
</label>
<div
className={cn(
"flex flex-col h-full flex-1 overflow-y-auto gap-2.5 pt-1 ",
)}
>
{filteredNodes()?.map((node) => (
<SidebarButton
key={node.num}
preventCollapse
label={node.user?.longName ?? t("unknown.shortName")}
count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={
numericChatId === node.num && chatType === MessageType.Direct
}
onClick={() => {
navigateToChat(MessageType.Direct, node.num.toString());
resetUnread(node.num);
}}
>
<Avatar
text={node.user?.shortName ?? t("unknown.shortName")}
className={cn(hasNodeError(node.num) && "text-red-500")}
showError={hasNodeError(node.num)}
showFavorite={node.isFavorite}
size="sm"
/>
</SidebarButton>
))}
</div>
</SidebarSection>
),
[
filteredNodes,
searchTerm,
numericChatId,
chatType,
navigateToChat,
resetUnread,
hasNodeError,
t,
],
);
return (
<PageLayout
label={`${t("page.title", {
interpolation: { escapeValue: false },
chatName:
isBroadcast && currentChannel
? getChannelName(currentChannel)
: isDirect && otherNode
? (otherNode.user?.longName ?? t("unknown.longName"))
: t("emptyState.title"),
})}
`}
rightBar={rightSidebar}
leftBar={leftSidebar}
actions={
isDirect && otherNode
? [
{
key: "encryption",
icon: otherNode.user?.publicKey?.length
? LockIcon
: LockOpenIcon,
iconClasses: otherNode.user?.publicKey?.length
? "text-green-600"
: "text-yellow-300",
onClick() {
toast({
title: otherNode.user?.publicKey?.length
? t("toast.messages.pkiEncryption.title")
: t("toast.messages.pskEncryption.title"),
});
},
},
]
: []
}
>
<div className="flex flex-1 flex-col overflow-hidden">
{renderChatContent()}
<div className="flex-none p-2">
{isBroadcast || isDirect ? (
<MessageInput
to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText}
maxBytes={200}
/>
) : (
<div className="p-4 text-center text-slate-400 italic">
{t("sendMessage.sendButton", { ns: "messages" })}
</div>
)}
</div>
</div>
</PageLayout>
);
};
export default MessagesPage;

245
packages/web/src/pages/Messages/index.tsx

@ -0,0 +1,245 @@
import { messagesWithParamsRoute } from "@app/routes.tsx";
import { usePageHeader } from "@components/Header/index.ts";
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import {
MessageState,
MessageType,
useDevice,
useMessages,
useNodeDB,
} from "@core/stores";
import type { ActionItem } from "@core/stores/headerStore";
import { randId } from "@core/utils/randId.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Config/ChannelConfig.tsx";
import { useNavigate, useParams } from "@tanstack/react-router";
import { LockIcon, LockOpenIcon, MessageCircle } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
function SelectMessageChat() {
const { t } = useTranslation("messages");
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2 text-slate-500">
<MessageCircle className="h-12 w-12 opacity-50" />
<span>{t("selectChatPrompt.text", { ns: "messages" })}</span>
</div>
</div>
);
}
export const MessagesPage = () => {
const { channels, connection } = useDevice();
const { getNode, getMyNode } = useNodeDB();
const { getMessages, setMessageState } = useMessages();
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useTranslation(["messages", "channels", "ui"]);
const navigateToChat = useCallback(
(type: MessageType, id: string) => {
const typeParam = type === MessageType.Direct ? "direct" : "broadcast";
navigate({ to: `/messages/${typeParam}/${id}` });
},
[navigate],
);
const chatType =
type === "direct" ? MessageType.Direct : MessageType.Broadcast;
const numericChatId = Number(chatId);
const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
useEffect(() => {
if (!type && !chatId && filteredChannels.length > 0) {
const defaultChannel = filteredChannels[0];
navigateToChat(
MessageType.Broadcast,
defaultChannel?.index.toString() ?? "0",
);
}
}, [type, chatId, filteredChannels, navigateToChat]);
const currentChannel = channels.get(numericChatId);
const otherNode = getNode(numericChatId);
const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast;
// const filteredNodes = useCallback((): NodeInfoWithUnread[] => {
// const q = deferredSearch.toLowerCase();
// return getNodes((node: Protobuf.Mesh.NodeInfo) => {
// const longName = node.user?.longName?.toLowerCase() ?? "";
// const shortName = node.user?.shortName?.toLowerCase() ?? "";
// return longName.includes(q) || shortName.includes(q);
// })
// .map((node: Protobuf.Mesh.NodeInfo) => ({
// ...node,
// unreadCount: getUnreadCount(node.num) ?? 0,
// }))
// .sort((a: NodeInfoWithUnread, b: NodeInfoWithUnread) => {
// const diff = b.unreadCount - a.unreadCount;
// if (diff !== 0) {
// return diff;
// }
// return Number(b.isFavorite) - Number(a.isFavorite);
// });
// }, [deferredSearch, getNodes, getUnreadCount]);
const sendText = useCallback(
async (message: string) => {
const toValue = isDirect ? numericChatId : MessageType.Broadcast;
const channelValue = isDirect
? Types.ChannelNumber.Primary
: numericChatId;
let messageId: number | undefined;
try {
messageId = await connection?.sendText(
message,
toValue,
true,
channelValue,
);
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId,
newState: MessageState.Ack,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNode().num,
nodeB: numericChatId,
messageId,
newState: MessageState.Ack,
});
}
} else {
console.warn("sendText completed but messageId is undefined");
}
} catch (e: unknown) {
console.error("Failed to send message:", e);
const failedId = messageId ?? randId();
if (chatType === MessageType.Broadcast) {
setMessageState({
type: MessageType.Broadcast,
channelId: channelValue,
messageId: failedId,
newState: MessageState.Failed,
});
} else {
setMessageState({
type: MessageType.Direct,
nodeA: getMyNode().num,
nodeB: numericChatId,
messageId: failedId,
newState: MessageState.Failed,
});
}
}
},
[numericChatId, chatType, connection, getMyNode, setMessageState, isDirect],
);
const renderChatContent = () => {
switch (chatType) {
case MessageType.Broadcast:
return (
<ChannelChat
messages={getMessages({
type: MessageType.Broadcast,
channelId: numericChatId,
}).reverse()}
/>
);
case MessageType.Direct:
return (
<ChannelChat
messages={getMessages({
type: MessageType.Direct,
nodeA: getMyNode().num,
nodeB: numericChatId,
}).reverse()}
/>
);
default:
return <SelectMessageChat />;
}
};
const headerTitle = useMemo(() => {
return t("page.title", {
interpolation: { escapeValue: false },
chatName:
isBroadcast && currentChannel
? getChannelName(currentChannel)
: isDirect && otherNode
? (otherNode.user?.longName ?? t("unknown.longName"))
: t("emptyState.title"),
});
}, [t, isBroadcast, currentChannel, isDirect, otherNode]);
const headerActions = useMemo<ActionItem[]>(() => {
if (!(isDirect && otherNode)) {
return [];
}
return [
{
key: "encryption",
icon: otherNode.user?.publicKey?.length ? LockIcon : LockOpenIcon,
iconClasses: otherNode.user?.publicKey?.length
? "text-green-600"
: "text-yellow-300",
onClick() {
toast({
title: otherNode.user?.publicKey?.length
? t("toast.messages.pkiEncryption.title")
: t("toast.messages.pskEncryption.title"),
});
},
label: undefined, // add a label if you want visible text
},
];
}, [isDirect, otherNode, t, toast]);
usePageHeader({ title: headerTitle, actions: headerActions });
return (
<div className="flex h-full min-h-0">
<div className="w-full flex flex-col">
<div className="flex flex-1 flex-col overflow-hidden px-2">
{renderChatContent()}
<div className="flex-none p-2">
{isBroadcast || isDirect ? (
<MessageInput
to={isDirect ? numericChatId : MessageType.Broadcast}
onSend={sendText}
maxBytes={200}
/>
) : (
<div className="p-4 text-center text-slate-400 italic">
{t("sendMessage.sendButton", { ns: "messages" })}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default MessagesPage;

9
packages/web/src/pages/Nodes/index.tsx

@ -1,5 +1,7 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
import { MeshAvatar } from "@app/components/MeshAvatar";
import { Avatar } from "@app/components/UI/avatar";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import {
type FilterState,
@ -13,8 +15,6 @@ import {
} from "@components/generic/Table/index.tsx";
import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx";
import { Input } from "@components/UI/Input.tsx";
import useLang from "@core/hooks/useLang.ts";
import { useAppStore, useDevice, useNodeDB } from "@core/stores";
@ -151,7 +151,7 @@ const NodesPage = (): JSX.Element => {
cells: [
{
content: (
<Avatar
<MeshAvatar
text={shortName}
showFavorite={node.isFavorite}
showError={hasNodeError(node.num)}
@ -247,14 +247,13 @@ const NodesPage = (): JSX.Element => {
});
return (
<PageLayout label="" leftBar={<Sidebar />}>
<PageLayout label="">
<div className="pl-2 pt-2 flex flex-row">
<div className="flex-1 mr-2">
<Input
placeholder={t("search.nodes")}
value={filterState.nodeName}
className="bg-transparent"
showClearButton={!!filterState.nodeName}
onChange={(e) =>
setFilterState((prev) => ({
...prev,

2
packages/web/src/routes.tsx

@ -3,7 +3,7 @@ import type { useAppStore, useMessageStore } from "@core/stores";
import ConfigPage from "@pages/Config/index.tsx";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import MapPage from "@pages/Map/index.tsx";
import MessagesPage from "@pages/Messages.tsx";
import MessagesPage from "@app/pages/Messages/index.tsx";
import NodesPage from "@pages/Nodes/index.tsx";
import {
createRootRouteWithContext,

258
pnpm-lock.yaml

@ -114,9 +114,15 @@ importers:
'@radix-ui/react-accordion':
specifier: ^1.2.11
version: 1.2.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-avatar':
specifier: ^1.1.10
version: 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-checkbox':
specifier: ^1.3.2
version: 1.3.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-dialog':
specifier: ^1.1.14
version: 1.1.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@ -167,7 +173,7 @@ importers:
version: 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@tailwindcss/vite':
specifier: ^4.1.11
version: 4.1.11([email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 4.1.11([email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
'@tanstack/react-router':
specifier: ^1.127.9
version: 1.131.2([email protected]([email protected]))([email protected])
@ -253,14 +259,14 @@ importers:
specifier: ^1.5.4
version: 1.5.4
vite:
specifier: ^7.1.5
version: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
specifier: ^7.1.6
version: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vite-plugin-html:
specifier: ^3.2.2
version: 3.2.2([email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 3.2.2([email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
vite-plugin-pwa:
specifier: ^1.0.3
version: 1.0.3([email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected](@types/[email protected]))([email protected])
version: 1.0.3([email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected](@types/[email protected]))([email protected])
zod:
specifier: ^4.0.5
version: 4.0.17
@ -270,7 +276,7 @@ importers:
devDependencies:
'@tanstack/router-plugin':
specifier: ^1.127.9
version: 1.131.2(@tanstack/[email protected]([email protected]([email protected]))([email protected]))([email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
version: 1.131.2(@tanstack/[email protected]([email protected]([email protected]))([email protected]))([email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
'@testing-library/jest-dom':
specifier: ^6.6.3
version: 6.6.4
@ -302,8 +308,8 @@ importers:
specifier: ^1.0.8
version: 1.0.8
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
specifier: ^5.0.3
version: 5.0.3([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
autoprefixer:
specifier: ^10.4.21
version: 10.4.21([email protected])
@ -320,11 +326,11 @@ importers:
specifier: ^3.3.1
version: 3.3.1
tailwindcss:
specifier: ^4.1.11
version: 4.1.11
specifier: ^4.1.13
version: 4.1.13
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7([email protected]1)
version: 1.0.7([email protected]3)
tar:
specifier: ^7.4.3
version: 7.4.3
@ -384,6 +390,10 @@ packages:
resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==}
engines: {node: '>=6.9.0'}
@ -491,6 +501,10 @@ packages:
resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
engines: {node: '>=6.9.0'}
'@babel/[email protected]':
resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==}
engines: {node: '>=6.0.0'}
@ -1347,6 +1361,9 @@ packages:
'@jridgewell/[email protected]':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/[email protected]':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@ -1439,6 +1456,9 @@ packages:
'@radix-ui/[email protected]':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/[email protected]':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/[email protected]':
resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==}
peerDependencies:
@ -1465,6 +1485,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==}
peerDependencies:
@ -1491,6 +1524,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
@ -1692,6 +1738,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
@ -1893,6 +1952,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
@ -2028,8 +2096,8 @@ packages:
cpu: [x64]
os: [win32]
'@rolldown/[email protected].27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
'@rolldown/[email protected].35':
resolution: {integrity: sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==}
'@rolldown/[email protected]':
resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==}
@ -3044,9 +3112,9 @@ packages:
maplibre-gl:
optional: true
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
'@vitejs/plugin-react@5.0.3':
resolution: {integrity: sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
@ -4875,6 +4943,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==}
[email protected]:
resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==}
[email protected]:
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
engines: {node: '>=6'}
@ -5196,8 +5267,8 @@ packages:
yaml:
optional: true
[email protected].5:
resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==}
[email protected].6:
resolution: {integrity: sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@ -5480,6 +5551,26 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/[email protected]':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.3
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.28.3(@babel/[email protected])
'@babel/helpers': 7.28.4
'@babel/parser': 7.28.4
'@babel/template': 7.27.2
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.1
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/[email protected]':
dependencies:
'@babel/parser': 7.28.0
@ -5586,6 +5677,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/[email protected](@babel/[email protected])':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/traverse': 7.28.4
transitivePeerDependencies:
- supports-color
'@babel/[email protected]':
dependencies:
'@babel/types': 7.28.2
@ -5636,6 +5736,11 @@ snapshots:
'@babel/template': 7.27.2
'@babel/types': 7.28.2
'@babel/[email protected]':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.28.4
'@babel/[email protected]':
dependencies:
'@babel/types': 7.28.2
@ -5975,14 +6080,14 @@ snapshots:
'@babel/core': 7.28.0
'@babel/helper-plugin-utils': 7.27.1
'@babel/[email protected](@babel/[email protected].0)':
'@babel/[email protected](@babel/[email protected].4)':
dependencies:
'@babel/core': 7.28.0
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/[email protected](@babel/[email protected].0)':
'@babel/[email protected](@babel/[email protected].4)':
dependencies:
'@babel/core': 7.28.0
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/[email protected](@babel/[email protected])':
@ -6450,6 +6555,11 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/[email protected]':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]':
@ -6553,6 +6663,8 @@ snapshots:
'@radix-ui/[email protected]': {}
'@radix-ui/[email protected]': {}
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -6579,6 +6691,19 @@ snapshots:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-is-hydrated': 0.1.0(@types/[email protected])([email protected])
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
react: 19.1.1
react-dom: 19.1.1([email protected])
optionalDependencies:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -6611,6 +6736,22 @@ snapshots:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
react: 19.1.1
react-dom: 19.1.1([email protected])
optionalDependencies:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
@ -6829,6 +6970,16 @@ snapshots:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
react: 19.1.1
react-dom: 19.1.1([email protected])
optionalDependencies:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
@ -7061,6 +7212,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.9
'@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies:
react: 19.1.1
use-sync-external-store: 1.5.0([email protected])
optionalDependencies:
'@types/react': 19.1.9
'@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies:
react: 19.1.1
@ -7142,7 +7300,7 @@ snapshots:
'@rolldown/[email protected]':
optional: true
'@rolldown/[email protected].27': {}
'@rolldown/[email protected].35': {}
'@rolldown/[email protected]': {}
@ -7451,12 +7609,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.11
'@tailwindcss/oxide-win32-x64-msvc': 4.1.11
'@tailwindcss/[email protected]([email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
'@tailwindcss/[email protected]([email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@tailwindcss/node': 4.1.11
'@tailwindcss/oxide': 4.1.11
tailwindcss: 4.1.11
vite: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vite: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
'@tanstack/[email protected]': {}
@ -7546,7 +7704,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/[email protected](@tanstack/[email protected]([email protected]([email protected]))([email protected]))([email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
'@tanstack/[email protected](@tanstack/[email protected]([email protected]([email protected]))([email protected]))([email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-syntax-jsx': 7.27.1(@babel/[email protected])
@ -7564,7 +7722,7 @@ snapshots:
zod: 3.25.76
optionalDependencies:
'@tanstack/react-router': 1.131.2([email protected]([email protected]))([email protected])
vite: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vite: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
transitivePeerDependencies:
- supports-color
@ -8737,24 +8895,24 @@ snapshots:
'@types/[email protected]':
dependencies:
'@babel/parser': 7.28.0
'@babel/types': 7.28.2
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/[email protected]':
dependencies:
'@babel/types': 7.28.2
'@babel/types': 7.28.4
'@types/[email protected]':
dependencies:
'@babel/parser': 7.28.0
'@babel/types': 7.28.2
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@types/[email protected]':
dependencies:
'@babel/types': 7.28.2
'@babel/types': 7.28.4
'@types/[email protected]':
dependencies:
@ -8850,15 +9008,15 @@ snapshots:
optionalDependencies:
maplibre-gl: 5.6.1
'@vitejs/plugin-react@4.7.0([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
'@vitejs/plugin-react@5.0.3([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/[email protected].0)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/[email protected].0)
'@rolldown/pluginutils': 1.0.0-beta.27
'@babel/core': 7.28.4
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/[email protected].4)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/[email protected].4)
'@rolldown/pluginutils': 1.0.0-beta.35
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vite: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
transitivePeerDependencies:
- supports-color
@ -10827,12 +10985,14 @@ snapshots:
[email protected]: {}
[email protected]([email protected]1):
[email protected]([email protected]3):
dependencies:
tailwindcss: 4.1.11
tailwindcss: 4.1.13
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@ -11105,7 +11265,7 @@ snapshots:
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vite: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
transitivePeerDependencies:
- '@types/node'
- jiti
@ -11126,7 +11286,7 @@ snapshots:
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vite: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
transitivePeerDependencies:
- '@types/node'
- jiti
@ -11141,7 +11301,7 @@ snapshots:
- tsx
- yaml
[email protected]([email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
[email protected]([email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])):
dependencies:
'@rollup/pluginutils': 4.2.1
colorette: 2.0.20
@ -11155,14 +11315,14 @@ snapshots:
html-minifier-terser: 6.1.0
node-html-parser: 5.4.2
pathe: 0.2.0
vite: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vite: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
[email protected]([email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected](@types/[email protected]))([email protected]):
[email protected]([email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected](@types/[email protected]))([email protected]):
dependencies:
debug: 4.4.1
pretty-bytes: 6.1.1
tinyglobby: 0.2.14
vite: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
vite: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected])
workbox-build: 7.3.0(@types/[email protected])
workbox-window: 7.3.0
transitivePeerDependencies:
@ -11200,7 +11360,7 @@ snapshots:
terser: 5.44.0
tsx: 4.20.3
[email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
[email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
dependencies:
esbuild: 0.25.9
fdir: 6.5.0([email protected])
@ -11216,7 +11376,7 @@ snapshots:
terser: 5.44.0
tsx: 4.20.3
[email protected].5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
[email protected].6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]):
dependencies:
esbuild: 0.25.9
fdir: 6.5.0([email protected])

Loading…
Cancel
Save