update profile ui

This commit is contained in:
Eduard Gert
2026-05-19 18:27:05 +02:00
parent 1c5254cb31
commit 8748f3810d
7 changed files with 240 additions and 180 deletions

View File

@@ -1,9 +1,4 @@
import { import { forwardRef, ComponentPropsWithoutRef, ElementRef, HTMLAttributes } from "react";
forwardRef,
ComponentPropsWithoutRef,
ElementRef,
HTMLAttributes,
} from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { X } from "lucide-react"; import { X } from "lucide-react";
@@ -22,14 +17,13 @@ export const Overlay = forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 grid place-items-start overflow-y-auto py-16", "fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"bg-black/40 backdrop-blur-sm", "bg-black/40 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out", "data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0", "data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"duration-150 ease-out", "duration-150 ease-out",
className, className,
)} )}
style={{ scrollbarGutter: "stable both-edges" }}
{...props} {...props}
/> />
); );
@@ -40,60 +34,53 @@ type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
maxWidthClass?: string; maxWidthClass?: string;
}; };
export const Content = forwardRef< export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
ElementRef<typeof DialogPrimitive.Content>, function DialogContent(
ContentProps { className, children, showClose = true, maxWidthClass = "max-w-md", ...props },
>(function DialogContent( ref,
{ ) {
className, return (
children, <DialogPrimitive.Portal>
showClose = true, <Overlay>
maxWidthClass = "max-w-md", <DialogPrimitive.Content
...props ref={ref}
className={cn(
"mx-auto relative z-[52] w-full outline-none ring-0",
"focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0",
"border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl rounded-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-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1",
"duration-150 ease-out",
maxWidthClass,
className,
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
className={cn(
"absolute right-3 top-3 z-10 rounded-md p-2 transition-colors",
"text-nb-gray-300 hover:text-nb-gray-100 hover:bg-nb-gray-900",
"focus:outline-none disabled:pointer-events-none",
)}
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</Overlay>
</DialogPrimitive.Portal>
);
}, },
ref, );
) {
return (
<DialogPrimitive.Portal>
<Overlay>
<DialogPrimitive.Content
ref={ref}
className={cn(
"mx-auto relative z-[52] w-full outline-none ring-0",
"focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0",
"border border-nb-gray-900 bg-nb-gray py-6 shadow-2xl rounded-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-[state=closed]:slide-out-to-left-1 data-[state=open]:slide-in-from-left-1",
"duration-150 ease-out",
maxWidthClass,
className,
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
className={cn(
"absolute right-4 top-4 z-10 rounded-sm opacity-70 transition-opacity",
"hover:opacity-100 focus:outline-none disabled:pointer-events-none",
"text-nb-gray-300",
)}
aria-label="Close"
>
<X className="h-4 w-4" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</Overlay>
</DialogPrimitive.Portal>
);
});
export const Title = forwardRef< export const Title = forwardRef<
ElementRef<typeof DialogPrimitive.Title>, ElementRef<typeof DialogPrimitive.Title>,
@@ -118,10 +105,7 @@ export const Description = forwardRef<
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn( className={cn("text-sm text-nb-gray-400 mt-2 leading-snug", className)}
"text-sm text-nb-gray-400 mt-2 leading-snug",
className,
)}
{...props} {...props}
/> />
); );
@@ -131,15 +115,12 @@ type FooterProps = HTMLAttributes<HTMLDivElement> & {
separator?: boolean; separator?: boolean;
}; };
export const Footer = ({ export const Footer = ({ className, separator = true, ...props }: FooterProps) => (
className,
separator = true,
...props
}: FooterProps) => (
<div className={cn(separator && "border-t border-nb-gray-900 mt-6")}> <div className={cn(separator && "border-t border-nb-gray-900 mt-6")}>
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-3", "flex flex-col-reverse gap-3 sm:flex-row sm:justify-end",
"[&>*]:w-full sm:[&>*]:w-auto",
"px-8 pt-6", "px-8 pt-6",
className, className,
)} )}

View File

