From 1c5254cb31026306a8a752764d37245a4aa3e020 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 19 May 2026 14:21:14 +0200 Subject: [PATCH] update profile ui --- client/ui/frontend/src/components/Avatar.tsx | 12 +- .../frontend/src/components/DropdownMenu.tsx | 233 ++++++++++++++++++ .../ui/frontend/src/components/IconButton.tsx | 51 ++-- .../frontend/src/components/ProfileAvatar.tsx | 70 ++++++ .../src/components/ProfileDropdown.tsx | 180 ++++++++++++++ .../frontend/src/i18n/locales/en/common.json | 5 + client/ui/frontend/src/layouts/AppLayout.tsx | 12 +- client/ui/frontend/src/layouts/Header.tsx | 76 ++---- client/ui/frontend/src/layouts/Main.tsx | 20 +- client/ui/main.go | 9 +- 10 files changed, 541 insertions(+), 127 deletions(-) create mode 100644 client/ui/frontend/src/components/DropdownMenu.tsx create mode 100644 client/ui/frontend/src/components/ProfileAvatar.tsx create mode 100644 client/ui/frontend/src/components/ProfileDropdown.tsx diff --git a/client/ui/frontend/src/components/Avatar.tsx b/client/ui/frontend/src/components/Avatar.tsx index 0bc04293d..5314db092 100644 --- a/client/ui/frontend/src/components/Avatar.tsx +++ b/client/ui/frontend/src/components/Avatar.tsx @@ -19,13 +19,19 @@ export const Avatar = forwardRef(function Avatar( ref={ref} type={type} className={cn( - "flex items-center justify-center rounded-full bg-nb-gray-900", - "text-xs font-semibold cursor-default outline-none", + "inline-grid place-items-center rounded-full bg-nb-gray-850 p-0 text-center", + "text-[0.9rem] font-semibold cursor-default outline-none", "transition-colors duration-150 hover:bg-nb-gray-850", "data-[state=open]:bg-nb-gray-850", className, )} - style={{ width: size, height: size, color }} + style={{ + width: size, + height: size, + color, + lineHeight: 0, + letterSpacing: 0, + }} {...props} > {initial} diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx new file mode 100644 index 000000000..53b712664 --- /dev/null +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -0,0 +1,233 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { cva } from "class-variance-authority"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; +import { cn } from "@/lib/cn"; + +const DropdownMenu = DropdownMenuPrimitive.Root; +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +const DropdownMenuGroup = DropdownMenuPrimitive.Group; +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +const DropdownMenuSub = DropdownMenuPrimitive.Sub; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const menuItemVariants = cva("", { + variants: { + variant: { + default: + "text-nb-gray-200 focus:bg-nb-gray-900 focus:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50", + danger: "text-red-500 focus:bg-red-900/20 focus:text-red-500", + }, + }, + defaultVariants: { variant: "default" }, +}); + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "danger"; + } +>(({ className, inset, children, variant, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: "default" | "danger"; + href?: string; + target?: string; + rel?: string; + } +>(({ className, inset, variant, onClick, href, target, rel, children, ...props }, ref) => ( + { + if (href) return; + e.preventDefault(); + e.stopPropagation(); + onClick?.(e); + }} + {...props} + > + {href ? ( + + {children} + + ) : ( + children + )} + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; diff --git a/client/ui/frontend/src/components/IconButton.tsx b/client/ui/frontend/src/components/IconButton.tsx index efe000abf..1153307ac 100644 --- a/client/ui/frontend/src/components/IconButton.tsx +++ b/client/ui/frontend/src/components/IconButton.tsx @@ -9,33 +9,24 @@ type Props = HTMLMotionProps<"button"> & { iconClassName?: string; }; -export const IconButton = forwardRef( - function IconButton( - { - icon: Icon, - iconSize = 18, - iconClassName, - className, - type = "button", - ...props - }, - ref, - ) { - return ( - - - - ); - }, -); +export const IconButton = forwardRef(function IconButton( + { icon: Icon, iconSize = 17, iconClassName, className, type = "button", ...props }, + ref, +) { + return ( + + + + ); +}); diff --git a/client/ui/frontend/src/components/ProfileAvatar.tsx b/client/ui/frontend/src/components/ProfileAvatar.tsx new file mode 100644 index 000000000..a4baf4e93 --- /dev/null +++ b/client/ui/frontend/src/components/ProfileAvatar.tsx @@ -0,0 +1,70 @@ +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { + Briefcase, + Building2, + Gamepad2, + GraduationCap, + House, + Server, + ServerCog, + SquareCode, + TestTube, + UserCircle, + UserPlus, + Users, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/cn"; + +const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [ + [/\b(default|user|me|personal)\b/i, UserCircle], + [/\b(work|business|office|company|corp|corporate)\b/i, Briefcase], + [/\b(home|house|private)\b/i, House], + [/\b(dev|development|developer|code|coding|engineering)\b/i, SquareCode], + [/\b(local|localhost|loopback)\b/i, SquareCode], + [/\b(test|testing|staging|qa|stage)\b/i, TestTube], + [/\b(prod|production|live)\b/i, Server], + [/\b(selfhosted|self-hosted|on-prem|onprem)\b/i, ServerCog], + [/\b(school|university|edu|study|student)\b/i, GraduationCap], + [/\b(client|customer)\b/i, Building2], + [/\b(family)\b/i, Users], + [/\b(gaming|game)\b/i, Gamepad2], + [/\b(guest)\b/i, UserPlus], +]; + +export const pickProfileIcon = (name: string | undefined): LucideIcon | null => { + if (!name) return null; + for (const [pattern, Icon] of ICON_MAP) { + if (pattern.test(name)) return Icon; + } + return null; +}; + +type Props = ButtonHTMLAttributes & { + name?: string; + size?: number; +}; + +export const ProfileAvatar = forwardRef(function ProfileAvatar( + { name = "", size = 28, className, type = "button", ...props }, + ref, +) { + const Icon = pickProfileIcon(name) ?? UserCircle; + return ( + + ); +}); diff --git a/client/ui/frontend/src/components/ProfileDropdown.tsx b/client/ui/frontend/src/components/ProfileDropdown.tsx new file mode 100644 index 000000000..decfd1a7c --- /dev/null +++ b/client/ui/frontend/src/components/ProfileDropdown.tsx @@ -0,0 +1,180 @@ +import { forwardRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Dialogs } from "@wailsio/runtime"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Check, ChevronDown, PlusCircle, Settings2, UserCircle } from "lucide-react"; +import { pickProfileIcon } from "@/components/ProfileAvatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/DropdownMenu"; +import { NewProfileDialog } from "@/components/NewProfileDialog"; +import { useProfile } from "@/modules/profile/ProfileContext"; +import { cn } from "@/lib/cn"; + +type ProfileDropdownProps = { + onManageProfiles?: () => void; +}; + +export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { + const { t } = useTranslation(); + const { activeProfile, profiles, addProfile, switchProfile } = useProfile(); + const [open, setOpen] = useState(false); + const [newProfileOpen, setNewProfileOpen] = useState(false); + const [busy, setBusy] = useState(false); + + const sortedProfiles = [...profiles].sort((a, b) => + a.name.localeCompare(b.name), + ); + + const guarded = async (title: string, fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (e) { + await Dialogs.Error({ + Title: title, + Message: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + const handleSelect = (name: string) => { + setOpen(false); + if (name === activeProfile) return; + void guarded(t("profile.error.switchTitle"), () => switchProfile(name)); + }; + + const handleAdd = () => { + setOpen(false); + setNewProfileOpen(true); + }; + + const handleManage = () => { + setOpen(false); + onManageProfiles?.(); + }; + + const handleCreateProfile = async (name: string) => { + try { + await addProfile(name); + } catch (e) { + await Dialogs.Error({ + Title: t("profile.error.createTitle"), + Message: e instanceof Error ? e.message : String(e), + }); + } + }; + + const displayName = activeProfile || t("profile.selector.loading"); + + return ( + <> + + + + + + {sortedProfiles.length > 0 && ( + <> + + + {sortedProfiles.map((profile) => { + const isActive = profile.name === activeProfile; + const Icon = pickProfileIcon(profile.name) ?? UserCircle; + return ( + handleSelect(profile.name)} + > +
+ + + {profile.name} + + {isActive && ( + + )} +
+
+ ); + })} +
+ + + +
+ + + )} + + +
+ + {t("profile.dropdown.addProfile")} +
+
+ +
+ + {t("profile.dropdown.manageProfiles")} +
+
+
+
+ + + ); +}; + +type ProfileTriggerButtonProps = React.ButtonHTMLAttributes & { + name: string; +}; + +const ProfileTriggerButton = forwardRef( + function ProfileTriggerButton({ name, className, ...props }, ref) { + const Icon = pickProfileIcon(name) ?? UserCircle; + return ( + + ); + }, +); diff --git a/client/ui/frontend/src/i18n/locales/en/common.json b/client/ui/frontend/src/i18n/locales/en/common.json index e464a11b2..a14a13304 100644 --- a/client/ui/frontend/src/i18n/locales/en/common.json +++ b/client/ui/frontend/src/i18n/locales/en/common.json @@ -91,6 +91,11 @@ "profile.error.createTitle": "Create Profile Failed", "profile.error.loadTitle": "Load Profiles Failed", + "profile.dropdown.activeProfile": "Active profile", + "profile.dropdown.addProfile": "Add Profile", + "profile.dropdown.manageProfiles": "Manage Profiles", + "profile.dropdown.settings": "Settings", + "settings.error.loadTitle": "Load Settings Failed", "settings.error.saveTitle": "Save Settings Failed", "settings.error.debugBundleTitle": "Debug Bundle Failed", diff --git a/client/ui/frontend/src/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx index 0adb39169..df8c571c0 100644 --- a/client/ui/frontend/src/layouts/AppLayout.tsx +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { Outlet } from "react-router-dom"; import { Header } from "@/layouts/Header.tsx"; import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx"; @@ -6,22 +5,15 @@ import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx"; import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx"; import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx"; -// The wide-panel toggle lives in plain React state here so every launch -// starts in the small layout — no localStorage, no cross-machine drift. -// Header drives the toggle; Main reads it via Outlet context to decide -// whether to mount the right-side panel. -export type MainOutletContext = { expanded: boolean }; - export const AppLayout = () => { - const [expanded, setExpanded] = useState(false); return (
-
- +
+ diff --git a/client/ui/frontend/src/layouts/Header.tsx b/client/ui/frontend/src/layouts/Header.tsx index 209bf39ad..3623a6246 100644 --- a/client/ui/frontend/src/layouts/Header.tsx +++ b/client/ui/frontend/src/layouts/Header.tsx @@ -1,54 +1,10 @@ -import { useEffect, useRef } from "react"; -import { PanelRightCloseIcon, PanelRightOpenIcon, SettingsIcon } from "lucide-react"; -import { Window } from "@wailsio/runtime"; +import { SettingsIcon } from "lucide-react"; import { WindowManager } from "@bindings/services"; -import { ProfileSelector } from "@/components/ProfileSelector.tsx"; -import { IconButton } from "@/components/IconButton.tsx"; +import { IconButton } from "@/components/IconButton"; +import { ProfileDropdown } from "@/components/ProfileDropdown"; import { cn } from "@/lib/cn"; -const WINDOW_SMALL_WIDTH = 380; -const WINDOW_BIG_WIDTH = 925; -const WINDOW_HEIGHT = 615; -const EXPANDED_THRESHOLD = 500; - -type HeaderProps = { - expanded: boolean; - setExpanded: (next: boolean) => void; -}; - -export const Header = ({ expanded, setExpanded }: HeaderProps) => { - const didInitialResize = useRef(false); - - useEffect(() => { - if (didInitialResize.current) return; - didInitialResize.current = true; - const w = expanded ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH; - void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!didInitialResize.current) return; - const w = expanded ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH; - void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {}); - }, [expanded]); - - useEffect(() => { - const onResize = () => { - const isWide = window.innerWidth >= EXPANDED_THRESHOLD; - if (isWide !== expanded) setExpanded(isWide); - }; - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); - }, [expanded, setExpanded]); - - const togglePanel = () => { - const next = !expanded; - setExpanded(next); - const w = next ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH; - void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {}); - }; - +export const Header = () => { const openSettings = () => { void WindowManager.OpenSettings().catch(() => {}); }; @@ -56,19 +12,23 @@ export const Header = ({ expanded, setExpanded }: HeaderProps) => { return (
-
- +
+
+ +
+
+
- - -
); }; diff --git a/client/ui/frontend/src/layouts/Main.tsx b/client/ui/frontend/src/layouts/Main.tsx index acb5a97dd..b6f31829c 100644 --- a/client/ui/frontend/src/layouts/Main.tsx +++ b/client/ui/frontend/src/layouts/Main.tsx @@ -1,29 +1,11 @@ -import { useOutletContext } from "react-router-dom"; import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx"; -import { MainRightSide } from "@/layouts/MainRightSide.tsx"; -import { Navigation } from "@/layouts/Navigation.tsx"; -import { Peers } from "@/modules/peers/Peers.tsx"; -import type { MainOutletContext } from "@/layouts/AppLayout.tsx"; -import { cn } from "@/lib/cn"; export const Main = () => { - const { expanded } = useOutletContext(); return (
-
+
-
- {expanded && ( - - - - )}
); }; diff --git a/client/ui/main.go b/client/ui/main.go index 766b24705..9ba2c429a 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -167,15 +167,10 @@ func main() { app.RegisterService(application.NewService(services.NewI18n(bundle))) app.RegisterService(application.NewService(services.NewPreferences(prefStore))) - // Initial size matches AppearanceContext's default `expanded: false` - // (small / simple view). When the user has previously expanded the - // window, Header.tsx's mount effect resizes back up to 925 via - // Window.SetSize — that's a one-shot grow rather than a shrink, which - // reads better than starting wide and snapping narrow on every launch. window := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "NetBird", - Width: 380, - Height: 615, + Width: 310, + Height: 420, Hidden: true, BackgroundColour: application.NewRGB(24, 26, 29), URL: "/",