42 changed files with 1902 additions and 1290 deletions
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
} |
|||
@ -0,0 +1,2 @@ |
|||
export { default as HeaderActions } from "./HeaderActions.tsx"; |
|||
export { default as usePageHeader } from "./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)]); |
|||
} |
|||
@ -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,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> |
|||
); |
|||
} |
|||
@ -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> |
|||
); |
|||
}; |
|||
@ -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 }; |
|||
|
|||
@ -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 } |
|||
|
|||
@ -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 } |
|||
@ -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 } |
|||
@ -0,0 +1 @@ |
|||
export { useSidebar } from "@components/UI/sidebar.tsx"; |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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: [] }), |
|||
})); |
|||
@ -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; |
|||
}; |
|||
@ -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; |
|||
@ -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; |
|||
@ -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…
Reference in new issue