diff --git a/client/ui/frontend/package.json b/client/ui/frontend/package.json index 68b744a1d..46ed4319b 100644 --- a/client/ui/frontend/package.json +++ b/client/ui/frontend/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@wailsio/runtime": "latest", "chroma-js": "^3.2.0", diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml index 1e17913ea..e0ba0c3c0 100644 --- a/client/ui/frontend/pnpm-lock.yaml +++ b/client/ui/frontend/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-visually-hidden': specifier: ^1.2.4 version: 1.2.4(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) @@ -1196,6 +1199,37 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false + /@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + 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 + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@18.3.1): resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1306,6 +1340,26 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + 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 + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==} peerDependencies: diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx new file mode 100644 index 000000000..c923667da --- /dev/null +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -0,0 +1,74 @@ +import { ReactNode, useRef, useState } from "react"; +import * as RTooltip from "@radix-ui/react-tooltip"; +import { cn } from "@/lib/cn"; + +type Props = { + content: ReactNode; + children: ReactNode; + side?: RTooltip.TooltipContentProps["side"]; + align?: RTooltip.TooltipContentProps["align"]; + delayDuration?: number; + sideOffset?: number; + interactive?: boolean; + keepOpenOnClick?: boolean; +}; + +export const Tooltip = ({ + content, + children, + side = "bottom", + align = "center", + delayDuration = 200, + sideOffset = 6, + interactive = false, + keepOpenOnClick = true, +}: Props) => { + const [open, setOpen] = useState(false); + const hoveringRef = useRef(false); + + const handleOpenChange = (next: boolean) => { + if (!next && keepOpenOnClick && hoveringRef.current) return; + setOpen(next); + }; + + return ( + + + { + hoveringRef.current = true; + }} + onPointerLeave={() => { + hoveringRef.current = false; + setOpen(false); + }} + > + {children} + + + e.preventDefault() + } + className={cn( + "z-50 select-none rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1", + "text-xs text-nb-gray-100 shadow-lg", + "data-[state=delayed-open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0", + !interactive && "pointer-events-none", + )} + > + {content} + + + + + ); +}; diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index f36fccaff..c9609cfc4 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -1,4 +1,4 @@ -import { ComponentType, forwardRef } from "react"; +import { ComponentType, ReactNode, forwardRef } from "react"; import * as Tabs from "@radix-ui/react-tabs"; import { LucideProps } from "lucide-react"; import { cn } from "@/lib/cn"; @@ -33,11 +33,12 @@ type TriggerProps = Tabs.TabsTriggerProps & { icon: ComponentType; title: string; iconSize?: number; + adornment?: ReactNode; }; const Trigger = forwardRef( function VerticalTabsTrigger( - { icon: Icon, title, iconSize = 16, className, ...props }, + { icon: Icon, title, iconSize = 16, adornment, className, ...props }, ref, ) { return ( @@ -67,6 +68,7 @@ const Trigger = forwardRef( > {title} + {adornment &&
{adornment}
} ); }, diff --git a/client/ui/frontend/src/layouts/Header.tsx b/client/ui/frontend/src/layouts/Header.tsx index 94f3eeb8f..1bdfd8924 100644 --- a/client/ui/frontend/src/layouts/Header.tsx +++ b/client/ui/frontend/src/layouts/Header.tsx @@ -1,13 +1,19 @@ import { useLocation, useNavigate } from "react-router-dom"; -import { SettingsIcon } from "lucide-react"; +import { ArrowUpCircleIcon, SettingsIcon } from "lucide-react"; import { ProfileSelector } from "@/components/ProfileSelector.tsx"; import { IconButton } from "@/components/IconButton.tsx"; +import { Tooltip } from "@/components/Tooltip.tsx"; +import { useStatus } from "@/hooks/useStatus"; import { cn } from "@/lib/cn"; export const Header = () => { const navigate = useNavigate(); const location = useLocation(); const isSettingsPage = location.pathname.startsWith("/settings"); + const { status } = useStatus(); + const updateAvailable = (status?.events ?? []).some((e) => + Boolean(e.metadata?.["new_version_available"]), + ); return (
{
+ {updateAvailable && ( + +
+ + + navigate("/settings", { state: { tab: "about" } }) + } + className={"absolute inset-0"} + /> +
+
+ )} navigate(isSettingsPage ? "/" : "/settings")} diff --git a/client/ui/frontend/src/modules/settings/Settings.tsx b/client/ui/frontend/src/modules/settings/Settings.tsx index 3825364de..841aebfa5 100644 --- a/client/ui/frontend/src/modules/settings/Settings.tsx +++ b/client/ui/frontend/src/modules/settings/Settings.tsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useLocation } from "react-router-dom"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { cn } from "@/lib/cn"; import { MainRightSide } from "@/layouts/MainRightSide.tsx"; @@ -14,7 +15,13 @@ import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshoot import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx"; export const Settings = () => { - const [active, setActive] = useState("general"); + const location = useLocation(); + const navState = location.state as { tab?: string } | null; + const [active, setActive] = useState(navState?.tab ?? "general"); + + useEffect(() => { + if (navState?.tab) setActive(navState.tab); + }, [navState?.tab, location.key]); return ( diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx index 269be8827..8e6253569 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigationTriggers.tsx @@ -1,5 +1,8 @@ +import { Tooltip } from "@/components/Tooltip.tsx"; import { VerticalTabs } from "@/components/VerticalTabs.tsx"; +import { useStatus } from "@/hooks/useStatus"; import { + ArrowUpCircleIcon, BoltIcon, InfoIcon, LifeBuoyIcon, @@ -10,6 +13,24 @@ import { } from "lucide-react"; export const SettingsNavigationTriggers = () => { + const { status } = useStatus(); + const updateAvailable = (status?.events ?? []).some((e) => + Boolean(e.metadata?.["new_version_available"]), + ); + + const aboutAdornment = updateAvailable ? ( + +
+ + +
+
+ ) : undefined; + return (
@@ -47,6 +68,7 @@ export const SettingsNavigationTriggers = () => { value={"about"} icon={InfoIcon} title={"About"} + adornment={aboutAdornment} />