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:
@@ -19,13 +19,19 @@ export const Avatar = forwardRef<HTMLButtonElement, Props>(function Avatar(
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full bg-nb-gray-900",
|
||||
"text-xs font-semibold cursor-default outline-none",
|
||||
"inline-grid place-items-center rounded-full bg-nb-gray-850 p-0 text-center",
|
||||
"text-[0.9rem] font-semibold cursor-default outline-none",
|
||||
"transition-colors duration-150 hover:bg-nb-gray-850",
|
||||
"data-[state=open]:bg-nb-gray-850",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size, color }}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
lineHeight: 0,
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
|
||||
233
client/ui/frontend/src/components/DropdownMenu.tsx
Normal file
233
client/ui/frontend/src/components/DropdownMenu.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const menuItemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"text-nb-gray-200 focus:bg-nb-gray-900 focus:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
|
||||
danger: "text-red-500 focus:bg-red-900/20 focus:text-red-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
});
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
}
|
||||
>(({ className, inset, children, variant, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex select-none items-center rounded-md pl-3 pr-2 py-1.5 text-sm outline-none cursor-default",
|
||||
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-nb-gray-900 bg-nb-gray-930 p-1 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-nb-gray-900 bg-nb-gray-935 p-1 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "danger";
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
>(({ className, inset, variant, onClick, href, target, rel, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex select-none items-center rounded-md pl-3 pr-2 py-1.5 text-sm outline-none cursor-default",
|
||||
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
menuItemVariants({ variant }),
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (href) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{href ? (
|
||||
<a href={href} target={target} rel={rel} className="flex w-full items-center gap-3">
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"transition-colors text-nb-gray-200 focus:bg-nb-gray-900 focus:text-nb-gray-50",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"transition-colors text-nb-gray-200 focus:bg-nb-gray-900 focus:text-nb-gray-50",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-nb-gray-200",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-nb-gray-910", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-nb-gray-400 opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
@@ -9,33 +9,24 @@ type Props = HTMLMotionProps<"button"> & {
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||
function IconButton(
|
||||
{
|
||||
icon: Icon,
|
||||
iconSize = 18,
|
||||
iconClassName,
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
type={type}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={cn(
|
||||
"h-11 w-11 flex items-center justify-center rounded-md cursor-default outline-none",
|
||||
"text-nb-gray-400 hover:text-nb-gray-300 hover:bg-nb-gray-930",
|
||||
"transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={iconSize} className={iconClassName} />
|
||||
</motion.button>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
|
||||
{ icon: Icon, iconSize = 17, iconClassName, className, type = "button", ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<motion.button
|
||||
ref={ref}
|
||||
type={type}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={cn(
|
||||
"h-10 w-10 flex items-center justify-center rounded-lg cursor-default outline-none",
|
||||
"text-nb-gray-400 hover:text-nb-gray-300 hover:bg-nb-gray-900",
|
||||
"transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={iconSize} className={iconClassName} />
|
||||
</motion.button>
|
||||
);
|
||||
});
|
||||
|
||||
70
client/ui/frontend/src/components/ProfileAvatar.tsx
Normal file
70
client/ui/frontend/src/components/ProfileAvatar.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import {
|
||||
Briefcase,
|
||||
Building2,
|
||||
Gamepad2,
|
||||
GraduationCap,
|
||||
House,
|
||||
Server,
|
||||
ServerCog,
|
||||
SquareCode,
|
||||
TestTube,
|
||||
UserCircle,
|
||||
UserPlus,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
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],
|
||||
];
|
||||
|
||||
export const pickProfileIcon = (name: string | undefined): LucideIcon | null => {
|
||||
if (!name) return null;
|
||||
for (const [pattern, Icon] of ICON_MAP) {
|
||||
if (pattern.test(name)) return Icon;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
name?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ProfileAvatar = forwardRef<HTMLButtonElement, Props>(function ProfileAvatar(
|
||||
{ name = "", size = 28, className, type = "button", ...props },
|
||||
ref,
|
||||
) {
|
||||
const Icon = pickProfileIcon(name) ?? UserCircle;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
"inline-grid place-items-center rounded-full bg-nb-gray-900 p-0 text-center",
|
||||
"cursor-default outline-none",
|
||||
"transition-colors duration-150 hover:bg-nb-gray-850",
|
||||
"data-[state=open]:bg-nb-gray-850",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={Math.round(size * 0.4)} className={"text-nb-gray-200"} />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
180
client/ui/frontend/src/components/ProfileDropdown.tsx
Normal file
180
client/ui/frontend/src/components/ProfileDropdown.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { forwardRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
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 { useProfile } from "@/modules/profile/ProfileContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
};
|
||||
|
||||
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) =>
|
||||
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 (
|
||||
<>
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger 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"
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent py-1",
|
||||
)}
|
||||
>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -91,6 +91,11 @@
|
||||
"profile.error.createTitle": "Create Profile Failed",
|
||||
"profile.error.loadTitle": "Load Profiles Failed",
|
||||
|
||||
"profile.dropdown.activeProfile": "Active profile",
|
||||
"profile.dropdown.addProfile": "Add Profile",
|
||||
"profile.dropdown.manageProfiles": "Manage Profiles",
|
||||
"profile.dropdown.settings": "Settings",
|
||||
|
||||
"settings.error.loadTitle": "Load Settings Failed",
|
||||
"settings.error.saveTitle": "Save Settings Failed",
|
||||
"settings.error.debugBundleTitle": "Debug Bundle Failed",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Header } from "@/layouts/Header.tsx";
|
||||
import { ClientVersionProvider } from "@/modules/auto-update/ClientVersionContext.tsx";
|
||||
@@ -6,22 +5,15 @@ import { StatusProvider } from "@/modules/daemon-status/StatusContext.tsx";
|
||||
import { DebugBundleProvider } from "@/modules/debug-bundle/DebugBundleContext.tsx";
|
||||
import { ProfileProvider } from "@/modules/profile/ProfileContext.tsx";
|
||||
|
||||
// The wide-panel toggle lives in plain React state here so every launch
|
||||
// starts in the small layout — no localStorage, no cross-machine drift.
|
||||
// Header drives the toggle; Main reads it via Outlet context to decide
|
||||
// whether to mount the right-side panel.
|
||||
export type MainOutletContext = { expanded: boolean };
|
||||
|
||||
export const AppLayout = () => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return (
|
||||
<div className={"relative flex h-full flex-col"}>
|
||||
<StatusProvider>
|
||||
<ProfileProvider>
|
||||
<DebugBundleProvider>
|
||||
<ClientVersionProvider>
|
||||
<Header expanded={expanded} setExpanded={setExpanded} />
|
||||
<Outlet context={{ expanded } satisfies MainOutletContext} />
|
||||
<Header />
|
||||
<Outlet />
|
||||
</ClientVersionProvider>
|
||||
</DebugBundleProvider>
|
||||
</ProfileProvider>
|
||||
|
||||
@@ -1,54 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { PanelRightCloseIcon, PanelRightOpenIcon, SettingsIcon } from "lucide-react";
|
||||
import { Window } from "@wailsio/runtime";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { ProfileSelector } from "@/components/ProfileSelector.tsx";
|
||||
import { IconButton } from "@/components/IconButton.tsx";
|
||||
import { IconButton } from "@/components/IconButton";
|
||||
import { ProfileDropdown } from "@/components/ProfileDropdown";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
const WINDOW_SMALL_WIDTH = 380;
|
||||
const WINDOW_BIG_WIDTH = 925;
|
||||
const WINDOW_HEIGHT = 615;
|
||||
const EXPANDED_THRESHOLD = 500;
|
||||
|
||||
type HeaderProps = {
|
||||
expanded: boolean;
|
||||
setExpanded: (next: boolean) => void;
|
||||
};
|
||||
|
||||
export const Header = ({ expanded, setExpanded }: HeaderProps) => {
|
||||
const didInitialResize = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (didInitialResize.current) return;
|
||||
didInitialResize.current = true;
|
||||
const w = expanded ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH;
|
||||
void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didInitialResize.current) return;
|
||||
const w = expanded ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH;
|
||||
void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {});
|
||||
}, [expanded]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const isWide = window.innerWidth >= EXPANDED_THRESHOLD;
|
||||
if (isWide !== expanded) setExpanded(isWide);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [expanded, setExpanded]);
|
||||
|
||||
const togglePanel = () => {
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
const w = next ? WINDOW_BIG_WIDTH : WINDOW_SMALL_WIDTH;
|
||||
void Window.SetSize(w, WINDOW_HEIGHT).catch(() => {});
|
||||
};
|
||||
|
||||
export const Header = () => {
|
||||
const openSettings = () => {
|
||||
void WindowManager.OpenSettings().catch(() => {});
|
||||
};
|
||||
@@ -56,19 +12,23 @@ export const Header = ({ expanded, setExpanded }: HeaderProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 cursor-default wails-draggable flex items-center justify-end px-4 gap-3 bg-gradient-to-b from-nb-gray-800/15",
|
||||
"pt-4",
|
||||
"shrink-0 cursor-default wails-draggable grid grid-cols-3 items-center",
|
||||
//"bg-gradient-to-b from-nb-gray-850/30",
|
||||
//"bg-nb-gray-935 border border-b border-nb-gray-850",
|
||||
"py-3 px-3",
|
||||
)}
|
||||
>
|
||||
<div className={"ml-20"}>
|
||||
<ProfileSelector />
|
||||
<div />
|
||||
<div className={"flex justify-center ml-3"}>
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
<div className={"flex justify-end"}>
|
||||
<IconButton
|
||||
icon={SettingsIcon}
|
||||
iconClassName={"text-nb-gray-200"}
|
||||
onClick={openSettings}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
icon={expanded ? PanelRightOpenIcon : PanelRightCloseIcon}
|
||||
onClick={togglePanel}
|
||||
/>
|
||||
<IconButton icon={SettingsIcon} onClick={openSettings} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { ConnectionStatusSwitch } from "@/layouts/ConnectionStatusSwitch.tsx";
|
||||
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
|
||||
import { Navigation } from "@/layouts/Navigation.tsx";
|
||||
import { Peers } from "@/modules/peers/Peers.tsx";
|
||||
import type { MainOutletContext } from "@/layouts/AppLayout.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export const Main = () => {
|
||||
const { expanded } = useOutletContext<MainOutletContext>();
|
||||
return (
|
||||
<div className={"wails-draggable flex flex-1 min-h-0 p-4 gap-4"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col w-full shrink-0 items-center",
|
||||
expanded && "max-w-xs",
|
||||
)}
|
||||
>
|
||||
<div className={"flex flex-col w-full shrink-0 items-center"}>
|
||||
<ConnectionStatusSwitch />
|
||||
<Navigation peersActive />
|
||||
</div>
|
||||
{expanded && (
|
||||
<MainRightSide>
|
||||
<Peers />
|
||||
</MainRightSide>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -167,15 +167,10 @@ func main() {
|
||||
app.RegisterService(application.NewService(services.NewI18n(bundle)))
|
||||
app.RegisterService(application.NewService(services.NewPreferences(prefStore)))
|
||||
|
||||
// Initial size matches AppearanceContext's default `expanded: false`
|
||||
// (small / simple view). When the user has previously expanded the
|
||||
// window, Header.tsx's mount effect resizes back up to 925 via
|
||||
// Window.SetSize — that's a one-shot grow rather than a shrink, which
|
||||
// reads better than starting wide and snapping narrow on every launch.
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "NetBird",
|
||||
Width: 380,
|
||||
Height: 615,
|
||||
Width: 310,
|
||||
Height: 420,
|
||||
Hidden: true,
|
||||
BackgroundColour: application.NewRGB(24, 26, 29),
|
||||
URL: "/",
|
||||
|
||||
Reference in New Issue
Block a user