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 {
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<
<DialogPrimitive.Overlay
ref={ref}
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",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"duration-150 ease-out",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
);
@@ -40,60 +34,53 @@ type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
maxWidthClass?: string;
};
export const Content = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
ContentProps
>(function DialogContent(
{
className,
children,
showClose = true,
maxWidthClass = "max-w-md",
...props
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
function DialogContent(
{ className, children, showClose = true, maxWidthClass = "max-w-md", ...props },
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-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<
ElementRef<typeof DialogPrimitive.Title>,
@@ -118,10 +105,7 @@ export const Description = forwardRef<
return (
<DialogPrimitive.Description
ref={ref}
className={cn(
"text-sm text-nb-gray-400 mt-2 leading-snug",
className,
)}
className={cn("text-sm text-nb-gray-400 mt-2 leading-snug", className)}
{...props}
/>
);
@@ -131,15 +115,12 @@ type FooterProps = HTMLAttributes<HTMLDivElement> & {
separator?: boolean;
};
export const Footer = ({
className,
separator = true,
...props
}: FooterProps) => (
export const Footer = ({ className, separator = true, ...props }: FooterProps) => (
<div className={cn(separator && "border-t border-nb-gray-900 mt-6")}>
<div
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",
className,
)}

View File

@@ -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 (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content
maxWidthClass="max-w-md"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Dialog.Content maxWidthClass="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
<form onSubmit={handleSubmit}>
<div className="px-8 pt-2">
<div className="px-8">
<Dialog.Title>{t("profile.dialog.title")}</Dialog.Title>
<Dialog.Description>
<Dialog.Description className="mt-1">
{t("profile.dialog.description")}
</Dialog.Description>
</div>
@@ -51,20 +48,15 @@ export const NewProfileDialog = ({ open, onOpenChange, onCreate }: Props) => {
/>
</div>
<Dialog.Footer>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Dialog.Footer separator={false} className="pt-4">
<Button
type="submit"
variant="primary"
size={"md"}
disabled={!canSubmit}
className="w-full"
>
{t("common.create")}
{t("profile.dialog.submit")}
</Button>
</Dialog.Footer>
</form>

View File

@@ -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 => {

View File

@@ -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<void>) => {
if (busy) return;
@@ -76,71 +78,96 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
return (
<>
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<ProfileTriggerButton name={displayName} />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start">
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
<ScrollArea.Viewport className="max-h-56 px-1">
{sortedProfiles.map((profile) => {
const isActive = profile.name === activeProfile;
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
return (
<DropdownMenuItem
key={profile.name}
onClick={() => handleSelect(profile.name)}
>
<div className="flex items-center gap-3 w-full min-w-0">
<Icon size={14} className="shrink-0" />
<span className="capitalize truncate flex-1">
{profile.name}
</span>
{isActive && (
<Check
size={14}
className="shrink-0 text-nb-gray-200"
/>
)}
</div>
</DropdownMenuItem>
);
})}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation="vertical"
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="center"
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => 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",
)}
>
<Command
loop
shouldFilter={false}
onKeyDown={(e) => e.stopPropagation()}
>
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root type="auto" className="overflow-hidden">
<ScrollArea.Viewport className="max-h-60 py-1.5">
<Command.List>
{sortedProfiles.map((profile) => (
<ProfileRow
key={profile.name}
profile={profile}
isActive={profile.name === activeProfile}
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(
"flex select-none touch-none transition-colors",
"w-1.5 bg-transparent py-1",
"flex items-center gap-2 px-2 py-1.5 mx-1.5 my-0.5",
"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" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleAdd}>
<div className="flex items-center gap-3">
<PlusCircle size={14} />
{t("profile.dropdown.addProfile")}
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleManage}
disabled={!onManageProfiles}
>
<div className="flex items-center gap-3">
<Settings2 size={14} />
{t("profile.dropdown.manageProfiles")}
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<NewProfileDialog
<PlusCircle size={14} className="shrink-0" />
<span className="truncate flex-1">
{t("profile.dropdown.addProfile")}
</span>
</Command.Item>
<Command.Item
value={MANAGE_VALUE}
onSelect={handleManage}
disabled={!onManageProfiles}
className={cn(
"flex items-center gap-2 px-2 py-1.5 mx-1.5 my-0.5",
"rounded-md outline-none cursor-default text-sm",
"data-[selected=true]:bg-nb-gray-900",
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
)}
>
<Settings2 size={14} className="shrink-0" />
<span className="truncate flex-1">
{t("profile.dropdown.manageProfiles")}
</span>
</Command.Item>
</div>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<NewProfileModal
open={newProfileOpen}
onOpenChange={setNewProfileOpen}
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 { 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 = () => {
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<NewProfileDialog
<NewProfileModal
open={newOpen}
onOpenChange={setNewOpen}
onCreate={handleCreateProfile}

View File

@@ -76,9 +76,10 @@
"profile.selector.deregister": "Deregister",
"profile.selector.delete": "Delete Profile",
"profile.dialog.title": "New Profile",
"profile.dialog.description": "Profiles let you keep separate NetBird connections side by side. Give your profile a memorable name.",
"profile.dialog.title": "Enter Profile Name",
"profile.dialog.description": "Choose a memorable name.",
"profile.dialog.placeholder": "e.g. Work",
"profile.dialog.submit": "Create 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.",
@@ -92,6 +93,8 @@
"profile.error.loadTitle": "Load Profiles Failed",
"profile.dropdown.activeProfile": "Active profile",
"profile.dropdown.switchProfile": "Switch Profile",
"profile.dropdown.noEmail": "Other",
"profile.dropdown.addProfile": "Add Profile",
"profile.dropdown.manageProfiles": "Manage Profiles",
"profile.dropdown.settings": "Settings",