update profile ui

This commit is contained in:
Eduard Gert
2026-05-19 14:21:14 +02:00
parent 3f8cd29006
commit 1c5254cb31
10 changed files with 541 additions and 127 deletions

View File

@@ -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}

View 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,
};

View File

@@ -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>
);
});

View 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>
);
});

View 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>
);
},
);