Files
netbird/client/ui/frontend/src/components/ProfileDropdown.tsx
2026-05-19 18:27:05 +02:00

262 lines
11 KiB
TypeScript

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 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";
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();
const [open, setOpen] = useState(false);
const [newProfileOpen, setNewProfileOpen] = useState(false);
const [busy, setBusy] = useState(false);
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;
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 (
<>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<ProfileTriggerButton name={displayName} />
</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 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",
)}
>
<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}
/>
</>
);
};
type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
name: string;
};
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
function ProfileTriggerButton({ name, className, ...props }, ref) {
const Icon = pickProfileIcon(name) ?? UserCircle;
return (
<button
ref={ref}
type="button"
className={cn(
"h-10 flex items-center gap-2 px-3 rounded-lg outline-none cursor-default",
"text-nb-gray-200 hover:bg-nb-gray-900",
"data-[state=open]:bg-nb-gray-900",
"transition-colors duration-150",
className,
)}
{...props}
>
<Icon size={16} className={"text-nb-gray-200 shrink-0"} />
<span className={"text-sm font-medium capitalize truncate max-w-[140px]"}>
{name}
</span>
<ChevronDown size={14} className={"text-nb-gray-200 shrink-0"} />
</button>
);
},
);
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>;
};