remove unused stuff, refactor frontend folder structure

This commit is contained in:
Eduard Gert
2026-05-28 16:26:13 +02:00
parent e09bc8894d
commit 51b243bdfa
92 changed files with 953 additions and 1629 deletions
@@ -1,40 +0,0 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { generateColorFromString } from "@/lib/color";
import { cn } from "@/lib/cn";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
name?: string;
size?: number;
};
export const Avatar = forwardRef<HTMLButtonElement, Props>(function Avatar(
{ name = "", size = 28, className, type = "button", ...props },
ref,
) {
const initial = (name.trim().charAt(0) || "?").toUpperCase();
const color = generateColorFromString(name);
return (
<button
ref={ref}
type={type}
className={cn(
"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,
lineHeight: 0,
letterSpacing: 0,
}}
{...props}
>
{initial}
</button>
);
});
@@ -1,60 +0,0 @@
import { ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/cn";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
children?: ReactNode;
className?: string;
};
export const BottomSheet = ({ open, onOpenChange, children, className }: Props) => {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
return createPortal(
<AnimatePresence>
{open && (
<div className={"fixed inset-0 z-50"}>
<motion.div
className={"absolute inset-0 bg-black/40 backdrop-blur-sm"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
onClick={() => onOpenChange(false)}
/>
<motion.div
role={"dialog"}
aria-modal={"true"}
className={cn(
"absolute left-0 right-0 bottom-0",
"bg-nb-gray-925 border-t border-nb-gray-850 rounded-t-2xl",
"shadow-2xl outline-none",
"max-h-[85vh] overflow-hidden",
className,
)}
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", stiffness: 360, damping: 34 }}
>
<div className={"flex justify-center pt-2"}>
<div className={"h-1 w-10 rounded-full bg-nb-gray-700"} />
</div>
<div className={"px-5 pt-4 pb-6"}>{children}</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
};
@@ -1,14 +0,0 @@
import { HTMLAttributes } from "react";
import { cn } from "../lib/cn";
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-lg border border-nb-gray-200 bg-white p-4 dark:border-nb-gray-800 dark:bg-nb-gray-925",
className,
)}
{...rest}
/>
);
}
@@ -1,86 +0,0 @@
import {
ButtonHTMLAttributes,
ComponentType,
forwardRef,
ReactNode,
} from "react";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
icon?: ComponentType<LucideProps>;
iconNode?: ReactNode;
title: string;
description?: string;
active?: boolean;
iconSize?: number;
};
export const CardNavItem = forwardRef<HTMLButtonElement, Props>(
function CardNavItem(
{
icon: Icon,
iconNode,
title,
description,
active = false,
iconSize = 15,
className,
type = "button",
...props
},
ref,
) {
return (
<button
ref={ref}
type={type}
className={cn(
"w-full flex items-center gap-3 p-1.5 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
active ? "bg-nb-gray-930" : "hover:bg-nb-gray-940",
className,
)}
{...props}
>
<div
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
"transition-colors duration-150",
active ? "bg-nb-gray-800" : "bg-nb-gray-920",
)}
>
{iconNode ?? (Icon && (
<Icon
size={iconSize}
className={cn(
"transition-colors duration-150",
active ? "text-nb-gray-200" : "text-nb-gray-400",
)}
/>
))}
</div>
<div className={"min-w-0"}>
<h2
className={cn(
"font-medium text-[0.81rem] truncate",
active ? "text-nb-gray-100" : "text-nb-gray-200",
)}
>
{title}
</h2>
{description && (
<p
className={cn(
"text-xs font-medium truncate",
active ? "text-nb-gray-300" : "text-nb-gray-400",
)}
>
{description}
</p>
)}
</div>
</button>
);
},
);
@@ -1,76 +0,0 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { CheckIcon } from "lucide-react";
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
type RootProps = {
value: string;
onChange: (value: string) => void;
children: ReactNode;
className?: string;
};
const Root = ({ value, onChange, children, className }: RootProps) => {
return (
<RadioGroup.Root
value={value}
onValueChange={onChange}
className={cn("grid grid-cols-2 gap-3", className)}
>
{children}
</RadioGroup.Root>
);
};
type OptionProps = {
value: string;
title: string;
description?: string;
preview?: ReactNode;
className?: string;
};
const Option = ({ value, title, description, preview, className }: OptionProps) => {
return (
<RadioGroup.Item
value={value}
className={cn(
"group relative flex flex-col items-stretch text-left rounded-lg",
"border border-nb-gray-850 bg-nb-gray-925 p-3 cursor-default outline-none",
"transition-colors duration-150",
"hover:border-nb-gray-800",
"data-[state=checked]:border-netbird data-[state=checked]:ring-1 data-[state=checked]:ring-netbird",
className,
)}
>
<span
className={cn(
"absolute top-2.5 right-2.5 flex h-4 w-4 items-center justify-center rounded-[4px]",
"border border-nb-gray-700 bg-nb-gray-900",
"group-data-[state=checked]:border-netbird group-data-[state=checked]:bg-netbird",
)}
>
<RadioGroup.Indicator className={"flex items-center justify-center"}>
<CheckIcon size={11} className={"text-white"} strokeWidth={3} />
</RadioGroup.Indicator>
</span>
<div
className={cn(
"h-48 -mx-3 -mt-3 mb-3 overflow-hidden",
"bg-gradient-to-b from-nb-gray-800/15 to-nb-gray",
"rounded-t-lg flex items-center justify-center",
)}
>
{preview}
</div>
<h3 className={"text-sm font-semibold text-nb-gray-100"}>{title}</h3>
{description && (
<p className={"text-[0.72rem] leading-snug text-nb-gray-400 mt-0.5"}>
{description}
</p>
)}
</RadioGroup.Item>
);
};
export const CardSelect = Object.assign(Root, { Option });
@@ -1,34 +0,0 @@
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import * as React from "react";
import { cn } from "@/lib/cn";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardPortal = HoverCardPrimitive.Portal;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "start", sideOffset = 6, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-80 rounded-lg border border-nb-gray-900 bg-nb-gray-935",
"p-3 text-nb-gray-200 shadow-lg outline-none",
"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}
/>
</HoverCardPrimitive.Portal>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardContent, HoverCardPortal, HoverCardTrigger };
@@ -0,0 +1,241 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Dialogs } from "@wailsio/runtime";
import { CheckIcon, ChevronDown, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
// Flags live alongside the rest of the SVG flag library under
// assets/flags/1x1 and are filename-matched to the language code
// (de → de.svg, en → en.svg, hu → hu.svg). Vite eager-globs them at
// build time; the JS bundle only holds URL refs, not the SVG bytes.
const FLAG_URLS = import.meta.glob<string>("@/assets/flags/1x1/*.svg", {
eager: true,
import: "default",
query: "?url",
});
const flagByCode: Record<string, string> = {};
for (const path in FLAG_URLS) {
const match = path.match(/1x1\/([^/]+)\.svg$/);
if (match) flagByCode[match[1]] = FLAG_URLS[path];
}
const flagFor = (code: string): string | undefined => flagByCode[code.toLowerCase().split("-")[0]];
function Flag({ code, label }: { code: string; label: string }) {
const src = flagFor(code);
if (!src) {
return (
<span
className={"h-3.5 w-3.5 rounded-full bg-nb-gray-800 shrink-0 inline-block"}
aria-hidden
/>
);
}
return (
<img
src={src}
alt={label}
className={"h-3.5 w-3.5 rounded-full object-cover shrink-0 select-none"}
draggable={false}
/>
);
}
export function LanguagePicker() {
const { t, i18n } = useTranslation();
const [languages, setLanguages] = useState<Language[]>([]);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
loadLanguages()
.then((list) => {
if (!cancelled) setLanguages(list);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const sorted = useMemo(
() => [...languages].sort((a, b) => a.displayName.localeCompare(b.displayName)),
[languages],
);
const current = useMemo(
() =>
languages.find((l) => l.code === i18n.language) ??
languages.find((l) => l.code === "en"),
[languages, i18n.language],
);
const select = async (code: string) => {
if (busy || code === i18n.language) {
setOpen(false);
return;
}
setBusy(true);
try {
await Preferences.SetLanguage(code as LanguageCode);
} catch (e) {
await Dialogs.Error({
Title: t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
setOpen(false);
}
};
return (
<div className={"flex items-center gap-6 justify-between"}>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>{t("settings.general.language.label")}</Label>
<HelpText margin={false}>{t("settings.general.language.help")}</HelpText>
</div>
<div className={"shrink-0"}>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type={"button"}
disabled={busy || languages.length === 0}
className={cn(
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"text-xs font-semibold text-nb-gray-100 cursor-default outline-none",
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
"disabled:opacity-50",
)}
>
{current && <Flag code={current.code} label={current.displayName} />}
<span className={"truncate flex-1 text-left"}>
{current?.displayName ?? "—"}
</span>
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"start"}
sideOffset={6}
onCloseAutoFocus={(e) => e.preventDefault()}
className={cn(
"w-[var(--radix-popover-trigger-width)]",
"rounded-lg border border-nb-gray-850 bg-nb-gray-920 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",
)}
>
<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-1 h-8"}>
<Search size={14} className={"text-nb-gray-200 shrink-0"} />
<Command.Input
autoFocus
placeholder={t("settings.general.language.search")}
className={cn(
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
"outline-none border-none",
)}
/>
</div>
</div>
<ScrollArea.Root type={"auto"} className={"overflow-hidden -mx-1"}>
<ScrollArea.Viewport className={"max-h-64 px-1"}>
<Command.List>
<Command.Empty>
<div
className={
"px-3 py-4 text-center text-[0.7rem] text-nb-gray-400"
}
>
{t("settings.general.language.empty")}
</div>
</Command.Empty>
{sorted.map((lang) => {
const checked = lang.code === i18n.language;
return (
<Command.Item
key={lang.code}
value={`${lang.displayName} ${lang.englishName} ${lang.code}`}
onSelect={() => void select(lang.code)}
className={cn(
"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",
"data-[selected=true]:bg-nb-gray-850 data-[selected=true]:text-nb-gray-50",
)}
>
<Flag
code={lang.code}
label={lang.displayName}
/>
<span className={"flex-1 truncate"}>
{lang.displayName}
</span>
<span
className={
"w-4 shrink-0 flex items-center justify-center"
}
>
{checked && (
<CheckIcon
size={14}
className={"text-netbird"}
/>
)}
</span>
</Command.Item>
);
})}
</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>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import netbirdLogo from "@/assets/logos/netbird.svg";
import { SwitchItem } from "@/components/switches/SwitchItem";
import { SwitchItemGroup } from "@/components/switches/SwitchItemGroup";
import { ManagementMode } from "@/hooks/useManagementUrl.ts";
type Props = {
value: ManagementMode;
onChange: (mode: ManagementMode) => void;
};
export const ManagementServerSwitch = ({ value, onChange }: Props) => {
const { t, i18n } = useTranslation();
return (
<SwitchItemGroup
key={i18n.language}
value={value}
onChange={(v) => onChange(v as ManagementMode)}
>
<SwitchItem value={ManagementMode.Cloud}>
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
{t("settings.general.management.cloud")}
</SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted}>
{t("settings.general.management.selfHosted")}
</SwitchItem>
</SwitchItemGroup>
);
};
@@ -1,81 +0,0 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusCircle } from "lucide-react";
import * as Dialog from "@/components/Dialog";
import { Input } from "@/components/Input";
import { Button } from "@/components/Button";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (name: string) => void;
};
export const NewProfileModal = ({ 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>
);
};
@@ -1,79 +0,0 @@
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>
);
});
@@ -1,256 +0,0 @@
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 "@/components/ProfileAvatar";
import type { Profile } from "@bindings/services/models.js";
import { NewProfileModal } from "@/components/NewProfileModal";
import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/modules/profile/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>
<NewProfileModal
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>;
};
@@ -1,48 +0,0 @@
import type { ReactNode } from "react";
import { Check, Loader2, XCircle } from "lucide-react";
import { cn } from "@/lib/cn";
type Variant = "loading" | "success" | "error";
type Props = {
variant: Variant;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
};
const VARIANTS: Record<Variant, { icon: ReactNode; className: string }> = {
loading: {
icon: <Loader2 className={"animate-spin text-nb-gray-950"} size={16} />,
className: "bg-nb-gray-100",
},
success: {
icon: <Check className={"text-white"} size={18} />,
className: "bg-green-500",
},
error: {
icon: <XCircle className={"text-white"} size={18} />,
className: "bg-red-500",
},
};
export function StatusPanel({ variant, title, description, children, actions }: Props) {
const { icon, className } = VARIANTS[variant];
return (
<div className={"absolute inset-0 flex flex-col items-center justify-center gap-5 px-8"}>
<div className={cn("h-9 w-9 rounded-md flex items-center justify-center", className)}>
{icon}
</div>
<div className={"flex flex-col items-center gap-0.5 max-w-md text-center"}>
<p className={"text-base font-medium text-nb-gray-50"}>{title}</p>
{description && <p className={"text-sm text-nb-gray-300"}>{description}</p>}
</div>
{children && <div className={"w-full max-w-md flex flex-col gap-3"}>{children}</div>}
{actions && <div className={"flex items-center gap-2"}>{actions}</div>}
</div>
);
}
@@ -1,42 +0,0 @@
import { cn } from "../lib/cn";
interface Props {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
description?: string;
}
export function Switch({ checked, onChange, disabled, label, description }: Props) {
return (
<label className={cn("flex items-start gap-3", disabled && "opacity-60")}>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"mt-0.5 inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-netbird" : "bg-nb-gray-300 dark:bg-nb-gray-700",
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)}
/>
</button>
{(label || description) && (
<span className="flex flex-col">
{label && <span className="text-sm font-medium">{label}</span>}
{description && (
<span className="text-xs text-nb-gray-500">{description}</span>
)}
</span>
)}
</label>
);
}
@@ -1,40 +0,0 @@
import { ReactNode, useState } from "react";
import { cn } from "../lib/cn";
interface Tab {
value: string;
label: string;
content: ReactNode;
}
interface Props {
tabs: Tab[];
initial?: string;
}
export function Tabs({ tabs, initial }: Props) {
const [active, setActive] = useState(initial ?? tabs[0]?.value);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 gap-1 border-b border-nb-gray-200 dark:border-nb-gray-800">
{tabs.map((t) => (
<button
key={t.value}
onClick={() => setActive(t.value)}
className={cn(
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
active === t.value
? "border-netbird text-netbird"
: "border-transparent text-nb-gray-500 hover:text-nb-gray-800 dark:hover:text-nb-gray-200",
)}
>
{t.label}
</button>
))}
</div>
<div className="flex-1 overflow-auto">
{tabs.find((t) => t.value === active)?.content}
</div>
</div>
);
}
@@ -3,15 +3,15 @@ import classNames from "classnames";
import { Check, Copy } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
export type ButtonVariants = VariantProps<typeof buttonVariants>;
type ButtonVariants = VariantProps<typeof buttonVariants>;
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVariants {
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
}
export const buttonVariants = cva(
const buttonVariants = cva(
[
"relative",
"text-sm focus:z-10 focus:ring-2 font-semibold focus:outline-none whitespace-nowrap shadow-sm select-none",
@@ -5,11 +5,8 @@ import { X } from "lucide-react";
import { cn } from "@/lib/cn";
export const Root = DialogPrimitive.Root;
export const Trigger = DialogPrimitive.Trigger;
export const Close = DialogPrimitive.Close;
export const Portal = DialogPrimitive.Portal;
export const Overlay = forwardRef<
const Overlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function DialogOverlay({ className, ...props }, ref) {
@@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next";
import { AlertCircleIcon, BookText } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Button } from "@/components/buttons/Button";
import { useStatus } from "@/contexts/StatusContext.tsx";
const DOCS_URL = "https://docs.netbird.io/how-to/installation";
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
}
export const DaemonUnavailableOverlay = () => {
const { t } = useTranslation();
const { isDaemonUnavailable } = useStatus();
if (!isDaemonUnavailable) return null;
return (
<div
className={
"fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950 backdrop-blur-sm cursor-default select-none wails-draggable"
}
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}>
<div
className={
"h-11 w-11 rounded-xl flex items-center justify-center bg-nb-gray-920 border border-nb-gray-900 text-red-500"
}
>
<AlertCircleIcon size={20} />
</div>
<div className={"flex flex-col items-center gap-1"}>
<p className={"text-base font-medium text-nb-gray-50"}>
{t("daemon.unavailable.title")}
</p>
<p className={"text-sm text-nb-gray-300"}>
{t("daemon.unavailable.description")}
</p>
</div>
<div className={"wails-no-draggable"}>
<Button variant={"secondary"} size={"xs"} onClick={() => openUrl(DOCS_URL)}>
<BookText size={14} />
{t("daemon.unavailable.docsLink")}
</Button>
</div>
</div>
</div>
);
};
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { ExternalLinkIcon, LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { SquareIcon } from "./SquareIcon";
import { SquareIcon } from "@/components/SquareIcon";
type Props = {
icon: ComponentType<LucideProps>;
@@ -2,7 +2,7 @@ import { cva, VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Label } from "@/components/Label";
import { Label } from "@/components/typography/Label";
type InputVariants = VariantProps<typeof inputVariants>;
@@ -1,7 +1,7 @@
import React from "react";
import { HelpText } from "@/components/HelpText";
import { Label } from "@/components/Label";
import { ToggleSwitch } from "@/components/ToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { ToggleSwitch } from "@/components/switches/ToggleSwitch";
import { cn } from "@/lib/cn";
interface Props {
@@ -2,7 +2,7 @@ import * as RadioGroup from "@radix-ui/react-radio-group";
import { motion } from "framer-motion";
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
import { useSwitchItemGroup } from "@/components/SwitchItemGroup";
import { useSwitchItemGroup } from "@/components/switches/SwitchItemGroup";
type Props = {
value: string;