Browse Source

fix: prevent left sidebar collapse, added battery component

pull/586/head
Dan Ditomaso 1 year ago
parent
commit
f460f34ea8
  1. 86
      src/components/BatteryStatus.tsx
  2. 24
      src/components/PageComponents/Map/NodeDetail.tsx
  3. 14
      src/components/Sidebar.tsx
  4. 2
      src/components/UI/Dialog.tsx
  5. 15
      src/components/UI/Sidebar/sidebarButton.tsx
  6. 65
      src/components/generic/Table/index.tsx
  7. 2
      src/pages/Config/index.tsx
  8. 3
      src/pages/Messages.tsx
  9. 3
      src/pages/Nodes.tsx

86
src/components/BatteryStatus.tsx

@ -0,0 +1,86 @@
import React from 'react';
import {
PlugZapIcon,
BatteryFullIcon,
BatteryMediumIcon,
BatteryLowIcon,
} from 'lucide-react';
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
interface DeviceMetrics {
batteryLevel?: number | null;
voltage?: number | null;
}
interface BatteryStatusProps {
deviceMetrics?: DeviceMetrics | null;
}
interface BatteryStateConfig {
condition: (level: number) => boolean;
Icon: React.ElementType;
className: string;
text: (level: number) => string;
}
const batteryStates: BatteryStateConfig[] = [
{
condition: level => level > 100,
Icon: PlugZapIcon,
className: 'text-gray-600',
text: () => 'Plugged in',
},
{
condition: level => level > 80,
Icon: BatteryFullIcon,
className: 'text-green-500',
text: level => `${level}% charging`,
},
{
condition: level => level > 20,
Icon: BatteryMediumIcon,
className: 'text-yellow-400',
text: level => `${level}% charging`,
},
{
condition: () => true,
Icon: BatteryLowIcon,
className: 'text-red-500',
text: level => `${level}% charging`,
},
];
const getBatteryState = (level: number) => {
return batteryStates.find(state => state.condition(level));
};
const BatteryStatus: React.FC<BatteryStatusProps> = ({ deviceMetrics }) => {
if (deviceMetrics?.batteryLevel === undefined || deviceMetrics?.batteryLevel === null) {
return null;
}
const { batteryLevel, voltage } = deviceMetrics;
const currentState = getBatteryState(batteryLevel) ?? batteryStates[batteryStates.length - 1];
const BatteryIcon = currentState.Icon;
const iconClassName = currentState.className;
const statusText = currentState.text(batteryLevel);
const voltageTitle = `${voltage?.toPrecision(3) ?? 'Unknown'} volts`;
return (
<div
className="flex items-center gap-1 mt-0.5 text-gray-500"
title={voltageTitle}
>
<BatteryIcon size={22} className={iconClassName} />
<Subtle aria-label="Battery">
{statusText}
</Subtle>
</div>
);
};
export default BatteryStatus;

24
src/components/PageComponents/Map/NodeDetail.tsx

