Files
meshtastic-web/src/components/Sidebar.tsx
T
828e5d0903 Replace zustand state management with URL-based routing (#640)
* feat: added router

* feat: added params to messages page

* fixing tests, added translation labels

* Update src/i18n/locales/en/ui.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/PageComponents/Map/NodeDetail.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/pages/Messages.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* updated dev tools

* fixing tests

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-06-06 18:23:49 -04:00

234 lines
6.8 KiB
TypeScript

import React, { useEffect, useState, useTransition } from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
CircleChevronLeft,
LayersIcon,
type LucideIcon,
MapIcon,
MessageSquareIcon,
SettingsIcon,
UsersIcon,
} from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { useAppStore } from "@core/stores/appStore.ts";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useTranslation } from "react-i18next";
import { DeviceInfoPanel } from "./DeviceInfoPanel.tsx";
import { useLocation, useNavigate } from "@tanstack/react-router";
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,
getNode,
getNodesLength,
metadata,
unreadCounts,
setDialogOpen,
} = useDevice();
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, startNodeCountTransition]);
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.channels"),
icon: LayersIcon,
page: "channels",
},
{
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={{
batteryLevel: myNode.deviceMetrics?.batteryLevel,
voltage: myNode.deviceMetrics?.voltage,
}}
/>
)}
</div>
</div>
);
};