img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
className,
)}
{...props}
@@ -20,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
) {
return (
);
@@ -42,7 +50,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
);
@@ -65,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
);
@@ -75,7 +83,10 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
);
diff --git a/src/components/ui/checkbox.tsx b/src/ui/components/checkbox.tsx
similarity index 100%
rename from src/components/ui/checkbox.tsx
rename to src/ui/components/checkbox.tsx
diff --git a/src/ui/components/command.tsx b/src/ui/components/command.tsx
new file mode 100644
index 00000000..a302540c
--- /dev/null
+++ b/src/ui/components/command.tsx
@@ -0,0 +1,130 @@
+import * as React from "react";
+import { Search } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Command = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Command.displayName = "Command";
+
+const CommandInput = React.forwardRef<
+ HTMLInputElement,
+ React.InputHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+CommandInput.displayName = "CommandInput";
+
+const CommandList = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CommandList.displayName = "CommandList";
+
+const CommandGroup = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & { heading?: string }
+>(({ className, heading, children, ...props }, ref) => (
+
+ {heading &&
{heading}
}
+ {children}
+
+));
+CommandGroup.displayName = "CommandGroup";
+
+const CommandSeparator = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = "CommandSeparator";
+
+const CommandItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & { onSelect?: () => void }
+>(({ className, onSelect, ...props }, ref) => (
+
+));
+CommandItem.displayName = "CommandItem";
+
+const CommandEmpty = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CommandEmpty.displayName = "CommandEmpty";
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+CommandShortcut.displayName = "CommandShortcut";
+
+export {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandSeparator,
+ CommandEmpty,
+ CommandShortcut,
+};
diff --git a/src/components/ui/dialog.tsx b/src/ui/components/dialog.tsx
similarity index 60%
rename from src/components/ui/dialog.tsx
rename to src/ui/components/dialog.tsx
index 64249170..4493c20f 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/ui/components/dialog.tsx
@@ -1,8 +1,9 @@
import * as React from "react";
-import * as DialogPrimitive from "@radix-ui/react-dialog";
-import { XIcon } from "lucide-react";
+import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
+import { Button } from "@/components/button";
+import { XIcon } from "lucide-react";
function Dialog({
...props
@@ -36,7 +37,7 @@ function DialogOverlay({
+
{children}
{showCloseButton && (
-
-
- Close
+
+
+
+ Close
+
)}
@@ -82,13 +86,20 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
);
}
-function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean;
+}) {
return (
) {
className,
)}
{...props}
- />
+ >
+ {children}
+ {showCloseButton && (
+
+ Close
+
+ )}
+
);
}
@@ -108,7 +126,7 @@ function DialogTitle({
return (
);
@@ -121,7 +139,10 @@ function DialogDescription({
return (
);
diff --git a/src/ui/components/dropdown-menu.tsx b/src/ui/components/dropdown-menu.tsx
new file mode 100644
index 00000000..c03b7987
--- /dev/null
+++ b/src/ui/components/dropdown-menu.tsx
@@ -0,0 +1,271 @@
+import * as React from "react";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+import { CheckIcon, ChevronRightIcon } from "lucide-react";
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuContent({
+ className,
+ align = "start",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/src/components/ui/folder.tsx b/src/ui/components/folder.tsx
similarity index 100%
rename from src/components/ui/folder.tsx
rename to src/ui/components/folder.tsx
diff --git a/src/components/ui/form.tsx b/src/ui/components/form.tsx
similarity index 98%
rename from src/components/ui/form.tsx
rename to src/ui/components/form.tsx
index 50ee37c3..ca28b235 100644
--- a/src/components/ui/form.tsx
+++ b/src/ui/components/form.tsx
@@ -13,7 +13,7 @@ import {
} from "react-hook-form";
import { cn } from "@/lib/utils";
-import { Label } from "@/components/ui/label";
+import { Label } from "@/components/label";
const Form = FormProvider;
diff --git a/src/ui/components/input.tsx b/src/ui/components/input.tsx
new file mode 100644
index 00000000..bc0898a2
--- /dev/null
+++ b/src/ui/components/input.tsx
@@ -0,0 +1,19 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/src/ui/components/kbd.tsx b/src/ui/components/kbd.tsx
new file mode 100644
index 00000000..bd172665
--- /dev/null
+++ b/src/ui/components/kbd.tsx
@@ -0,0 +1,40 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface KbdProps extends React.HTMLAttributes {}
+
+const Kbd = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Kbd.displayName = "Kbd";
+
+const KbdKey = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+KbdKey.displayName = "KbdKey";
+
+const KbdSeparator = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+ +
+
+);
+KbdSeparator.displayName = "KbdSeparator";
+
+export { Kbd, KbdKey, KbdSeparator };
diff --git a/src/components/ui/label.tsx b/src/ui/components/label.tsx
similarity index 100%
rename from src/components/ui/label.tsx
rename to src/ui/components/label.tsx
diff --git a/src/components/ui/password-input.tsx b/src/ui/components/password-input.tsx
similarity index 96%
rename from src/components/ui/password-input.tsx
rename to src/ui/components/password-input.tsx
index 5eac52b5..d8a57ff0 100644
--- a/src/components/ui/password-input.tsx
+++ b/src/ui/components/password-input.tsx
@@ -2,7 +2,7 @@
import * as React from "react";
import { Eye, EyeOff } from "lucide-react";
-import { Input } from "@/components/ui/input";
+import { Input } from "@/components/input";
import { cn } from "@/lib/utils";
type PasswordInputProps = React.InputHTMLAttributes;
diff --git a/src/components/ui/popover.tsx b/src/ui/components/popover.tsx
similarity index 100%
rename from src/components/ui/popover.tsx
rename to src/ui/components/popover.tsx
diff --git a/src/components/ui/scroll-area.tsx b/src/ui/components/scroll-area.tsx
similarity index 100%
rename from src/components/ui/scroll-area.tsx
rename to src/ui/components/scroll-area.tsx
diff --git a/src/ui/components/section-card.tsx b/src/ui/components/section-card.tsx
new file mode 100644
index 00000000..a1622b96
--- /dev/null
+++ b/src/ui/components/section-card.tsx
@@ -0,0 +1,82 @@
+import { useState } from "react";
+import type React from "react";
+
+export function SectionCard({
+ title,
+ icon,
+ action,
+ children,
+}: {
+ title: string;
+ icon: React.ReactNode;
+ action?: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
{icon}
+
+ {title}
+
+ {action &&
{action}
}
+
+
{children}
+
+ );
+}
+
+export function SettingRow({
+ label,
+ badge,
+ description,
+ children,
+}: {
+ label: string;
+ badge?: string;
+ description?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+ {badge && (
+
+ {badge}
+
+ )}
+
+ {description && (
+
{description}
+ )}
+
+
{children}
+
+ );
+}
+
+export function FakeSwitch({
+ defaultChecked = false,
+ onChange,
+}: {
+ defaultChecked?: boolean;
+ onChange?: (v: boolean) => void;
+}) {
+ const [on, setOn] = useState(defaultChecked);
+ return (
+ {
+ const next = !on;
+ setOn(next);
+ onChange?.(next);
+ }}
+ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
+ >
+
+
+ );
+}
diff --git a/src/components/ui/select.tsx b/src/ui/components/select.tsx
similarity index 100%
rename from src/components/ui/select.tsx
rename to src/ui/components/select.tsx
diff --git a/src/components/ui/separator.tsx b/src/ui/components/separator.tsx
similarity index 64%
rename from src/components/ui/separator.tsx
rename to src/ui/components/separator.tsx
index 72c18e33..d7fc9faa 100644
--- a/src/components/ui/separator.tsx
+++ b/src/ui/components/separator.tsx
@@ -1,7 +1,5 @@
-"use client";
-
import * as React from "react";
-import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
@@ -17,7 +15,7 @@ function Separator({
decorative={decorative}
orientation={orientation}
className={cn(
- "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+ "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
diff --git a/src/components/ui/shadcn-io/status/index.tsx b/src/ui/components/shadcn-io/status/index.tsx
similarity index 97%
rename from src/components/ui/shadcn-io/status/index.tsx
rename to src/ui/components/shadcn-io/status/index.tsx
index 5131e84c..0ee2b367 100644
--- a/src/components/ui/shadcn-io/status/index.tsx
+++ b/src/ui/components/shadcn-io/status/index.tsx
@@ -1,5 +1,5 @@
import type { ComponentProps, HTMLAttributes } from "react";
-import { Badge } from "@/components/ui/badge";
+import { Badge } from "@/components/badge";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
diff --git a/src/components/ui/sheet.tsx b/src/ui/components/sheet.tsx
similarity index 52%
rename from src/components/ui/sheet.tsx
rename to src/ui/components/sheet.tsx
index f449ce91..c4168fe8 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/ui/components/sheet.tsx
@@ -1,8 +1,9 @@
import * as React from "react";
-import * as SheetPrimitive from "@radix-ui/react-dialog";
-import { XIcon } from "lucide-react";
+import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
+import { Button } from "@/components/button";
+import { XIcon } from "lucide-react";
function Sheet({ ...props }: React.ComponentProps) {
return ;
@@ -34,7 +35,7 @@ function SheetOverlay({
& {
side?: "top" | "right" | "bottom" | "left";
+ showCloseButton?: boolean;
}) {
return (
{children}
-
-
- Close
-
+ {showCloseButton && (
+
+
+
+ Close
+
+
+ )}
);
@@ -83,7 +87,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
);
@@ -106,7 +110,10 @@ function SheetTitle({
return (
);
@@ -119,7 +126,7 @@ function SheetDescription({
return (
);
diff --git a/src/components/ui/sidebar.tsx b/src/ui/components/sidebar.tsx
similarity index 63%
rename from src/components/ui/sidebar.tsx
rename to src/ui/components/sidebar.tsx
index 8413b712..d2deb02f 100644
--- a/src/components/ui/sidebar.tsx
+++ b/src/ui/components/sidebar.tsx
@@ -1,25 +1,29 @@
-/* eslint-disable react-refresh/only-export-components */
+"use client";
+
import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
-import { PanelLeftIcon } from "lucide-react";
+import { Slot } from "radix-ui";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Separator } from "@/components/ui/separator";
-import { Skeleton } from "@/components/ui/skeleton";
+import { Button } from "@/components/button";
+import { Input } from "@/components/input";
+import { Separator } from "@/components/separator";
import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/sheet";
+import { Skeleton } from "@/components/skeleton";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip";
+import { PanelLeftIcon } from "lucide-react";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
+const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -119,25 +123,23 @@ function SidebarProvider({
return (
-
-
- {children}
-
-
+
+ {children}
+
);
}
@@ -148,20 +150,21 @@ function Sidebar({
collapsible = "offcanvas",
className,
children,
+ dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
- const { state } = useSidebar();
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
- //
- //
- // Sidebar
- // Displays the mobile sidebar.
- //
- // {children}
- //
- //
- // )
- // }
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ );
+ }
return (
+ {/* This is what handles the sidebar gap on desktop */}
{children}
@@ -256,8 +258,8 @@ function SidebarTrigger({
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
- size="icon"
- className={cn("size-7", className)}
+ size="icon-sm"
+ className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
@@ -282,10 +284,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
- "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
+ "absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
- "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
+ "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
@@ -300,8 +302,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
);
@@ -353,7 +354,7 @@ function SidebarSeparator({
);
@@ -365,7 +366,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
- "flex min-h-0 flex-1 flex-col gap-2 overflow-auto thin-scrollbar group-data-[collapsible=icon]:overflow-hidden",
+ "no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
@@ -389,15 +390,14 @@ function SidebarGroupLabel({
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "div";
+ const Comp = asChild ? Slot.Root : "div";
return (
svg]:size-4 [&>svg]:shrink-0",
- "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
+ "flex h-8 shrink-0 items-center rounded-none px-2 text-xs text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className,
)}
{...props}
@@ -410,17 +410,14 @@ function SidebarGroupAction({
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? Slot.Root : "button";
return (
svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "group-data-[collapsible=icon]:hidden",
+ "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-none p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
)}
{...props}
@@ -436,7 +433,7 @@ function SidebarGroupContent({
);
@@ -447,7 +444,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
);
@@ -465,7 +462,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
- "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-none p-2 text-left text-xs ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
@@ -474,9 +471,9 @@ const sidebarMenuButtonVariants = cva(
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
- default: "h-8 text-sm",
+ default: "h-8 text-xs",
sm: "h-7 text-xs",
- lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ lg: "h-12 text-xs group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
@@ -499,7 +496,7 @@ function SidebarMenuButton({
isActive?: boolean;
tooltip?: string | React.ComponentProps;
} & VariantProps) {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? Slot.Root : "button";
const { isMobile, state } = useSidebar();
const button = (
@@ -545,22 +542,16 @@ function SidebarMenuAction({
asChild?: boolean;
showOnHover?: boolean;
}) {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? Slot.Root : "button";
return (
svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "peer-data-[size=sm]/menu-button:top-1",
- "peer-data-[size=default]/menu-button:top-1.5",
- "peer-data-[size=lg]/menu-button:top-2.5",
- "group-data-[collapsible=icon]:hidden",
+ "absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-none p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
- "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className,
)}
{...props}
@@ -577,12 +568,7 @@ function SidebarMenuBadge({
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
- "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
- "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
- "peer-data-[size=sm]/menu-button:top-1",
- "peer-data-[size=default]/menu-button:top-1.5",
- "peer-data-[size=lg]/menu-button:top-2.5",
- "group-data-[collapsible=icon]:hidden",
+ "pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-none px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className,
)}
{...props}
@@ -606,12 +592,12 @@ function SidebarMenuSkeleton({
{showIcon && (
)}
@@ -634,8 +620,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
- "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
- "group-data-[collapsible=icon]:hidden",
+ "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
@@ -668,7 +653,7 @@ function SidebarMenuSubButton({
size?: "sm" | "md";
isActive?: boolean;
}) {
- const Comp = asChild ? Slot : "a";
+ const Comp = asChild ? Slot.Root : "a";
return (
svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
- "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
- size === "sm" && "text-xs",
- size === "md" && "text-sm",
- "group-data-[collapsible=icon]:hidden",
+ "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-none px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-xs data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className,
)}
{...props}
diff --git a/src/components/ui/skeleton.tsx b/src/ui/components/skeleton.tsx
similarity index 74%
rename from src/components/ui/skeleton.tsx
rename to src/ui/components/skeleton.tsx
index 01689981..ac4620a7 100644
--- a/src/components/ui/skeleton.tsx
+++ b/src/ui/components/skeleton.tsx
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
);
diff --git a/src/components/ui/slider.tsx b/src/ui/components/slider.tsx
similarity index 100%
rename from src/components/ui/slider.tsx
rename to src/ui/components/slider.tsx
diff --git a/src/ui/components/sonner.tsx b/src/ui/components/sonner.tsx
new file mode 100644
index 00000000..900e3bfe
--- /dev/null
+++ b/src/ui/components/sonner.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useTheme } from "@/components/theme-provider";
+import { Toaster as Sonner } from "sonner";
+
+type ToasterProps = React.ComponentProps;
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+
+ );
+};
+
+export { Toaster };
diff --git a/src/components/ui/switch.tsx b/src/ui/components/switch.tsx
similarity index 100%
rename from src/components/ui/switch.tsx
rename to src/ui/components/switch.tsx
diff --git a/src/components/ui/table.tsx b/src/ui/components/table.tsx
similarity index 100%
rename from src/components/ui/table.tsx
rename to src/ui/components/table.tsx
diff --git a/src/components/ui/tabs.tsx b/src/ui/components/tabs.tsx
similarity index 100%
rename from src/components/ui/tabs.tsx
rename to src/ui/components/tabs.tsx
diff --git a/src/components/ui/textarea.tsx b/src/ui/components/textarea.tsx
similarity index 95%
rename from src/components/ui/textarea.tsx
rename to src/ui/components/textarea.tsx
index a5c84926..2f68150e 100644
--- a/src/components/ui/textarea.tsx
+++ b/src/ui/components/textarea.tsx
@@ -1,6 +1,6 @@
import * as React from "react";
-import { cn } from "../../lib/utils";
+import { cn } from "@/lib/utils";
export type TextareaProps = React.TextareaHTMLAttributes;
diff --git a/src/components/theme-provider.tsx b/src/ui/components/theme-provider.tsx
similarity index 53%
rename from src/components/theme-provider.tsx
rename to src/ui/components/theme-provider.tsx
index ba8b8e24..efb4bd78 100644
--- a/src/components/theme-provider.tsx
+++ b/src/ui/components/theme-provider.tsx
@@ -1,29 +1,32 @@
-/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useState } from "react";
-
-type Theme =
- | "dark"
- | "light"
- | "system"
- | "dracula"
- | "gentlemansChoice"
- | "midnightEspresso"
- | "catppuccinMocha";
+import type { ThemeId } from "@/types/ui-types";
type ThemeProviderProps = {
children: React.ReactNode;
- defaultTheme?: Theme;
+ defaultTheme?: ThemeId;
storageKey?: string;
};
type ThemeProviderState = {
- theme: Theme;
- setTheme: (theme: Theme) => void;
- setThemePreview: (theme: Theme | null) => void;
+ theme: ThemeId;
+ setTheme: (theme: ThemeId) => void;
+ setThemePreview: (theme: ThemeId | null) => void;
};
+const ALL_THEME_CLASSES = [
+ "light",
+ "dark",
+ "dracula",
+ "catppuccin",
+ "nord",
+ "solarized",
+ "tokyo-night",
+ "one-dark",
+ "gruvbox",
+];
+
const initialState: ThemeProviderState = {
- theme: "system",
+ theme: "dark",
setTheme: () => null,
setThemePreview: () => null,
};
@@ -32,60 +35,41 @@ const ThemeProviderContext = createContext(initialState);
export function ThemeProvider({
children,
- defaultTheme = "system",
+ defaultTheme = "dark",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
- const [theme, setTheme] = useState(
- () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
+ const [theme, setTheme] = useState(
+ () => (localStorage.getItem(storageKey) as ThemeId) || defaultTheme,
);
- const [previewTheme, setPreviewTheme] = useState(null);
+ const [previewTheme, setPreviewTheme] = useState(null);
+
+ const activeTheme = previewTheme ?? theme;
useEffect(() => {
const root = window.document.documentElement;
-
- root.classList.remove(
- "light",
- "dark",
- "dracula",
- "gentlemansChoice",
- "midnightEspresso",
- "catppuccinMocha",
- );
-
- const activeTheme = previewTheme || theme;
+ root.classList.remove(...ALL_THEME_CLASSES);
if (activeTheme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
-
root.classList.add(systemTheme);
return;
}
root.classList.add(activeTheme);
-
- const darkCustomThemes: Theme[] = [
- "dracula",
- "gentlemansChoice",
- "midnightEspresso",
- "catppuccinMocha",
- ];
- if (darkCustomThemes.includes(activeTheme)) {
- root.classList.add("dark");
- }
- }, [theme, previewTheme]);
+ }, [activeTheme]);
const value = {
theme,
- setTheme: (theme: Theme) => {
+ setTheme: (theme: ThemeId) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
- setThemePreview: (theme: Theme | null) => {
- setPreviewTheme(theme);
+ setThemePreview: (preview: ThemeId | null) => {
+ setPreviewTheme(preview);
},
};
@@ -98,9 +82,7 @@ export function ThemeProvider({
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
-
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
-
return context;
};
diff --git a/src/ui/components/tooltip.tsx b/src/ui/components/tooltip.tsx
new file mode 100644
index 00000000..71ebfff5
--- /dev/null
+++ b/src/ui/components/tooltip.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import * as React from "react";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
diff --git a/src/components/ui/version-alert.tsx b/src/ui/components/version-alert.tsx
similarity index 98%
rename from src/components/ui/version-alert.tsx
rename to src/ui/components/version-alert.tsx
index 4493e749..b4df3c32 100644
--- a/src/components/ui/version-alert.tsx
+++ b/src/ui/components/version-alert.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
-import { Button } from "@/components/ui/button.tsx";
+import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
+import { Button } from "@/components/button.tsx";
import { ExternalLink, Download, AlertTriangle, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/dashboard/Dashboard.tsx
similarity index 75%
rename from src/ui/desktop/apps/dashboard/Dashboard.tsx
rename to src/ui/dashboard/Dashboard.tsx
index 3727e5eb..ebfec4f9 100644
--- a/src/ui/desktop/apps/dashboard/Dashboard.tsx
+++ b/src/ui/dashboard/Dashboard.tsx
@@ -1,7 +1,7 @@
-import React, { useEffect, useState } from "react";
-import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
-import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
-import { Button } from "@/components/ui/button.tsx";
+import React, { useEffect, useState, useRef, useCallback } from "react";
+import { Auth } from "@/auth/LoginPage.tsx";
+import { AlertManager } from "@/dashboard/panels/alerts/AlertManager.tsx";
+import { Button } from "@/components/button.tsx";
import {
getUserInfo,
getDatabaseHealth,
@@ -19,21 +19,21 @@ import {
getGuacamoleTokenFromHost,
isCurrentAuthInvalidationError,
type RecentActivityItem,
-} from "@/ui/main-axios.ts";
-import { useSidebar } from "@/components/ui/sidebar.tsx";
-import { Separator } from "@/components/ui/separator.tsx";
-import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
-import { Kbd } from "@/components/ui/kbd";
+} from "@/main-axios.ts";
+import { useSidebar } from "@/components/sidebar.tsx";
+import { Separator } from "@/components/separator.tsx";
+import { useTabs } from "@/shell/TabContext.tsx";
+import { Kbd } from "@/components/kbd";
import { useTranslation } from "react-i18next";
import { Settings as SettingsIcon } from "lucide-react";
-import { ServerOverviewCard } from "@/ui/desktop/apps/dashboard/cards/ServerOverviewCard";
-import { RecentActivityCard } from "@/ui/desktop/apps/dashboard/cards/RecentActivityCard";
-import { QuickActionsCard } from "@/ui/desktop/apps/dashboard/cards/QuickActionsCard";
-import { ServerStatsCard } from "@/ui/desktop/apps/dashboard/cards/ServerStatsCard";
-import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
-import { useDashboardPreferences } from "@/ui/desktop/apps/dashboard/hooks/useDashboardPreferences";
-import { DashboardSettingsDialog } from "@/ui/desktop/apps/dashboard/components/DashboardSettingsDialog";
-import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader";
+import { ServerOverviewCard } from "@/dashboard/cards/ServerOverviewCard";
+import { RecentActivityCard } from "@/dashboard/cards/RecentActivityCard";
+import { QuickActionsCard } from "@/dashboard/cards/QuickActionsCard";
+import { ServerStatsCard } from "@/dashboard/cards/ServerStatsCard";
+import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
+import { useDashboardPreferences } from "@/dashboard/hooks/useDashboardPreferences";
+import { DashboardSettingsDialog } from "@/dashboard/components/DashboardSettingsDialog";
+import { SimpleLoader } from "@/lib/SimpleLoader";
interface DashboardProps {
onSelectView: (view: string) => void;
@@ -101,6 +101,34 @@ export function Dashboard({
resetLayout,
} = useDashboardPreferences(loggedIn);
+ const containerRef = useRef(null);
+ const mainWidthPct = layout?.mainWidthPct ?? 68;
+
+ const handleDividerMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ const startX = e.clientX;
+ const startPct = mainWidthPct;
+ const onMove = (ev: MouseEvent) => {
+ if (!containerRef.current) return;
+ const totalW = containerRef.current.getBoundingClientRect().width;
+ const delta = ev.clientX - startX;
+ const newPct = Math.min(
+ 85,
+ Math.max(30, startPct + (delta / totalW) * 100),
+ );
+ if (layout) updateLayout({ ...layout, mainWidthPct: newPct });
+ };
+ const onUp = () => {
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ },
+ [mainWidthPct, layout, updateLayout],
+ );
+
let sidebarState: "expanded" | "collapsed" = "expanded";
try {
const sidebar = useSidebar();
@@ -683,79 +711,130 @@ export function Dashboard({
-
- {!preferencesLoading && layout && (
-
- {layout.cards
+
+ {!preferencesLoading &&
+ layout &&
+ (() => {
+ const enabledCards = layout.cards
.filter((card) => card.enabled)
- .sort((a, b) => a.order - b.order)
- .map((card) => {
- if (card.id === "server_overview") {
- return (
-
a.order - b.order);
+
+ const mainCards = enabledCards.filter(
+ (c) => (c.panel ?? "main") === "main",
+ );
+ const sideCards = enabledCards.filter(
+ (c) => c.panel === "side",
+ );
+
+ const renderCard = (card: (typeof enabledCards)[0]) => {
+ if (card.id === "server_overview") {
+ return (
+
+ );
+ } else if (card.id === "recent_activity") {
+ return (
+
+ );
+ } else if (card.id === "network_graph") {
+ return (
+
+ );
+ } else if (card.id === "quick_actions") {
+ return (
+
+ );
+ } else if (card.id === "server_stats") {
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ return (
+ <>
+
+ {mainCards.map((card) => (
+
- );
- } else if (card.id === "recent_activity") {
- return (
-
+ {renderCard(card)}
+
+ ))}
+
+
+
+
+
+ {sideCards.map((card) => (
+
- );
- } else if (card.id === "network_graph") {
- return (
-
- );
- } else if (card.id === "quick_actions") {
- return (
-
- );
- } else if (card.id === "server_stats") {
- return (
-
- );
- }
- return null;
- })}
-
- )}
+ style={
+ card.height
+ ? { height: card.height, flexShrink: 0 }
+ : { flex: 1, minHeight: 280 }
+ }
+ >
+ {renderCard(card)}
+
+ ))}
+
+ >
+ );
+ })()}
diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx
new file mode 100644
index 00000000..ea3c8f7b
--- /dev/null
+++ b/src/ui/dashboard/DashboardTab.tsx
@@ -0,0 +1,1471 @@
+import { useState, useRef, useCallback, useEffect } from "react";
+import { useIsMobile } from "@/hooks/use-mobile";
+import { Button } from "@/components/button";
+import { Card } from "@/components/card";
+import { Separator } from "@/components/separator";
+import {
+ Activity,
+ Database,
+ GripHorizontal,
+ GripVertical,
+ KeyRound,
+ LayoutDashboard,
+ Network,
+ Plus,
+ RefreshCw,
+ Server,
+ Settings,
+ Terminal,
+ Trash2,
+ User,
+ Zap,
+} from "lucide-react";
+import CytoscapeComponent from "react-cytoscapejs";
+import { Kbd } from "@/components/kbd";
+import { DASHBOARD_CARDS } from "@/lib/theme";
+import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
+import {
+ getSSHHosts,
+ getUptime,
+ getRecentActivity,
+ getTunnelStatuses,
+ getCredentials,
+ resetRecentActivity,
+} from "@/main-axios";
+import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios";
+
+function sshHostToHost(h: SSHHostWithStatus): Host {
+ return {
+ id: String(h.id),
+ name: h.name,
+ user: h.username,
+ address: h.ip,
+ port: h.port,
+ folder: h.folder ?? "",
+ online: h.status === "online",
+ cpu: 0,
+ ram: 0,
+ lastAccess: "",
+ tags: h.tags ?? [],
+ authType: h.authType,
+ password: h.password,
+ key: typeof h.key === "string" ? h.key : undefined,
+ keyPassword: h.keyPassword,
+ keyType: h.keyType,
+ credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
+ notes: h.notes,
+ pin: h.pin ?? false,
+ macAddress: h.macAddress,
+ enableTerminal: h.enableTerminal ?? true,
+ enableTunnel: h.enableTunnel ?? false,
+ enableFileManager: h.enableFileManager ?? false,
+ enableDocker: h.enableDocker ?? false,
+ enableSsh: h.connectionType === "ssh" || !h.connectionType,
+ enableRdp: h.connectionType === "rdp",
+ enableVnc: h.connectionType === "vnc",
+ enableTelnet: h.connectionType === "telnet",
+ sshPort: h.port,
+ rdpPort: 3389,
+ vncPort: 5900,
+ telnetPort: 23,
+ quickActions: (h.quickActions ?? []).map((a) => ({
+ name: a.name,
+ snippetId: String(a.snippetId),
+ })),
+ serverTunnels: [],
+ defaultPath: h.defaultPath,
+ terminalConfig: h.terminalConfig as Host["terminalConfig"],
+ useSocks5: h.useSocks5,
+ socks5Host: h.socks5Host,
+ socks5Port: h.socks5Port,
+ socks5Username: h.socks5Username,
+ socks5Password: h.socks5Password,
+ };
+}
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+type PanelId = "main" | "side";
+
+type CardSlot = {
+ id: DashboardCardId;
+ panel: PanelId;
+ order: number;
+ height: number | null;
+};
+
+type DragState = {
+ id: DashboardCardId;
+ sourcePanel: PanelId;
+ sourceOrder: number;
+} | null;
+
+// ─── Default layout ───────────────────────────────────────────────────────────
+
+const DEFAULT_SLOTS: CardSlot[] = [
+ { id: "stats_bar", panel: "main", order: 0, height: 96 },
+ { id: "counters_bar", panel: "main", order: 1, height: 48 },
+ { id: "quick_actions", panel: "main", order: 2, height: 160 },
+ { id: "host_status", panel: "main", order: 3, height: null },
+ { id: "recent_activity", panel: "side", order: 0, height: null },
+];
+
+const CARD_META: Record = {
+ stats_bar: { label: "Status Bar" },
+ counters_bar: { label: "Counters" },
+ quick_actions: { label: "Quick Actions" },
+ host_status: { label: "Host Status" },
+ recent_activity: { label: "Recent Activity" },
+ network_graph: { label: "Network Graph" },
+};
+
+// ─── useColumnResize ──────────────────────────────────────────────────────────
+
+function useColumnResize(
+ containerRef: React.RefObject,
+ mainWidthPct: number,
+ setMainWidthPct: (v: number) => void,
+) {
+ const dragging = useRef(false);
+ const startX = useRef(0);
+ const startPct = useRef(0);
+
+ const onMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ dragging.current = true;
+ startX.current = e.clientX;
+ startPct.current = mainWidthPct;
+
+ const onMove = (ev: MouseEvent) => {
+ if (!dragging.current || !containerRef.current) return;
+ const totalW = containerRef.current.getBoundingClientRect().width;
+ const delta = ev.clientX - startX.current;
+ const newPct = Math.min(
+ 85,
+ Math.max(30, startPct.current + (delta / totalW) * 100),
+ );
+ setMainWidthPct(newPct);
+ };
+ const onUp = () => {
+ dragging.current = false;
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ },
+ [containerRef, mainWidthPct, setMainWidthPct],
+ );
+
+ return onMouseDown;
+}
+
+// ─── Card components ──────────────────────────────────────────────────────────
+
+function StatsBarCard({
+ hosts,
+ uptimeFormatted,
+}: {
+ hosts: Host[];
+ uptimeFormatted: string;
+}) {
+ const online = hosts.filter((h) => h.online).length;
+ return (
+
+
+
+ Version
+
+
+ v2.2.0
+
+
+ STABLE
+
+
+
+
+ Uptime
+
+
+ {uptimeFormatted || "—"}
+
+
+
+
+ Database
+
+
+ Healthy
+
+
+
+
+ Hosts Online
+
+
+ {online}
+
+ /{hosts.length}
+
+
+
+
+ );
+}
+
+function CountersBarCard({
+ hosts,
+ credentialCount,
+ activeTunnelCount,
+}: {
+ hosts: Host[];
+ credentialCount: number;
+ activeTunnelCount: number;
+}) {
+ return (
+
+
+
+ {hosts.length}
+
+ Total Hosts
+
+
+
+
+ {credentialCount}
+
+ Credentials
+
+
+
+
+ {activeTunnelCount}
+
+ Active Tunnels
+
+
+
+ );
+}
+
+function QuickActionsCard({
+ onOpenSingletonTab,
+}: {
+ onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
+}) {
+ return (
+
+
+
+
+ Quick Actions
+
+
+
+
+
+ onOpenSingletonTab("host-manager", "host-manager:add-host")
+ }
+ className="group/btn flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer border-b border-border flex-1"
+ >
+
+
+ Add Host
+
+ Register a new server
+
+
+
+
+ onOpenSingletonTab("host-manager", "host-manager:add-credential")
+ }
+ className="group/btn flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer flex-1"
+ >
+
+
+
+
+ Add Credential
+
+ Store SSH keys or passwords
+
+
+
+
+
+
onOpenSingletonTab("admin-settings")}
+ className="group/btn flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer border-b border-border flex-1"
+ >
+
+
+
+
+ Admin Settings
+
+ Manage users and roles
+
+
+
+
onOpenSingletonTab("user-profile")}
+ className="group/btn flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer flex-1"
+ >
+
+
+
+
+ User Profile
+
+ Manage your account
+
+
+
+
+
+
+ );
+}
+
+function HostStatusCard({
+ hosts,
+ onOpenTab,
+}: {
+ hosts: Host[];
+ onOpenTab: (host: Host, type: TabType) => void;
+}) {
+ const online = hosts.filter((h) => h.online).length;
+ return (
+
+
+
+
+
+ Host Status
+
+
+
+ {online}/{hosts.length} online
+
+
+
+ {hosts.length === 0 && (
+
+ No hosts configured
+
+ )}
+ {hosts.map((host, i) => (
+
onOpenTab(host, "stats")}
+ className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
+ >
+
+
+
+ {host.name}
+
+ {host.ip}
+
+
+
+
+ {host.online ? (
+
+
+
+
+ CPU
+
+
+ {host.cpu ?? 0}%
+
+
+
+
+
+
+
+ RAM
+
+
+ {host.ram ?? 0}%
+
+
+
+
+
+ ) : (
+
+
+ —
+
+
+ —
+
+
+ )}
+
+ {host.online ? "ONLINE" : "OFFLINE"}
+
+
+
+ ))}
+
+
+ );
+}
+
+function RecentActivityCard({
+ activity,
+ hosts,
+ onOpenTab,
+ onClear,
+}: {
+ activity: RecentActivityItem[];
+ hosts: Host[];
+ onOpenTab: (host: Host, type: TabType) => void;
+ onClear: () => void;
+}) {
+ const typeIcon: Record = {
+ terminal: ,
+ file_manager: ,
+ server_stats: ,
+ tunnel: ,
+ docker: ,
+ rdp: ,
+ vnc: ,
+ telnet: ,
+ };
+ const typeToTab: Record = {
+ terminal: "terminal",
+ file_manager: "files",
+ server_stats: "stats",
+ tunnel: "tunnel",
+ docker: "docker",
+ rdp: "rdp",
+ vnc: "vnc",
+ telnet: "telnet",
+ };
+ function formatTime(ts: string) {
+ const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
+ if (diff < 60) return `${diff}s ago`;
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ return `${Math.floor(diff / 86400)}d ago`;
+ }
+ return (
+
+
+
+
+
+ Recent Activity
+
+
+
+ Clear
+
+
+
+ {activity.length === 0 && (
+
+ No recent activity
+
+ )}
+ {activity.map((item) => {
+ const host = hosts.find((h) => h.id === String(item.hostId));
+ return (
+
{
+ if (host) onOpenTab(host, typeToTab[item.type]);
+ }}
+ className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
+ >
+
+
+
+
+ {item.hostName}
+
+
+ {typeIcon[item.type]}
+
+ {item.type.replace("_", " ")}
+
+
+
+
+
+ {formatTime(item.timestamp)}
+
+
+ );
+ })}
+
+
+ );
+}
+
+function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
+ const cyRef = useRef(null);
+ const [contextMenu, setContextMenu] = useState<{
+ visible: boolean;
+ x: number;
+ y: number;
+ node: any;
+ } | null>(null);
+ const contextMenuRef = useRef(null);
+
+ const elements = hosts.map((h, i) => ({
+ data: {
+ id: h.id,
+ label: h.name,
+ ip: `${h.ip}:${h.port ?? 22}`,
+ status: h.online ? "online" : "offline",
+ },
+ position: { x: 120 + (i % 4) * 160, y: 80 + Math.floor(i / 4) * 100 },
+ }));
+
+ const buildNodeStyle = useCallback((ele: any) => {
+ const isOnline = ele.data("status") === "online";
+ const name = ele.data("label") || "";
+ const ip = ele.data("ip") || "";
+ const statusColor = isOnline ? "rgb(251,146,60)" : "rgb(100,116,139)";
+ const svg = `
+
+
+
+
+
+ ${name}
+ ${ip}
+ `;
+ return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
+ }, []);
+
+ const handleCyInit = useCallback(
+ (cy: any) => {
+ cyRef.current = cy;
+ cy.style()
+ .selector("node")
+ .style({
+ label: "",
+ width: "160px",
+ height: "72px",
+ shape: "round-rectangle",
+ "border-width": "0px",
+ "background-opacity": 0,
+ "background-image": buildNodeStyle,
+ "background-fit": "contain",
+ })
+ .selector("edge")
+ .style({
+ width: "1.5px",
+ "line-color": "#2a2a2c",
+ "curve-style": "bezier",
+ "target-arrow-shape": "none",
+ })
+ .selector("node:selected")
+ .style({
+ "overlay-color": "#fb923c",
+ "overlay-opacity": 0.08,
+ "overlay-padding": "4px",
+ })
+ .update();
+ cy.nodes().ungrabify();
+ cy.on("tap", (evt: any) => {
+ if (evt.target === cy) setContextMenu(null);
+ });
+ cy.on("cxttap tap", "node", (evt: any) => {
+ evt.stopPropagation();
+ const node = evt.target;
+ setContextMenu({
+ visible: true,
+ x: evt.originalEvent.clientX,
+ y: evt.originalEvent.clientY,
+ node: node.data(),
+ });
+ });
+ cy.on("zoom pan", () => setContextMenu(null));
+ },
+ [buildNodeStyle],
+ );
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (
+ contextMenuRef.current &&
+ !contextMenuRef.current.contains(e.target as Node)
+ )
+ setContextMenu(null);
+ };
+ document.addEventListener("mousedown", handler, true);
+ return () => document.removeEventListener("mousedown", handler, true);
+ }, []);
+
+ return (
+
+
+
+
+
+ Network Graph
+
+
+
+
+ {hosts.length} nodes
+
+ cyRef.current?.fit()}
+ >
+
+
+
+
+
+ {contextMenu?.visible && (
+
+
+
+ {contextMenu.node.label}
+
+
+ {contextMenu.node.ip}
+
+
+
+
+ Terminal
+
+
+
+ Server Stats
+
+
+ )}
+ {hosts.length > 0 ? (
+
+ ) : (
+
+ No hosts to display
+
+ )}
+
+
+
+
+ Online
+
+
+
+
+
+ Offline
+
+
+
+
+
+ );
+}
+
+// ─── CardItem ─────────────────────────────────────────────────────────────────
+
+function CardItem({
+ slot,
+ editMode,
+ isDragging,
+ onDragStart,
+ onDrop,
+ onDragOver,
+ onRemove,
+ onHeightChange,
+ onOpenSingletonTab,
+ onOpenTab,
+ hosts,
+ uptimeFormatted,
+ credentialCount,
+ activeTunnelCount,
+ activity,
+ onClearActivity,
+}: {
+ slot: CardSlot;
+ editMode: boolean;
+ isDragging: boolean;
+ onDragStart: () => void;
+ onDrop: () => void;
+ onDragOver: (e: React.DragEvent) => void;
+ onRemove: () => void;
+ onHeightChange: (id: DashboardCardId, h: number) => void;
+ onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
+ onOpenTab: (host: Host, type: TabType) => void;
+ hosts: Host[];
+ uptimeFormatted: string;
+ credentialCount: number;
+ activeTunnelCount: number;
+ activity: RecentActivityItem[];
+ onClearActivity: () => void;
+}) {
+ const cardRef = useRef(null);
+
+ const onResizeMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const startY = e.clientY;
+ const startH = cardRef.current?.getBoundingClientRect().height ?? 100;
+ const onMove = (ev: MouseEvent) => {
+ onHeightChange(slot.id, Math.max(50, startH + (ev.clientY - startY)));
+ };
+ const onUp = () => {
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ },
+ [slot.id, onHeightChange],
+ );
+
+ const isFlex = slot.height === null;
+ const cardProps = {
+ hosts,
+ uptimeFormatted,
+ credentialCount,
+ activeTunnelCount,
+ activity,
+ onOpenTab,
+ onOpenSingletonTab,
+ };
+
+ return (
+
+ {editMode && (
+
+ )}
+ {editMode && (
+
+ )}
+
+ {slot.id === "stats_bar" && (
+
+ )}
+ {slot.id === "counters_bar" && (
+
+ )}
+ {slot.id === "quick_actions" && (
+
+ )}
+ {slot.id === "host_status" && (
+
+ )}
+ {slot.id === "recent_activity" && (
+
+ )}
+ {slot.id === "network_graph" && (
+
+ )}
+
+ {editMode && !isFlex && (
+
+ )}
+
+ );
+}
+
+// ─── DropZone ─────────────────────────────────────────────────────────────────
+
+function DropZone({
+ panel,
+ order,
+ onDrop,
+ onDragOver,
+ active,
+}: {
+ panel: PanelId;
+ order: number;
+ onDrop: (panel: PanelId, order: number) => void;
+ onDragOver: (e: React.DragEvent) => void;
+ active: boolean;
+}) {
+ const [over, setOver] = useState(false);
+ if (!active) return null;
+ return (
+ {
+ onDragOver(e);
+ setOver(true);
+ }}
+ onDragLeave={() => setOver(false)}
+ onDrop={() => {
+ setOver(false);
+ onDrop(panel, order);
+ }}
+ />
+ );
+}
+
+// ─── AddCardTray ──────────────────────────────────────────────────────────────
+
+function AddCardTray({
+ activeIds,
+ onAdd,
+}: {
+ activeIds: DashboardCardId[];
+ onAdd: (id: DashboardCardId) => void;
+}) {
+ const available = DASHBOARD_CARDS.filter((c) => !activeIds.includes(c.id));
+ if (available.length === 0) return null;
+ return (
+
+
+ Add:
+
+ {available.map((card) => (
+
onAdd(card.id)}
+ className="flex items-center gap-1.5 px-2.5 py-1 border border-dashed border-border text-xs text-muted-foreground hover:text-foreground hover:border-accent-brand/60 hover:bg-accent-brand/5 transition-colors"
+ >
+
+ {CARD_META[card.id].label}
+
+ ))}
+
+ );
+}
+
+// ─── PanelColumn ─────────────────────────────────────────────────────────────
+
+type PanelColumnProps = {
+ panel: PanelId;
+ slots: CardSlot[];
+ editMode: boolean;
+ dragState: DragState;
+ onDragStart: (slot: CardSlot) => void;
+ onDrop: (targetPanel: PanelId, targetOrder: number) => void;
+ onDragOver: (e: React.DragEvent) => void;
+ onRemove: (id: DashboardCardId) => void;
+ onAdd: (id: DashboardCardId, panel: PanelId) => void;
+ onHeightChange: (id: DashboardCardId, h: number) => void;
+ onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
+ onOpenTab: (host: Host, type: TabType) => void;
+ hosts: Host[];
+ uptimeFormatted: string;
+ credentialCount: number;
+ activeTunnelCount: number;
+ activity: RecentActivityItem[];
+ onClearActivity: () => void;
+};
+
+function PanelColumn({
+ panel,
+ slots,
+ editMode,
+ dragState,
+ onDragStart,
+ onDrop,
+ onDragOver,
+ onRemove,
+ onAdd,
+ onHeightChange,
+ onOpenSingletonTab,
+ onOpenTab,
+ hosts,
+ uptimeFormatted,
+ credentialCount,
+ activeTunnelCount,
+ activity,
+ onClearActivity,
+}: PanelColumnProps) {
+ const sorted = [...slots].sort((a, b) => a.order - b.order);
+ const allIds = slots.map((s) => s.id);
+
+ return (
+
+
+ {sorted.map((slot, idx) => (
+
+ {idx > 0 && (
+
+
+
+ )}
+
onDragStart(slot)}
+ onDrop={() => onDrop(slot.panel, slot.order)}
+ onDragOver={onDragOver}
+ onRemove={() => onRemove(slot.id)}
+ onHeightChange={onHeightChange}
+ onOpenSingletonTab={onOpenSingletonTab}
+ onOpenTab={onOpenTab}
+ hosts={hosts}
+ uptimeFormatted={uptimeFormatted}
+ credentialCount={credentialCount}
+ activeTunnelCount={activeTunnelCount}
+ activity={activity}
+ onClearActivity={onClearActivity}
+ />
+
+ ))}
+
+ {editMode && (
+
onAdd(id, panel)} />
+ )}
+ {sorted.length === 0 && !editMode && (
+
+ Empty
+
+ )}
+
+ );
+}
+
+function ColumnDivider({
+ onMouseDown,
+}: {
+ onMouseDown: (e: React.MouseEvent) => void;
+}) {
+ return (
+
+ );
+}
+
+// ─── DashboardTab ─────────────────────────────────────────────────────────────
+
+export function DashboardTab({
+ onOpenSingletonTab,
+ onOpenTab,
+}: {
+ onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
+ onOpenTab: (host: Host, type: TabType) => void;
+}) {
+ const [slots, setSlots] = useState
(DEFAULT_SLOTS);
+ const [editMode, setEditMode] = useState(false);
+ const [dragState, setDragState] = useState(null);
+ const [mainWidthPct, setMainWidthPct] = useState(68);
+
+ const [hosts, setHosts] = useState([]);
+ const [uptimeFormatted, setUptimeFormatted] = useState("");
+ const [credentialCount, setCredentialCount] = useState(0);
+ const [activeTunnelCount, setActiveTunnelCount] = useState(0);
+ const [activity, setActivity] = useState([]);
+
+ useEffect(() => {
+ getSSHHosts()
+ .then((raw) => setHosts(raw.map(sshHostToHost)))
+ .catch(() => {});
+ getUptime()
+ .then((u) => setUptimeFormatted(u.formatted))
+ .catch(() => {});
+ getRecentActivity(50)
+ .then(setActivity)
+ .catch(() => {});
+ getCredentials()
+ .then((res: any) =>
+ setCredentialCount(
+ Array.isArray(res?.credentials) ? res.credentials.length : 0,
+ ),
+ )
+ .catch(() => {});
+ getTunnelStatuses()
+ .then((statuses) => {
+ const active = Object.values(statuses ?? {}).filter(
+ (s: any) => s?.status === "CONNECTED",
+ ).length;
+ setActiveTunnelCount(active);
+ })
+ .catch(() => {});
+ }, []);
+
+ const handleClearActivity = async () => {
+ try {
+ await resetRecentActivity();
+ setActivity([]);
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const todayLabel = new Date().toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+ const bodyRef = useRef(null);
+
+ const mainSlots = slots
+ .filter((s) => s.panel === "main")
+ .sort((a, b) => a.order - b.order);
+ const sideSlots = slots
+ .filter((s) => s.panel === "side")
+ .sort((a, b) => a.order - b.order);
+ const hasSide = sideSlots.length > 0;
+
+ const onColumnDividerMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ const startX = e.clientX;
+ const startPct = mainWidthPct;
+ const onMove = (ev: MouseEvent) => {
+ if (!bodyRef.current) return;
+ const totalW = bodyRef.current.getBoundingClientRect().width;
+ setMainWidthPct(
+ Math.min(
+ 85,
+ Math.max(25, startPct + ((ev.clientX - startX) / totalW) * 100),
+ ),
+ );
+ };
+ const onUp = () => {
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ };
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ },
+ [mainWidthPct],
+ );
+
+ const handleDragStart = (slot: CardSlot) =>
+ setDragState({
+ id: slot.id,
+ sourcePanel: slot.panel,
+ sourceOrder: slot.order,
+ });
+ const handleDragOver = (e: React.DragEvent) => e.preventDefault();
+ const handleDrop = (targetPanel: PanelId, targetOrder: number) => {
+ if (!dragState) return;
+ setSlots((prev) => {
+ const without = prev.filter((s) => s.id !== dragState.id);
+ const panelSlots = without
+ .filter((s) => s.panel === targetPanel)
+ .sort((a, b) => a.order - b.order);
+ const others = without.filter((s) => s.panel !== targetPanel);
+ const insertIdx = panelSlots.findIndex((s) => s.order > targetOrder);
+ const insertAt = insertIdx === -1 ? panelSlots.length : insertIdx;
+ const newPanelSlots = [
+ ...panelSlots.slice(0, insertAt),
+ {
+ id: dragState.id,
+ panel: targetPanel,
+ order: 0,
+ height: prev.find((s) => s.id === dragState.id)?.height ?? null,
+ },
+ ...panelSlots.slice(insertAt),
+ ].map((s, i) => ({ ...s, order: i }));
+ return [...others, ...newPanelSlots];
+ });
+ setDragState(null);
+ };
+ const handleRemove = (id: DashboardCardId) =>
+ setSlots((prev) => prev.filter((s) => s.id !== id));
+ const handleAdd = (id: DashboardCardId, panel: PanelId) => {
+ setSlots((prev) => {
+ const panelSlots = prev.filter((s) => s.panel === panel);
+ const maxOrder =
+ panelSlots.length > 0
+ ? Math.max(...panelSlots.map((s) => s.order)) + 1
+ : 0;
+ const defaultHeight: number | null =
+ id === "host_status" ||
+ id === "recent_activity" ||
+ id === "network_graph"
+ ? null
+ : 150;
+ return [...prev, { id, panel, order: maxOrder, height: defaultHeight }];
+ });
+ };
+ const handleHeightChange = (id: DashboardCardId, h: number) =>
+ setSlots((prev) =>
+ prev.map((s) => (s.id === id ? { ...s, height: h } : s)),
+ );
+ const handleReset = () => {
+ setSlots(DEFAULT_SLOTS);
+ setMainWidthPct(72);
+ setEditMode(false);
+ };
+
+ const columnProps = {
+ hosts,
+ uptimeFormatted,
+ credentialCount,
+ activeTunnelCount,
+ activity,
+ onClearActivity: handleClearActivity,
+ onOpenSingletonTab,
+ onOpenTab,
+ };
+
+ const isMobile = useIsMobile();
+
+ if (isMobile) {
+ const allSlots = [...mainSlots, ...sideSlots];
+ return (
+
+
+
+
+
Dashboard
+
{todayLabel}
+
+
+
+ {allSlots.map((slot) => (
+
+ {slot.id === "stats_bar" && (
+
+ )}
+ {slot.id === "counters_bar" && (
+
+ )}
+ {slot.id === "quick_actions" && (
+
+ )}
+ {slot.id === "host_status" && (
+
+ )}
+ {slot.id === "recent_activity" && (
+
+ )}
+ {slot.id === "network_graph" && (
+
+ )}
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+
Dashboard
+
{todayLabel}
+
+
+
+
+ Command Palette
+
+
+ Shift
+ +
+ Shift
+
+
+
+
+ GitHub
+
+
+
+
+ Support
+
+
+
+
+ Discord
+
+
+
+ {editMode ? (
+ <>
+
+ Reset
+
+
setEditMode(false)}
+ >
+ Done
+
+ >
+ ) : (
+
setEditMode(true)}
+ title="Customize Dashboard"
+ >
+
+
+ )}
+
+
+
+ {editMode && (
+
+
+
+ Drag cards to reorder · Drag the column divider to resize columns ·
+ Drag the bottom edge of a card to resize its height · Trash to
+ remove
+
+
+ )}
+
+
+
+
+ {(hasSide || editMode) &&
+ (editMode ? (
+
+ ) : (
+
+ ))}
+
+ {(hasSide || editMode) && (
+
+ )}
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx
similarity index 99%
rename from src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx
rename to src/ui/dashboard/cards/NetworkGraphCard.tsx
index 54434392..9c29c7f9 100644
--- a/src/ui/desktop/apps/dashboard/cards/NetworkGraphCard.tsx
+++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx
@@ -15,24 +15,24 @@ import {
type SSHHostWithStatus,
type NetworkTopologyEdge,
type NetworkTopologyNode,
-} from "@/ui/main-axios";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
+} from "@/main-axios";
+import { Button } from "@/components/button";
+import { Badge } from "@/components/badge";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogAction,
-} from "@/components/ui/alert-dialog";
+} from "@/components/alert-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
+} from "@/components/dialog";
+import { Input } from "@/components/input";
+import { Label } from "@/components/label";
import {
Plus,
Trash2,
@@ -58,21 +58,17 @@ import {
ArrowDownUp,
} from "lucide-react";
import { useTranslation } from "react-i18next";
-import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
+import { useTabs } from "@/shell/TabContext";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
-} from "@/components/ui/command";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
+} from "@/components/command";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import { cn } from "@/lib/utils";
-import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader";
+import { SimpleLoader } from "@/lib/SimpleLoader";
const AVAILABLE_COLORS = [
{ value: "#ef4444", label: "Red" },
diff --git a/src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx b/src/ui/dashboard/cards/QuickActionsCard.tsx
similarity index 99%
rename from src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx
rename to src/ui/dashboard/cards/QuickActionsCard.tsx
index 1bc1bccf..1d2ce32b 100644
--- a/src/ui/desktop/apps/dashboard/cards/QuickActionsCard.tsx
+++ b/src/ui/dashboard/cards/QuickActionsCard.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { FastForward, Server, Key, Settings, User } from "lucide-react";
-import { Button } from "@/components/ui/button";
+import { Button } from "@/components/button";
interface QuickActionsCardProps {
isAdmin: boolean;
diff --git a/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx b/src/ui/dashboard/cards/RecentActivityCard.tsx
similarity index 97%
rename from src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
rename to src/ui/dashboard/cards/RecentActivityCard.tsx
index d030be15..80304b5f 100644
--- a/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
+++ b/src/ui/dashboard/cards/RecentActivityCard.tsx
@@ -12,8 +12,8 @@ import {
Eye,
MessagesSquare,
} from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { type RecentActivityItem } from "@/ui/main-axios";
+import { Button } from "@/components/button";
+import { type RecentActivityItem } from "@/main-axios";
interface RecentActivityCardProps {
activities: RecentActivityItem[];
diff --git a/src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx b/src/ui/dashboard/cards/ServerOverviewCard.tsx
similarity index 97%
rename from src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx
rename to src/ui/dashboard/cards/ServerOverviewCard.tsx
index 4821e305..775cd3b5 100644
--- a/src/ui/desktop/apps/dashboard/cards/ServerOverviewCard.tsx
+++ b/src/ui/dashboard/cards/ServerOverviewCard.tsx
@@ -8,8 +8,8 @@ import {
Key,
ArrowDownUp,
} from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog";
+import { Button } from "@/components/button";
+import { UpdateLog } from "@/dashboard/panels/UpdateLog";
interface ServerOverviewCardProps {
loggedIn: boolean;
diff --git a/src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx b/src/ui/dashboard/cards/ServerStatsCard.tsx
similarity index 98%
rename from src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx
rename to src/ui/dashboard/cards/ServerStatsCard.tsx
index 6e74dc0d..662da0a9 100644
--- a/src/ui/desktop/apps/dashboard/cards/ServerStatsCard.tsx
+++ b/src/ui/dashboard/cards/ServerStatsCard.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ChartLine, Loader2, Server } from "lucide-react";
-import { Button } from "@/components/ui/button";
+import { Button } from "@/components/button";
interface ServerStat {
id: number;
diff --git a/src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx b/src/ui/dashboard/components/DashboardSettingsDialog.tsx
similarity index 72%
rename from src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx
rename to src/ui/dashboard/components/DashboardSettingsDialog.tsx
index 84da2239..8e89f81b 100644
--- a/src/ui/desktop/apps/dashboard/components/DashboardSettingsDialog.tsx
+++ b/src/ui/dashboard/components/DashboardSettingsDialog.tsx
@@ -6,12 +6,19 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import { Checkbox } from "@/components/ui/checkbox";
+} from "@/components/dialog";
+import { Button } from "@/components/button";
+import { Label } from "@/components/label";
+import { Checkbox } from "@/components/checkbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/select";
import { useTranslation } from "react-i18next";
-import type { DashboardLayout } from "@/ui/main-axios";
+import type { DashboardLayout } from "@/main-axios";
interface DashboardSettingsDialogProps {
open: boolean;
@@ -44,6 +51,15 @@ export function DashboardSettingsDialog({
}));
};
+ const handleCardPanel = (cardId: string, panel: "main" | "side") => {
+ setLayout((prev) => ({
+ ...prev,
+ cards: prev.cards.map((card) =>
+ card.id === cardId ? { ...card, panel } : card,
+ ),
+ }));
+ };
+
const handleSave = () => {
onSave(layout);
onOpenChange(false);
@@ -96,6 +112,24 @@ export function DashboardSettingsDialog({
>
{cardLabels[card.id] || card.id}
+
+ handleCardPanel(card.id, v as "main" | "side")
+ }
+ >
+
+
+
+
+
+ {t("dashboard.panelMain")}
+
+
+ {t("dashboard.panelSide")}
+
+
+
))}
diff --git a/src/ui/desktop/apps/dashboard/hooks/useDashboardPreferences.ts b/src/ui/dashboard/hooks/useDashboardPreferences.ts
similarity index 61%
rename from src/ui/desktop/apps/dashboard/hooks/useDashboardPreferences.ts
rename to src/ui/dashboard/hooks/useDashboardPreferences.ts
index f77578bf..3b262d78 100644
--- a/src/ui/desktop/apps/dashboard/hooks/useDashboardPreferences.ts
+++ b/src/ui/dashboard/hooks/useDashboardPreferences.ts
@@ -3,16 +3,17 @@ import {
getDashboardPreferences,
saveDashboardPreferences,
type DashboardLayout,
-} from "@/ui/main-axios";
+} from "@/main-axios";
const DEFAULT_LAYOUT: DashboardLayout = {
cards: [
- { id: "server_overview", enabled: true, order: 1 },
- { id: "recent_activity", enabled: true, order: 2 },
- { id: "network_graph", enabled: false, order: 3 },
- { id: "quick_actions", enabled: true, order: 4 },
- { id: "server_stats", enabled: true, order: 5 },
+ { id: "server_overview", enabled: true, order: 1, panel: "main" },
+ { id: "quick_actions", enabled: true, order: 2, panel: "main" },
+ { id: "server_stats", enabled: true, order: 3, panel: "main" },
+ { id: "network_graph", enabled: false, order: 4, panel: "main" },
+ { id: "recent_activity", enabled: true, order: 1, panel: "side" },
],
+ mainWidthPct: 68,
};
export function useDashboardPreferences(enabled: boolean = true) {
@@ -31,7 +32,25 @@ export function useDashboardPreferences(enabled: boolean = true) {
try {
const preferences = await getDashboardPreferences();
if (preferences?.cards && Array.isArray(preferences.cards)) {
- setLayout(preferences);
+ // Migrate old layouts that don't have panel assignments
+ const needsMigration = preferences.cards.some((c) => !c.panel);
+ if (needsMigration) {
+ const defaultCardMap = new Map(
+ DEFAULT_LAYOUT.cards.map((c) => [c.id, c]),
+ );
+ const migrated: DashboardLayout = {
+ ...preferences,
+ mainWidthPct:
+ preferences.mainWidthPct ?? DEFAULT_LAYOUT.mainWidthPct,
+ cards: preferences.cards.map((c) => ({
+ ...c,
+ panel: c.panel ?? defaultCardMap.get(c.id)?.panel ?? "main",
+ })),
+ };
+ setLayout(migrated);
+ } else {
+ setLayout(preferences);
+ }
} else {
setLayout(DEFAULT_LAYOUT);
}
diff --git a/src/ui/desktop/apps/dashboard/apps/UpdateLog.tsx b/src/ui/dashboard/panels/UpdateLog.tsx
similarity index 97%
rename from src/ui/desktop/apps/dashboard/apps/UpdateLog.tsx
rename to src/ui/dashboard/panels/UpdateLog.tsx
index ff54d502..9531449f 100644
--- a/src/ui/desktop/apps/dashboard/apps/UpdateLog.tsx
+++ b/src/ui/dashboard/panels/UpdateLog.tsx
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from "react";
-import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
-import { Button } from "@/components/ui/button.tsx";
-import { Sheet, SheetContent } from "@/components/ui/sheet.tsx";
-import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
+import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
+import { Button } from "@/components/button.tsx";
+import { Sheet, SheetContent } from "@/components/sheet.tsx";
+import { getReleasesRSS, getVersionInfo } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
diff --git a/src/ui/desktop/apps/dashboard/apps/alerts/AlertCard.tsx b/src/ui/dashboard/panels/alerts/AlertCard.tsx
similarity index 94%
rename from src/ui/desktop/apps/dashboard/apps/alerts/AlertCard.tsx
rename to src/ui/dashboard/panels/alerts/AlertCard.tsx
index eaaa3053..31faf754 100644
--- a/src/ui/desktop/apps/dashboard/apps/alerts/AlertCard.tsx
+++ b/src/ui/dashboard/panels/alerts/AlertCard.tsx
@@ -5,9 +5,9 @@ import {
CardFooter,
CardHeader,
CardTitle,
-} from "@/components/ui/card.tsx";
-import { Button } from "@/components/ui/button.tsx";
-import { Badge } from "@/components/ui/badge.tsx";
+} from "@/components/card.tsx";
+import { Button } from "@/components/button.tsx";
+import { Badge } from "@/components/badge.tsx";
import {
X,
ExternalLink,
@@ -17,7 +17,7 @@ import {
AlertCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
-import type { TermixAlert } from "../../../../../../types";
+import type { TermixAlert } from "@/types";
interface AlertCardProps {
alert: TermixAlert;
diff --git a/src/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx b/src/ui/dashboard/panels/alerts/AlertManager.tsx
similarity index 96%
rename from src/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx
rename to src/ui/dashboard/panels/alerts/AlertManager.tsx
index 52b06104..0d15c18c 100644
--- a/src/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx
+++ b/src/ui/dashboard/panels/alerts/AlertManager.tsx
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react";
import { AlertCard } from "./AlertCard.tsx";
-import { Button } from "@/components/ui/button.tsx";
-import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
+import { Button } from "@/components/button.tsx";
+import { getUserAlerts, dismissAlert } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
-import type { TermixAlert } from "../../../../../../types";
+import type { TermixAlert } from "@/types";
import { toast } from "sonner";
interface AlertManagerProps {
diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx
deleted file mode 100644
index 9ab6bca6..00000000
--- a/src/ui/desktop/DesktopApp.tsx
+++ /dev/null
@@ -1,810 +0,0 @@
-import React, {
- useCallback,
- Component,
- Suspense,
- lazy,
- type ReactNode,
- useEffect,
- useRef,
- useState,
-} from "react";
-import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
-import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
-import {
- TabProvider,
- useTabs,
-} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
-import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
-import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
-import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext";
-import { Toaster } from "@/components/ui/sonner.tsx";
-import { toast } from "sonner";
-import {
- getUserInfo,
- logoutUser,
- isCurrentAuthInvalidationError,
-} from "@/ui/main-axios.ts";
-import { useTheme } from "@/components/theme-provider";
-import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
-import { useTranslation } from "react-i18next";
-import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
-
-const Dashboard = lazy(() =>
- import("@/ui/desktop/apps/dashboard/Dashboard.tsx").then((module) => ({
- default: module.Dashboard,
- })),
-);
-const HostManager = lazy(() =>
- import("@/ui/desktop/apps/host-manager/hosts/HostManager.tsx").then(
- (module) => ({
- default: module.HostManager,
- }),
- ),
-);
-const AdminSettings = lazy(() =>
- import("@/ui/desktop/apps/admin/AdminSettings.tsx").then((module) => ({
- default: module.AdminSettings,
- })),
-);
-const UserProfile = lazy(() =>
- import("@/ui/desktop/user/UserProfile.tsx").then((module) => ({
- default: module.UserProfile,
- })),
-);
-const CommandPalette = lazy(() =>
- import("@/ui/desktop/apps/command-palette/CommandPalette.tsx").then(
- (module) => ({
- default: module.CommandPalette,
- }),
- ),
-);
-
-function AppContent({
- onAuthStateChange,
-}: {
- onAuthStateChange?: (isAuthenticated: boolean) => void;
-}) {
- const { t } = useTranslation();
- const [isAuthenticated, setIsAuthenticated] = useState(false);
- const [username, setUsername] = useState
(null);
- const [isAdmin, setIsAdmin] = useState(false);
- const [authLoading, setAuthLoading] = useState(true);
- const [isTopbarOpen, setIsTopbarOpen] = useState(() => {
- const saved = localStorage.getItem("topNavbarOpen");
- return saved !== null ? JSON.parse(saved) : true;
- });
- const [isTransitioning, setIsTransitioning] = useState(false);
- const [transitionPhase, setTransitionPhase] = useState<
- "idle" | "fadeOut" | "fadeIn"
- >("idle");
- const { currentTab, tabs, updateTab, addTab } = useTabs();
- const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
- const { theme, setTheme } = useTheme();
- const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
- const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
- const isAuthenticatedRef = useRef(false);
-
- const isDarkMode =
- theme === "dark" ||
- theme === "dracula" ||
- theme === "gentlemansChoice" ||
- theme === "midnightEspresso" ||
- theme === "catppuccinMocha" ||
- (theme === "system" &&
- window.matchMedia("(prefers-color-scheme: dark)").matches);
- const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
-
- const lastShiftPressTime = useRef(0);
-
- const lastAltPressTime = useRef(0);
-
- useEffect(() => {
- const DEGRADED_TOAST_ID = "db-connection-degraded";
-
- const handleDatabaseConnectionDegraded = () => {
- // Non-blocking, non-dismissible status toast that stays visible until
- // connectivity is recovered. A Reload action lets users force-refresh
- // the page if they want to, but the app itself remains fully usable.
- toast.loading(
- t("common.connectionDegraded", "Server connection lost, recovering…"),
- {
- id: DEGRADED_TOAST_ID,
- duration: Infinity,
- dismissible: false,
- closeButton: false,
- action: {
- label: t("common.reload", "Reload"),
- onClick: () => window.location.reload(),
- },
- },
- );
- };
-
- const handleDatabaseConnectionDegradedCleared = () => {
- toast.dismiss(DEGRADED_TOAST_ID);
- toast.success(t("common.backendReconnected"));
- };
-
- const handleSessionExpired = () => {
- setIsAuthenticated(false);
- setIsAdmin(false);
- setUsername(null);
- };
-
- dbHealthMonitor.on(
- "database-connection-degraded",
- handleDatabaseConnectionDegraded,
- );
- dbHealthMonitor.on(
- "database-connection-degraded-cleared",
- handleDatabaseConnectionDegradedCleared,
- );
- dbHealthMonitor.on("session-expired", handleSessionExpired);
-
- return () => {
- dbHealthMonitor.off(
- "database-connection-degraded",
- handleDatabaseConnectionDegraded,
- );
- dbHealthMonitor.off(
- "database-connection-degraded-cleared",
- handleDatabaseConnectionDegradedCleared,
- );
- dbHealthMonitor.off("session-expired", handleSessionExpired);
- toast.dismiss(DEGRADED_TOAST_ID);
- };
- }, [t]);
-
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.code === "ShiftLeft") {
- if (event.repeat) {
- return;
- }
- const shortcutEnabled =
- localStorage.getItem("commandPaletteShortcutEnabled") !== "false";
- if (!shortcutEnabled) {
- return;
- }
- const now = Date.now();
- if (now - lastShiftPressTime.current < 300) {
- setIsCommandPaletteOpen((isOpen) => !isOpen);
- lastShiftPressTime.current = 0;
- } else {
- lastShiftPressTime.current = now;
- }
- }
-
- if (event.code === "AltLeft" && !event.repeat) {
- const now = Date.now();
- if (now - lastAltPressTime.current < 300) {
- const currentIsDark =
- theme === "dark" ||
- (theme === "system" &&
- window.matchMedia("(prefers-color-scheme: dark)").matches);
- const newTheme = currentIsDark ? "light" : "dark";
- setTheme(newTheme);
- lastAltPressTime.current = 0;
- } else {
- lastAltPressTime.current = now;
- }
- }
-
- if (event.key === "Escape") {
- setIsCommandPaletteOpen(false);
- }
- };
-
- window.addEventListener("keydown", handleKeyDown);
- return () => {
- window.removeEventListener("keydown", handleKeyDown);
- };
- }, [theme, setTheme]);
-
- useEffect(() => {
- const path = window.location.pathname;
- const terminalMatch = path.match(/^\/terminal\/([a-zA-Z0-9_-]+)$/);
- const legacyMatch = path.match(/^\/hosts\/([a-zA-Z0-9_-]+)\/terminal$/);
- const hostIdentifier = terminalMatch?.[1] || legacyMatch?.[1];
-
- if (hostIdentifier) {
- const openTerminal = async () => {
- try {
- const { getSSHHostById, getSSHHosts } =
- await import("@/ui/main-axios.ts");
- let host = null;
-
- if (/^\d+$/.test(hostIdentifier)) {
- host = await getSSHHostById(parseInt(hostIdentifier, 10));
- } else {
- const hosts = await getSSHHosts();
- host =
- hosts.find((h: { name?: string }) => h.name === hostIdentifier) ||
- null;
- }
-
- if (host) {
- addTab({
- type: "terminal",
- title: host.name || host.ip,
- data: { host, initialCommand: "" },
- });
- window.history.replaceState({}, "", "/");
- } else {
- toast.error(`Host "${hostIdentifier}" not found`);
- }
- } catch (error) {
- console.error("Failed to open terminal:", error);
- toast.error("Failed to open terminal for host");
- }
- };
- openTerminal();
- }
- }, [addTab]);
-
- const isCheckingAuth = useRef(false);
- const clientTunnelAutoStartStarted = useRef(false);
-
- const startClientTunnelAutoStart = useCallback(() => {
- if (
- clientTunnelAutoStartStarted.current ||
- !window.electronAPI?.isElectron
- ) {
- return;
- }
-
- clientTunnelAutoStartStarted.current = true;
- window.electronAPI.startC2SAutoStartTunnels?.().catch((error) => {
- clientTunnelAutoStartStarted.current = false;
- console.error("Failed to start client tunnel auto-start entries:", error);
- });
- }, []);
-
- useEffect(() => {
- const checkAuth = () => {
- if (isCheckingAuth.current) return;
- isCheckingAuth.current = true;
- setAuthLoading(true);
- getUserInfo()
- .then((meRes) => {
- if (typeof meRes === "string" || !meRes.username) {
- setIsAuthenticated(false);
- setIsAdmin(false);
- setUsername(null);
- } else {
- setIsAuthenticated(true);
- setIsAdmin(!!meRes.is_admin);
- setUsername(meRes.username || null);
- startClientTunnelAutoStart();
- }
- })
- .catch((err) => {
- if (isCurrentAuthInvalidationError(err)) {
- setIsAuthenticated(false);
- setIsAdmin(false);
- setUsername(null);
- console.warn("Session expired - please log in again");
- return;
- }
-
- if (!isAuthenticatedRef.current) {
- setIsAuthenticated(false);
- setIsAdmin(false);
- setUsername(null);
- }
- })
- .finally(() => {
- setAuthLoading(false);
- isCheckingAuth.current = false;
- });
- };
-
- checkAuth();
-
- const handleStorageChange = () => checkAuth();
- window.addEventListener("storage", handleStorageChange);
-
- return () => window.removeEventListener("storage", handleStorageChange);
- }, [startClientTunnelAutoStart]);
-
- useEffect(() => {
- localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
- }, [isTopbarOpen]);
-
- useEffect(() => {
- onAuthStateChange?.(isAuthenticated);
- isAuthenticatedRef.current = isAuthenticated;
- }, [isAuthenticated, onAuthStateChange]);
-
- const handleAuthSuccess = useCallback(
- (authData: {
- isAdmin: boolean;
- username: string | null;
- userId: string | null;
- }) => {
- setIsTransitioning(true);
- setTransitionPhase("fadeOut");
-
- setTimeout(() => {
- setIsAuthenticated(true);
- setIsAdmin(authData.isAdmin);
- setUsername(authData.username);
- startClientTunnelAutoStart();
- setTransitionPhase("fadeIn");
-
- setTimeout(() => {
- setIsTransitioning(false);
- setTransitionPhase("idle");
- }, 800);
- }, 1200);
- },
- [startClientTunnelAutoStart],
- );
-
- const handleLogout = useCallback(async () => {
- setIsTransitioning(true);
- setTransitionPhase("fadeOut");
-
- setTimeout(async () => {
- try {
- await logoutUser();
- } catch (error) {
- console.error("Logout failed:", error);
- }
-
- window.location.reload();
- }, 1200);
- }, []);
-
- const currentTabData = tabs.find((tab) => tab.id === currentTab);
- const showTerminalView =
- currentTabData?.type === "terminal" ||
- currentTabData?.type === "server_stats" ||
- currentTabData?.type === "file_manager" ||
- currentTabData?.type === "rdp" ||
- currentTabData?.type === "vnc" ||
- currentTabData?.type === "telnet" ||
- currentTabData?.type === "tunnel" ||
- currentTabData?.type === "docker" ||
- currentTabData?.type === "network_graph";
- const showHome = currentTabData?.type === "home";
- const showSshManager = currentTabData?.type === "ssh_manager";
- const showAdmin = currentTabData?.type === "admin";
- const showProfile = currentTabData?.type === "user_profile";
-
- if (authLoading) {
- return (
-
-
-
-
-
-
- {t("common.checkingAuthentication")}
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- {!isAuthenticated && (
-
-
-
-
-
- )}
-
- {isAuthenticated && (
-
-
-
- {showHome && (
-
-
-
-
- }
- >
-
-
-
- )}
-
- {showSshManager && (
-
-
-
-
- }
- >
-
-
-
- )}
-
- {showAdmin && (
-