mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-20 15:49:55 +00:00
update dropdown ui padding, remove unused stuff
This commit is contained in:
@@ -37,7 +37,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|
|||||||
|
|
||||||
## Directory layout (src/)
|
## Directory layout (src/)
|
||||||
|
|
||||||
The split between `pages/`, `screens/`, and `modules/` is historical and not load-bearing. **Today:** `modules/` owns the polished AppLayout-shell-driven UI, `pages/` owns the few routes that live outside that shell, and `screens/` is the unsorted legacy bucket. Don't add new code under `screens/` — pick `pages/` (own route, no shell) or `modules/<feature>/` (lives inside the shell). `lib/MainModuleContext.tsx` is exported but unused — candidate for deletion.
|
The split between `pages/`, `screens/`, and `modules/` is historical and not load-bearing. **Today:** `modules/` owns the polished AppLayout-shell-driven UI, `pages/` owns the few routes that live outside that shell, and `screens/` is the unsorted legacy bucket. Don't add new code under `screens/` — pick `pages/` (own route, no shell) or `modules/<feature>/` (lives inside the shell).
|
||||||
|
|
||||||
## Wails event bus
|
## Wails event bus
|
||||||
|
|
||||||
@@ -164,7 +164,6 @@ Defined in `tailwind.config.ts`. `nb-gray` is the neutral palette (background =
|
|||||||
- **`modules/authentication/SessionExpiredDialog.tsx`** and **`modules/authentication/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. Today they're only triggered via the DEV-only "Development" tab in Settings (`SettingsDevelopment.tsx`) — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`.
|
- **`modules/authentication/SessionExpiredDialog.tsx`** and **`modules/authentication/SessionAboutToExpireDialog.tsx`** are the always-on-top auxiliary windows. Today they're only triggered via the DEV-only "Development" tab in Settings (`SettingsDevelopment.tsx`) — a daemon-status hook (status `SessionExpired`, plus a future "about-to-expire" signal) will drive them later. Sign-in / Stay-connected emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow; Logout uses `Connection.Logout({profileName, username})`.
|
||||||
- **`screens/QuickActions.tsx`** is wired to `/quick` in the route table but nothing on the Go side currently navigates there.
|
- **`screens/QuickActions.tsx`** is wired to `/quick` in the route table but nothing on the Go side currently navigates there.
|
||||||
- **`UpdateAvailableBanner`** is force-enabled via `FORCE_UPDATE_AVAILABLE = true` and additionally TODO-commented for the "only when management has auto updates enabled + force updates is disabled" case.
|
- **`UpdateAvailableBanner`** is force-enabled via `FORCE_UPDATE_AVAILABLE = true` and additionally TODO-commented for the "only when management has auto updates enabled + force updates is disabled" case.
|
||||||
- **`lib/MainModuleContext.tsx`** is exported but unused. Candidate for deletion.
|
|
||||||
|
|
||||||
## Wails Go API reference
|
## Wails Go API reference
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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",
|
"z-50 min-w-[8rem] overflow-hidden rounded-lg 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=open]:animate-in data-[state=closed]:animate-out",
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"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]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||||
@@ -102,7 +102,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex select-none items-center rounded-md pl-3 pr-2 py-1.5 text-sm outline-none cursor-default",
|
"relative flex select-none items-center rounded-md pl-2 pr-2 py-1.5 text-sm outline-none cursor-default",
|
||||||
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
menuItemVariants({ variant }),
|
menuItemVariants({ variant }),
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
collisionPadding={12}
|
collisionPadding={12}
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
className={cn(
|
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",
|
"z-50 min-w-64 overflow-hidden rounded-lg 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=open]:animate-in data-[state=closed]:animate-out",
|
||||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"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]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||||
@@ -98,15 +98,11 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Command
|
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||||
loop
|
|
||||||
shouldFilter={false}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{sortedProfiles.length > 0 && (
|
{sortedProfiles.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<ScrollArea.Root type="auto" className="overflow-hidden">
|
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||||
<ScrollArea.Viewport className="max-h-60 py-1.5">
|
<ScrollArea.Viewport className="max-h-60 px-1">
|
||||||
<Command.List>
|
<Command.List>
|
||||||
{sortedProfiles.map((profile) => (
|
{sortedProfiles.map((profile) => (
|
||||||
<ProfileRow
|
<ProfileRow
|
||||||
@@ -128,16 +124,16 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
||||||
</ScrollArea.Scrollbar>
|
</ScrollArea.Scrollbar>
|
||||||
</ScrollArea.Root>
|
</ScrollArea.Root>
|
||||||
<div className="h-px bg-nb-gray-910" />
|
<div className="-mx-1 h-px bg-nb-gray-910" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="py-1">
|
<div className={"pt-1"}>
|
||||||
<Command.Item
|
<Command.Item
|
||||||
value={ADD_VALUE}
|
value={ADD_VALUE}
|
||||||
onSelect={handleAdd}
|
onSelect={handleAdd}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-2 py-1.5 mx-1.5 my-0.5",
|
"flex items-center gap-2 px-2 py-1.5 my-0.5",
|
||||||
"rounded-md outline-none cursor-default text-sm",
|
"rounded-md outline-none cursor-default text-sm",
|
||||||
"data-[selected=true]:bg-nb-gray-900",
|
"data-[selected=true]:bg-nb-gray-900",
|
||||||
)}
|
)}
|
||||||
@@ -152,7 +148,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
|||||||
onSelect={handleManage}
|
onSelect={handleManage}
|
||||||
disabled={!onManageProfiles}
|
disabled={!onManageProfiles}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-2 py-1.5 mx-1.5 my-0.5",
|
"flex items-center gap-2 px-2 py-1.5 my-0.5",
|
||||||
"rounded-md outline-none cursor-default text-sm",
|
"rounded-md outline-none cursor-default text-sm",
|
||||||
"data-[selected=true]:bg-nb-gray-900",
|
"data-[selected=true]:bg-nb-gray-900",
|
||||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||||
@@ -221,7 +217,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
|||||||
value={profile.name}
|
value={profile.name}
|
||||||
onSelect={() => onSelect(profile.name)}
|
onSelect={() => onSelect(profile.name)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-2 px-2 py-1.5 mx-1.5 my-0.5 w-auto",
|
"flex gap-2 px-2 py-2 pr-3 my-0.5 first:mt-0 last:mb-1 w-auto",
|
||||||
"rounded-md outline-none cursor-default text-sm",
|
"rounded-md outline-none cursor-default text-sm",
|
||||||
"data-[selected=true]:bg-nb-gray-900",
|
"data-[selected=true]:bg-nb-gray-900",
|
||||||
showEmail ? "items-start" : "items-center",
|
showEmail ? "items-start" : "items-center",
|
||||||
@@ -233,10 +229,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
|||||||
{showEmail && <TruncatedEmail email={profile.email!} />}
|
{showEmail && <TruncatedEmail email={profile.email!} />}
|
||||||
</div>
|
</div>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<Check
|
<Check size={16} className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")} />
|
||||||
size={16}
|
|
||||||
className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Command.Item>
|
</Command.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,365 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
|
||||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
|
||||||
import { Command } from "cmdk";
|
|
||||||
import { Dialogs } from "@wailsio/runtime";
|
|
||||||
import { ChevronDown, MoreVertical, PlusCircle, Search, Trash2, UserMinus } from "lucide-react";
|
|
||||||
import type { Profile } from "@bindings/services/models.js";
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { generateColorFromString } from "@/lib/color";
|
|
||||||
import { NewProfileModal } from "@/components/NewProfileModal";
|
|
||||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
|
||||||
|
|
||||||
const DEFAULT_PROFILE = "default";
|
|
||||||
|
|
||||||
export const ProfileSelector = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
profiles,
|
|
||||||
activeProfile,
|
|
||||||
loaded,
|
|
||||||
switchProfile,
|
|
||||||
addProfile,
|
|
||||||
removeProfile,
|
|
||||||
logoutProfile,
|
|
||||||
} = useProfile();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [newOpen, setNewOpen] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const selected =
|
|
||||||
profiles.find((p) => p.name === activeProfile) ??
|
|
||||||
profiles.find((p) => p.isActive) ??
|
|
||||||
profiles[0];
|
|
||||||
|
|
||||||
const sorted = [...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 handleDeregister = async (name: string) => {
|
|
||||||
const cancelLabel = t("common.cancel");
|
|
||||||
const confirmLabel = t("profile.deregister.confirm");
|
|
||||||
const result = await Dialogs.Warning({
|
|
||||||
Title: t("profile.deregister.title"),
|
|
||||||
Message: t("profile.deregister.message", { name }),
|
|
||||||
Buttons: [
|
|
||||||
{ Label: cancelLabel, IsCancel: true },
|
|
||||||
{ Label: confirmLabel, IsDefault: true },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (result !== confirmLabel) return;
|
|
||||||
void guarded(t("profile.error.deregisterTitle"), () => logoutProfile(name));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (name: string) => {
|
|
||||||
if (name === DEFAULT_PROFILE) return;
|
|
||||||
const cancelLabel = t("common.cancel");
|
|
||||||
const confirmLabel = t("common.delete");
|
|
||||||
const result = await Dialogs.Warning({
|
|
||||||
Title: t("profile.delete.title"),
|
|
||||||
Message: t("profile.delete.message", { name }),
|
|
||||||
Buttons: [
|
|
||||||
{ Label: cancelLabel, IsCancel: true },
|
|
||||||
{ Label: confirmLabel, IsDefault: true },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (result !== confirmLabel) return;
|
|
||||||
void guarded(t("profile.error.deleteTitle"), () => removeProfile(name));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNewProfile = () => {
|
|
||||||
setOpen(false);
|
|
||||||
setNewOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateProfile = (name: string) => {
|
|
||||||
void guarded(t("profile.error.createTitle"), () => addProfile(name));
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayName =
|
|
||||||
selected?.name ??
|
|
||||||
(loaded ? t("profile.selector.noProfile") : t("profile.selector.loading"));
|
|
||||||
const initial = (selected?.name ?? "?").charAt(0).toUpperCase();
|
|
||||||
const initialColor = generateColorFromString(selected?.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
|
||||||
<Popover.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={
|
|
||||||
"h-11 rounded-md text-nb-gray-300 flex items-center gap-1 text-xs hover:bg-nb-gray-930 data-[state=open]:bg-nb-gray-930 px-2 -mx-1 outline-none cursor-default transition-colors duration-150"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center bg-nb-gray-900 rounded-md text-xs font-semibold h-6 w-6",
|
|
||||||
)}
|
|
||||||
style={{ color: initialColor }}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"whitespace-nowrap flex flex-col ml-1 text-left justify-center"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={"leading-none text-nb-gray-200 font-semibold"}>
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown size={14} className={"ml-2 mr-2"} />
|
|
||||||
</button>
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Portal>
|
|
||||||
<Popover.Content
|
|
||||||
align="end"
|
|
||||||
sideOffset={6}
|
|
||||||
className={cn(
|
|
||||||
"w-72 rounded-md border border-nb-gray-900 bg-nb-gray-930 shadow-lg",
|
|
||||||
"p-1 z-50 origin-[var(--radix-popover-content-transform-origin)]",
|
|
||||||
"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]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
|
||||||
"data-[side=bottom]:slide-in-from-top-1",
|
|
||||||
"data-[side=top]:slide-in-from-bottom-1",
|
|
||||||
"duration-150 ease-out",
|
|
||||||
)}
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Command
|
|
||||||
loop
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col",
|
|
||||||
"[&_[cmdk-input-wrapper]]:flex [&_[cmdk-input-wrapper]]:items-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="px-1 pb-1">
|
|
||||||
<div className="group flex items-center gap-2 px-2 h-8">
|
|
||||||
<Search size={12} className="text-nb-gray-300 shrink-0" />
|
|
||||||
<Command.Input
|
|
||||||
autoFocus
|
|
||||||
placeholder={t("profile.selector.searchPlaceholder")}
|
|
||||||
className={cn(
|
|
||||||
"w-full bg-transparent text-xs text-nb-gray-200 placeholder:text-nb-gray-400",
|
|
||||||
"outline-none border-none",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
|
||||||
<ScrollArea.Viewport className="max-h-64 px-1 pb-1">
|
|
||||||
<Command.List>
|
|
||||||
<Command.Empty>
|
|
||||||
<div className="flex flex-col items-center text-center px-4 pt-2 pb-3">
|
|
||||||
<h3 className="text-xs font-semibold text-nb-gray-200">
|
|
||||||
{t("profile.selector.emptyTitle")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[0.7rem] leading-snug text-nb-gray-400 mt-1 text-balance">
|
|
||||||
{t("profile.selector.emptyDescription")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Command.Empty>
|
|
||||||
|
|
||||||
{sorted.map((profile) => (
|
|
||||||
<ProfileRow
|
|
||||||
key={profile.name}
|
|
||||||
profile={profile}
|
|
||||||
selected={profile.name === activeProfile}
|
|
||||||
onSelect={() => handleSelect(profile.name)}
|
|
||||||
onDeregister={() => handleDeregister(profile.name)}
|
|
||||||
onDelete={() => handleDelete(profile.name)}
|
|
||||||
deletable={profile.name !== DEFAULT_PROFILE}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Command.List>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="h-px bg-nb-gray-920 -mx-1 my-1" />
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNewProfile}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
|
|
||||||
"text-netbird hover:bg-nb-gray-910",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
"h-6 w-6 flex items-center justify-center rounded-md bg-nb-gray-900 shrink-0"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PlusCircle size={12} className="text-netbird" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold">{t("profile.selector.newProfile")}</span>
|
|
||||||
</button>
|
|
||||||
</Command>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
<NewProfileModal
|
|
||||||
open={newOpen}
|
|
||||||
onOpenChange={setNewOpen}
|
|
||||||
onCreate={handleCreateProfile}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProfileRowProps = {
|
|
||||||
profile: Profile;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onDeregister: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
deletable: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ProfileRow = ({
|
|
||||||
profile,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
onDeregister,
|
|
||||||
onDelete,
|
|
||||||
deletable,
|
|
||||||
}: ProfileRowProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
const initial = profile.name.charAt(0).toUpperCase();
|
|
||||||
const initialColor = generateColorFromString(profile.name);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Command.Item
|
|
||||||
value={profile.name}
|
|
||||||
onSelect={() => onSelect()}
|
|
||||||
className={cn(
|
|
||||||
"group flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-md cursor-default outline-none",
|
|
||||||
"data-[selected=true]:bg-nb-gray-910",
|
|
||||||
selected && "bg-nb-gray-910",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6 flex items-center justify-center rounded-md text-[0.65rem] font-semibold shrink-0 bg-nb-gray-900",
|
|
||||||
"group-data-[selected=true]:bg-nb-gray-850",
|
|
||||||
selected && "bg-nb-gray-850",
|
|
||||||
)}
|
|
||||||
style={{ color: initialColor }}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex-1 truncate text-xs",
|
|
||||||
selected ? "text-nb-gray-200 font-semibold" : "text-nb-gray-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{profile.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<DropdownMenu.Root open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
|
|
||||||
<DropdownMenu.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6 flex items-center justify-center rounded text-nb-gray-400 cursor-default",
|
|
||||||
"hover:bg-nb-gray-800 hover:text-nb-gray-200 outline-none",
|
|
||||||
"data-[state=open]:bg-nb-gray-800 data-[state=open]:text-nb-gray-200",
|
|
||||||
)}
|
|
||||||
aria-label={t("profile.selector.moreOptions")}
|
|
||||||
>
|
|
||||||
<MoreVertical size={14} />
|
|
||||||
</button>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
sideOffset={4}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
className={cn(
|
|
||||||
"w-44 rounded-md border border-nb-gray-850 bg-nb-gray-910 shadow-lg p-1 z-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onDeregister();
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
|
|
||||||
"text-xs text-nb-gray-200 data-[highlighted]:bg-nb-gray-850",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<UserMinus size={14} className="text-nb-gray-300" />
|
|
||||||
<span>{t("profile.selector.deregister")}</span>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
disabled={!deletable}
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!deletable) return;
|
|
||||||
onDelete();
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-default outline-none font-medium",
|
|
||||||
"text-xs data-[highlighted]:bg-nb-gray-850",
|
|
||||||
deletable
|
|
||||||
? "text-red-500"
|
|
||||||
: "text-nb-gray-500 cursor-not-allowed",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
<span>{t("profile.selector.delete")}</span>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</Command.Item>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { createContext, ReactNode, useContext, useState } from "react";
|
|
||||||
|
|
||||||
export type MainModule = "peers" | "settings";
|
|
||||||
|
|
||||||
type Ctx = {
|
|
||||||
active: MainModule;
|
|
||||||
setActive: (m: MainModule) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MainModuleContext = createContext<Ctx | null>(null);
|
|
||||||
|
|
||||||
export const MainModuleProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [active, setActive] = useState<MainModule>("peers");
|
|
||||||
return (
|
|
||||||
<MainModuleContext.Provider value={{ active, setActive }}>
|
|
||||||
{children}
|
|
||||||
</MainModuleContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMainModule = () => {
|
|
||||||
const ctx = useContext(MainModuleContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useMainModule must be used within MainModuleProvider");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
};
|
|
||||||
@@ -135,7 +135,7 @@ export function LanguagePicker() {
|
|||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[var(--radix-popover-trigger-width)]",
|
"w-[var(--radix-popover-trigger-width)]",
|
||||||
"rounded-md border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50",
|
"rounded-lg border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50",
|
||||||
"origin-[var(--radix-popover-content-transform-origin)]",
|
"origin-[var(--radix-popover-content-transform-origin)]",
|
||||||
"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",
|
||||||
@@ -189,7 +189,7 @@ export function LanguagePicker() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none my-0.5",
|
"flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none my-0.5",
|
||||||
"text-xs font-semibold text-nb-gray-200",
|
"text-xs font-semibold text-nb-gray-200",
|
||||||
"data-[selected=true]:bg-nb-gray-900 data-[selected=true]:text-nb-gray-50",
|
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Flag
|
<Flag
|
||||||
|
|||||||
Reference in New Issue
Block a user