From 8748f3810d6ebbc3d5b9937a4100242cc06ca4b9 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 19 May 2026 18:27:05 +0200 Subject: [PATCH] update profile ui --- client/ui/frontend/src/components/Dialog.tsx | 123 ++++------ ...wProfileDialog.tsx => NewProfileModal.tsx} | 24 +- .../frontend/src/components/ProfileAvatar.tsx | 33 +-- .../src/components/ProfileDropdown.tsx | 227 ++++++++++++------ .../src/components/ProfileSelector.tsx | 4 +- .../frontend/src/i18n/locales/en/common.json | 7 +- client/ui/main.go | 2 +- 7 files changed, 240 insertions(+), 180 deletions(-) rename client/ui/frontend/src/components/{NewProfileDialog.tsx => NewProfileModal.tsx} (72%) diff --git a/client/ui/frontend/src/components/Dialog.tsx b/client/ui/frontend/src/components/Dialog.tsx index 15f6e727d..1c8c8470e 100644 --- a/client/ui/frontend/src/components/Dialog.tsx +++ b/client/ui/frontend/src/components/Dialog.tsx @@ -1,9 +1,4 @@ -import { - forwardRef, - ComponentPropsWithoutRef, - ElementRef, - HTMLAttributes, -} from "react"; +import { forwardRef, ComponentPropsWithoutRef, ElementRef, HTMLAttributes } from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { X } from "lucide-react"; @@ -22,14 +17,13 @@ export const Overlay = forwardRef< ); @@ -40,60 +34,53 @@ type ContentProps = ComponentPropsWithoutRef & { maxWidthClass?: string; }; -export const Content = forwardRef< - ElementRef, - ContentProps ->(function DialogContent( - { - className, - children, - showClose = true, - maxWidthClass = "max-w-md", - ...props +export const Content = forwardRef, ContentProps>( + function DialogContent( + { className, children, showClose = true, maxWidthClass = "max-w-md", ...props }, + ref, + ) { + return ( + + + e.stopPropagation()} + {...props} + > + + Dialog + + {children} + {showClose && ( + + + + )} + + + + ); }, - ref, -) { - return ( - - - e.stopPropagation()} - {...props} - > - - Dialog - - {children} - {showClose && ( - - - - )} - - - - ); -}); +); export const Title = forwardRef< ElementRef, @@ -118,10 +105,7 @@ export const Description = forwardRef< return ( ); @@ -131,15 +115,12 @@ type FooterProps = HTMLAttributes & { separator?: boolean; }; -export const Footer = ({ - className, - separator = true, - ...props -}: FooterProps) => ( +export const Footer = ({ className, separator = true, ...props }: FooterProps) => (
*]:w-full sm:[&>*]:w-auto", "px-8 pt-6", className, )} diff --git a/client/ui/frontend/src/components/NewProfileDialog.tsx b/client/ui/frontend/src/components/NewProfileModal.tsx similarity index 72% rename from client/ui/frontend/src/components/NewProfileDialog.tsx rename to client/ui/frontend/src/components/NewProfileModal.tsx index 6fc7d0e9f..72aa0314c 100644 --- a/client/ui/frontend/src/components/NewProfileDialog.tsx +++ b/client/ui/frontend/src/components/NewProfileModal.tsx @@ -10,7 +10,7 @@ type Props = { onCreate: (name: string) => void; }; -export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => { +export const NewProfileModal = ({ open, onOpenChange, onCreate }: Props) => { const { t } = useTranslation(); const [name, setName] = useState(""); @@ -30,14 +30,11 @@ export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => { return ( - e.preventDefault()} - > + e.preventDefault()}>
-
+
{t("profile.dialog.title")} - + {t("profile.dialog.description")}
@@ -51,20 +48,15 @@ export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => { />
- - + diff --git a/client/ui/frontend/src/components/ProfileAvatar.tsx b/client/ui/frontend/src/components/ProfileAvatar.tsx index a4baf4e93..47531973f 100644 --- a/client/ui/frontend/src/components/ProfileAvatar.tsx +++ b/client/ui/frontend/src/components/ProfileAvatar.tsx @@ -1,11 +1,12 @@ import { ButtonHTMLAttributes, forwardRef } from "react"; import { + Beaker, Briefcase, - Building2, + Building, Gamepad2, GraduationCap, House, - Server, + Cloud, ServerCog, SquareCode, TestTube, @@ -17,19 +18,21 @@ import { 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], + [/(default|personal)/i, UserCircle], + [/(work|business|office|company|corp|corporate)/i, Briefcase], + [/(home|house|private)/i, House], + [/(dev|development|developer|code|coding|engineering)/i, SquareCode], + [/(local|localhost|loopback)/i, SquareCode], + [/(stage|staging)/i, Beaker], + [/(test|testing|qa)/i, TestTube], + [/(prod|production)/i, Cloud], + [/(live)/i, Cloud], + [/(selfhosted|self-hosted|on-prem|onprem)/i, ServerCog], + [/(school|university|edu|study|student)/i, GraduationCap], + [/(client|customer)/i, Building], + [/(family)/i, Users], + [/(gaming|game)/i, Gamepad2], + [/(guest)/i, UserPlus], ]; export const pickProfileIcon = (name: string | undefined): LucideIcon | null => { diff --git a/client/ui/frontend/src/components/ProfileDropdown.tsx b/client/ui/frontend/src/components/ProfileDropdown.tsx index decfd1a7c..fcf2b2268 100644 --- a/client/ui/frontend/src/components/ProfileDropdown.tsx +++ b/client/ui/frontend/src/components/ProfileDropdown.tsx @@ -1,17 +1,14 @@ -import { forwardRef, useState } from "react"; +import { forwardRef, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Dialogs } from "@wailsio/runtime"; +import * as Popover from "@radix-ui/react-popover"; import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { Command } from "cmdk"; 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 type { Profile } from "@bindings/services/models.js"; +import { NewProfileModal } from "@/components/NewProfileModal"; +import { Tooltip } from "@/components/Tooltip"; import { useProfile } from "@/modules/profile/ProfileContext"; import { cn } from "@/lib/cn"; @@ -19,6 +16,9 @@ type ProfileDropdownProps = { onManageProfiles?: () => void; }; +const ADD_VALUE = "__add_profile__"; +const MANAGE_VALUE = "__manage_profiles__"; + export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { const { t } = useTranslation(); const { activeProfile, profiles, addProfile, switchProfile } = useProfile(); @@ -26,9 +26,11 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { const [newProfileOpen, setNewProfileOpen] = useState(false); const [busy, setBusy] = useState(false); - const sortedProfiles = [...profiles].sort((a, b) => - a.name.localeCompare(b.name), - ); + const sortedProfiles = [...profiles].sort((a, b) => { + if (a.name === activeProfile) return -1; + if (b.name === activeProfile) return 1; + return a.name.localeCompare(b.name); + }); const guarded = async (title: string, fn: () => Promise) => { if (busy) return; @@ -76,71 +78,96 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { 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 && ( - - )} -
-
- ); - })} -
- + + e.preventDefault()} + className={cn( + "z-50 min-w-64 overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-935 text-nb-gray-200 shadow-lg", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", + "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + )} + > + e.stopPropagation()} + > + {sortedProfiles.length > 0 && ( + <> + + + + {sortedProfiles.map((profile) => ( + + ))} + + + + + + +
+ + )} + +
+ - - - - - - )} - - -
- - {t("profile.dropdown.addProfile")} -
-
- -
- - {t("profile.dropdown.manageProfiles")} -
-
- - - + + {t("profile.dropdown.addProfile")} + +
+ + + + {t("profile.dropdown.manageProfiles")} + + +
+ + + + + void; +}; + +const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => { + const showEmail = !!profile.email; + const Icon = pickProfileIcon(profile.name) ?? UserCircle; + return ( + onSelect(profile.name)} + className={cn( + "flex gap-2 px-2 py-1.5 mx-1.5 my-0.5 w-auto", + "rounded-md outline-none cursor-default text-sm", + "data-[selected=true]:bg-nb-gray-900", + showEmail ? "items-start" : "items-center", + )} + > + +
+ {profile.name} + {showEmail && } +
+ {isActive && ( + + )} +
+ ); +}; + +const TruncatedEmail = ({ email }: { email: string }) => { + const ref = useRef(null); + const [overflowing, setOverflowing] = useState(false); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + setOverflowing(el.scrollWidth > el.clientWidth); + }, [email]); + + const span = ( + + {email} + + ); + if (!overflowing) return span; + return {span}; +}; diff --git a/client/ui/frontend/src/components/ProfileSelector.tsx b/client/ui/frontend/src/components/ProfileSelector.tsx index 7a5af60bf..6f93c6276 100644 --- a/client/ui/frontend/src/components/ProfileSelector.tsx +++ b/client/ui/frontend/src/components/ProfileSelector.tsx @@ -9,7 +9,7 @@ import { ChevronDown, MoreVertical, PlusCircle, Search, Trash2, UserMinus } from import type { Profile } from "@bindings/services/models.js"; import { cn } from "@/lib/cn"; import { generateColorFromString } from "@/lib/color"; -import { NewProfileDialog } from "@/components/NewProfileDialog"; +import { NewProfileModal } from "@/components/NewProfileModal"; import { useProfile } from "@/modules/profile/ProfileContext.tsx"; const DEFAULT_PROFILE = "default"; @@ -233,7 +233,7 @@ export const ProfileSelector = () => { -