diff --git a/packages/web/package.json b/packages/web/package.json index 0150238b..a0336b5b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -35,7 +35,9 @@ "@meshtastic/transport-web-serial": "workspace:*", "@noble/curves": "^1.9.2", "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -81,7 +83,7 @@ "react-map-gl": "8.0.4", "react-qrcode-logo": "^3.0.0", "rfc4648": "^1.5.4", - "vite": "^7.1.5", + "vite": "^7.1.6", "vite-plugin-html": "^3.2.2", "vite-plugin-pwa": "^1.0.3", "zod": "^4.0.5", @@ -99,13 +101,13 @@ "@types/react-dom": "^19.1.6", "@types/serviceworker": "^0.0.142", "@types/w3c-web-serial": "^1.0.8", - "@vitejs/plugin-react": "^4.6.0", + "@vitejs/plugin-react": "^5.0.3", "autoprefixer": "^10.4.21", "gzipper": "^8.2.1", "happy-dom": "^18.0.1", "simple-git-hooks": "^2.13.0", "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.11", + "tailwindcss": "^4.1.13", "tailwindcss-animate": "^1.0.7", "tar": "^7.4.3", "testing-library": "^0.0.2", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 800dd3a7..bbc63f13 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -6,18 +6,27 @@ import { KeyBackupReminder } from "@components/KeyBackupReminder.tsx"; import { Toaster } from "@components/Toaster.tsx"; import { ErrorPage } from "@components/UI/ErrorPage.tsx"; import Footer from "@components/UI/Footer.tsx"; -import { SidebarProvider, useAppStore, useDeviceStore } from "@core/stores"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@components/UI/sidebar.tsx"; +import { useAppStore, useDeviceStore, useHeaderStore } from "@core/stores"; import { Dashboard } from "@pages/Dashboard/index.tsx"; import { Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { ErrorBoundary } from "react-error-boundary"; import { MapProvider } from "react-map-gl/maplibre"; +import { AppSidebar } from "./components/AppSidebar/app-sidebar.tsx"; +import { HeaderActions } from "./components/Header/index.ts"; import { ThemeProvider } from "./components/theme-provider.tsx"; +import { NodeSidebar } from "./components/NodeSidebar/node-sidebar.tsx"; export function App() { const { getDevice } = useDeviceStore(); const { selectedDeviceId, setConnectDialogOpen, connectDialogOpen } = useAppStore(); + const { title, actions } = useHeaderStore(); const device = getDevice(selectedDeviceId); @@ -26,36 +35,56 @@ export function App() { { - setConnectDialogOpen(open); - }} + onOpenChange={setConnectDialogOpen} /> + -
- -
- {device ? ( -
- - - - +
+ {device ? ( + + + + + + + +
+ + {/* Breadcrumbs slot (optional / future) */} + + +
+ {title || "—"} +
+
+ +
+ + +
- -
- ) : ( - <> - -
- - )} + + + +
+ + + + ) : ( +
+
+ +
+
- + )}
diff --git a/packages/web/src/components/AppSidebar/app-sidebar.tsx b/packages/web/src/components/AppSidebar/app-sidebar.tsx new file mode 100644 index 00000000..c92b84e6 --- /dev/null +++ b/packages/web/src/components/AppSidebar/app-sidebar.tsx @@ -0,0 +1,406 @@ +// "use client"; + +// import { Button } from "@components/UI/button.tsx"; +// import { +// Collapsible, +// CollapsibleContent, +// CollapsibleTrigger, +// } from "@components/UI/collapsible.tsx"; +// import { Input } from "@components/UI/input.tsx"; +// import { +// Sidebar, +// SidebarContent, +// SidebarFooter, +// SidebarGroup, +// SidebarGroupContent, +// SidebarGroupLabel, +// SidebarInset, +// SidebarMenu, +// SidebarMenuBadge, +// SidebarMenuButton, +// SidebarMenuItem, +// SidebarProvider, +// SidebarSeparator, +// } from "@components/UI/sidebar.tsx"; +// import { Outlet } from "@tanstack/react-router"; +// import { +// ChevronDown, +// Hash, +// Layers, +// MapIcon, +// MessageCircle, +// Radio, +// Send, +// Settings, +// Users, +// } from "lucide-react"; +// import { SidebarHeader } from "./sidebar-header.tsx"; +// import { MeshAvatar } from "../MeshAvatar.tsx"; + +// export function AppSidebar() { +// return ( +// +// +// +// +// +// Navigation +// +// +// +// +// +// +// Messages +// +// +// +// +// +// Map +// +// +// +// +// +// +//
+// +// Config +//
+// +//
+//
+// +// +// +// Radio Config +// +// +// +// Module Config +// +// +// +// Channel Config +// +// +//
+//
+// +// +// +// Nodes +// 203 +// +// +//
+//
+//
+ +// + +// +// +// Channels +// +// +// +// +// +//
+// Primary +// +// +// +// +// + +//
+//
Plugged in
+//
Version: 4.3b.V
+//
Firmware: 2.7.6.834c365
+//
+// +//
+//
+// +//
+//
+// +//
+//
+// +//
+//
+// + +// +// +// +// +// +// +// +// +// +// + +// ); +// } + +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 ( + + {/* Header */} + + + + + {/* Optional: a thin rail you can grab/hover on (like ChatGPT) */} + + + + + {/* -------- Primary Nav -------- */} + + + Navigation + + + + + {/* Messages */} + + + + + Messages + + + + + {/* Map */} + + + + + Map + + + + + {/* Config */} + + + + + +
+ + Config +
+ {/* Keep chevron visible in expanded mode; hidden in rail */} + +
+
+
+ + + + + Radio Config + + + + Module Config + + + + Channel Config + + +
+
+ + {/* Nodes */} + + + + + Nodes + {/* Keep badge, it’ll sit beside the icon in rail mode */} + 203 + + + +
+
+
+ + + + {/* -------- Channels -------- */} + + + Channels + + + + + + + +
+ Primary + + + + + + + + {/* -------- Footer Info (auto-hides text when collapsed) -------- */} +
+
Plugged in
+
Version: 4.3b.V
+
Firmware: 2.7.6.834c365
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + Account + + + + + + + ); +} + +/** Shows a tooltip only when the sidebar is in collapsed (icon) mode. */ +function TooltipWrapper({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + + {/* keep button focus/hover behavior intact */} +
{children}
+
+ {/* Hidden when expanded; visible when collapsed */} + + {label} + +
+ ); +} diff --git a/packages/web/src/components/AppSidebar/sidebar-footer.tsx b/packages/web/src/components/AppSidebar/sidebar-footer.tsx new file mode 100644 index 00000000..2099c697 --- /dev/null +++ b/packages/web/src/components/AppSidebar/sidebar-footer.tsx @@ -0,0 +1,47 @@ +import { + SidebarFooter as ShadcnSidebarFooter, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@components/UI/sidebar.tsx"; +import { ChevronUp } from "lucide-react"; +import { MeshAvatar } from "../MeshAvatar.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../UI/DropdownMenu.tsx"; + +export function SidebarFooter() { + return ( + + + + + + + Username + + + + + + Account + + + Billing + + + Sign out + + + + + + + ); +} diff --git a/packages/web/src/components/AppSidebar/sidebar-header.tsx b/packages/web/src/components/AppSidebar/sidebar-header.tsx new file mode 100644 index 00000000..3e2ace6b --- /dev/null +++ b/packages/web/src/components/AppSidebar/sidebar-header.tsx @@ -0,0 +1,33 @@ +import { + SidebarTrigger, + SidebarHeader as UISidebarHeader, +} from "@components/UI/sidebar.tsx"; +import { Heading } from "../UI/Typography/Heading.tsx"; + +export function SidebarHeader() { + return ( + + {/* Logo container - always visible */} +
+ Meshtastic logo +
+ + + Meshtastic + + + +
+ ); +} diff --git a/packages/web/src/components/CommandPalette/index.tsx b/packages/web/src/components/CommandPalette/index.tsx index ba53c980..931fc622 100644 --- a/packages/web/src/components/CommandPalette/index.tsx +++ b/packages/web/src/components/CommandPalette/index.tsx @@ -1,4 +1,4 @@ -import { Avatar } from "@components/UI/Avatar.tsx"; +import { Avatar } from "@app/components/UI/avatar"; import { CommandDialog, CommandEmpty, @@ -41,6 +41,7 @@ import { } from "lucide-react"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { MeshAvatar } from "../MeshAvatar.tsx"; export interface Group { id: string; @@ -127,7 +128,7 @@ export const CommandPalette = () => { getNode(device.hardware.myNodeNum)?.user?.longName ?? t("unknown.shortName"), icon: ( - + {actions.map((action) => ( + + ))} +
+ ); +} diff --git a/packages/web/src/components/Header/index.ts b/packages/web/src/components/Header/index.ts new file mode 100644 index 00000000..f010c664 --- /dev/null +++ b/packages/web/src/components/Header/index.ts @@ -0,0 +1,2 @@ +export { default as HeaderActions } from "./HeaderActions.tsx"; +export { default as usePageHeader } from "./useHeaderActions.ts"; diff --git a/packages/web/src/components/Header/useHeaderActions.ts b/packages/web/src/components/Header/useHeaderActions.ts new file mode 100644 index 00000000..e724c8b4 --- /dev/null +++ b/packages/web/src/components/Header/useHeaderActions.ts @@ -0,0 +1,25 @@ +import type { ActionItem } from "@core/stores/"; +import { useHeaderStore } from "@core/stores/"; +import { useEffect } from "react"; + +/** + * Pages call this hook to publish their header title/actions into the App header. + * On unmount, we reset to avoid stale UI. + */ +export default function usePageHeader(opts: { + title?: string; + actions?: ActionItem[]; +}) { + const { setTitle, setActions, reset } = useHeaderStore(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: because we want to reset on unmount + useEffect(() => { + if (opts.title !== undefined) { + setTitle(opts.title); + } + if (opts.actions !== undefined) { + setActions(opts.actions); + } + return () => reset(); + }, [opts.title, JSON.stringify(opts.actions)]); +} diff --git a/packages/web/src/components/MeshAvatar.tsx b/packages/web/src/components/MeshAvatar.tsx new file mode 100644 index 00000000..2d3aded6 --- /dev/null +++ b/packages/web/src/components/MeshAvatar.tsx @@ -0,0 +1,107 @@ +import { + AvatarFallback, + Avatar as ShadcnAvatar, +} from "@app/components/UI/avatar"; +import { + Tooltip, + TooltipArrow, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@app/components/UI/tooltip.tsx"; +import { getColorFromText, isLightColor } from "@app/core/utils/color"; +import { cn } from "@core/utils/cn"; +import { LockKeyholeOpenIcon, StarIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface MeshAvatarProps { + text: string | number; + size?: "sm"; + className?: string; + showError?: boolean; + showFavorite?: boolean; +} + +export function MeshAvatar({ + text, + size = "sm", + className, + showError = false, + showFavorite = false, +}: MeshAvatarProps) { + const { t } = useTranslation(); + + const sizes = { + sm: "size-11 font-normal text-xs", + }; + + const safeText = text?.toString().toUpperCase(); + const bgColor = getColorFromText(safeText); + const isLight = isLightColor(bgColor); + const textColor = isLight ? "#000000" : "#FFFFFF"; + const initials = safeText?.slice(0, 4) ?? t("unknown.shortName"); + + return ( +
+ + + {initials} + + + + {/* Favorite badge */} + {showFavorite && ( + + + + + + {t("nodeDetail.favorite.label", { ns: "nodes" })} + + + + + )} + + {/* Error badge */} + {showError && ( + + + + + + {t("nodeDetail.error.label", { ns: "nodes" })} + + + + + )} +
+ ); +} diff --git a/packages/web/src/components/Map.tsx b/packages/web/src/components/MeshMap.tsx similarity index 100% rename from packages/web/src/components/Map.tsx rename to packages/web/src/components/MeshMap.tsx diff --git a/packages/web/src/components/NodeSidebar/node-sidebar.tsx b/packages/web/src/components/NodeSidebar/node-sidebar.tsx new file mode 100644 index 00000000..55018340 --- /dev/null +++ b/packages/web/src/components/NodeSidebar/node-sidebar.tsx @@ -0,0 +1,173 @@ +import { useDevice, useNodeDB } from "@app/core/stores"; +import { MeshAvatar } from "@components/MeshAvatar.tsx"; +import { Input } from "@components/UI/input.tsx"; +import { cn } from "@core/utils/cn.ts"; +import { Users } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarSeparator, +} from "../UI/sidebar.tsx"; + +type NodeUser = { + shortName?: string | null; + longName?: string | null; +}; + +export type NodeListItem = { + num: number; + user?: NodeUser | null; + isFavorite?: boolean; + unreadCount?: number; +}; + +type NavigateType = "direct" | "channel"; + +type NodeSidebarProps = { + /** List of nodes to render */ + nodes: NodeListItem[]; + + /** If the current view is a direct-message thread */ + isDirect?: boolean; + + /** Currently active node number (for highlighting) */ + activeNodeNum?: number | null; + + /** Called when a node row is clicked */ + onNavigate?: (type: NavigateType, id: string) => void; + + /** Clear unread count for a node */ + onResetUnread?: (nodeNum: number) => void; + + /** Whether the node currently has an error */ + hasNodeError?: (nodeNum: number) => boolean; + + /** Optional additional className for the outer Sidebar */ + className?: string; +}; + +/* --------------------------- Component --------------------------- */ + +export function NodeSidebar({ + // nodes, + // isDirect = true, + // activeNodeNum = null, + // onNavigate = () => {}, + // onResetUnread = () => {}, + // hasNodeError = () => false, + className, +}: NodeSidebarProps) { + const [searchTerm, setSearchTerm] = useState(""); + + const { t } = useTranslation(); + const { getNode, getMyNode, getNodes } = useNodeDB(); + const nodes = getNodes(); + + const filteredNodes = useMemo(() => { + const q = searchTerm.trim().toLowerCase(); + if (!q) { + return nodes; + } + return nodes.filter((n) => { + const shortName = n.user?.shortName?.toLowerCase() ?? ""; + const longName = n.user?.longName?.toLowerCase() ?? ""; + const id = String(n.num); + return shortName.includes(q) || longName.includes(q) || id.includes(q); + }); + }, [nodes, searchTerm]); + + return ( + + + + + + + {t("messages:nodes", { defaultValue: "Nodes" })} + + +
+ + {/** biome-ignore lint/correctness/useUniqueElementIds: */} + setSearchTerm(e.target.value)} + autoComplete="off" + /> +
+ + + + {filteredNodes.map((node) => { + const active = Boolean(isDirect && activeNodeNum === node.num); + const hasError = hasNodeError(node.num); + + return ( + + { + onNavigate("direct", node.num.toString()); + onResetUnread(node.num); + }} + aria-current={active ? "page" : undefined} + > +
+ + + {node.user?.longName ?? + t("messages:unknown.shortName", { + defaultValue: "Unknown", + })} + +
+ + {!!node.unreadCount && node.unreadCount > 0 && ( + + {node.unreadCount} + + )} +
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx b/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx index c5dade90..ec0ada1b 100644 --- a/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx +++ b/packages/web/src/components/PageComponents/Map/Markers/NodeMarker.tsx @@ -1,6 +1,7 @@ +import { MeshAvatar } from "@app/components/MeshAvatar"; +import { Avatar } from "@app/components/UI/avatar"; import { cn } from "@app/core/utils/cn"; import type { PxOffset } from "@components/PageComponents/Map/cluster.ts"; -import { Avatar } from "@components/UI/Avatar.tsx"; import { Tooltip, TooltipArrow, @@ -67,7 +68,7 @@ export const NodeMarker = memo(function NodeMarker({ style={style} onClick={(e) => onClick(id, { originalEvent: e.nativeEvent })} > - {
- +
{ @@ -113,7 +114,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
- {name} + {name} {hardwareType !== t("unset") && {hardwareType}} {!!node.deviceMetrics?.batteryLevel && ( diff --git a/packages/web/src/components/PageComponents/Messages/MessageInput.tsx b/packages/web/src/components/PageComponents/Messages/MessageInput.tsx index 398265e7..91c7d137 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageInput.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageInput.tsx @@ -53,9 +53,10 @@ export const MessageInput = ({ onSend, to, maxBytes }: MessageInputProps) => { return (
-
+
-
diff --git a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx index 663881c9..21dfb456 100644 --- a/packages/web/src/components/PageComponents/Messages/MessageItem.tsx +++ b/packages/web/src/components/PageComponents/Messages/MessageItem.tsx @@ -1,3 +1,5 @@ +import { MeshAvatar } from "@app/components/MeshAvatar"; +import { Avatar } from "@app/components/UI/avatar"; import { Tooltip, TooltipArrow, @@ -5,7 +7,6 @@ import { TooltipProvider, TooltipTrigger, } from "@app/components/UI/tooltip"; -import { Avatar } from "@components/UI/Avatar.tsx"; import { MessageState, useDevice, useNodeDB } from "@core/stores"; import type { Message } from "@core/stores/messageStore/types.ts"; import { cn } from "@core/utils/cn.ts"; @@ -157,7 +158,7 @@ export const MessageItem = ({ message }: MessageItemProps) => { return (
  • - { return (
    - {/* Left Sidebar */} - {leftBar && ( - - )} -
    - {/* Header */} -
    - {/* Header Content */} -
    - - {label} - -
    - {actions?.map((action) => { - return ( - - ); - })} -
    -
    -
    -
    {children}
    -
    - - {/* Right Sidebar */} - {rightBar && ( - - )}
    ); diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx deleted file mode 100644 index 6d4daced..00000000 --- a/packages/web/src/components/Sidebar.tsx +++ /dev/null @@ -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 ( - - ); -}; - -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 ( -
    - - -
    - {t("app.logo")} -

    - {t("app.title")} -

    -
    - - - {pages.map((link) => { - return ( - { - if (myNode !== undefined) { - navigate({ to: `/${link.page}` }); - } - }} - active={link.page === pathname} - disabled={myNode === undefined} - /> - ); - })} - - -
    - {children} -
    - -
    - {myNode === undefined ? ( -
    - - - {t("loading")} - -
    - ) : ( - 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, - }} - /> - )} -
    -
    - ); -}; diff --git a/packages/web/src/components/UI/Avatar.tsx b/packages/web/src/components/UI/Avatar.tsx index 7a258cf0..dfec04db 100644 --- a/packages/web/src/components/UI/Avatar.tsx +++ b/packages/web/src/components/UI/Avatar.tsx @@ -1,97 +1,50 @@ -import { - Tooltip, - TooltipArrow, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@app/components/UI/tooltip"; -import { getColorFromText, isLightColor } from "@app/core/utils/color"; -import { cn } from "@core/utils/cn.ts"; -import { LockKeyholeOpenIcon, StarIcon } from "lucide-react"; -import { useTranslation } from "react-i18next"; +import { cn } from "@app/lib/utils"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import type * as React from "react"; -interface AvatarProps { - text: string | number; - size?: "sm" | "lg"; - className?: string; - showError?: boolean; - showFavorite?: boolean; +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); } -export const Avatar = ({ - text, - size = "sm", - showError = false, - showFavorite = false, +function AvatarImage({ className, -}: AvatarProps) => { - const { t } = useTranslation(); - - const sizes = { - sm: "size-10 text-xs font-light", - lg: "size-16 text-lg", - }; - - const safeText = text?.toString().toUpperCase(); - const bgColor = getColorFromText(safeText); - const isLight = isLightColor(bgColor); - const textColor = isLight ? "#000000" : "#FFFFFF"; - const initials = safeText?.slice(0, 4) ?? t("unknown.shortName"); + ...props +}: React.ComponentProps) { + return ( + + ); +} +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { return ( -
    - {showFavorite ? ( - - - - - - {t("nodeDetail.favorite.label", { ns: "nodes" })} - - - - - ) : null} - {showError ? ( - - - - - - {t("nodeDetail.error.label", { ns: "nodes" })} - - - - - ) : null} -

    {initials}

    -
    + {...props} + /> ); -}; +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/packages/web/src/components/UI/Footer.tsx b/packages/web/src/components/UI/Footer.tsx index f4fe2ea7..61b4bc7b 100644 --- a/packages/web/src/components/UI/Footer.tsx +++ b/packages/web/src/components/UI/Footer.tsx @@ -1,9 +1,13 @@ import { cn } from "@core/utils/cn.ts"; -import React from "react"; +import * as React from "react"; import { Trans } from "react-i18next"; type FooterProps = { className?: string; + /** Show the "Ctrl+K / Cmd+K" hint */ + showHotkeyHint?: boolean; + /** Custom label for the hotkey hint */ + hotkeyLabel?: string; }; const Footer = ({ className, ...props }: FooterProps) => { @@ -15,53 +19,46 @@ const Footer = ({ className, ...props }: FooterProps) => { () => String(import.meta.env.VITE_COMMIT_HASH) ?.toUpperCase() - .slice(0, 7) || "unk", + .slice(0, 7) || "UNK", [], ); return ( -