mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-26 16:49:08 +02:00
remove unused stuff, refactor frontend folder structure
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import {
|
||||
Briefcase,
|
||||
Building,
|
||||
Cloud,
|
||||
Construction,
|
||||
FlaskConical,
|
||||
Gamepad2,
|
||||
GraduationCap,
|
||||
House,
|
||||
Radio,
|
||||
Server,
|
||||
SquareCode,
|
||||
Terminal,
|
||||
UserCircle,
|
||||
UserPlus,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Patterns match substrings, case-insensitive — "Proxytest" hits FlaskConical
|
||||
// just like "test" does. The list is scanned in order, so more-specific
|
||||
// tokens (e.g. "staging" before "stage") should come first when they share
|
||||
// roots.
|
||||
const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [
|
||||
[/(default|personal)/i, UserCircle],
|
||||
[/(work|business|office|company|corp|corporate)/i, Briefcase],
|
||||
[/(home|house|private)/i, House],
|
||||
[/(dev|development|developer|code|coding|engineering)/i, SquareCode],
|
||||
[/(local|localhost|loopback)/i, Terminal],
|
||||
[/(stage|staging|preprod|pre-prod)/i, Construction],
|
||||
[/(test|testing|qa)/i, FlaskConical],
|
||||
[/(prod|production)/i, Cloud],
|
||||
[/(live)/i, Radio],
|
||||
[/(selfhosted|self-hosted|on-prem|onprem)/i, Server],
|
||||
[/(school|university|edu|study|student)/i, GraduationCap],
|
||||
[/(client|customer)/i, Building],
|
||||
[/(family)/i, Users],
|
||||
[/(gaming|game)/i, Gamepad2],
|
||||
[/(guest)/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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import * as Dialog from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (name: string) => void;
|
||||
};
|
||||
|
||||
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setError(t("profile.dialog.required"));
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
onCreate(trimmed);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setName(value);
|
||||
if (error) setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidthClass="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-8">
|
||||
<Dialog.Title>{t("profile.dialog.title")}</Dialog.Title>
|
||||
<Dialog.Description className="mt-1">
|
||||
{t("profile.dialog.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div className="px-8 pt-3">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
placeholder={t("profile.dialog.placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer separator={false} className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size={"md"}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusCircle size={14} />
|
||||
{t("profile.dialog.submit")}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
import { Check, ChevronDown, PlusCircle, Settings2, UserCircle } from "lucide-react";
|
||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
};
|
||||
|
||||
const ADD_VALUE = "__add_profile__";
|
||||
const MANAGE_VALUE = "__manage_profiles__";
|
||||
|
||||
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, profiles, addProfile, switchProfile } = useProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newProfileOpen, setNewProfileOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const sortedProfiles = [...profiles].sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: title,
|
||||
Message: formatErrorMessage(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);
|
||||
await switchProfile(name);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: t("profile.error.createTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = activeProfile || t("profile.selector.loading");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ProfileTriggerButton name={displayName} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||
<ScrollArea.Viewport className="max-h-60 px-1">
|
||||
<Command.List>
|
||||
{sortedProfiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
<div className="-mx-1 h-px bg-nb-gray-910" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={"pt-1"}>
|
||||
<Command.Item
|
||||
value={ADD_VALUE}
|
||||
onSelect={handleAdd}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 my-0.5",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<PlusCircle size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.addProfile")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
value={MANAGE_VALUE}
|
||||
onSelect={handleManage}
|
||||
disabled={!onManageProfiles}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5 my-0.5",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Settings2 size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
</div>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<ProfileCreationModal
|
||||
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 select-none wails-no-draggable",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-900",
|
||||
"transition-colors duration-150 wails-no-draggable",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Icon size={16} className={"text-nb-gray-200 shrink-0 wails-no-draggable"} />
|
||||
<span className={"text-sm font-medium truncate max-w-[140px] wails-no-draggable"}>
|
||||
{name}
|
||||
</span>
|
||||
<ChevronDown size={14} className={"text-nb-gray-200 shrink-0 wails-no-draggable"} />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type ProfileRowProps = {
|
||||
profile: Profile;
|
||||
isActive: boolean;
|
||||
onSelect: (name: string) => void;
|
||||
};
|
||||
|
||||
const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
||||
const showEmail = !!profile.email;
|
||||
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
|
||||
return (
|
||||
<Command.Item
|
||||
value={profile.name}
|
||||
onSelect={() => onSelect(profile.name)}
|
||||
className={cn(
|
||||
"flex gap-2 px-2 py-2 pr-3 my-0.5 first:mt-0 last:mb-1 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="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>;
|
||||
};
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import HelpText from "@/components/typography/HelpText";
|
||||
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
|
||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
export function ProfilesTab() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
profiles,
|
||||
activeProfile,
|
||||
loaded,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
logoutProfile,
|
||||
} = useProfile();
|
||||
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const sorted = [...profiles].sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: title,
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeregister = async (name: string) => {
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("profile.deregister.confirm");
|
||||
const result = await Dialogs.Warning({
|
||||
Title: i18next.t("profile.deregister.title"),
|
||||
Message: i18next.t("profile.deregister.message", { name }),
|
||||
Buttons: [
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
],
|
||||
});
|
||||
if (result !== confirmLabel) return;
|
||||
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
if (name === DEFAULT_PROFILE) return;
|
||||
const cancelLabel = i18next.t("common.cancel");
|
||||
const confirmLabel = i18next.t("common.delete");
|
||||
const result = await Dialogs.Warning({
|
||||
Title: i18next.t("profile.delete.title"),
|
||||
Message: i18next.t("profile.delete.message", { name }),
|
||||
Buttons: [
|
||||
{ Label: cancelLabel, IsCancel: true },
|
||||
{ Label: confirmLabel, IsDefault: true },
|
||||
],
|
||||
});
|
||||
if (result !== confirmLabel) return;
|
||||
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string) => {
|
||||
try {
|
||||
await addProfile(name);
|
||||
await switchProfile(name);
|
||||
} catch (e) {
|
||||
await Dialogs.Error({
|
||||
Title: i18next.t("profile.error.createTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={t("settings.profiles.section.profiles")}>
|
||||
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"bg-nb-gray-930/60 border border-nb-gray-900 rounded-xl overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<table className={"w-full text-sm"}>
|
||||
<tbody>
|
||||
{sorted.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onDeregister={() => handleDeregister(profile.name)}
|
||||
onDelete={() => handleDelete(profile.name)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{loaded && sorted.length === 0 && (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center py-10 text-center"
|
||||
}
|
||||
>
|
||||
<UserCircle size={28} className={"text-nb-gray-500 mb-2"} />
|
||||
<p className={"text-sm font-semibold text-nb-gray-200"}>
|
||||
{t("settings.profiles.emptyTitle")}
|
||||
</p>
|
||||
<p className={"mt-1 text-xs text-nb-gray-400 max-w-sm text-balance"}>
|
||||
{t("settings.profiles.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SettingsBottomBar>
|
||||
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
|
||||
<PlusCircle size={14} />
|
||||
{t("settings.profiles.addProfile")}
|
||||
</Button>
|
||||
</SettingsBottomBar>
|
||||
</SectionGroup>
|
||||
|
||||
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type ProfileRowProps = {
|
||||
profile: Profile;
|
||||
isActive: boolean;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
|
||||
const showEmail = !!profile.email;
|
||||
|
||||
return (
|
||||
<tr className={"border-b border-nb-gray-910 last:border-b-0"}>
|
||||
<td className={"px-4 py-2.5 align-middle"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 min-w-0 leading-tight",
|
||||
showEmail ? "items-start" : "items-center",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
className={cn(
|
||||
"text-nb-gray-200 shrink-0",
|
||||
|
||||
showEmail ? "mt-0.5" : "",
|
||||
)}
|
||||
/>
|
||||
<div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
|
||||
<div className={"flex items-center gap-2 min-w-0"}>
|
||||
<span className={"truncate font-medium text-nb-gray-100 select-text cursor-text"}>
|
||||
{profile.name}
|
||||
</span>
|
||||
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
|
||||
</div>
|
||||
{showEmail && <TruncatedEmail email={profile.email!} />}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={"px-4 py-2.5 text-right align-middle"}>
|
||||
<RowActions
|
||||
canDeregister={!!profile.email}
|
||||
canDelete={profile.name !== DEFAULT_PROFILE}
|
||||
onDeregister={onDeregister}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
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 text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}>
|
||||
{email}
|
||||
</span>
|
||||
);
|
||||
if (!overflowing) return span;
|
||||
return <Tooltip content={email}>{span}</Tooltip>;
|
||||
};
|
||||
|
||||
type RowActionsProps = {
|
||||
canDeregister: boolean;
|
||||
canDelete: boolean;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={"inline-flex items-center gap-1"}>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.deregister")}
|
||||
icon={CircleMinus}
|
||||
onClick={onDeregister}
|
||||
hidden={!canDeregister}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.delete")}
|
||||
icon={Trash2}
|
||||
onClick={onDelete}
|
||||
variant={"danger"}
|
||||
hidden={!canDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ActionIconButtonProps = {
|
||||
label: string;
|
||||
icon: typeof CircleMinus;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
/** When true the button still occupies space (preserves row layout)
|
||||
* but is invisible and non-interactive. */
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
const ActionIconButton = ({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
variant = "default",
|
||||
hidden = false,
|
||||
}: ActionIconButtonProps) => {
|
||||
const button = (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
aria-hidden={hidden || undefined}
|
||||
tabIndex={hidden ? -1 : undefined}
|
||||
className={cn(
|
||||
"h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none",
|
||||
"transition-colors duration-150",
|
||||
variant === "danger"
|
||||
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
|
||||
hidden && "opacity-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
if (hidden) return button;
|
||||
return (
|
||||
<Tooltip content={label} side={"top"}>
|
||||
{button}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user