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 { |
import { cn } from "@app/lib/utils"; |
||||
Tooltip, |
import * as AvatarPrimitive from "@radix-ui/react-avatar"; |
||||
TooltipArrow, |
import type * as React from "react"; |
||||
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"; |
|
||||
|
|
||||
interface AvatarProps { |
function Avatar({ |
||||
text: string | number; |
className, |
||||
size?: "sm" | "lg"; |
...props |
||||
className?: string; |
}: React.ComponentProps<typeof AvatarPrimitive.Root>) { |
||||
showError?: boolean; |
return ( |
||||
showFavorite?: boolean; |
<AvatarPrimitive.Root |
||||
|
data-slot="avatar" |
||||
|
className={cn( |
||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full", |
||||
|
className, |
||||
|
)} |
||||
|
{...props} |
||||
|
/> |
||||
|
); |
||||
} |
} |
||||
|
|
||||
export const Avatar = ({ |
function AvatarImage({ |
||||
text, |
|
||||
size = "sm", |
|
||||
showError = false, |
|
||||
showFavorite = false, |
|
||||
className, |
className, |
||||
}: AvatarProps) => { |
...props |
||||
const { t } = useTranslation(); |
}: React.ComponentProps<typeof AvatarPrimitive.Image>) { |
||||
|
return ( |
||||
const sizes = { |
<AvatarPrimitive.Image |
||||
sm: "size-10 text-xs font-light", |
data-slot="avatar-image" |
||||
lg: "size-16 text-lg", |
className={cn("aspect-square size-full", className)} |
||||
}; |
{...props} |
||||
|
/> |
||||
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"); |
|
||||
|
|
||||
|
function AvatarFallback({ |
||||
|
className, |
||||
|
...props |
||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) { |
||||
return ( |
return ( |
||||
<div |
<AvatarPrimitive.Fallback |
||||
|
data-slot="avatar-fallback" |
||||
className={cn( |
className={cn( |
||||
`relative flex items-center justify-center rounded-full font-semibold
|
"bg-muted flex size-full items-center justify-center rounded-full", |
||||
`,
|
|
||||
sizes[size], |
|
||||
"bg-[rgb(var(--bg-r),var(--bg-g),var(--bg-b))]", // allow override with className
|
|
||||
className, |
className, |
||||
)} |
)} |
||||
style={ |
{...props} |
||||
{ |
/> |
||||
"--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> |
|
||||
); |
); |
||||
}; |
} |
||||
|
|
||||
|
export { Avatar, AvatarImage, AvatarFallback }; |
||||
|
|||||
@ -1,52 +1,64 @@ |
|||||
import { cn } from "@core/utils/cn.ts"; |
import * as React from "react" |
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"; |
import * as TabsPrimitive from "@radix-ui/react-tabs" |
||||
import * as React from "react"; |
|
||||
|
|
||||
const Tabs = TabsPrimitive.Root; |
import { cn } from "@app/lib/utils" |
||||
|
|
||||
const TabsList = React.forwardRef< |
function Tabs({ |
||||
React.ElementRef<typeof TabsPrimitive.List>, |
className, |
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> |
...props |
||||
>(({ className, ...props }, ref) => ( |
}: React.ComponentProps<typeof TabsPrimitive.Root>) { |
||||
<TabsPrimitive.List |
return ( |
||||
ref={ref} |
<TabsPrimitive.Root |
||||
className={cn( |
data-slot="tabs" |
||||
"inline-flex flex-wrap items-center rounded-md p-1 mt-2 bg-slate-200 dark:bg-slate-100", |
className={cn("flex flex-col gap-2", className)} |
||||
className, |
{...props} |
||||
)} |
/> |
||||
{...props} |
) |
||||
/> |
} |
||||
)); |
|
||||
TabsList.displayName = TabsPrimitive.List.displayName; |
|
||||
|
|
||||
const TabsTrigger = React.forwardRef< |
function TabsList({ |
||||
React.ElementRef<typeof TabsPrimitive.Trigger>, |
className, |
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> |
...props |
||||
>(({ className, ...props }, ref) => ( |
}: React.ComponentProps<typeof TabsPrimitive.List>) { |
||||
<TabsPrimitive.Trigger |
return ( |
||||
className={cn( |
<TabsPrimitive.List |
||||
"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", |
data-slot="tabs-list" |
||||
className, |
className={cn( |
||||
)} |
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", |
||||
{...props} |
className |
||||
ref={ref} |
)} |
||||
/> |
{...props} |
||||
)); |
/> |
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; |
) |
||||
|
} |
||||
|
|
||||
const TabsContent = React.forwardRef< |
function TabsTrigger({ |
||||
React.ElementRef<typeof TabsPrimitive.Content>, |
className, |
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> |
...props |
||||
>(({ className, ...props }, ref) => ( |
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) { |
||||
<TabsPrimitive.Content |
return ( |
||||
className={cn( |
<TabsPrimitive.Trigger |
||||
"mt-2 rounded-md border border-slate-200 p-6 dark:border-slate-700", |
data-slot="tabs-trigger" |
||||
className, |
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", |
||||
{...props} |
className |
||||
ref={ref} |
)} |
||||
/> |
{...props} |
||||
)); |
/> |
||||
TabsContent.displayName = TabsPrimitive.Content.displayName; |
) |
||||
|
} |
||||
|
|
||||
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': |
'@radix-ui/react-accordion': |
||||
specifier: ^1.2.11 |
specifier: ^1.2.11 |
||||
version: 1.2.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) |
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': |
'@radix-ui/react-checkbox': |
||||
specifier: ^1.3.2 |
specifier: ^1.3.2 |
||||
version: 1.3.2(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) |
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': |
'@radix-ui/react-dialog': |
||||
specifier: ^1.1.14 |
specifier: ^1.1.14 |
||||
version: 1.1.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) |
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]) |
version: 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected]) |
||||
'@tailwindcss/vite': |
'@tailwindcss/vite': |
||||
specifier: ^4.1.11 |
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': |
'@tanstack/react-router': |
||||
specifier: ^1.127.9 |
specifier: ^1.127.9 |
||||
version: 1.131.2([email protected]([email protected]))([email protected]) |
version: 1.131.2([email protected]([email protected]))([email protected]) |
||||
@ -253,14 +259,14 @@ importers: |
|||||
specifier: ^1.5.4 |
specifier: ^1.5.4 |
||||
version: 1.5.4 |
version: 1.5.4 |
||||
vite: |
vite: |
||||
specifier: ^7.1.5 |
specifier: ^7.1.6 |
||||
version: 7.1.5(@types/[email protected])([email protected])([email protected])([email protected])([email protected]) |
version: 7.1.6(@types/[email protected])([email protected])([email protected])([email protected])([email protected]) |
||||
vite-plugin-html: |
vite-plugin-html: |
||||
specifier: ^3.2.2 |
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: |
vite-plugin-pwa: |
||||
specifier: ^1.0.3 |
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: |
zod: |
||||
specifier: ^4.0.5 |
specifier: ^4.0.5 |
||||
version: 4.0.17 |
version: 4.0.17 |
||||
@ -270,7 +276,7 @@ importers: |
|||||
devDependencies: |
devDependencies: |
||||
'@tanstack/router-plugin': |
'@tanstack/router-plugin': |
||||
specifier: ^1.127.9 |
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': |
'@testing-library/jest-dom': |
||||
specifier: ^6.6.3 |
specifier: ^6.6.3 |
||||
version: 6.6.4 |
version: 6.6.4 |
||||
@ -302,8 +308,8 @@ importers: |
|||||
specifier: ^1.0.8 |
specifier: ^1.0.8 |
||||
version: 1.0.8 |
version: 1.0.8 |
||||
'@vitejs/plugin-react': |
'@vitejs/plugin-react': |
||||
specifier: ^4.6.0 |
specifier: ^5.0.3 |
||||
version: 4.7.0([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])) |
version: 5.0.3([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])) |
||||
autoprefixer: |
autoprefixer: |
||||
specifier: ^10.4.21 |
specifier: ^10.4.21 |
||||
version: 10.4.21([email protected]) |
version: 10.4.21([email protected]) |
||||
@ -320,11 +326,11 @@ importers: |
|||||
specifier: ^3.3.1 |
specifier: ^3.3.1 |
||||
version: 3.3.1 |
version: 3.3.1 |
||||
tailwindcss: |
tailwindcss: |
||||
specifier: ^4.1.11 |
specifier: ^4.1.13 |
||||
version: 4.1.11 |
version: 4.1.13 |
||||
tailwindcss-animate: |
tailwindcss-animate: |
||||
specifier: ^1.0.7 |
specifier: ^1.0.7 |
||||
version: 1.0.7([email protected]1) |
version: 1.0.7([email protected]3) |
||||
tar: |
tar: |
||||
specifier: ^7.4.3 |
specifier: ^7.4.3 |
||||
version: 7.4.3 |
version: 7.4.3 |
||||
@ -384,6 +390,10 @@ packages: |
|||||
resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} |
resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} |
||||
engines: {node: '>=6.9.0'} |
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]': |
'@babel/[email protected]': |
||||
resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} |
resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} |
||||
engines: {node: '>=6.9.0'} |
engines: {node: '>=6.9.0'} |
||||
@ -491,6 +501,10 @@ packages: |
|||||
resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==} |
resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==} |
||||
engines: {node: '>=6.9.0'} |
engines: {node: '>=6.9.0'} |
||||
|
|
||||
|
'@babel/[email protected]': |
||||
|
resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} |
||||
|
engines: {node: '>=6.9.0'} |
||||
|
|
||||
'@babel/[email protected]': |
'@babel/[email protected]': |
||||
resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} |
resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} |
||||
engines: {node: '>=6.0.0'} |
engines: {node: '>=6.0.0'} |
||||
@ -1347,6 +1361,9 @@ packages: |
|||||
'@jridgewell/[email protected]': |
'@jridgewell/[email protected]': |
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} |
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} |
||||
|
|
||||
|
'@jridgewell/[email protected]': |
||||
|
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} |
||||
|
|
||||
'@jridgewell/[email protected]': |
'@jridgewell/[email protected]': |
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} |
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} |
||||
engines: {node: '>=6.0.0'} |
engines: {node: '>=6.0.0'} |
||||
@ -1439,6 +1456,9 @@ packages: |
|||||
'@radix-ui/[email protected]': |
'@radix-ui/[email protected]': |
||||
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} |
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} |
||||
|
|
||||
|
'@radix-ui/[email protected]': |
||||
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} |
||||
|
|
||||
'@radix-ui/[email protected]': |
'@radix-ui/[email protected]': |
||||
resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==} |
resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==} |
||||
peerDependencies: |
peerDependencies: |
||||
@ -1465,6 +1485,19 @@ packages: |
|||||
'@types/react-dom': |
'@types/react-dom': |
||||
optional: true |
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]': |
'@radix-ui/[email protected]': |
||||
resolution: {integrity: sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==} |
resolution: {integrity: sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==} |
||||
peerDependencies: |
peerDependencies: |
||||
@ -1491,6 +1524,19 @@ packages: |
|||||
'@types/react-dom': |
'@types/react-dom': |
||||
optional: true |
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]': |
'@radix-ui/[email protected]': |
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} |
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} |
||||
peerDependencies: |
peerDependencies: |
||||
@ -1692,6 +1738,19 @@ packages: |
|||||
'@types/react-dom': |
'@types/react-dom': |
||||
optional: true |
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]': |
'@radix-ui/[email protected]': |
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} |
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} |
||||
peerDependencies: |
peerDependencies: |
||||
@ -1893,6 +1952,15 @@ packages: |
|||||
'@types/react': |
'@types/react': |
||||
optional: true |
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]': |
'@radix-ui/[email protected]': |
||||
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} |
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} |
||||
peerDependencies: |
peerDependencies: |
||||
@ -2028,8 +2096,8 @@ packages: |
|||||
cpu: [x64] |
cpu: [x64] |
||||
os: [win32] |
os: [win32] |
||||
|
|
||||
'@rolldown/[email protected].27': |
'@rolldown/[email protected].35': |
||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} |
resolution: {integrity: sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==} |
||||
|
|
||||
'@rolldown/[email protected]': |
'@rolldown/[email protected]': |
||||
resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==} |
resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==} |
||||
@ -3044,9 +3112,9 @@ packages: |
|||||
maplibre-gl: |
maplibre-gl: |
||||
optional: true |
optional: true |
||||
|
|
||||
'@vitejs/plugin-react@4.7.0': |
'@vitejs/plugin-react@5.0.3': |
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} |
resolution: {integrity: sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==} |
||||
engines: {node: ^14.18.0 || >=16.0.0} |
engines: {node: ^20.19.0 || >=22.12.0} |
||||
peerDependencies: |
peerDependencies: |
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 |
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 |
||||
|
|
||||
@ -4875,6 +4943,9 @@ packages: |
|||||
[email protected]: |
[email protected]: |
||||
resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==} |
resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==} |
||||
|
|
||||
|
[email protected]: |
||||
|
resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==} |
||||
|
|
||||
[email protected]: |
[email protected]: |
||||
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} |
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} |
||||
engines: {node: '>=6'} |
engines: {node: '>=6'} |
||||
@ -5196,8 +5267,8 @@ packages: |
|||||
yaml: |
yaml: |
||||
optional: true |
optional: true |
||||
|
|
||||
[email protected].5: |
[email protected].6: |
||||
resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} |
resolution: {integrity: sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==} |
||||
engines: {node: ^20.19.0 || >=22.12.0} |
engines: {node: ^20.19.0 || >=22.12.0} |
||||
hasBin: true |
hasBin: true |
||||
peerDependencies: |
peerDependencies: |
||||
@ -5480,6 +5551,26 @@ snapshots: |
|||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- 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]': |
'@babel/[email protected]': |
||||
dependencies: |
dependencies: |
||||
'@babel/parser': 7.28.0 |
'@babel/parser': 7.28.0 |
||||
@ -5586,6 +5677,15 @@ snapshots: |
|||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- 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]': |
'@babel/[email protected]': |
||||
dependencies: |
dependencies: |
||||
'@babel/types': 7.28.2 |
'@babel/types': 7.28.2 |
||||
@ -5636,6 +5736,11 @@ snapshots: |
|||||
'@babel/template': 7.27.2 |
'@babel/template': 7.27.2 |
||||
'@babel/types': 7.28.2 |
'@babel/types': 7.28.2 |
||||
|
|
||||
|
'@babel/[email protected]': |
||||
|
dependencies: |
||||
|
'@babel/template': 7.27.2 |
||||
|
'@babel/types': 7.28.4 |
||||
|
|
||||
'@babel/[email protected]': |
'@babel/[email protected]': |
||||
dependencies: |
dependencies: |
||||
'@babel/types': 7.28.2 |
'@babel/types': 7.28.2 |
||||
@ -5975,14 +6080,14 @@ snapshots: |
|||||
'@babel/core': 7.28.0 |
'@babel/core': 7.28.0 |
||||
'@babel/helper-plugin-utils': 7.27.1 |
'@babel/helper-plugin-utils': 7.27.1 |
||||
|
|
||||
'@babel/[email protected](@babel/[email protected].0)': |
'@babel/[email protected](@babel/[email protected].4)': |
||||
dependencies: |
dependencies: |
||||
'@babel/core': 7.28.0 |
'@babel/core': 7.28.4 |
||||
'@babel/helper-plugin-utils': 7.27.1 |
'@babel/helper-plugin-utils': 7.27.1 |
||||
|
|
||||
'@babel/[email protected](@babel/[email protected].0)': |
'@babel/[email protected](@babel/[email protected].4)': |
||||
dependencies: |
dependencies: |
||||
'@babel/core': 7.28.0 |
'@babel/core': 7.28.4 |
||||
'@babel/helper-plugin-utils': 7.27.1 |
'@babel/helper-plugin-utils': 7.27.1 |
||||
|
|
||||
'@babel/[email protected](@babel/[email protected])': |
'@babel/[email protected](@babel/[email protected])': |
||||
@ -6450,6 +6555,11 @@ snapshots: |
|||||
'@jridgewell/sourcemap-codec': 1.5.5 |
'@jridgewell/sourcemap-codec': 1.5.5 |
||||
'@jridgewell/trace-mapping': 0.3.31 |
'@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]': {} |
||||
|
|
||||
'@jridgewell/[email protected]': |
'@jridgewell/[email protected]': |
||||
@ -6553,6 +6663,8 @@ snapshots: |
|||||
|
|
||||
'@radix-ui/[email protected]': {} |
'@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])': |
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': |
||||
dependencies: |
dependencies: |
||||
'@radix-ui/primitive': 1.1.2 |
'@radix-ui/primitive': 1.1.2 |
||||
@ -6579,6 +6691,19 @@ snapshots: |
|||||
'@types/react': 19.1.9 |
'@types/react': 19.1.9 |
||||
'@types/react-dom': 19.1.7(@types/[email protected]) |
'@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])': |
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': |
||||
dependencies: |
dependencies: |
||||
'@radix-ui/primitive': 1.1.2 |
'@radix-ui/primitive': 1.1.2 |
||||
@ -6611,6 +6736,22 @@ snapshots: |
|||||
'@types/react': 19.1.9 |
'@types/react': 19.1.9 |
||||
'@types/react-dom': 19.1.7(@types/[email protected]) |
'@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])': |
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': |
||||
dependencies: |
dependencies: |
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected]) |
'@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': 19.1.9 |
||||
'@types/react-dom': 19.1.7(@types/[email protected]) |
'@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])': |
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])': |
||||
dependencies: |
dependencies: |
||||
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected]) |
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected]) |
||||
@ -7061,6 +7212,13 @@ snapshots: |
|||||
optionalDependencies: |
optionalDependencies: |
||||
'@types/react': 19.1.9 |
'@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])': |
'@radix-ui/[email protected](@types/[email protected])([email protected])': |
||||
dependencies: |
dependencies: |
||||
react: 19.1.1 |
react: 19.1.1 |
||||
@ -7142,7 +7300,7 @@ snapshots: |
|||||
'@rolldown/[email protected]': |
'@rolldown/[email protected]': |
||||
optional: true |
optional: true |
||||
|
|
||||
'@rolldown/[email protected].27': {} |
'@rolldown/[email protected].35': {} |
||||
|
|
||||
'@rolldown/[email protected]': {} |
'@rolldown/[email protected]': {} |
||||
|
|
||||
@ -7451,12 +7609,12 @@ snapshots: |
|||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.11 |
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.11 |
||||
'@tailwindcss/oxide-win32-x64-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: |
dependencies: |
||||
'@tailwindcss/node': 4.1.11 |
'@tailwindcss/node': 4.1.11 |
||||
'@tailwindcss/oxide': 4.1.11 |
'@tailwindcss/oxide': 4.1.11 |
||||
tailwindcss: 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]': {} |
'@tanstack/[email protected]': {} |
||||
|
|
||||
@ -7546,7 +7704,7 @@ snapshots: |
|||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
- supports-color |
- 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: |
dependencies: |
||||
'@babel/core': 7.28.0 |
'@babel/core': 7.28.0 |
||||
'@babel/plugin-syntax-jsx': 7.27.1(@babel/[email protected]) |
'@babel/plugin-syntax-jsx': 7.27.1(@babel/[email protected]) |
||||
@ -7564,7 +7722,7 @@ snapshots: |
|||||
zod: 3.25.76 |
zod: 3.25.76 |
||||
optionalDependencies: |
optionalDependencies: |
||||
'@tanstack/react-router': 1.131.2([email protected]([email protected]))([email protected]) |
'@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: |
transitivePeerDependencies: |
||||
- supports-color |
- supports-color |
||||
|
|
||||
@ -8737,24 +8895,24 @@ snapshots: |
|||||
|
|
||||
'@types/[email protected]': |
'@types/[email protected]': |
||||
dependencies: |
dependencies: |
||||
'@babel/parser': 7.28.0 |
'@babel/parser': 7.28.4 |
||||
'@babel/types': 7.28.2 |
'@babel/types': 7.28.4 |
||||
'@types/babel__generator': 7.27.0 |
'@types/babel__generator': 7.27.0 |
||||
'@types/babel__template': 7.4.4 |
'@types/babel__template': 7.4.4 |
||||
'@types/babel__traverse': 7.28.0 |
'@types/babel__traverse': 7.28.0 |
||||
|
|
||||
'@types/[email protected]': |
'@types/[email protected]': |
||||
dependencies: |
dependencies: |
||||
'@babel/types': 7.28.2 |
'@babel/types': 7.28.4 |
||||
|
|
||||
'@types/[email protected]': |
'@types/[email protected]': |
||||
dependencies: |
dependencies: |
||||
'@babel/parser': 7.28.0 |
'@babel/parser': 7.28.4 |
||||
'@babel/types': 7.28.2 |
'@babel/types': 7.28.4 |
||||
|
|
||||
'@types/[email protected]': |
'@types/[email protected]': |
||||
dependencies: |
dependencies: |
||||
'@babel/types': 7.28.2 |
'@babel/types': 7.28.4 |
||||
|
|
||||
'@types/[email protected]': |
'@types/[email protected]': |
||||
dependencies: |
dependencies: |
||||
@ -8850,15 +9008,15 @@ snapshots: |
|||||
optionalDependencies: |
optionalDependencies: |
||||
maplibre-gl: 5.6.1 |
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: |
dependencies: |
||||
'@babel/core': 7.28.0 |
'@babel/core': 7.28.4 |
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/[email protected].0) |
'@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].0) |
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/[email protected].4) |
||||
'@rolldown/pluginutils': 1.0.0-beta.27 |
'@rolldown/pluginutils': 1.0.0-beta.35 |
||||
'@types/babel__core': 7.20.5 |
'@types/babel__core': 7.20.5 |
||||
react-refresh: 0.17.0 |
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: |
transitivePeerDependencies: |
||||
- supports-color |
- supports-color |
||||
|
|
||||
@ -10827,12 +10985,14 @@ snapshots: |
|||||
|
|
||||
[email protected]: {} |
[email protected]: {} |
||||
|
|
||||
[email protected]([email protected]1): |
[email protected]([email protected]3): |
||||
dependencies: |
dependencies: |
||||
tailwindcss: 4.1.11 |
tailwindcss: 4.1.13 |
||||
|
|
||||
[email protected]: {} |
[email protected]: {} |
||||
|
|
||||
|
[email protected]: {} |
||||
|
|
||||
[email protected]: {} |
[email protected]: {} |
||||
|
|
||||
[email protected]: |
[email protected]: |
||||
@ -11105,7 +11265,7 @@ snapshots: |
|||||
debug: 4.4.1 |
debug: 4.4.1 |
||||
es-module-lexer: 1.7.0 |
es-module-lexer: 1.7.0 |
||||
pathe: 2.0.3 |
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: |
transitivePeerDependencies: |
||||
- '@types/node' |
- '@types/node' |
||||
- jiti |
- jiti |
||||
@ -11126,7 +11286,7 @@ snapshots: |
|||||
debug: 4.4.1 |
debug: 4.4.1 |
||||
es-module-lexer: 1.7.0 |
es-module-lexer: 1.7.0 |
||||
pathe: 2.0.3 |
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: |
transitivePeerDependencies: |
||||
- '@types/node' |
- '@types/node' |
||||
- jiti |
- jiti |
||||
@ -11141,7 +11301,7 @@ snapshots: |
|||||
- tsx |
- tsx |
||||
- yaml |
- 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: |
dependencies: |
||||
'@rollup/pluginutils': 4.2.1 |
'@rollup/pluginutils': 4.2.1 |
||||
colorette: 2.0.20 |
colorette: 2.0.20 |
||||
@ -11155,14 +11315,14 @@ snapshots: |
|||||
html-minifier-terser: 6.1.0 |
html-minifier-terser: 6.1.0 |
||||
node-html-parser: 5.4.2 |
node-html-parser: 5.4.2 |
||||
pathe: 0.2.0 |
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: |
dependencies: |
||||
debug: 4.4.1 |
debug: 4.4.1 |
||||
pretty-bytes: 6.1.1 |
pretty-bytes: 6.1.1 |
||||
tinyglobby: 0.2.14 |
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-build: 7.3.0(@types/[email protected]) |
||||
workbox-window: 7.3.0 |
workbox-window: 7.3.0 |
||||
transitivePeerDependencies: |
transitivePeerDependencies: |
||||
@ -11200,7 +11360,7 @@ snapshots: |
|||||
terser: 5.44.0 |
terser: 5.44.0 |
||||
tsx: 4.20.3 |
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: |
dependencies: |
||||
esbuild: 0.25.9 |
esbuild: 0.25.9 |
||||
fdir: 6.5.0([email protected]) |
fdir: 6.5.0([email protected]) |
||||
@ -11216,7 +11376,7 @@ snapshots: |
|||||
terser: 5.44.0 |
terser: 5.44.0 |
||||
tsx: 4.20.3 |
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: |
dependencies: |
||||
esbuild: 0.25.9 |
esbuild: 0.25.9 |
||||
fdir: 6.5.0([email protected]) |
fdir: 6.5.0([email protected]) |
||||
|
|||||
Loading…
Reference in new issue