@ -8,10 +8,6 @@ import { TimeAgo } from "@components/generic/TimeAgo.tsx";
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";
import type { Protobuf as ProtobufType } from "@meshtastic/core"; import type { Protobuf as ProtobufType } from "@meshtastic/core";
import { import {
BatteryChargingIcon,
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
Dot, Dot,
LockIcon, LockIcon,
LockOpenIcon, LockOpenIcon,
@ -28,6 +24,7 @@ import {
} from "@radix-ui/react-tooltip"; } from "@radix-ui/react-tooltip";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
export interface NodeDetailProps { export interface NodeDetailProps {
node: ProtobufType.Mesh.NodeInfo; node: ProtobufType.Mesh.NodeInfo;
@ -112,24 +109,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>} {hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}
{!!node.deviceMetrics?.batteryLevel && ( {!!node.deviceMetrics?.batteryLevel && (
<div <BatteryStatus deviceMetrics={node.deviceMetrics} />
className="flex items-center gap-1 mt-0.5 text-gray-500"
title={`${node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
} volts`}
>
{node.deviceMetrics?.batteryLevel > 100
? <BatteryChargingIcon size={22} className="text-gray-600" />
: node.deviceMetrics?.batteryLevel > 80
? <BatteryFullIcon size={22} className="text-green-500" />
: node.deviceMetrics?.batteryLevel > 20
? <BatteryMediumIcon size={22} className="text-yellow-400" />
: <BatteryLowIcon size={22} className="text-red-500" />}
<Subtle aria-label="Battery">
{node.deviceMetrics?.batteryLevel > 100
? "Charging"
: `${node.deviceMetrics?.batteryLevel}%`}
</Subtle>
</div>
)} )}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">

14
src/components/Sidebar.tsx

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { SidebarButton } from "./UI/Sidebar/sidebarButton.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import type { Page } from "@core/stores/deviceStore.ts"; import type { Page } from "@core/stores/deviceStore.ts";
@ -8,7 +8,6 @@ import { Spinner } from "@components/UI/Spinner.tsx";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { import {
BatteryMediumIcon,
CircleChevronLeft, CircleChevronLeft,
CpuIcon, CpuIcon,
LayersIcon, LayersIcon,
@ -25,6 +24,7 @@ import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx"; import { useSidebar } from "@core/stores/sidebarStore.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
export interface SidebarProps { export interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -64,13 +64,6 @@ const CollapseToggleButton = () => {
); );
} }
const getBatteryStatus = (level: number | undefined): string => {
if (level === undefined) return "UNK";
if (level > 100) return "Charging";
return `${level}%`;
};
export const Sidebar = ({ children }: SidebarProps) => { export const Sidebar = ({ children }: SidebarProps) => {
const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice(); const { hardware, getNode, getNodesLength, metadata, activePage, setActivePage, setDialogOpen } = useDevice();
const { setCommandPaletteOpen } = useAppStore(); const { setCommandPaletteOpen } = useAppStore();
@ -207,8 +200,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
)} )}
> >
<div className="inline-flex gap-2"> <div className="inline-flex gap-2">
<BatteryMediumIcon size={22} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> <BatteryStatus deviceMetrics={myNode.deviceMetrics} />
<Subtle>{getBatteryStatus(myNode.deviceMetrics?.batteryLevel)}</Subtle>
</div> </div>
<div className="inline-flex gap-2"> <div className="inline-flex gap-2">
<ZapIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> <ZapIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />

2
src/components/UI/Dialog.tsx

@ -44,7 +44,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed z-50 grid w-full bg-slate-100 dark:bg-slate-800 dark:text-slate-200 text-slate-900 max-w-[512px] max-h-[100vh] overflow-y-auto scale-100 gap-4 p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0", "fixed z-50 grid w-full bg-white dark:bg-slate-800 dark:text-slate-200 text-slate-900 max-w-[512px] max-h-[100vh] overflow-y-auto scale-100 gap-4 p-6 opacity-100 animate-in fade-in-90 slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0",
className, className,
)} )}
{...props} {...props}

15
src/components/UI/Sidebar/sidebarButton.tsx