@@ -10,7 +10,7 @@ type Props = {
onCreate: (name: string) => void; onCreate: (name: string) => void;
}; };
export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => { export const NewProfileModal = ({ open, onOpenChange, onCreate }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -30,14 +30,11 @@ export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
return ( return (
<Dialog.Root open={open} onOpenChange={onOpenChange}> <Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content <Dialog.Content maxWidthClass="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
maxWidthClass="max-w-md"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="px-8 pt-2"> <div className="px-8">
<Dialog.Title>{t("profile.dialog.title")}</Dialog.Title> <Dialog.Title>{t("profile.dialog.title")}</Dialog.Title>
<Dialog.Description> <Dialog.Description className="mt-1">
{t("profile.dialog.description")} {t("profile.dialog.description")}
</Dialog.Description> </Dialog.Description>
</div> </div>
@@ -51,20 +48,15 @@ export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
/> />
</div> </div>
<Dialog.Footer> <Dialog.Footer separator={false} className="pt-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
size={"md"}
disabled={!canSubmit} disabled={!canSubmit}
className="w-full"
> >
{t("common.create")} {t("profile.dialog.submit")}
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
</form> </form>

View File

@@ -1,11 +1,12 @@
import { ButtonHTMLAttributes, forwardRef } from "react"; import { ButtonHTMLAttributes, forwardRef } from "react";
import { import {
Beaker,
Briefcase, Briefcase,
Building2, Building,
Gamepad2, Gamepad2,
GraduationCap, GraduationCap,
House, House,
Server, Cloud,
ServerCog, ServerCog,
SquareCode, SquareCode,
TestTube, TestTube,
@@ -17,19 +18,21 @@ import {
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [ const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [
[/\b(default|user|me|personal)\b/i, UserCircle], [/(default|personal)/i, UserCircle],
[/\b(work|business|office|company|corp|corporate)\b/i, Briefcase], [/(work|business|office|company|corp|corporate)/i, Briefcase],
[/\b(home|house|private)\b/i, House], [/(home|house|private)/i, House],
[/\b(dev|development|developer|code|coding|engineering)\b/i, SquareCode], [/(dev|development|developer|code|coding|engineering)/i, SquareCode],
[/\b(local|localhost|loopback)\b/i, SquareCode], [/(local|localhost|loopback)/i, SquareCode],
[/\b(test|testing|staging|qa|stage)\b/i, TestTube], [/(stage|staging)/i, Beaker],
[/\b(prod|production|live)\b/i, Server], [/(test|testing|qa)/i, TestTube],
[/\b(selfhosted|self-hosted|on-prem|onprem)\b/i, ServerCog], [/(prod|production)/i, Cloud],
[/\b(school|university|edu|study|student)\b/i, GraduationCap], [/(live)/i, Cloud],
[/\b(client|customer)\b/i, Building2], [/(selfhosted|self-hosted|on-prem|onprem)/i, ServerCog],
[/\b(family)\b/i, Users], [/(school|university|edu|study|student)/i, GraduationCap],
[/\b(gaming|game)\b/i, Gamepad2], [/(client|customer)/i, Building],
[/\b(guest)\b/i, UserPlus], [/(family)/i, Users],
[/(gaming|game)/i, Gamepad2],
[/(guest)/i, UserPlus],
]; ];
export const pickProfileIcon = (name: string | undefined): LucideIcon | null => { export const pickProfileIcon = (name: string | undefined): LucideIcon | null => {

View File

@@ -1,17 +1,14 @@
import { forwardRef, useState } from "react"; import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Dialogs } from "@wailsio/runtime"; import { Dialogs } from "@wailsio/runtime";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Check, ChevronDown, PlusCircle, Settings2, UserCircle } from "lucide-react"; import { Check, ChevronDown, PlusCircle, Settings2, UserCircle } from "lucide-react";
import { pickProfileIcon } from "@/components/ProfileAvatar"; import { pickProfileIcon } from "@/components/ProfileAvatar";
import { import type { Profile } from "@bindings/services/models.js";
DropdownMenu, import { NewProfileModal } from "@/components/NewProfileModal";
DropdownMenuContent, import { Tooltip } from "@/components/Tooltip";
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { NewProfileDialog } from "@/components/NewProfileDialog";
import { useProfile } from "@/modules/profile/ProfileContext"; import { useProfile } from "@/modules/profile/ProfileContext";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -19,6 +16,9 @@ type ProfileDropdownProps = {
onManageProfiles?: () => void; onManageProfiles?: () => void;
}; };
const ADD_VALUE = "__add_profile__";
const MANAGE_VALUE = "__manage_profiles__";
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { activeProfile, profiles, addProfile, switchProfile } = useProfile(); const { activeProfile, profiles, addProfile, switchProfile } = useProfile();
@@ -26,9 +26,11 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const [newProfileOpen, setNewProfileOpen] = useState(false); const [newProfileOpen, setNewProfileOpen] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const sortedProfiles = [...profiles].sort((a, b) => const sortedProfiles = [...profiles].sort((a, b) => {
a.name.localeCompare(b.name), 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<void>) => { const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return; if (busy) return;
@@ -76,71 +78,96 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
return ( return (
<> <>
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}> <Popover.Root open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <Popover.Trigger asChild>
<ProfileTriggerButton name={displayName} /> <ProfileTriggerButton name={displayName} />
</DropdownMenuTrigger> </Popover.Trigger>
<DropdownMenuContent className="w-64" align="start"> <Popover.Portal>
{sortedProfiles.length > 0 && ( <Popover.Content
<> align="center"
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1"> sideOffset={8}
<ScrollArea.Viewport className="max-h-56 px-1"> collisionPadding={12}
{sortedProfiles.map((profile) => { onOpenAutoFocus={(e) => e.preventDefault()}
const isActive = profile.name === activeProfile; className={cn(
const Icon = pickProfileIcon(profile.name) ?? UserCircle; "z-50 min-w-64 overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-935 text-nb-gray-200 shadow-lg",
return ( "data-[state=open]:animate-in data-[state=closed]:animate-out",
<DropdownMenuItem "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
key={profile.name} "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
onClick={() => handleSelect(profile.name)} "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",
<div className="flex items-center gap-3 w-full min-w-0"> )}
<Icon size={14} className="shrink-0" /> >
<span className="capitalize truncate flex-1"> <Command
{profile.name} loop
</span> shouldFilter={false}
{isActive && ( onKeyDown={(e) => e.stopPropagation()}
<Check >
size={14} {sortedProfiles.length > 0 && (
className="shrink-0 text-nb-gray-200" <>
/> <ScrollArea.Root type="auto" className="overflow-hidden">
)} <ScrollArea.Viewport className="max-h-60 py-1.5">
</div> <Command.List>
</DropdownMenuItem> {sortedProfiles.map((profile) => (
); <ProfileRow
})} key={profile.name}
</ScrollArea.Viewport> profile={profile}
<ScrollArea.Scrollbar isActive={profile.name === activeProfile}
orientation="vertical" onSelect={handleSelect}
/>
))}
</Command.List>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation="vertical"
className={cn(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<div className="h-px bg-nb-gray-910" />
</>
)}
<div className="py-1">
<Command.Item
value={ADD_VALUE}
onSelect={handleAdd}
className={cn( className={cn(
"flex select-none touch-none transition-colors", "flex items-center gap-2 px-2 py-1.5 mx-1.5 my-0.5",
"w-1.5 bg-transparent py-1", "rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
)} )}
> >
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" /> <PlusCircle size={14} className="shrink-0" />
</ScrollArea.Scrollbar> <span className="truncate flex-1">
</ScrollArea.Root> {t("profile.dropdown.addProfile")}
<DropdownMenuSeparator /> </span>
</> </Command.Item>
)} <Command.Item
value={MANAGE_VALUE}
<DropdownMenuItem onClick={handleAdd}> onSelect={handleManage}
<div className="flex items-center gap-3"> disabled={!onManageProfiles}
<PlusCircle size={14} /> className={cn(
{t("profile.dropdown.addProfile")} "flex items-center gap-2 px-2 py-1.5 mx-1.5 my-0.5",
</div> "rounded-md outline-none cursor-default text-sm",
</DropdownMenuItem> "data-[selected=true]:bg-nb-gray-900",
<DropdownMenuItem "data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
onClick={handleManage} )}
disabled={!onManageProfiles} >
> <Settings2 size={14} className="shrink-0" />
<div className="flex items-center gap-3"> <span className="truncate flex-1">
<Settings2 size={14} /> {t("profile.dropdown.manageProfiles")}
{t("profile.dropdown.manageProfiles")} </span>
</div> </Command.Item>
</DropdownMenuItem> </div>
</DropdownMenuContent> </Command>
</DropdownMenu> </Popover.Content>
<NewProfileDialog </Popover.Portal>
</Popover.Root>
<NewProfileModal
open={newProfileOpen} open={newProfileOpen}
onOpenChange={setNewProfileOpen} onOpenChange={setNewProfileOpen}
onCreate={handleCreateProfile} onCreate={handleCreateProfile}
@@ -178,3 +205,57 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
); );
}, },
); );
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
onSelect: (name: string) => void;
};
const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
const showEmail = !!profile.email;
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
return (
<Command.Item
value={profile.name}
onSelect={() => 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",
)}
>
<Icon size={14} className={cn("shrink-0", showEmail && "mt-0.5")} />
<div className="flex flex-col min-w-0 flex-1 leading-tight">
<span className="capitalize truncate">{profile.name}</span>
{showEmail && <TruncatedEmail email={profile.email!} />}
</div>
{isActive && (
<Check
size={16}
className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")}
/>
)}
</Command.Item>
);
};
const TruncatedEmail = ({ email }: { email: string }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [email]);
const span = (
<span ref={ref} className="text-xs mt-0.5 text-nb-gray-300 truncate max-w-[180px]">
{email}
</span>
);
if (!overflowing) return span;
return <Tooltip content={email}>{span}</Tooltip>;
};

View File

@@ -9,7 +9,7 @@ import { ChevronDown, MoreVertical, PlusCircle, Search, Trash2, UserMinus } from
import type { Profile } from "@bindings/services/models.js"; import type { Profile } from "@bindings/services/models.js";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { generateColorFromString } from "@/lib/color"; import { generateColorFromString } from "@/lib/color";
import { NewProfileDialog } from "@/components/NewProfileDialog"; import { NewProfileModal } from "@/components/NewProfileModal";
import { useProfile } from "@/modules/profile/ProfileContext.tsx"; import { useProfile } from "@/modules/profile/ProfileContext.tsx";
const DEFAULT_PROFILE = "default"; const DEFAULT_PROFILE = "default";
@@ -233,7 +233,7 @@ export const ProfileSelector = () => {
</Popover.Content> </Popover.Content>
</Popover.Portal> </Popover.Portal>
</Popover.Root> </Popover.Root>
<NewProfileDialog <NewProfileModal
open={newOpen} open={newOpen}
onOpenChange={setNewOpen} onOpenChange={setNewOpen}
onCreate={handleCreateProfile} onCreate={handleCreateProfile}

View File

@@ -76,9 +76,10 @@
"profile.selector.deregister": "Deregister", "profile.selector.deregister": "Deregister",
"profile.selector.delete": "Delete Profile", "profile.selector.delete": "Delete Profile",
"profile.dialog.title": "New Profile", "profile.dialog.title": "Enter Profile Name",
"profile.dialog.description": "Profiles let you keep separate NetBird connections side by side. Give your profile a memorable name.", "profile.dialog.description": "Choose a memorable name.",
"profile.dialog.placeholder": "e.g. Work", "profile.dialog.placeholder": "e.g. Work",
"profile.dialog.submit": "Create Profile",
"profile.deregister.title": "Deregister Profile", "profile.deregister.title": "Deregister Profile",
"profile.deregister.message": "Are you sure you want to deregister \"{name}\"? You will need to log in again to use it.", "profile.deregister.message": "Are you sure you want to deregister \"{name}\"? You will need to log in again to use it.",
@@ -92,6 +93,8 @@
"profile.error.loadTitle": "Load Profiles Failed", "profile.error.loadTitle": "Load Profiles Failed",
"profile.dropdown.activeProfile": "Active profile", "profile.dropdown.activeProfile": "Active profile",
"profile.dropdown.switchProfile": "Switch Profile",
"profile.dropdown.noEmail": "Other",
"profile.dropdown.addProfile": "Add Profile", "profile.dropdown.addProfile": "Add Profile",
"profile.dropdown.manageProfiles": "Manage Profiles", "profile.dropdown.manageProfiles": "Manage Profiles",
"profile.dropdown.settings": "Settings", "profile.dropdown.settings": "Settings",

View File

@@ -177,7 +177,7 @@ func main() {
MaximiseButtonState: application.ButtonHidden, MaximiseButtonState: application.ButtonHidden,
Mac: application.MacWindow{ Mac: application.MacWindow{
InvisibleTitleBarHeight: 38, InvisibleTitleBarHeight: 38,
Backdrop: application.MacBackdropTranslucent, Backdrop: application.MacBackdropNormal,
TitleBar: application.MacTitleBarHiddenInset, TitleBar: application.MacTitleBarHiddenInset,
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
}, },