mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-20 15:49:55 +00:00
update profile ui
This commit is contained in:
@@ -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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user