@ -12,6 +12,7 @@ export interface SidebarButtonProps {
children?: React.ReactNode; children?: React.ReactNode;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
preventCollapse?: boolean;
} }
export const SidebarButton = ({ export const SidebarButton = ({
@ -22,8 +23,10 @@ export const SidebarButton = ({
children, children,
onClick, onClick,
disabled = false, disabled = false,
preventCollapse = false,
}: SidebarButtonProps) => { }: SidebarButtonProps) => {
const { isCollapsed } = useSidebar(); const { isCollapsed: isSidebarCollapsed } = useSidebar();
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
return ( return (
<Button <Button
@ -32,7 +35,7 @@ export const SidebarButton = ({
size="sm" size="sm"
className={cn( className={cn(
"flex w-full items-center text-wrap", "flex w-full items-center text-wrap",
isCollapsed isButtonCollapsed
? 'justify-center gap-0 px-2 h-9' ? 'justify-center gap-0 px-2 h-9'
: 'justify-start gap-2 min-h-9' : 'justify-start gap-2 min-h-9'
)} )}
@ -40,7 +43,7 @@ export const SidebarButton = ({
> >
{Icon && ( {Icon && (
<Icon <Icon
size={isCollapsed ? 20 : 18} size={isButtonCollapsed ? 20 : 18}
className="flex-shrink-0" className="flex-shrink-0"
/> />
)} )}
@ -53,7 +56,7 @@ export const SidebarButton = ({
'min-w-0', 'min-w-0',
'px-1', 'px-1',
'transition-all duration-300 ease-in-out', 'transition-all duration-300 ease-in-out',
isCollapsed isButtonCollapsed
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden' ? 'opacity-0 max-w-0 invisible w-0 overflow-hidden'
: 'opacity-100 max-w-full visible flex-1 whitespace-normal' : 'opacity-100 max-w-full visible flex-1 whitespace-normal'
)} )}
@ -61,13 +64,13 @@ export const SidebarButton = ({
{label} {label}
</span> </span>
{!isCollapsed && !active && count && count > 0 && ( {!isButtonCollapsed && !active && count && count > 0 && (
<div <div
className={cn( className={cn(
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600", "ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600",
"flex-shrink-0", "flex-shrink-0",
"transition-opacity duration-300 ease-in-out", "transition-opacity duration-300 ease-in-out",
isCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible' isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
)} )}
> >
{count} {count}

65
src/components/generic/Table/index.tsx

@ -17,11 +17,11 @@ export interface Heading {
* @returns number of hopsAway or `0` if hopsAway is 'Direct' * @returns number of hopsAway or `0` if hopsAway is 'Direct'
*/ */
function numericHops(hopsAway: string): number { function numericHops(hopsAway: string): number {
if(hopsAway.match(/direct/i)){ if (hopsAway.match(/direct/i)) {
return 0; return 0;
} }
if ( hopsAway.match(/\d+\s+hop/gi) ) { if (hopsAway.match(/\d+\s+hop/gi)) {
return Number( hopsAway.match(/(\d+)\s+hop/i)?.[1] ); return Number(hopsAway.match(/(\d+)\s+hop/i)?.[1]);
} }
return Number.MAX_SAFE_INTEGER; return Number.MAX_SAFE_INTEGER;
} }
@ -46,7 +46,6 @@ export const Table = ({ headings, rows }: TableProps) => {
const aValue = a[columnIndex].props.children; const aValue = a[columnIndex].props.children;
const bValue = b[columnIndex].props.children; const bValue = b[columnIndex].props.children;
// Custom comparison for 'Last Heard' column
if (sortColumn === "Last Heard") { if (sortColumn === "Last Heard") {
const aTimestamp = aValue.props.timestamp ?? 0; const aTimestamp = aValue.props.timestamp ?? 0;
const bTimestamp = bValue.props.timestamp ?? 0; const bTimestamp = bValue.props.timestamp ?? 0;
@ -60,11 +59,10 @@ export const Table = ({ headings, rows }: TableProps) => {
return 0; return 0;
} }
// Custom comparison for 'Connection' column
if (sortColumn === "Connection") { if (sortColumn === "Connection") {
const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue); const aNumHops = numericHops(aValue instanceof Array ? aValue[0] : aValue);
const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue); const bNumHops = numericHops(bValue instanceof Array ? bValue[0] : bValue);
if (aNumHops < bNumHops) { if (aNumHops < bNumHops) {
return sortOrder === "asc" ? -1 : 1; return sortOrder === "asc" ? -1 : 1;
} }
@ -74,7 +72,6 @@ export const Table = ({ headings, rows }: TableProps) => {
return 0; return 0;
} }
// Default comparison for other columns
if (aValue < bValue) { if (aValue < bValue) {
return sortOrder === "asc" ? -1 : 1; return sortOrder === "asc" ? -1 : 1;
} }
@ -86,17 +83,16 @@ export const Table = ({ headings, rows }: TableProps) => {
return ( return (
<table className="min-w-full"> <table className="min-w-full">
<thead className="bg-backgound-primary text-sm font-semibold text-text-primary"> <thead className="text-xs font-semibold">
<tr> <tr>
{headings.map((heading) => ( {headings.map((heading) => (
<th <th
key={heading.title} key={heading.title}
scope="col" scope="col"
className={`py-2 pr-3 text-left ${ className={`py-2 pr-3 text-left ${heading.sortable
heading.sortable ? "cursor-pointer hover:brightness-hover active:brightness-press"
? "cursor-pointer hover:brightness-hover active:brightness-press" : ""
: "" }`}
}`}
onClick={() => heading.sortable && headingSort(heading.title)} onClick={() => heading.sortable && headingSort(heading.title)}
onKeyUp={() => heading.sortable && headingSort(heading.title)} onKeyUp={() => heading.sortable && headingSort(heading.title)}
> >
@ -111,28 +107,31 @@ export const Table = ({ headings, rows }: TableProps) => {
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="max-w-fit">
{sortedRows.map((row, index) => ( {sortedRows.map((row, index) => {
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed. // biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
<tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}> return (<tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}>
{row.map((item, index) => ( {row.map((item, index) => {
index === 0 ? console.log(item);
<th
key={item.key ?? index} return (index === 0 ?
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2" <th
scope="row" key={item.key ?? index}
> className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
{item} scope="row"
</th> : >
<td {item}
key={item.key ?? index} </th> :
className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2" <td
> key={item.key ?? index}
{item} className="whitespace-nowrap py-2 text-sm text-text-secondary first:pl-2"
</td> >
))} {item}
</td>)
})}
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
); );

2
src/pages/Config/index.tsx

@ -3,7 +3,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx"; import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { SidebarButton } from "../../components/UI/Sidebar/sidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx"; import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx"; import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";

3
src/pages/Messages.tsx

@ -3,7 +3,7 @@ import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx"; import { SidebarButton } from "../components/UI/Sidebar/sidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, Types } from "@meshtastic/core"; import { Protobuf, Types } from "@meshtastic/core";
@ -121,6 +121,7 @@ export const MessagesPage = () => {
{filteredNodes()?.map((node) => ( {filteredNodes()?.map((node) => (
<SidebarButton <SidebarButton
key={node.num} key={node.num}
preventCollapse={true}
label={node.user?.longName ?? `UNK`} label={node.user?.longName ?? `UNK`}
count={node.unreadCount > 0 ? node.unreadCount : undefined} count={node.unreadCount > 0 ? node.unreadCount : undefined}
active={activeChat === node.num && chatType === MessageType.Direct} active={activeChat === node.num && chatType === MessageType.Direct}

3
src/pages/Nodes.tsx

@ -1,7 +1,6 @@
import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx"; import { LocationResponseDialog } from "@app/components/Dialog/LocationResponseDialog.tsx";
import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog.tsx"; import { NodeOptionsDialog } from "@app/components/Dialog/NodeOptionsDialog.tsx";
import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx"; import { TracerouteResponseDialog } from "@app/components/Dialog/TracerouteResponseDialog.tsx";
import Footer from "@app/components/UI/Footer.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { Mono } from "@components/generic/Mono.tsx"; import { Mono } from "@components/generic/Mono.tsx";
@ -115,7 +114,7 @@ const NodesPage = (): JSX.Element => {
> >
{node.user?.longName ?? numberToHexUnpadded(node.num)} {node.user?.longName ?? numberToHexUnpadded(node.num)}
</h1>, </h1>,
<Mono key="hops"> <Mono key="hops" className="w-16">
{node.lastHeard !== 0 {node.lastHeard !== 0
? node.viaMqtt === false && node.hopsAway === 0 ? node.viaMqtt === false && node.hopsAway === 0
? "Direct" ? "Direct"

Loading…
Cancel
Save