Move settings to modals & better modal handling (#194)

* feat(ui): Add other session handling route and modal

* feat(ui): Add dedicated update route and refactor update dialog state management

* feat(ui): Add local authentication route

* refactor(ui): Remove LocalAuthPasswordDialog component and clean up related code

* refactor(ui): Remove OtherSessionConnectedModal component

* feat(ui): Add dedicated mount route and refactor mount media dialog

* refactor(ui): Simplify Escape key navigation in device route

* refactor(ui): Add TODO comments for future URL-based state migration

* refactor(ui): Migrate settings and update routes to dedicated routes

This commit introduces a comprehensive refactoring of the UI routing and state management:
- Removed sidebar-based settings view
- Replaced global modal state with URL-based routing
- Added dedicated routes for settings, including general, security, and update sections
- Simplified modal and sidebar interactions
- Improved animation and transition handling using motion library
- Removed deprecated components and simplified route structure

* fix(ui): Add TODO comment for modal session interaction

* refactor(ui): Move USB configuration to new settings setup

This commit introduces several improvements to the USB configuration workflow:
- Refactored USB configuration dialog component
- Simplified USB config state management
- Moved USB configuration to hardware settings route
- Updated JSON-RPC type definitions
- Cleaned up unused imports and components
- Improved error handling and notifications

* refactor(ui): Replace react-router-dom navigation with custom navigation hook

This commit introduces a new custom navigation hook `useDeviceUiNavigation` to replace direct usage of `useNavigate` across multiple components:
- Removed direct `useNavigate` imports in various components
- Added `navigateTo` method from new navigation hook
- Updated navigation calls in ActionBar, MountPopover, UpdateInProgressStatusCard, and other routes
- Simplified navigation logic and prepared for potential future navigation enhancements
- Removed console logs and unnecessary comments

* refactor(ui): Remove unused react-router-dom import

Clean up unnecessary import of `useNavigate` from react-router-dom in device settings route

* feat(ui): Improve mobile navigation and scrolling in device settings

* refactor(ui): Reorganize device access and security settings

This commit introduces several changes to the device access and security settings:
- Renamed "Security" section to "Access" in settings navigation
- Moved local authentication routes from security to access
- Removed deprecated security settings route
- Added new route for device access settings with cloud and local authentication management
- Updated cloud URL and adoption logic to be part of the access settings
- Simplified routing and component structure for better user experience

* fix(ui): Update logout button hover state color

* fix(ui): Adjust device de-registration button size to small

* fix(ui): Update appearance settings section header and description

* refactor(ui): Replace SectionHeader with new SettingsPageHeader and SettingsSectionHeader components

This commit introduces two new header components for settings pages:
- Created SettingsPageHeader for main page headers
- Created SettingsSectionHeader for subsection headers
- Replaced all existing SectionHeader imports with new components
- Updated styling and type definitions to support more flexible header rendering

* feat(ui): Add dev channel toggle to advanced settings

Move dev channel update option from general settings to advanced settings
- Introduced new state and handler for dev channel toggle
- Removed dev channel option from general settings route
- Added dev channel toggle in advanced settings with error handling
This commit is contained in:
Adam Shiervani 2025-02-27 16:48:50 +01:00 committed by GitHub
parent 77263e73f7
commit 4052b3d225
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 2887 additions and 2341 deletions

View File

@ -53,7 +53,7 @@ var defaultConfig = &Config{
ProductId: "0x0104", //Multifunction Composite Gadget ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "", SerialNumber: "",
Manufacturer: "JetKVM", Manufacturer: "JetKVM",
Product: "JetKVM USB Emulation Device", Product: "USB Emulation Device",
}, },
} }

View File

@ -780,6 +780,10 @@ func rpcResetCloudUrl() error {
return nil return nil
} }
func rpcGetDefaultCloudUrl() (string, error) {
return defaultConfig.CloudURL, nil
}
var rpcHandlers = map[string]RPCHandler{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID}, "getDeviceID": {Func: rpcGetDeviceID},
@ -841,4 +845,5 @@ var rpcHandlers = map[string]RPCHandler{
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"url"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"url"}},
"getCloudUrl": {Func: rpcGetCloudUrl}, "getCloudUrl": {Func: rpcGetCloudUrl},
"resetCloudUrl": {Func: rpcResetCloudUrl}, "resetCloudUrl": {Func: rpcResetCloudUrl},
"getDefaultCloudUrl": {Func: rpcGetDefaultCloudUrl},
} }

View File

@ -1,2 +1,2 @@
# Used in settings page to know where to link to when user wants to adopt a device to the cloud # Used in settings page to know where to link to when user wants to adopt a device to the cloud
VITE_CLOUD_APP=http://localhost:5173 VITE_CLOUD_APP=http://app.jetkvm.com

65
ui/package-lock.json generated
View File

@ -22,6 +22,7 @@
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"motion": "^12.4.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -4245,6 +4246,31 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/motion": {
"version": "12.4.7",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.4.7.tgz",
"integrity": "sha512-mhegHAbf1r80fr+ytC6OkjKvIUegRNXKLWNPrCN2+GnixlNSPwT03FtKqp9oDny1kNcLWZvwbmEr+JqVryFrcg==",
"dependencies": {
"framer-motion": "^12.4.7",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "11.14.3", "version": "11.14.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz",
@ -4255,6 +4281,45 @@
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz",
"integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==" "integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="
}, },
"node_modules/motion/node_modules/framer-motion": {
"version": "12.4.7",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.7.tgz",
"integrity": "sha512-VhrcbtcAMXfxlrjeHPpWVu2+mkcoR31e02aNSR7OUS/hZAciKa8q6o3YN2mA1h+jjscRsSyKvX6E1CiY/7OLMw==",
"dependencies": {
"motion-dom": "^12.4.5",
"motion-utils": "^12.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion/node_modules/motion-dom": {
"version": "12.4.5",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.4.5.tgz",
"integrity": "sha512-Q2xmhuyYug1CGTo0jdsL05EQ4RhIYXlggFS/yPhQQRNzbrhjKQ1tbjThx5Plv68aX31LsUQRq4uIkuDxdO5vRQ==",
"dependencies": {
"motion-utils": "^12.0.0"
}
},
"node_modules/motion/node_modules/motion-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz",
"integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA=="
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View File

@ -31,6 +31,7 @@
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"motion": "^12.4.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@ -17,12 +17,14 @@ import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react"; import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid"; import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover"; import ExtensionPopover from "./popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function Actionbar({ export default function Actionbar({
requestFullscreen, requestFullscreen,
}: { }: {
requestFullscreen: () => Promise<void>; requestFullscreen: () => Promise<void>;
}) { }) {
const { navigateTo } = useDeviceUiNavigation();
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
@ -260,15 +262,16 @@ export default function Actionbar({
/> />
</div> </div>
<div className="hidden xs:block "> <div>
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Settings" text="Settings"
LeadingIcon={LuSettings} LeadingIcon={LuSettings}
onClick={() => toggleSidebarView("system")} onClick={() => navigateTo("/settings")}
/> />
</div> </div>
<div className="hidden items-center gap-x-2 lg:flex"> <div className="hidden items-center gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" /> <div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button <Button

View File

@ -22,7 +22,7 @@ const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
{...props} {...props}
height={height} height={height}
duration={300} duration={300}
contentClassName="auto-content pointer-events-none" contentClassName="h-fit"
contentRef={contentDiv} contentRef={contentDiv}
disableDisplayNone disableDisplayNone
> >

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { forwardRef } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
type CardPropsType = { type CardPropsType = {
@ -16,23 +16,28 @@ export const GridCard = ({
return ( return (
<Card className={cx("overflow-hidden", cardClassName)}> <Card className={cx("overflow-hidden", cardClassName)}>
<div className="relative h-full"> <div className="relative h-full">
<div className="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" /> <div className="absolute inset-0 z-0 h-full w-full bg-gradient-to-tr from-blue-50/30 to-blue-50/20 transition-colors duration-300 ease-in-out dark:from-slate-800/30 dark:to-slate-800/20" />
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" /> <div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
<div className="h-full isolate">{children}</div> <div className="isolate h-full">{children}</div>
</div> </div>
</Card> </Card>
); );
}; };
export default function Card({ children, className }: CardPropsType) { const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className }, ref) => {
return ( return (
<div <div
ref={ref}
className={cx( className={cx(
"w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30", "w-full rounded border-none bg-white shadow outline outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20",
className, className,
)} )}
> >
{children} {children}
</div> </div>
); );
} });
Card.displayName = "Card";
export default Card;

View File

@ -1,7 +1,7 @@
import { Fragment, useCallback } from "react"; import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid"; import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton, Transition } from "@headlessui/react"; import { Menu, MenuButton } from "@headlessui/react";
import Container from "@/components/Container"; import Container from "@/components/Container";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu"; import { LuMonitorSmartphone } from "react-icons/lu";
@ -37,9 +37,7 @@ export default function DashboardNavbar({
const setUser = useUserStore(state => state.setUser); const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate(); const navigate = useNavigate();
const onLogout = useCallback(async () => { const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
? `${DEVICE_API}/auth/logout`
: `${CLOUD_API}/logout`;
const res = await api.POST(logoutUrl); const res = await api.POST(logoutUrl);
if (!res.ok) return; if (!res.ok) return;
@ -51,10 +49,10 @@ export default function DashboardNavbar({
const usbState = useHidStore(state => state.usbState); const usbState = useHidStore(state => state.usbState);
return ( return (
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900"> <div className="w-full select-none border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<Container> <Container>
<div className="flex items-center justify-between h-14"> <div className="flex h-14 items-center justify-between">
<div className="flex items-center shrink-0 gap-x-8"> <div className="flex shrink-0 items-center gap-x-8">
<div className="inline-block shrink-0"> <div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" /> <img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" /> <img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
@ -75,10 +73,10 @@ export default function DashboardNavbar({
})} })}
</div> </div>
</div> </div>
<div className="flex items-center justify-end w-full gap-x-2"> <div className="flex w-full items-center justify-end gap-x-2">
<div className="flex items-center space-x-4 shrink-0"> <div className="flex shrink-0 items-center space-x-4">
{showConnectionStatus && ( {showConnectionStatus && (
<div className="items-center hidden gap-x-2 md:flex"> <div className="hidden items-center gap-x-2 md:flex">
<div className="w-[159px]"> <div className="w-[159px]">
<PeerConnectionStatusCard <PeerConnectionStatusCard
state={peerConnectionState} state={peerConnectionState}
@ -105,66 +103,55 @@ export default function DashboardNavbar({
text={ text={
<> <>
{picture ? <></> : userEmail} {picture ? <></> : userEmail}
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" /> <ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" />
</> </>
} }
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) =>
picture && ( picture && (
<img <img
src={picture} src={picture}
alt="Avatar" alt="Avatar"
className={cx( className={cx(
className, className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700", "h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)} )}
/> />
) )
)} }
/> />
</MenuButton> </MenuButton>
</div> </div>
<Transition
as={Fragment} <Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none">
enter="transition ease-in-out duration-75" <Card className="overflow-hidden">
enterFrom="transform opacity-0" <div className="space-y-1 p-1 dark:text-white">
enterTo="transform opacity-100" {userEmail && (
leave="transition ease-in-out duration-75" <div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white">
{userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div className="p-2">
<div className="text-xs font-display">
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
</div>
</div>
</Menu.Item>
</div>
)}
<div>
<Menu.Item> <Menu.Item>
<div onClick={onLogout}> <div className="p-2">
<button className="block w-full"> <div className="font-display text-xs">Logged in as</div>
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700"> <div className="w-[200px] truncate font-display text-sm font-semibold">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" /> {userEmail}
<div className="font-display">Log out</div> </div>
</div>
</button>
</div> </div>
</Menu.Item> </Menu.Item>
</div> </div>
)}
<div>
<Menu.Item>
<div onClick={onLogout}>
<button className="block w-full">
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
<div className="font-display">Log out</div>
</div>
</button>
</div>
</Menu.Item>
</div> </div>
</Card> </div>
</Menu.Items> </Card>
</Transition> </Menu.Items>
</Menu> </Menu>
</> </>
) : null} ) : null}

View File

@ -2,7 +2,7 @@ import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"; import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
export default function Modal({ const Modal = React.memo(function Modal({
children, children,
className, className,
open, open,
@ -17,22 +17,25 @@ export default function Modal({
<Dialog open={open} onClose={onClose} className="relative z-10"> <Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop <DialogBackdrop
transition transition
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in" className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
/> />
<div className="fixed inset-0 z-10 w-screen overflow-y-auto"> <div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0"> {/* TODO: This doesn't work well with other-sessions */}
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
<DialogPanel <DialogPanel
transition transition
className={cx( className={cx(
"pointer-events-none relative w-full sm:my-8", "pointer-events-none relative w-full md:my-8 md:!mt-[10vh]",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in", "transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in",
className, className,
)} )}
> >
<div className="inline-block w-full text-left pointer-events-auto"> <div className="pointer-events-auto inline-block w-full text-left">
<div className="flex justify-center" onClick={onClose}> <div className="flex justify-center" onClick={onClose}>
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}> <div
className="pointer-events-none w-full"
onClick={e => e.stopPropagation()}
>
{children} {children}
</div> </div>
</div> </div>
@ -42,4 +45,6 @@ export default function Modal({
</div> </div>
</Dialog> </Dialog>
); );
} });
export default Modal;

View File

@ -19,7 +19,7 @@ type SelectMenuProps = Pick<
direction?: "vertical" | "horizontal"; direction?: "vertical" | "horizontal";
error?: string; error?: string;
fullWidth?: boolean; fullWidth?: boolean;
} & React.ComponentProps<typeof FieldLabel>; } & Partial<React.ComponentProps<typeof FieldLabel>>;
const sizes = { const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs", XS: "h-[24.5px] pl-3 pr-8 text-xs",
@ -60,7 +60,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
)} )}
> >
{label && <FieldLabel label={label} id={id} as="span" />} {label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0"> <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
<select <select
ref={ref} ref={ref}
name={name} name={name}
@ -69,10 +69,13 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
classes, classes,
// General styling // General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0", "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
// Hover // Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900", "hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",
// Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-800",
// Invalid // Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2", "invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
@ -82,9 +85,6 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
// Disabled // Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80", "disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)} )}
value={value} value={value}
id={id} id={id}

View File

@ -1,6 +1,6 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
export function SectionHeader({ export function SettingsPageHeader({
title, title,
description, description,
}: { }: {
@ -8,8 +8,8 @@ export function SectionHeader({
description: string | ReactNode; description: string | ReactNode;
}) { }) {
return ( return (
<div> <div className="select-none">
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2> <h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div> <div className="text-sm text-black dark:text-slate-300">{description}</div>
</div> </div>
); );

View File

@ -0,0 +1,16 @@
import { ReactNode } from "react";
export function SettingsSectionHeader({
title,
description,
}: {
title: string | ReactNode;
description: string | ReactNode;
}) {
return (
<div className="select-none">
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
</div>
);
}

View File

@ -1,83 +1,25 @@
import { GridCard } from "@/components/Card";
import {useCallback, useEffect, useState} from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { InputFieldWithLabel } from "./InputField"; import { InputFieldWithLabel } from "./InputField";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { UsbConfigState } from "@/hooks/stores";
import { useUsbConfigModalStore } from "@/hooks/stores"; import { useEffect, useCallback, useState } from "react";
import ExtLink from "@components/ExtLink"; import { useJsonRpc } from "../hooks/useJsonRpc";
import { UsbConfigState } from "@/hooks/stores" import { USBConfig } from "../routes/devices.$id.settings.hardware";
export default function USBConfigDialog({ export default function UpdateUsbConfigModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const { modalView, setModalView } = useUsbConfigModalStore();
const [error, setError] = useState<string | null>(null);
const [send] = useJsonRpc();
const handleUsbConfigChange = useCallback((usbConfig: object) => {
send("setUsbConfig", { usbConfig }, resp => {
if ("error" in resp) {
setError(`Failed to update USB Config: ${resp.error.data || "Unknown error"}`);
return;
}
setModalView("updateUsbConfigSuccess");
});
}, [send, setModalView]);
return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div className="p-10">
{modalView === "updateUsbConfig" && (
<UpdateUsbConfigModal
onSetUsbConfig={handleUsbConfigChange}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "updateUsbConfigSuccess" && (
<SuccessModal
headline="USB Configuration Updated Successfully"
description="You've successfully updated the USB Configuration"
onClose={() => setOpen(false)}
/>
)}
</div>
</GridCard>
);
}
function UpdateUsbConfigModal({
onSetUsbConfig, onSetUsbConfig,
onCancel, onRestoreToDefault,
error,
}: { }: {
onSetUsbConfig: (usb_config: object) => void; onSetUsbConfig: (usbConfig: USBConfig) => void;
onCancel: () => void; onRestoreToDefault: () => void;
error: string | null;
}) { }) {
const [usbConfigState, setUsbConfigState] = useState<UsbConfigState>({ const [usbConfigState, setUsbConfigState] = useState<USBConfig>({
vendor_id: '', vendor_id: "",
product_id: '', product_id: "",
serial_number: '', serial_number: "",
manufacturer: '', manufacturer: "",
product: '' product: "",
}); });
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const syncUsbConfig = useCallback(() => { const syncUsbConfig = useCallback(() => {
@ -90,53 +32,34 @@ function UpdateUsbConfigModal({
}); });
}, [send, setUsbConfigState]); }, [send, setUsbConfigState]);
// Load stored usb config from the backend // Load stored usb config from the backend
useEffect(() => { useEffect(() => {
syncUsbConfig(); syncUsbConfig();
}, [syncUsbConfig]); }, [syncUsbConfig]);
const handleUsbVendorIdChange = (value: string) => { const handleUsbVendorIdChange = (value: string) => {
setUsbConfigState({... usbConfigState, vendor_id: value}) setUsbConfigState({ ...usbConfigState, vendor_id: value });
}; };
const handleUsbProductIdChange = (value: string) => { const handleUsbProductIdChange = (value: string) => {
setUsbConfigState({... usbConfigState, product_id: value}) setUsbConfigState({ ...usbConfigState, product_id: value });
}; };
const handleUsbSerialChange = (value: string) => { const handleUsbSerialChange = (value: string) => {
setUsbConfigState({... usbConfigState, serial_number: value}) setUsbConfigState({ ...usbConfigState, serial_number: value });
}; };
const handleUsbManufacturer = (value: string) => { const handleUsbManufacturer = (value: string) => {
setUsbConfigState({... usbConfigState, manufacturer: value}) setUsbConfigState({ ...usbConfigState, manufacturer: value });
}; };
const handleUsbProduct = (value: string) => { const handleUsbProduct = (value: string) => {
setUsbConfigState({... usbConfigState, product: value}) setUsbConfigState({ ...usbConfigState, product: value });
}; };
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="space-y-6">
<div> <div className="grid grid-cols-2 gap-4">
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">USB Emulation Configuration</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Set custom USB parameters to control how the USB device is emulated.
The device will rebind once the parameters are updated.
</p>
<div className="flex justify-start mt-4 text-xs text-slate-500 dark:text-slate-400">
<ExtLink
href={`https://the-sz.com/products/usbid/index.php`}
className="hover:underline"
>
Look up USB Device IDs here
</ExtLink>
</div>
</div>
<InputFieldWithLabel <InputFieldWithLabel
required required
label="Vendor ID" label="Vendor ID"
@ -174,42 +97,20 @@ function UpdateUsbConfigModal({
defaultValue={usbConfigState?.product} defaultValue={usbConfigState?.product}
onChange={e => handleUsbProduct(e.target.value)} onChange={e => handleUsbProduct(e.target.value)}
/> />
<div className="flex gap-x-2"> </div>
<Button <div className="flex gap-x-2">
size="SM" <Button
theme="primary" size="SM"
text="Update USB Config" theme="primary"
onClick={() => onSetUsbConfig(usbConfigState)} text="Update USB Config"
/> onClick={() => onSetUsbConfig(usbConfigState)}
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} /> />
</div> <Button
{error && <p className="text-sm text-red-500">{error}</p>} size="SM"
</div> theme="light"
</div> text="Restore to Default"
); onClick={onRestoreToDefault}
} />
function SuccessModal({
headline,
description,
onClose,
}: {
headline: string;
description: string;
onClose: () => void;
}) {
return (
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
</div> </div>
</div> </div>
); );

View File

@ -2,25 +2,19 @@ import { cx } from "@/cva.config";
import { Button } from "./Button"; import { Button } from "./Button";
import { GridCard } from "./Card"; import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner"; import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
interface UpdateInProgressStatusCardProps { export default function UpdateInProgressStatusCard() {
setIsUpdateDialogOpen: (isOpen: boolean) => void; const { navigateTo } = useDeviceUiNavigation();
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
return ( return (
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none"> <div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out">
<GridCard cardClassName="!shadow-xl"> <GridCard cardClassName="!shadow-xl">
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white"> <div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
<div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-3">
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} /> <LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-semibold leading-none transition text-ellipsis"> <div className="text-ellipsis text-sm font-semibold leading-none transition">
Update in Progress Update in Progress
</div> </div>
<div className="text-sm leading-none"> <div className="text-sm leading-none">
@ -37,10 +31,7 @@ export default function UpdateInProgressStatusCard({
className="pointer-events-auto" className="pointer-events-auto"
theme="light" theme="light"
text="View Details" text="View Details"
onClick={() => { onClick={() => navigateTo("/settings/general/update")}
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
/> />
</div> </div>
</GridCard> </GridCard>

View File

@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import { Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button"; import { LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { motion, AnimatePresence } from "motion/react";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; children: React.ReactNode;
@ -12,7 +12,7 @@ interface OverlayContentProps {
function OverlayContent({ children }: OverlayContentProps) { function OverlayContent({ children }: OverlayContentProps) {
return ( return (
<GridCard cardClassName="h-full pointer-events-auto !outline-none"> <GridCard cardClassName="h-full pointer-events-auto !outline-none">
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20"> <div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
{children} {children}
</div> </div>
</GridCard> </GridCard>
@ -25,28 +25,31 @@ interface LoadingOverlayProps {
export function LoadingOverlay({ show }: LoadingOverlayProps) { export function LoadingOverlay({ show }: LoadingOverlayProps) {
return ( return (
<Transition <AnimatePresence>
show={show} {show && (
enter="transition-opacity duration-300" <motion.div
enterFrom="opacity-0" className="absolute inset-0 aspect-video h-full w-full"
enterTo="opacity-100" initial={{ opacity: 0 }}
leave="transition-opacity duration-100" animate={{ opacity: 1 }}
leaveFrom="opacity-100" exit={{ opacity: 0 }}
leaveTo="opacity-0" transition={{
> duration: show ? 0.3 : 0.1,
<div className="absolute inset-0 w-full h-full aspect-video"> ease: "easeInOut"
<OverlayContent> }}
<div className="flex flex-col items-center justify-center gap-y-1"> >
<div className="flex items-center justify-center w-12 h-12 animate"> <OverlayContent>
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" /> <div className="flex flex-col items-center justify-center gap-y-1">
<div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
Loading video stream...
</p>
</div> </div>
<p className="text-sm text-center text-slate-700 dark:text-slate-300"> </OverlayContent>
Loading video stream... </motion.div>
</p> )}
</div> </AnimatePresence>
</OverlayContent>
</div>
</Transition>
); );
} }
@ -56,45 +59,48 @@ interface ConnectionErrorOverlayProps {
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) { export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return ( return (
<Transition <AnimatePresence>
show={show} {show && (
enter="transition duration-300" <motion.div
enterFrom="opacity-0" className="absolute inset-0 z-10 aspect-video h-full w-full"
enterTo="opacity-100" initial={{ opacity: 0 }}
leave="transition duration-300" animate={{ opacity: 1 }}
leaveFrom="opacity-100" exit={{ opacity: 0 }}
leaveTo="opacity-0" transition={{
> duration: 0.3,
<div className="absolute inset-0 z-10 w-full h-full aspect-video"> ease: "easeInOut"
<OverlayContent> }}
<div className="flex flex-col items-start gap-y-1"> >
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <OverlayContent>
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="flex flex-col items-start gap-y-1">
<div className="space-y-4"> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="space-y-2 text-black dark:text-white"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<h2 className="text-xl font-bold">Connection Issue Detected</h2> <div className="space-y-4">
<ul className="pl-4 space-y-2 text-left list-disc"> <div className="space-y-2 text-black dark:text-white">
<li>Verify that the device is powered on and properly connected</li> <h2 className="text-xl font-bold">Connection Issue Detected</h2>
<li>Check all cable connections for any loose or damaged wires</li> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure your network connection is stable and active</li> <li>Verify that the device is powered on and properly connected</li>
<li>Try restarting both the device and your computer</li> <li>Check all cable connections for any loose or damaged wires</li>
</ul> <li>Ensure your network connection is stable and active</li>
</div> <li>Try restarting both the device and your computer</li>
<div> </ul>
<LinkButton </div>
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} <div>
theme="light" <LinkButton
text="Troubleshooting Guide" to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
TrailingIcon={ArrowRightIcon} theme="light"
size="SM" text="Troubleshooting Guide"
/> TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </OverlayContent>
</OverlayContent> </motion.div>
</div> )}
</Transition> </AnimatePresence>
); );
} }
@ -109,85 +115,92 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
return ( return (
<> <>
<Transition <AnimatePresence>
show={show && isNoSignal} {show && isNoSignal && (
enter="transition duration-300" <motion.div
enterFrom="opacity-0" className="absolute inset-0 w-full h-full aspect-video"
enterTo="opacity-100" initial={{ opacity: 0 }}
leave="transition-all duration-300" animate={{ opacity: 1 }}
leaveFrom="opacity-100" exit={{ opacity: 0 }}
leaveTo="opacity-0" transition={{
> duration: 0.3,
<div className="absolute inset-0 w-full h-full aspect-video"> ease: "easeInOut"
<OverlayContent> }}
<div className="flex flex-col items-start gap-y-1"> >
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <OverlayContent>
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="flex flex-col items-start gap-y-1">
<div className="space-y-4"> <ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="space-y-2 text-black dark:text-white"> <div className="text-sm text-left text-slate-700 dark:text-slate-300">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2> <div className="space-y-4">
<ul className="pl-4 space-y-2 text-left list-disc"> <div className="space-y-2 text-black dark:text-white">
<li>Ensure the HDMI cable securely connected at both ends</li> <h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<li>Ensure source device is powered on and outputting a signal</li> <ul className="list-disc space-y-2 pl-4 text-left">
<li> <li>Ensure the HDMI cable securely connected at both ends</li>
If using an adapter, it&apos;s compatible and functioning <li>Ensure source device is powered on and outputting a signal</li>
correctly <li>
</li> If using an adapter, it&apos;s compatible and functioning
</ul> correctly
</div> </li>
<div> </ul>
<LinkButton </div>
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} <div>
theme="light" <LinkButton
text="Learn more" to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
TrailingIcon={ArrowRightIcon} theme="light"
size="SM" text="Learn more"
/> TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </OverlayContent>
</OverlayContent> </motion.div>
</div> )}
</Transition> </AnimatePresence>
<Transition
show={show && isOtherError} <AnimatePresence>
enter="transition duration-300" {show && isOtherError && (
enterFrom="opacity-0" <motion.div
enterTo="opacity-100" className="absolute inset-0 aspect-video h-full w-full"
leave="transition duration-300 " initial={{ opacity: 0 }}
leaveFrom="opacity-100" animate={{ opacity: 1 }}
leaveTo="opacity-0" exit={{ opacity: 0 }}
> transition={{
<div className="absolute inset-0 w-full h-full aspect-video"> duration: 0.3,
<OverlayContent> ease: "easeInOut"
<div className="flex flex-col items-start gap-y-1"> }}
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> >
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <OverlayContent>
<div className="space-y-4"> <div className="flex flex-col items-start gap-y-1">
<div className="space-y-2 text-black dark:text-white"> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<h2 className="text-xl font-bold">HDMI signal error detected.</h2> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<ul className="pl-4 space-y-2 text-left list-disc"> <div className="space-y-4">
<li>A loose or faulty HDMI connection</li> <div className="space-y-2 text-black dark:text-white">
<li>Incompatible resolution or refresh rate settings</li> <h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<li>Issues with the source device&apos;s HDMI output</li> <ul className="list-disc space-y-2 pl-4 text-left">
</ul> <li>A loose or faulty HDMI connection</li>
</div> <li>Incompatible resolution or refresh rate settings</li>
<div> <li>Issues with the source device&apos;s HDMI output</li>
<LinkButton </ul>
to={"/help/hdmi-error"} </div>
theme="light" <div>
text="Learn more" <LinkButton
TrailingIcon={ArrowRightIcon} to={"/help/hdmi-error"}
size="SM" theme="light"
/> text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </OverlayContent>
</OverlayContent> </motion.div>
</div> )}
</Transition> </AnimatePresence>
</> </>
); );
} }

View File

@ -5,7 +5,7 @@ import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useUiStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react"; import { motion, AnimatePresence } from "motion/react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
@ -182,276 +182,277 @@ function KeyboardWrapper() {
marginBottom: virtualKeyboard ? "0px" : `-${350}px`, marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}} }}
> >
<Transition <AnimatePresence>
show={virtualKeyboard} {virtualKeyboard && (
unmount={false} <motion.div
enter="transition-all transform-gpu duration-500 ease-in-out" initial={{ opacity: 0, y: "100%" }}
enterFrom="opacity-0 translate-y-[100%]" animate={{ opacity: 1, y: "0%" }}
enterTo="opacity-100 translate-y-[0%]" exit={{ opacity: 0, y: "100%" }}
leave="transition-all duration-500 ease-in-out" transition={{
leaveFrom="opacity-100 translate-y-[0%]" duration: 0.5,
leaveTo="opacity-0 translate-y-[100%]" ease: "easeInOut",
>
<div>
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}} }}
> >
<Card <div
className={cx("overflow-hidden", { className={cx(
"rounded-none": showAttachedVirtualKeyboard, !showAttachedVirtualKeyboard
})} ? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
> >
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20"> <Card
<div className="absolute flex items-center left-2 gap-x-2"> className={cx("overflow-hidden", {
{showAttachedVirtualKeyboard ? ( "rounded-none": showAttachedVirtualKeyboard,
})}
>
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="absolute left-2 flex items-center gap-x-2">
{showAttachedVirtualKeyboard ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Detach" text="Hide"
onClick={() => setShowAttachedVirtualKeyboard(false)} LeadingIcon={ChevronDownIcon}
/> onClick={() => setVirtualKeyboard(false)}
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
<div>
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layout={{
default: ["Home Pageup", "Delete End Pagedown"],
}}
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
/> />
</div> </div>
</div> </div>
</div>
</Card> <div>
</div> <div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
</div> <Keyboard
</Transition> baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layout={{
default: ["Home Pageup", "Delete End Pagedown"],
}}
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
/>
</div>
</div>
</div>
</Card>
</div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }

View File

@ -4,7 +4,6 @@ import {
useMouseStore, useMouseStore,
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
useUiStore,
useVideoStore, useVideoStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
@ -15,7 +14,9 @@ import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar"; import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay"; import { HDMIErrorOverlay } from "./VideoOverlay";
import { ConnectionErrorOverlay } from "./VideoOverlay";
import { LoadingOverlay } from "./VideoOverlay";
export default function WebRTCVideo() { export default function WebRTCVideo() {
// Video and stream related refs and states // Video and stream related refs and states
@ -402,26 +403,6 @@ export default function WebRTCVideo() {
], ],
); );
// Focus trap management
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const sidebarView = useUiStore(state => state.sidebarView);
useEffect(() => {
setTimeout(function () {
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
// (document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
} else {
setDisableVideoFocusTrap(false);
}
}, 300);
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
return ( return (
<div className="grid h-full w-full grid-rows-layout"> <div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]"> <div className="min-h-[39.5px]">

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu"; import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { useJsonRpc } from "../../hooks/useJsonRpc"; import { useJsonRpc } from "../../hooks/useJsonRpc";
@ -95,7 +95,7 @@ export function ATXPowerControl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="ATX Power Control" title="ATX Power Control"
description="Control your ATX power settings" description="Control your ATX power settings"
/> />

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { LuPower } from "react-icons/lu"; import { LuPower } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import FieldLabel from "../FieldLabel"; import FieldLabel from "../FieldLabel";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@ -52,7 +52,7 @@ export function DCPowerControl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="DC Power Control" title="DC Power Control"
description="Control your DC power settings" description="Control your DC power settings"
/> />

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { LuTerminal } from "react-icons/lu"; import { LuTerminal } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SelectMenuBasic } from "../SelectMenuBasic"; import { SelectMenuBasic } from "../SelectMenuBasic";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -52,7 +52,7 @@ export function SerialConsole() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Serial Console" title="Serial Console"
description="Configure your serial console settings" description="Configure your serial console settings"
/> />

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { Button } from "../Button"; import { Button } from "../Button";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu"; import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
@ -106,7 +106,7 @@ export default function ExtensionPopover() {
) : ( ) : (
// Extensions List View // Extensions List View
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Extensions" title="Extensions"
description="Load and manage your extensions" description="Load and manage your extensions"
/> />

View File

@ -5,7 +5,7 @@ import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react"; import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores"; import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { import {
LuArrowUpFromLine, LuArrowUpFromLine,
LuCheckCheck, LuCheckCheck,
@ -15,19 +15,15 @@ import {
} from "react-icons/lu"; } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications"; import notifications from "../../notifications";
import MountMediaModal from "../MountMediaDialog";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { useLocation } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => { const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const { const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
remoteVirtualMediaState, useMountMediaStore();
isMountMediaDialogOpen,
setModalView,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
} = useMountMediaStore();
const bytesSentPerSecond = useMemo(() => { const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null; if (diskDataChannelStats.size < 2) return null;
@ -78,7 +74,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" /> <PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-700 dark:text-white" />
</div> </div>
</Card> </Card>
</div> </div>
@ -103,20 +99,25 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" /> <LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3> <h3 className="text-base font-semibold text-black dark:text-white">
Streaming from Browser
</h3>
</div> </div>
<Card className="w-auto px-2 py-1"> <Card className="w-auto px-2 py-1">
<div className="w-full text-sm text-black truncate dark:text-white"> <div className="w-full truncate text-sm text-black dark:text-white">
{formatters.truncateMiddle(filename, 50)} {formatters.truncateMiddle(filename, 50)}
</div> </div>
</Card> </Card>
</div> </div>
<div className="flex flex-col items-center my-2 gap-y-2"> <div className="my-2 flex flex-col items-center gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100"> <div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span> <span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} /> <LuArrowUpFromLine
className="h-4 text-blue-700 dark:text-blue-500"
strokeWidth={2}
/>
<span> <span>
{bytesSentPerSecond !== null {bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s` ? `${formatters.bytes(bytesSentPerSecond)}/s`
@ -131,33 +132,49 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
case "HTTP": case "HTTP":
return ( return (
<div className=""> <div className="">
<div className="inline-block mb-0"> <div className="mb-0 inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" /> <LuLink className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-500" />
</div> </div>
</Card> </Card>
</div> </div>
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3> <h3 className="text-base font-semibold text-black dark:text-white">
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p> Streaming from URL
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p> </h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p> <p className="truncate text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(url, 55)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(filename, 30)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.bytes(size ?? 0)}
</p>
</div> </div>
); );
case "Storage": case "Storage":
return ( return (
<div className=""> <div className="">
<div className="inline-block mb-0"> <div className="mb-0 inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" /> <LuRadioReceiver className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-500" />
</div> </div>
</Card> </Card>
</div> </div>
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3> <h3 className="text-base font-semibold text-black dark:text-white">
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p> Mounted from JetKVM Storage
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p> </h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p> <p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(path, 50)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(filename, 30)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.bytes(size ?? 0)}
</p>
</div> </div>
); );
default: default:
@ -165,18 +182,21 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
} }
}; };
const close = useClose(); const close = useClose();
const location = useLocation();
useEffect(() => { useEffect(() => {
syncRemoteVirtualMediaState(); syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]); }, [syncRemoteVirtualMediaState, location.pathname]);
const { navigateTo } = useDeviceUiNavigation();
return ( return (
<GridCard> <GridCard>
<div className="p-4 py-3 space-y-4"> <div className="space-y-4 p-4 py-3">
<div ref={ref} className="grid h-full grid-rows-headerBody"> <div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 "> <div className="h-full space-y-4 ">
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Virtual Media" title="Virtual Media"
description="Mount an image to boot from or install an operating system." description="Mount an image to boot from or install an operating system."
/> />
@ -185,7 +205,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Card> <Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm"> <div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" /> <ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex items-center w-full text-black"> <div className="flex w-full items-center text-black">
<div>Closing this tab will unmount the image</div> <div>Closing this tab will unmount the image</div>
</div> </div>
</div> </div>
@ -193,7 +213,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
) : null} ) : null}
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -203,7 +223,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="group"> <div className="group">
<Card> <Card>
<div className="w-full px-4 py-8"> <div className="w-full px-4 py-8">
<div className="flex flex-col items-center justify-center h-full text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
{renderGridCardContent()} {renderGridCardContent()}
</div> </div>
</div> </div>
@ -211,8 +231,8 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div> </div>
</div> </div>
{remoteVirtualMediaState ? ( {remoteVirtualMediaState ? (
<div className="flex items-center justify-between text-xs select-none"> <div className="flex select-none items-center justify-between text-xs">
<div className="text-white select-none dark:text-slate-300"> <div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "} <span>Mounted as</span>{" "}
<span className="font-semibold"> <span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"} {remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
@ -244,7 +264,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z" d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor" fill="currentColor"
/> />
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" /> <path
d="M10 7.49976H0V9.22453H10V7.49976Z"
fill="currentColor"
/>
</g> </g>
<defs> <defs>
<clipPath id="clip0_3137_1186"> <clipPath id="clip0_3137_1186">
@ -261,16 +284,11 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div> </div>
</div> </div>
</div> </div>
<MountMediaModal
open={isMountMediaDialogOpen}
setOpen={setIsMountMediaDialogOpen}
/>
</div> </div>
{!remoteVirtualMediaState && ( {!remoteVirtualMediaState && (
<div <div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn" className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -290,7 +308,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
text="Add New Media" text="Add New Media"
onClick={() => { onClick={() => {
setModalView("mode"); setModalView("mode");
setIsMountMediaDialogOpen(true); navigateTo("/mount");
}} }}
LeadingIcon={LuPlus} LeadingIcon={LuPlus}
/> />

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications"; import notifications from "../../notifications";
@ -75,7 +75,7 @@ export default function PasteModal() {
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Paste text" title="Paste text"
description="Paste text from your client to the remote host" description="Paste text from your client to the remote host"
/> />

View File

@ -1,5 +1,5 @@
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
@ -102,7 +102,7 @@ export default function WakeOnLanModal() {
<div className="p-4 py-3 space-y-4"> <div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Wake On LAN" title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device." description="Send a Magic Packet to wake up a remote device."
/> />

View File

@ -1,6 +1,5 @@
import SidebarHeader from "@components/SidebarHeader"; import SidebarHeader from "@components/SidebarHeader";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { useEffect } from "react";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@components/StatChart"; import StatChart from "@components/StatChart";
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
@ -36,19 +35,7 @@ function createChartArray<T, K extends keyof T>(
}); });
} }
export default function ConnectionStatsSidebar () { export default function ConnectionStatsSidebar() {
const setModalView = useUiStore(state => state.setModalView);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setModalView(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setModalView]);
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats); const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const candidatePairStats = useRTCStore(state => state.candidatePairStats); const candidatePairStats = useRTCStore(state => state.candidatePairStats);
@ -111,9 +98,9 @@ export default function ConnectionStatsSidebar () {
}, 500); }, 500);
return ( return (
<div className="grid h-full shadow-sm grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody shadow-sm">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} /> <SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full px-4 py-2 pb-8 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900"> <div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4"> <div className="space-y-4">
{/* {/*
The entire sidebar component is always rendered, with a display none when not visible The entire sidebar component is always rendered, with a display none when not visible

File diff suppressed because it is too large Load Diff

View File

@ -20,8 +20,7 @@ const appendStatToMap = <T extends { timestamp: number }>(
}; };
// Constants and types // Constants and types
export type AvailableSidebarViews = "system" | "connection-stats"; export type AvailableSidebarViews = "connection-stats";
export type AvailableModalViews = "connection-stats" | "settings";
export type AvailableTerminalTypes = "kvm" | "serial" | "none"; export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export interface User { export interface User {
@ -47,9 +46,6 @@ interface UIState {
toggleSidebarView: (view: AvailableSidebarViews) => void; toggleSidebarView: (view: AvailableSidebarViews) => void;
modalView: AvailableModalViews | null;
setModalView: (view: AvailableModalViews | null) => void;
isAttachedVirtualKeyboardVisible: boolean; isAttachedVirtualKeyboardVisible: boolean;
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void; setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
@ -79,9 +75,6 @@ export const useUiStore = create<UIState>(set => ({
} }
}), }),
modalView: null,
setModalView: view => set({ modalView: view }),
isAttachedVirtualKeyboardVisible: true, isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled => setAttachedVirtualKeyboardVisibility: enabled =>
set({ isAttachedVirtualKeyboardVisible: enabled }), set({ isAttachedVirtualKeyboardVisible: enabled }),
@ -303,7 +296,8 @@ export const useSettingsStore = create(
dim_after: 10000, dim_after: 10000,
off_after: 50000, off_after: 50000,
}, },
setBacklightSettings: (settings: BacklightSettings) => set({ backlightSettings: settings }), setBacklightSettings: (settings: BacklightSettings) =>
set({ backlightSettings: settings }),
}), }),
{ {
name: "settings", name: "settings",
@ -484,8 +478,6 @@ export interface UpdateState {
| "updateCompleted" | "updateCompleted"
| "error"; | "error";
setModalView: (view: UpdateState["modalView"]) => void; setModalView: (view: UpdateState["modalView"]) => void;
isUpdateDialogOpen: boolean;
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setUpdateErrorMessage: (errorMessage: string) => void; setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null; updateErrorMessage: string | null;
} }
@ -520,16 +512,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
set({ updateDialogHasBeenMinimized: hasBeenMinimized }), set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading", modalView: "loading",
setModalView: view => set({ modalView: view }), setModalView: view => set({ modalView: view }),
isUpdateDialogOpen: false,
setIsUpdateDialogOpen: isOpen => set({ isUpdateDialogOpen: isOpen }),
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }), setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
})); }));
interface UsbConfigModalState { interface UsbConfigModalState {
modalView: modalView: "updateUsbConfig" | "updateUsbConfigSuccess";
| "updateUsbConfig"
| "updateUsbConfigSuccess";
errorMessage: string | null; errorMessage: string | null;
setModalView: (view: UsbConfigModalState["modalView"]) => void; setModalView: (view: UsbConfigModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void; setErrorMessage: (message: string | null) => void;
@ -558,14 +546,10 @@ interface LocalAuthModalState {
| "creationSuccess" | "creationSuccess"
| "deleteSuccess" | "deleteSuccess"
| "updateSuccess"; | "updateSuccess";
errorMessage: string | null;
setModalView: (view: LocalAuthModalState["modalView"]) => void; setModalView: (view: LocalAuthModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void;
} }
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword", modalView: "createPassword",
errorMessage: null,
setModalView: view => set({ modalView: view }), setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
})); }));

View File

@ -0,0 +1,58 @@
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
import { isOnDevice } from "../main";
import { useCallback, useMemo } from "react";
/**
* Hook that provides context-aware navigation and path generation
* that works in both cloud and device modes.
*
* In cloud mode, paths are prefixed with /devices/:id
* In device mode, paths start from the root
* Relative paths (starting with . or ..) are preserved in both modes
* Supports all React Router navigation options
*/
export function useDeviceUiNavigation() {
const navigate = useNavigate();
const params = useParams();
// Get the device ID from params
const deviceId = useMemo(() => params.id, [params.id]);
// Function to generate the correct path
const getPath = useCallback(
(path: string): string => {
// Check if it's a relative path (starts with . or ..)
const isRelativePath = path.startsWith(".") || path === "";
// If it's a relative path, don't modify it
if (isRelativePath) return path;
// Ensure absolute path starts with a slash
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (isOnDevice) {
return normalizedPath;
} else {
if (!deviceId) {
console.error("No device ID found in params when generating path");
throw new Error("No device ID found in params when generating path");
}
return `/devices/${deviceId}${normalizedPath}`;
}
},
[deviceId],
);
// Function to navigate to the correct path with all options
const navigateTo = useCallback(
(path: string, options?: NavigateOptions) => {
navigate(getPath(path), options);
},
[getPath, navigate],
);
return {
navigateTo,
getPath,
};
}

View File

@ -8,17 +8,25 @@ export interface JsonRpcRequest {
id: number | string; id: number | string;
} }
type JsonRpcResponse = export interface JsonRpcError {
| { code: number;
jsonrpc: string; data?: string;
result: boolean | number | object | string | []; message: string;
id: string | number; }
}
| { export interface JsonRpcSuccessResponse {
jsonrpc: string; jsonrpc: string;
error: { code: number; data?: string; message: string }; result: boolean | number | object | string | [];
id: string | number; id: string | number;
}; }
export interface JsonRpcErrorResponse {
jsonrpc: string;
error: JsonRpcError;
id: string | number;
}
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>(); const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
let requestCounter = 0; let requestCounter = 0;

View File

@ -105,7 +105,7 @@ video::-webkit-media-controls {
} }
.controlArrows { .controlArrows {
@apply flex items-center justify-between w-full md:w-1/5; @apply flex w-full items-center justify-between md:w-1/5;
flex-flow: column; flex-flow: column;
} }
@ -191,3 +191,13 @@ video::-webkit-media-controls {
scrollbar-color: theme("colors.gray.900") #002b36; scrollbar-color: theme("colors.gray.900") #002b36;
scrollbar-width: thin; scrollbar-width: thin;
} }
.hide-scrollbar {
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}

View File

@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import Root from "./root"; import Root from "./root";
import "./index.css"; import "./index.css";
@ -9,7 +8,7 @@ import {
RouterProvider, RouterProvider,
useRouteError, useRouteError,
} from "react-router-dom"; } from "react-router-dom";
import DeviceRoute from "@routes/devices.$id"; import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices"; import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
import SetupRoute from "@routes/devices.$id.setup"; import SetupRoute from "@routes/devices.$id.setup";
import LoginRoute from "@routes/login"; import LoginRoute from "@routes/login";
@ -25,14 +24,28 @@ import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Notifications from "./notifications"; import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local"; import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode"; import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
import WelcomeRoute from "./routes/welcome-local"; import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local";
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password"; import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { CLOUD_API } from "./ui.config"; import { CLOUD_API, DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session";
import MountRoute from "./routes/devices.$id.mount";
import * as SettingsRoute from "./routes/devices.$id.settings";
import SettingsKeyboardMouseRoute from "./routes/devices.$id.settings.mouse";
import api from "./api";
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index";
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
export const isOnDevice = import.meta.env.MODE === "device"; export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice; export const isInCloud = !isOnDevice;
export async function checkAuth() { export async function checkCloudAuth() {
const res = await fetch(`${CLOUD_API}/me`, { const res = await fetch(`${CLOUD_API}/me`, {
mode: "cors", mode: "cors",
credentials: "include", credentials: "include",
@ -46,6 +59,27 @@ export async function checkAuth() {
return await res.json(); return await res.json();
} }
export async function checkDeviceAuth() {
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode };
}
throw new Error("Error fetching device");
}
export async function checkAuth() {
return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth();
}
let router; let router;
if (isOnDevice) { if (isOnDevice) {
router = createBrowserRouter([ router = createBrowserRouter([
@ -75,7 +109,75 @@ if (isOnDevice) {
errorElement: <ErrorBoundary />, errorElement: <ErrorBoundary />,
element: <DeviceRoute />, element: <DeviceRoute />,
loader: DeviceRoute.loader, loader: DeviceRoute.loader,
children: [
{
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "mount",
element: <MountRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,
children: [
{
index: true,
loader: SettingsIndexRoute.loader,
},
{
path: "general",
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{
path: "update",
element: <SettingsGeneralUpdateRoute />,
},
],
},
{
path: "mouse",
element: <SettingsKeyboardMouseRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,
},
{
path: "hardware",
element: <SettingsHardwareRoute />,
},
{
path: "access",
children: [
{
index: true,
element: <SettingsAccessIndexRoute.default />,
loader: SettingsAccessIndexRoute.loader,
},
{
path: "local-auth",
element: <SecurityAccessLocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
],
},
],
}, },
{ {
path: "/adopt", path: "/adopt",
element: <AdoptRoute />, element: <AdoptRoute />,
@ -116,6 +218,73 @@ if (isOnDevice) {
path: "devices/:id", path: "devices/:id",
element: <DeviceRoute />, element: <DeviceRoute />,
loader: DeviceRoute.loader, loader: DeviceRoute.loader,
children: [
{
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "mount",
element: <MountRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,
children: [
{
index: true,
loader: SettingsIndexRoute.loader,
},
{
path: "general",
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{
path: "update",
element: <SettingsGeneralUpdateRoute />,
},
],
},
{
path: "mouse",
element: <SettingsKeyboardMouseRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,
},
{
path: "hardware",
element: <SettingsHardwareRoute />,
},
{
path: "access",
children: [
{
index: true,
element: <SettingsAccessIndexRoute.default />,
loader: SettingsAccessIndexRoute.loader,
},
{
path: "local-auth",
element: <SecurityAccessLocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
],
},
],
}, },
{ {
path: "devices/:id/deregister", path: "devices/:id/deregister",
@ -139,7 +308,7 @@ if (isOnDevice) {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Notifications <Notifications
toastOptions={{ toastOptions={{
@ -148,7 +317,7 @@ document.addEventListener("DOMContentLoaded", () => {
}} }}
max={2} max={2}
/> />
</React.StrictMode>, </>,
); );
}); });
@ -164,8 +333,8 @@ function ErrorBoundary() {
} }
return ( return (
<div className="w-full h-full"> <div className="h-full w-full">
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<EmptyCard <EmptyCard
IconElm={ExclamationTriangleIcon} IconElm={ExclamationTriangleIcon}

View File

@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { import {
MountMediaState, MountMediaState,
RemoteVirtualMediaState, RemoteVirtualMediaState,
@ -21,8 +20,8 @@ import {
} from "react-icons/lu"; } from "react-icons/lu";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { PlusCircleIcon } from "@heroicons/react/20/solid"; import { PlusCircleIcon } from "@heroicons/react/20/solid";
import AutoHeight from "./AutoHeight"; import AutoHeight from "@components/AutoHeight";
import { InputFieldWithLabel } from "./InputField"; import { InputFieldWithLabel } from "@/components/InputField";
import DebianIcon from "@/assets/debian-icon.png"; import DebianIcon from "@/assets/debian-icon.png";
import UbuntuIcon from "@/assets/ubuntu-icon.png"; import UbuntuIcon from "@/assets/ubuntu-icon.png";
import FedoraIcon from "@/assets/fedora-icon.png"; import FedoraIcon from "@/assets/fedora-icon.png";
@ -33,34 +32,29 @@ import { TrashIcon } from "@heroicons/react/16/solid";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications"; import notifications from "../notifications";
import Fieldset from "./Fieldset"; import Fieldset from "@/components/Fieldset";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import { useNavigate } from "react-router-dom";
export default function MountMediaModal({ export default function MountRoute() {
open, const navigate = useNavigate();
setOpen, {
}: { /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
open: boolean; }
setOpen: (open: boolean) => void; return <Dialog onClose={() => navigate("..")} />;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
} }
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { export function Dialog({ onClose }: { onClose: () => void }) {
const { const {
modalView, modalView,
setModalView, setModalView,
setLocalFile, setLocalFile,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState, setRemoteVirtualMediaState,
errorMessage, errorMessage,
setErrorMessage, setErrorMessage,
} = useMountMediaStore(); } = useMountMediaStore();
const navigate = useNavigate();
const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null); const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null);
const [mountInProgress, setMountInProgress] = useState(false); const [mountInProgress, setMountInProgress] = useState(false);
@ -99,9 +93,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
clearMountMediaState(); clearMountMediaState();
syncRemoteVirtualMediaState() syncRemoteVirtualMediaState()
.then(() => { .then(() => navigate(".."))
setIsMountMediaDialogOpen(false);
})
.catch(err => { .catch(err => {
triggerError(err instanceof Error ? err.message : String(err)); triggerError(err instanceof Error ? err.message : String(err));
}) })
@ -109,7 +101,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
setMountInProgress(false); setMountInProgress(false);
}); });
setIsMountMediaDialogOpen(false); navigate("..");
}); });
} }
@ -123,7 +115,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
clearMountMediaState(); clearMountMediaState();
syncRemoteVirtualMediaState() syncRemoteVirtualMediaState()
.then(() => { .then(() => {
setIsMountMediaDialogOpen(false); false;
}) })
.catch(err => { .catch(err => {
triggerError(err instanceof Error ? err.message : String(err)); triggerError(err instanceof Error ? err.message : String(err));
@ -156,7 +148,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
// We need to keep the local file in the store so that the browser can // We need to keep the local file in the store so that the browser can
// continue to stream the file to the device // continue to stream the file to the device
setLocalFile(file); setLocalFile(file);
setIsMountMediaDialogOpen(false); navigate("..");
}) })
.catch(err => { .catch(err => {
triggerError(err instanceof Error ? err.message : String(err)); triggerError(err instanceof Error ? err.message : String(err));
@ -188,16 +180,16 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<img <img
src={LogoBlueIcon} src={LogoBlueIcon}
alt="JetKVM Logo" alt="JetKVM Logo"
className="h-[24px] dark:hidden block" className="block h-[24px] dark:hidden"
/> />
<img <img
src={LogoWhiteIcon} src={LogoWhiteIcon}
alt="JetKVM Logo" alt="JetKVM Logo"
className="h-[24px] dark:block hidden dark:!mt-0" className="hidden h-[24px] dark:!mt-0 dark:block"
/> />
{modalView === "mode" && ( {modalView === "mode" && (
<ModeSelectionView <ModeSelectionView
onClose={() => setOpen(false)} onClose={() => onClose()}
selectedMode={selectedMode} selectedMode={selectedMode}
setSelectedMode={setSelectedMode} setSelectedMode={setSelectedMode}
/> />
@ -261,7 +253,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<ErrorView <ErrorView
errorMessage={errorMessage} errorMessage={errorMessage}
onClose={() => { onClose={() => {
setOpen(false); onClose();
setErrorMessage(null); setErrorMessage(null);
}} }}
onRetry={() => { onRetry={() => {
@ -291,7 +283,7 @@ function ModeSelectionView({
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="space-y-0 asnimate-fadeIn"> <div className="asnimate-fadeIn space-y-0">
<h2 className="text-lg font-bold leading-tight dark:text-white"> <h2 className="text-lg font-bold leading-tight dark:text-white">
Virtual Media Source Virtual Media Source
</h2> </h2>
@ -345,7 +337,7 @@ function ModeSelectionView({
)} )}
> >
<div <div
className="relative z-50 flex flex-col items-start p-4 select-none" className="relative z-50 flex select-none flex-col items-start p-4"
onClick={() => onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device") disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
} }
@ -353,7 +345,7 @@ function ModeSelectionView({
<div> <div>
<Card> <Card>
<div className="p-1"> <div className="p-1">
<Icon className="w-4 h-4 text-blue-700 shrink-0 dark:text-blue-400" /> <Icon className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
@ -373,7 +365,7 @@ function ModeSelectionView({
value={mode} value={mode}
disabled={disabled} disabled={disabled}
checked={selectedMode === mode} checked={selectedMode === mode}
className="absolute w-4 h-4 text-blue-700 right-4 top-4" className="absolute right-4 top-4 h-4 w-4 text-blue-700"
/> />
</div> </div>
</Card> </Card>
@ -381,13 +373,13 @@ function ModeSelectionView({
))} ))}
</div> </div>
<div <div
className="flex justify-end opacity-0 animate-fadeIn" className="flex animate-fadeIn justify-end opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
}} }}
> >
<div className="flex pt-2 gap-x-2"> <div className="flex gap-x-2 pt-2">
<Button size="MD" theme="blank" onClick={onClose} text="Cancel" /> <Button size="MD" theme="blank" onClick={onClose} text="Cancel" />
<Button <Button
size="MD" size="MD"
@ -445,18 +437,18 @@ function BrowserFileView({
className="block cursor-pointer select-none" className="block cursor-pointer select-none"
> >
<div <div
className="opacity-0 group animate-fadeIn" className="group animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
> >
<Card className="transition-all duration-300 outline-dashed hover:bg-blue-50/50"> <Card className="outline-dashed transition-all duration-300 hover:bg-blue-50/50">
<div className="w-full px-4 py-12"> <div className="w-full px-4 py-12">
<div className="flex flex-col items-center justify-center h-full text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
{selectedFile ? ( {selectedFile ? (
<> <>
<div className="space-y-1"> <div className="space-y-1">
<LuHardDrive className="w-6 h-6 mx-auto text-blue-700" /> <LuHardDrive className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm font-semibold leading-none"> <h3 className="text-sm font-semibold leading-none">
{formatters.truncateMiddle(selectedFile.name, 40)} {formatters.truncateMiddle(selectedFile.name, 40)}
</h3> </h3>
@ -467,7 +459,7 @@ function BrowserFileView({
</> </>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
<PlusCircleIcon className="w-6 h-6 mx-auto text-blue-700" /> <PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm font-semibold leading-none"> <h3 className="text-sm font-semibold leading-none">
Click to select a file Click to select a file
</h3> </h3>
@ -491,7 +483,7 @@ function BrowserFileView({
</div> </div>
<div <div
className="flex items-end justify-between w-full opacity-0 animate-fadeIn" className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -586,7 +578,7 @@ function UrlView({
/> />
<div <div
className="opacity-0 animate-fadeIn" className="animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -601,7 +593,7 @@ function UrlView({
/> />
</div> </div>
<div <div
className="flex items-end justify-between w-full opacity-0 animate-fadeIn" className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -627,7 +619,7 @@ function UrlView({
<hr className="border-slate-800/30 dark:border-slate-300/20" /> <hr className="border-slate-800/30 dark:border-slate-300/20" />
<div <div
className="opacity-0 animate-fadeIn" className="animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -636,7 +628,7 @@ function UrlView({
<h2 className="mb-2 text-sm font-semibold text-black dark:text-white"> <h2 className="mb-2 text-sm font-semibold text-black dark:text-white">
Popular images Popular images
</h2> </h2>
<Card className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20"> <Card className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
{popularImages.map((image, index) => ( {popularImages.map((image, index) => (
<div key={index} className="flex items-center justify-between gap-x-4 p-3.5"> <div key={index} className="flex items-center justify-between gap-x-4 p-3.5">
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
@ -805,7 +797,7 @@ function DeviceFileView({
description="Select an image to mount from the JetKVM storage" description="Select an image to mount from the JetKVM storage"
/> />
<div <div
className="w-full opacity-0 animate-fadeIn" className="w-full animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -816,7 +808,7 @@ function DeviceFileView({
<div className="flex items-center justify-center py-8 text-center"> <div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1"> <div className="space-y-1">
<PlusCircleIcon className="w-6 h-6 mx-auto text-blue-700 dark:text-blue-500" /> <PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm font-semibold leading-none text-black dark:text-white"> <h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No images available No images available
</h3> </h3>
@ -835,7 +827,7 @@ function DeviceFileView({
</div> </div>
</div> </div>
) : ( ) : (
<div className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20"> <div className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
{currentFiles.map((file, index) => ( {currentFiles.map((file, index) => (
<PreUploadedImageItem <PreUploadedImageItem
key={index} key={index}
@ -888,7 +880,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 ? ( {onStorageFiles.length > 0 ? (
<div <div
className="flex items-end justify-between opacity-0 animate-fadeIn" className="flex animate-fadeIn items-end justify-between opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.15s", animationDelay: "0.15s",
@ -916,7 +908,7 @@ function DeviceFileView({
</div> </div>
) : ( ) : (
<div <div
className="flex items-end justify-end opacity-0 animate-fadeIn" className="flex animate-fadeIn items-end justify-end opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.15s", animationDelay: "0.15s",
@ -929,31 +921,39 @@ function DeviceFileView({
)} )}
<hr className="border-slate-800/20 dark:border-slate-300/20" /> <hr className="border-slate-800/20 dark:border-slate-300/20" />
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.20s", animationDelay: "0.20s",
}} }}
> >
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">Available Storage</span> <span className="font-medium text-black dark:text-white">
<span className="text-slate-700 dark:text-slate-300">{percentageUsed}% used</span> Available Storage
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
</span>
</div> </div>
<div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700"> <div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700">
<div <div
className="h-full transition-all duration-300 ease-in-out bg-blue-700 rounded-sm dark:bg-blue-500" className="h-full rounded-sm bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500"
style={{ width: `${percentageUsed}%` }} style={{ width: `${percentageUsed}%` }}
></div> ></div>
</div> </div>
<div className="flex justify-between text-sm text-slate-600"> <div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesUsed)} used</span> <span className="text-slate-700 dark:text-slate-300">
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesFree)} free</span> {formatters.bytes(bytesUsed)} used
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
</span>
</div> </div>
</div> </div>
{onStorageFiles.length > 0 && ( {onStorageFiles.length > 0 && (
<div <div
className="w-full opacity-0 animate-fadeIn" className="w-full animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.25s", animationDelay: "0.25s",
@ -1245,7 +1245,7 @@ function UploadFileView({
} }
/> />
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -1261,17 +1261,18 @@ function UploadFileView({
<div className="group"> <div className="group">
<Card <Card
className={cx("transition-all duration-300", { className={cx("transition-all duration-300", {
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50": uploadState === "idle", "cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50":
uploadState === "idle",
})} })}
> >
<div className="h-[186px] w-full px-4"> <div className="h-[186px] w-full px-4">
<div className="flex flex-col items-center justify-center h-full text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
{uploadState === "idle" && ( {uploadState === "idle" && (
<div className="space-y-1"> <div className="space-y-1">
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" /> <PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
@ -1291,11 +1292,11 @@ function UploadFileView({
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuUpload className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" /> <LuUpload className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
<h3 className="text-lg font-semibold text-black leading-non dark:text-white"> <h3 className="leading-non text-lg font-semibold text-black dark:text-white">
Uploading {formatters.truncateMiddle(uploadedFileName, 30)} Uploading {formatters.truncateMiddle(uploadedFileName, 30)}
</h3> </h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300"> <p className="text-xs leading-none text-slate-700 dark:text-slate-300">
@ -1304,7 +1305,7 @@ function UploadFileView({
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700"> <div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700">
<div <div
className="h-3.5 rounded-full bg-blue-700 dark:bg-blue-500 transition-all duration-500 ease-linear" className="h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{ width: `${uploadProgress}%` }} style={{ width: `${uploadProgress}%` }}
></div> ></div>
</div> </div>
@ -1325,7 +1326,7 @@ function UploadFileView({
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuCheck className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" /> <LuCheck className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
@ -1350,13 +1351,15 @@ function UploadFileView({
className="hidden" className="hidden"
accept=".iso, .img" accept=".iso, .img"
/> />
{fileError && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>} {fileError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
)}
</div> </div>
{/* Display upload error if present */} {/* Display upload error if present */}
{uploadError && ( {uploadError && (
<div <div
className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn" className="mt-2 animate-fadeIn truncate text-sm text-red-600 opacity-0 dark:text-red-400"
style={{ animationDuration: "0.7s" }} style={{ animationDuration: "0.7s" }}
> >
Error: {uploadError} Error: {uploadError}
@ -1364,13 +1367,13 @@ function UploadFileView({
)} )}
<div <div
className="flex items-end w-full opacity-0 animate-fadeIn" className="flex w-full animate-fadeIn items-end opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
}} }}
> >
<div className="flex justify-end w-full space-x-2"> <div className="flex w-full justify-end space-x-2">
{uploadState === "uploading" ? ( {uploadState === "uploading" ? (
<Button <Button
size="MD" size="MD"
@ -1412,7 +1415,7 @@ function ErrorView({
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center space-x-2 text-red-600"> <div className="flex items-center space-x-2 text-red-600">
<ExclamationTriangleIcon className="w-6 h-6" /> <ExclamationTriangleIcon className="h-6 w-6" />
<h2 className="text-lg font-bold leading-tight">Mount Error</h2> <h2 className="text-lg font-bold leading-tight">Mount Error</h2>
</div> </div>
<p className="text-sm leading-snug text-slate-600"> <p className="text-sm leading-snug text-slate-600">
@ -1420,7 +1423,7 @@ function ErrorView({
</p> </p>
</div> </div>
{errorMessage && ( {errorMessage && (
<Card className="p-4 border border-red-200 bg-red-50"> <Card className="border border-red-200 bg-red-50 p-4">
<p className="text-sm font-medium text-red-800">{errorMessage}</p> <p className="text-sm font-medium text-red-800">{errorMessage}</p>
</Card> </Card>
)} )}
@ -1480,12 +1483,12 @@ function PreUploadedImageItem({
<div className="flex items-center gap-x-1 text-slate-600 dark:text-slate-400"> <div className="flex items-center gap-x-1 text-slate-600 dark:text-slate-400">
{formatters.date(new Date(uploadedAt), { month: "short" })} {formatters.date(new Date(uploadedAt), { month: "short" })}
</div> </div>
<div className="mx-1 h-[10px] w-[1px] bg-slate-300 dark:bg-slate-600 text-slate-300"></div> <div className="mx-1 h-[10px] w-[1px] bg-slate-300 text-slate-300 dark:bg-slate-600"></div>
<div className="text-gray-600 dark:text-slate-400">{size}</div> <div className="text-gray-600 dark:text-slate-400">{size}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="relative flex items-center select-none gap-x-3"> <div className="relative flex select-none items-center gap-x-3">
<div <div
className={cx("opacity-0 transition-opacity duration-200", { className={cx("opacity-0 transition-opacity duration-200", {
"w-auto opacity-100": isHovering, "w-auto opacity-100": isHovering,
@ -1509,7 +1512,7 @@ function PreUploadedImageItem({
checked={isSelected} checked={isSelected}
onChange={onSelect} onChange={onSelect}
name={name} name={name}
className="w-3 h-3 text-blue-700 bg-white dark:bg-slate-800 border-slate-800/30 dark:border-slate-300/20 focus:ring-blue-500 disabled:opacity-30" className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 focus:ring-blue-500 disabled:opacity-30 dark:border-slate-300/20 dark:bg-slate-800"
onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect
/> />
) : ( ) : (
@ -1549,7 +1552,7 @@ function UsbModeSelector({
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void; setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
}) { }) {
return ( return (
<div className="flex flex-col items-start space-y-1 select-none"> <div className="flex select-none flex-col items-start space-y-1">
<label className="text-sm font-semibold text-black dark:text-white">Mount as</label> <label className="text-sm font-semibold text-black dark:text-white">Mount as</label>
<div className="flex space-x-4"> <div className="flex space-x-4">
<label htmlFor="cdrom" className="flex items-center"> <label htmlFor="cdrom" className="flex items-center">
@ -1559,7 +1562,7 @@ function UsbModeSelector({
name="mountType" name="mountType"
onChange={() => setUsbMode("CDROM")} onChange={() => setUsbMode("CDROM")}
checked={usbMode === "CDROM"} checked={usbMode === "CDROM"}
className="w-3 h-3 text-blue-700 transition-opacity bg-white border-slate-800/30 focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800" className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/> />
<span className="ml-2 text-sm font-medium text-slate-900 dark:text-white"> <span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
CD/DVD CD/DVD
@ -1573,10 +1576,10 @@ function UsbModeSelector({
disabled disabled
checked={usbMode === "Disk"} checked={usbMode === "Disk"}
onChange={() => setUsbMode("Disk")} onChange={() => setUsbMode("Disk")}
className="w-3 h-3 text-blue-700 transition-opacity bg-white border-slate-800/30 focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800" className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/> />
<div className="flex flex-col ml-2 gap-y-0"> <div className="ml-2 flex flex-col gap-y-0">
<span className="text-sm font-medium leading-none opacity-50 text-slate-900 dark:text-white"> <span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
Disk Disk
</span> </span>
<div className="text-[10px] text-slate-500 dark:text-slate-400"> <div className="text-[10px] text-slate-500 dark:text-slate-400">

View File

@ -1,24 +1,23 @@
import { useNavigate, useOutletContext } from "react-router-dom";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg"; import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg"; import LogoWhite from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
export default function OtherSessionConnectedModal({ interface ContextType {
open, connectWebRTC: () => Promise<void>;
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
} }
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function OtherSessionRoute() {
const outletContext = useOutletContext<ContextType>();
const navigate = useNavigate();
// Function to handle closing the modal
const handleClose = () => {
outletContext?.connectWebRTC().then(() => navigate(".."));
};
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
return ( return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto"> <GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
<div className="p-10"> <div className="p-10">
@ -37,12 +36,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
this session? this session?
</p> </p>
<div className="flex items-center justify-start space-x-4"> <div className="flex items-center justify-start space-x-4">
<Button <Button size="SM" theme="primary" text="Use Here" onClick={handleClose} />
size="SM"
theme="primary"
text="Use Here"
onClick={() => setOpen(false)}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,5 @@
import { redirect } from "react-router-dom";
export function loader() {
return redirect("/settings/general");
}

View File

@ -0,0 +1,331 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useLoaderData } from "react-router-dom";
import { Button, LinkButton } from "../components/Button";
import { CLOUD_APP, DEVICE_API } from "../ui.config";
import api from "../api";
import { LocalDevice } from "./devices.$id";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { isOnDevice } from "../main";
import { GridCard } from "../components/Card";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import notifications from "../notifications";
import { useCallback, useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { InputFieldWithLabel } from "../components/InputField";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
export const loader = async () => {
const status = await api
.GET(`${DEVICE_API}/device`)
.then(res => res.json() as Promise<LocalDevice>);
return status;
};
export default function SettingsAccessIndexRoute() {
const { authMode } = useLoaderData() as LocalDevice;
const { navigateTo } = useDeviceUiNavigation();
const [send] = useJsonRpc();
const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [cloudUrl, setCloudUrl] = useState("");
const [cloudProviders, setCloudProviders] = useState<
{ value: string; label: string }[] | null
>([{ value: "https://api.jetkvm.com", label: "JetKVM Cloud" }]);
// The default value is just there so it doesn't flicker while we fetch the default Cloud URL and available providers
const [selectedUrlOption, setSelectedUrlOption] = useState<string>(
"https://api.jetkvm.com",
);
const [defaultCloudUrl, setDefaultCloudUrl] = useState<string>("");
const syncCloudUrl = useCallback(() => {
send("getCloudUrl", {}, resp => {
if ("error" in resp) return;
const url = resp.result as string;
setCloudUrl(url);
// Check if the URL matches any predefined option
if (cloudProviders?.some(provider => provider.value === url)) {
setSelectedUrlOption(url);
} else {
setSelectedUrlOption("custom");
// setCustomCloudUrl(url);
}
});
}, [cloudProviders, send]);
const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => {
if ("error" in resp) return console.error(resp.error);
const cloudState = resp.result as { connected: boolean };
setAdopted(cloudState.connected);
});
}, [send]);
const deregisterDevice = async () => {
send("deregisterDevice", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudState();
return;
});
};
const onCloudAdoptClick = useCallback(
(url: string) => {
if (!deviceId) {
notifications.error("No device ID available");
return;
}
send("setCloudUrl", { url }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
syncCloudUrl();
notifications.success("Cloud URL updated successfully");
const returnTo = new URL(window.location.href);
returnTo.pathname = "/adopt";
returnTo.search = "";
returnTo.hash = "";
window.location.href =
CLOUD_APP + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`;
});
},
[deviceId, syncCloudUrl, send],
);
useEffect(() => {
if (!defaultCloudUrl) return;
setSelectedUrlOption(defaultCloudUrl);
setCloudProviders([
{ value: defaultCloudUrl, label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
]);
}, [defaultCloudUrl]);
useEffect(() => {
getCloudState();
send("getDeviceID", {}, async resp => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});
}, [send, getCloudState]);
useEffect(() => {
send("getDefaultCloudUrl", {}, resp => {
if ("error" in resp) return console.error(resp.error);
setDefaultCloudUrl(resp.result as string);
});
}, [cloudProviders, syncCloudUrl, send]);
useEffect(() => {
if (!cloudProviders?.length) return;
syncCloudUrl();
}, [cloudProviders, syncCloudUrl]);
console.log("is adopted:", isAdopted);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Access"
description="Manage the Access Control of the device"
/>
<div className="space-y-4">
<SettingsSectionHeader
title="Local"
description="Manage the mode of local access to the device"
/>
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${authMode === "password" ? "Password protected" : "No password"}`}
>
{authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
onClick={() => {
navigateTo("./local-auth", { state: { init: "deletePassword" } });
}}
/>
) : (
<Button
size="SM"
theme="light"
text="Enable Password"
onClick={() => {
navigateTo("./local-auth", { state: { init: "createPassword" } });
}}
/>
)}
</SettingsItem>
{authMode === "password" && (
<SettingsItem
title="Change Password"
description="Update your device access password"
>
<Button
size="SM"
theme="light"
text="Change Password"
onClick={() => {
navigateTo("./local-auth", { state: { init: "updatePassword" } });
}}
/>
</SettingsItem>
)}
</div>
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="space-y-4">
<SettingsSectionHeader
title="Remote"
description="Manage the mode of Remote access to the device"
/>
{isOnDevice && (
<>
<div className="space-y-4">
{!isAdopted && (
<>
<SettingsItem
title="Cloud Provider"
description="Select the cloud provider for your device"
>
<SelectMenuBasic
size="SM"
value={selectedUrlOption}
onChange={e => {
const value = e.target.value;
setSelectedUrlOption(value);
}}
options={cloudProviders ?? []}
/>
</SettingsItem>
{selectedUrlOption === "custom" && (
<div className="mt-4 flex items-end gap-x-2 space-y-4">
<InputFieldWithLabel
size="SM"
label="Custom Cloud URL"
value={cloudUrl}
onChange={e => setCloudUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
)}
</>
)}
{/*
We do the harcoding here to avoid flickering when the default Cloud URL being fetched.
I've tried to avoid harcoding api.jetkvm.com, but it's the only reasonable way I could think of to avoid flickering for now.
*/}
{selectedUrlOption === (defaultCloudUrl || "https://api.jetkvm.com") && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" />
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li>Zero Trust security model</li>
<li>OIDC (OpenID Connect) authentication</li>
<li>All streams encrypted in transit</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "}
<a
href="https://github.com/jetkvm"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
GitHub
</a>
.
</div>
</div>
<hr className="block w-full dark:border-slate-600" />
<div>
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text="Learn about our cloud security"
/>
</div>
</div>
</div>
</GridCard>
)}
{!isAdopted ? (
<div className="flex items-end gap-x-2">
<Button
onClick={() => onCloudAdoptClick(cloudUrl)}
size="SM"
theme="primary"
text="Adopt KVM to Cloud"
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to JetKVM Cloud
</p>
<div>
<Button
size="SM"
theme="light"
text="De-register from Cloud"
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
"Are you sure you want to de-register this device?",
)
) {
deregisterDevice();
}
} else {
notifications.error("No device ID available");
}
}}
/>
</div>
</div>
</div>
)}
</div>
</>
)}
</div>
</div>
);
}

View File

@ -1,30 +1,35 @@
import { GridCard } from "@/components/Card"; import { useState, useEffect } from "react";
import { useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg"; import { InputFieldWithLabel } from "@/components/InputField";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { InputFieldWithLabel } from "./InputField";
import api from "@/api"; import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores"; import { useLocalAuthModalStore } from "@/hooks/stores";
import { useLocation, useRevalidator } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function LocalAuthPasswordDialog({ export default function SecurityAccessLocalAuthRoute() {
open, const { setModalView } = useLocalAuthModalStore();
setOpen, const { navigateTo } = useDeviceUiNavigation();
}: { const location = useLocation();
open: boolean; const init = location.state?.init;
setOpen: (open: boolean) => void;
}) { useEffect(() => {
return ( if (!init) {
<Modal open={open} onClose={() => setOpen(false)}> navigateTo("..");
<Dialog setOpen={setOpen} /> } else {
</Modal> setModalView(init);
); }
}, [init, navigateTo, setModalView]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigateTo("..")} />;
} }
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { export function Dialog({ onClose }: { onClose: () => void }) {
const { modalView, setModalView } = useLocalAuthModalStore(); const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const revalidator = useRevalidator();
const handleCreatePassword = async (password: string, confirmPassword: string) => { const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") { if (password === "") {
@ -41,6 +46,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const res = await api.POST("/auth/password-local", { password }); const res = await api.POST("/auth/password-local", { password });
if (res.ok) { if (res.ok) {
setModalView("creationSuccess"); setModalView("creationSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while setting the password"); setError(data.error || "An error occurred while setting the password");
@ -78,6 +85,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
if (res.ok) { if (res.ok) {
setModalView("updateSuccess"); setModalView("updateSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while changing the password"); setError(data.error || "An error occurred while changing the password");
@ -97,6 +106,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const res = await api.DELETE("/auth/local-password", { password }); const res = await api.DELETE("/auth/local-password", { password });
if (res.ok) { if (res.ok) {
setModalView("deleteSuccess"); setModalView("deleteSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while disabling the password"); setError(data.error || "An error occurred while disabling the password");
@ -107,12 +118,12 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
}; };
return ( return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800"> <div>
<div className="p-10"> <div>
{modalView === "createPassword" && ( {modalView === "createPassword" && (
<CreatePasswordModal <CreatePasswordModal
onSetPassword={handleCreatePassword} onSetPassword={handleCreatePassword}
onCancel={() => setOpen(false)} onCancel={onClose}
error={error} error={error}
/> />
)} )}
@ -120,7 +131,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
{modalView === "deletePassword" && ( {modalView === "deletePassword" && (
<DeletePasswordModal <DeletePasswordModal
onDeletePassword={handleDeletePassword} onDeletePassword={handleDeletePassword}
onCancel={() => setOpen(false)} onCancel={onClose}
error={error} error={error}
/> />
)} )}
@ -128,7 +139,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
{modalView === "updatePassword" && ( {modalView === "updatePassword" && (
<UpdatePasswordModal <UpdatePasswordModal
onUpdatePassword={handleUpdatePassword} onUpdatePassword={handleUpdatePassword}
onCancel={() => setOpen(false)} onCancel={onClose}
error={error} error={error}
/> />
)} )}
@ -137,7 +148,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal <SuccessModal
headline="Password Set Successfully" headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access." description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
@ -145,7 +156,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal <SuccessModal
headline="Password Protection Disabled" headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure." description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
@ -153,11 +164,11 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal <SuccessModal
headline="Password Updated Successfully" headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access." description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
</div> </div>
</GridCard> </div>
); );
} }
@ -175,13 +186,16 @@ function CreatePasswordModal({
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div> <form
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" /> className="space-y-4"
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" /> onSubmit={e => {
</div> e.preventDefault();
<div className="space-y-4"> }}
>
<div> <div>
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2> <h2 className="text-lg font-semibold dark:text-white">
Local Device Protection
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access. Create a password to protect your device from unauthorized local access.
</p> </p>
@ -191,6 +205,7 @@ function CreatePasswordModal({
type="password" type="password"
placeholder="Enter a strong password" placeholder="Enter a strong password"
value={password} value={password}
autoFocus
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
@ -211,7 +226,7 @@ function CreatePasswordModal({
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} /> <Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
</div> </form>
</div> </div>
); );
} }
@ -229,13 +244,11 @@ function DeletePasswordModal({
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2> <h2 className="text-lg font-semibold dark:text-white">
Disable Local Device Protection
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection. Enter your current password to disable local device protection.
</p> </p>
@ -281,13 +294,16 @@ function UpdatePasswordModal({
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div> <form
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" /> className="space-y-4"
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" /> onSubmit={e => {
</div> e.preventDefault();
<div className="space-y-4"> }}
>
<div> <div>
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2> <h2 className="text-lg font-semibold dark:text-white">
Change Local Device Password
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device Enter your current password and a new password to update your local device
protection. protection.
@ -324,7 +340,7 @@ function UpdatePasswordModal({
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} /> <Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
</div> </form>
</div> </div>
); );
} }
@ -339,11 +355,7 @@ function SuccessModal({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left"> <div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2> <h2 className="text-lg font-semibold dark:text-white">{headline}</h2>

View File

@ -0,0 +1,263 @@
import { SettingsItem } from "./devices.$id.settings";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import Checkbox from "../components/Checkbox";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { useCallback, useState, useEffect } from "react";
import notifications from "../notifications";
import { TextAreaWithLabel } from "../components/TextArea";
import { isOnDevice } from "../main";
import { Button } from "../components/Button";
import { useSettingsStore } from "../hooks/stores";
import { GridCard } from "@components/Card";
export default function SettingsAdvancedRoute() {
const [send] = useJsonRpc();
const [sshKey, setSSHKey] = useState<string>("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const settings = useSettingsStore();
useEffect(() => {
send("getDevModeState", {}, resp => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
send("getSSHKeyState", {}, resp => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
});
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
send("getDevChannelState", {}, resp => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
}, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [send]);
const handleUsbEmulationToggle = useCallback(
(enabled: boolean) => {
send("setUsbEmulationState", { enabled: enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
);
return;
}
setUsbEmulationEnabled(enabled);
getUsbEmulationState();
});
},
[getUsbEmulationState, send],
);
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Configuration reset to default successfully");
});
}, [send]);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("SSH key updated successfully");
});
}, [send, sshKey]);
const handleDevModeChange = useCallback(
(developerMode: boolean) => {
send("setDevModeState", { enabled: developerMode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDeveloperMode(developerMode);
});
},
[send, setDeveloperMode],
);
const handleDevChannelChange = (enabled: boolean) => {
send("setDevChannelState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDevChannel(enabled);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
/>
<div className="space-y-4">
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
>
<Checkbox
checked={devChannel}
onChange={e => {
handleDevChannelChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Developer Mode"
description="Enable advanced features for developers"
>
<Checkbox
checked={settings.developerMode}
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode && (
<GridCard>
<div className="flex select-none items-start gap-x-4 p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clipRule="evenodd"
/>
</svg>
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Developer Mode Enabled
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>Security is weakened while active</li>
<li>Only use if you understand the risks</li>
</ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
For advanced users only. Not for production use.
</div>
</div>
</div>
</GridCard>
)}
{isOnDevice && settings.developerMode && (
<div className="space-y-4">
<SettingsItem
title="SSH Access"
description="Add your SSH public key to enable secure remote access to the device"
/>
<div className="space-y-4">
<TextAreaWithLabel
label="SSH Public Key"
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder="Enter your SSH public key"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
The default SSH user is <strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Update SSH Key"
onClick={handleUpdateSSHKey}
/>
</div>
</div>
</div>
)}
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
>
<Checkbox
defaultChecked={settings.debugMode}
onChange={e => {
settings.setDebugMode(e.target.checked);
}}
/>
</SettingsItem>
{settings.debugMode && (
<>
<SettingsItem
title="USB Emulation"
description="Control the USB emulation state"
>
<Button
size="SM"
theme="light"
text={
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/>
</SettingsItem>
<SettingsItem
title="Reset Configuration"
description="Reset configuration to default. This will log you out."
>
<Button
size="SM"
theme="light"
text="Reset Config"
onClick={() => {
handleResetConfig();
window.location.reload();
}}
/>
</SettingsItem>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useCallback, useState } from "react";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAppearanceRoute() {
const [currentTheme, setCurrentTheme] = useState(() => {
return localStorage.theme || "system";
});
const handleThemeChange = useCallback((value: string) => {
const root = document.documentElement;
if (value === "system") {
localStorage.removeItem("theme");
// Check system preference
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.remove("light", "dark");
root.classList.add(systemTheme);
} else {
localStorage.theme = value;
root.classList.remove("light", "dark");
root.classList.add(value);
}
}, []);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Appearance"
description="Customize the look and feel of your JetKVM interface"
/>
<SettingsItem title="Theme" description="Choose your preferred color theme">
<SelectMenuBasic
size="SM"
label=""
value={currentTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
onChange={e => {
setCurrentTheme(e.target.value);
handleThemeChange(e.target.value);
}}
/>
</SettingsItem>
</div>
);
}

View File

@ -0,0 +1,108 @@
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useCallback, useState } from "react";
import { useEffect } from "react";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "../components/Button";
import notifications from "../notifications";
import Checkbox from "../components/Checkbox";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function SettingsGeneralRoute() {
const [send] = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [autoUpdate, setAutoUpdate] = useState(true);
const [currentVersions, setCurrentVersions] = useState<{
appVersion: string;
systemVersion: string;
} | null>(null);
const getCurrentVersions = useCallback(() => {
send("getUpdateStatus", {}, resp => {
if ("error" in resp) return;
const result = resp.result as SystemVersionInfo;
setCurrentVersions({
appVersion: result.local.appVersion,
systemVersion: result.local.systemVersion,
});
});
}, [send]);
useEffect(() => {
getCurrentVersions();
send("getAutoUpdateState", {}, resp => {
if ("error" in resp) return;
setAutoUpdate(resp.result as boolean);
});
}, [getCurrentVersions, send]);
const handleAutoUpdateChange = (enabled: boolean) => {
send("setAutoUpdateState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAutoUpdate(enabled);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="General"
description="Configure device settings and update preferences"
/>
<div className="space-y-4">
<div className="space-y-4 pb-2">
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Check for Updates"
description={
currentVersions ? (
<>
App: {currentVersions.appVersion}
<br />
System: {currentVersions.systemVersion}
</>
) : (
<>
App: Loading...
<br />
System: Loading...
</>
)
}
/>
<div>
<Button
size="SM"
theme="light"
text="Check for Updates"
onClick={() => navigateTo("./update")}
/>
</div>
</div>
<div className="space-y-4">
<SettingsItem
title="Auto Update"
description="Automatically update the device to the latest version"
>
<Checkbox
checked={autoUpdate}
onChange={e => {
handleAutoUpdateChange(e.target.checked);
}}
/>
</SettingsItem>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,14 +1,44 @@
import Card, { GridCard } from "@/components/Card"; import { useLocation, useNavigate } from "react-router-dom";
import Card from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { UpdateState, useUpdateStore } from "@/hooks/stores"; import { UpdateState, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "./LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
const location = useLocation();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
useEffect(() => {
if (otaState.updating) {
setModalView("updating");
} else if (otaState.error) {
setModalView("error");
} else if (updateSuccess) {
setModalView("updateCompleted");
} else {
setModalView("loading");
}
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
export interface SystemVersionInfo { export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string }; local: { appVersion: string; systemVersion: string };
@ -17,37 +47,15 @@ export interface SystemVersionInfo {
appUpdateAvailable: boolean; appUpdateAvailable: boolean;
} }
export default function UpdateDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
// We need to keep track of the update state in the dialog even if the dialog is minimized
const { setModalView } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} onConfirmUpdate={onConfirmUpdate} />
</Modal>
);
}
export function Dialog({ export function Dialog({
setOpen, onClose,
onConfirmUpdate, onConfirmUpdate,
}: { }: {
setOpen: (open: boolean) => void; onClose: () => void;
onConfirmUpdate: () => void; onConfirmUpdate: () => void;
}) { }) {
const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null); const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore(); const { modalView, setModalView, otaState } = useUpdateStore();
@ -73,27 +81,24 @@ export function Dialog({
}, [setModalView]); }, [setModalView]);
return ( return (
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto"> <div className="pointer-events-auto relative mx-auto text-left">
<div className="p-10"> <div>
{modalView === "error" && ( {modalView === "error" && (
<UpdateErrorState <UpdateErrorState
errorMessage={otaState.error} errorMessage={otaState.error}
onClose={() => setOpen(false)} onClose={onClose}
onRetryUpdate={() => setModalView("loading")} onRetryUpdate={() => setModalView("loading")}
/> />
)} )}
{modalView === "loading" && ( {modalView === "loading" && (
<LoadingState <LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
)} )}
{modalView === "updateAvailable" && ( {modalView === "updateAvailable" && (
<UpdateAvailableState <UpdateAvailableState
onConfirmUpdate={onConfirmUpdate} onConfirmUpdate={onConfirmUpdate}
onClose={() => setOpen(false)} onClose={onClose}
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
)} )}
@ -101,24 +106,20 @@ export function Dialog({
{modalView === "updating" && ( {modalView === "updating" && (
<UpdatingDeviceState <UpdatingDeviceState
otaState={otaState} otaState={otaState}
onMinimizeUpgradeDialog={() => { onMinimizeUpgradeDialog={() => navigateTo("/")}
setOpen(false);
}}
/> />
)} )}
{modalView === "upToDate" && ( {modalView === "upToDate" && (
<SystemUpToDateState <SystemUpToDateState
checkUpdate={() => setModalView("loading")} checkUpdate={() => setModalView("loading")}
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
{modalView === "updateCompleted" && ( {modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />}
<UpdateCompletedState onClose={() => setOpen(false)} />
)}
</div> </div>
</GridCard> </div>
); );
} }
@ -156,14 +157,12 @@ function LoadingState({
const animationTimer = setTimeout(() => { const animationTimer = setTimeout(() => {
setProgressWidth("100%"); setProgressWidth("100%");
}, 500); }, 0);
getVersionInfo() getVersionInfo()
.then(versionInfo => { .then(versionInfo => {
if (progressBarRef.current) { // Add a small delay to ensure it's not just flickering
progressBarRef.current?.classList.add("!duration-1000"); return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
}) })
.then(versionInfo => { .then(versionInfo => {
if (!signal.aborted) { if (!signal.aborted) {
@ -183,12 +182,8 @@ function LoadingState({
}, [getVersionInfo, onFinished]); }, [getVersionInfo, onFinished]);
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div> <div className="space-y-4">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="max-w-sm space-y-4">
<div className="space-y-0"> <div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Checking for updates... Checking for updates...
@ -201,18 +196,11 @@ function LoadingState({
<div <div
ref={progressBarRef} ref={progressBarRef}
style={{ width: progressWidth }} style={{ width: progressWidth }}
className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out" className="h-2.5 bg-blue-700 transition-all duration-1000 ease-in-out"
></div> ></div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Button <Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -294,11 +282,7 @@ function UpdatingDeviceState({
}; };
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="w-full max-w-sm space-y-4"> <div className="w-full max-w-sm space-y-4">
<div className="space-y-0"> <div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
@ -308,10 +292,10 @@ function UpdatingDeviceState({
Please don{"'"}t turn off your device. This process may take a few minutes. Please don{"'"}t turn off your device. This process may take a few minutes.
</p> </p>
</div> </div>
<Card className="p-4 space-y-4"> <Card className="space-y-4 p-4">
{areAllUpdatesComplete() ? ( {areAllUpdatesComplete() ? (
<div className="flex flex-col items-center my-2 space-y-2 text-center"> <div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300"> <div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white"> <span className="font-medium text-black dark:text-white">
Rebooting to complete the update... Rebooting to complete the update...
@ -321,8 +305,8 @@ function UpdatingDeviceState({
) : ( ) : (
<> <>
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && ( {!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
<div className="flex flex-col items-center my-2 space-y-2 text-center"> <div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
</div> </div>
)} )}
@ -333,9 +317,9 @@ function UpdatingDeviceState({
Linux System Update Linux System Update
</p> </p>
{calculateOverallProgress("system") < 100 ? ( {calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
) : ( ) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" /> <CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
)} )}
</div> </div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600"> <div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
@ -365,9 +349,9 @@ function UpdatingDeviceState({
App Update App Update
</p> </p>
{calculateOverallProgress("app") < 100 ? ( {calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
) : ( ) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" /> <CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
)} )}
</div> </div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600"> <div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
@ -390,7 +374,7 @@ function UpdatingDeviceState({
</> </>
)} )}
</Card> </Card>
<div className="flex justify-start mt-4 text-white gap-x-2"> <div className="mt-4 flex justify-start gap-x-2 text-white">
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
@ -411,11 +395,7 @@ function SystemUpToDateState({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
System is up to date System is up to date
@ -424,23 +404,9 @@ function SystemUpToDateState({
Your system is running the latest version. No updates are currently available. Your system is running the latest version. No updates are currently available.
</p> </p>
<div className="flex mt-4 gap-x-2"> <div className="mt-4 flex gap-x-2">
<Button <Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
size="SM" <Button size="SM" theme="blank" text="Back" onClick={onClose} />
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -457,11 +423,7 @@ function UpdateAvailableState({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Update available Update available
@ -495,11 +457,7 @@ function UpdateAvailableState({
function UpdateCompletedState({ onClose }: { onClose: () => void }) { function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white"> <p className="text-base font-semibold dark:text-white">
Update Completed Successfully Update Completed Successfully
@ -509,7 +467,7 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
features and improvements! features and improvements!
</p> </p>
<div className="flex items-center justify-start"> <div className="flex items-center justify-start">
<Button size="SM" theme="primary" text="Close" onClick={onClose} /> <Button size="SM" theme="primary" text="Back" onClick={onClose} />
</div> </div>
</div> </div>
</div> </div>
@ -526,11 +484,7 @@ function UpdateErrorState({
onRetryUpdate: () => void; onRetryUpdate: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white">Update Error</p> <p className="text-base font-semibold dark:text-white">Update Error</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400"> <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
@ -542,8 +496,8 @@ function UpdateErrorState({
</p> </p>
)} )}
<div className="flex items-center justify-start gap-x-2"> <div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Close" onClick={onClose} /> <Button size="SM" theme="light" text="Back" onClick={onClose} />
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} /> <Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,288 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, UsbConfigState, useSettingsStore } from "@/hooks/stores";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import USBConfigDialog from "@components/USBConfigDialog";
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&");
function generateNumber(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
function generateHex(min: number, max: number) {
const len = generateNumber(min, max);
const n = (Math.random() * 0xfffff * 1000000).toString(16);
return n.slice(0, len);
}
export interface USBConfig {
vendor_id: string;
product_id: string;
serial_number: string;
manufacturer: string;
product: string;
}
const usbConfigs = [
{
label: "JetKVM Default",
value: "USB Emulation Device",
},
{
label: "Logitech Universal Adapter",
value: "Logitech USB Input Device",
},
{
label: "Microsoft Wireless MultiMedia Keyboard",
value: "Wireless MultiMedia Keyboard",
},
{
label: "Dell Multimedia Pro Keyboard",
value: "Multimedia Pro Keyboard",
},
];
type UsbConfigMap = Record<string, USBConfig>;
export default function SettingsHardwareRoute() {
const [send] = useJsonRpc();
const settings = useSettingsStore();
const [usbConfigProduct, setUsbConfigProduct] = useState("");
const [deviceId, setDeviceId] = useState("");
const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings);
const usbConfigData: UsbConfigMap = useMemo(
() => ({
"USB Emulation Device": {
vendor_id: "0x1d6b",
product_id: "0x0104",
serial_number: deviceId,
manufacturer: "JetKVM",
product: "USB Emulation Device",
},
"Logitech USB Input Device": {
vendor_id: "0x046d",
product_id: "0xc52b",
serial_number: generatedSerialNumber,
manufacturer: "Logitech (x64)",
product: "Logitech USB Input Device",
},
"Wireless MultiMedia Keyboard": {
vendor_id: "0x045e",
product_id: "0x005f",
serial_number: generatedSerialNumber,
manufacturer: "Microsoft",
product: "Wireless MultiMedia Keyboard",
},
"Multimedia Pro Keyboard": {
vendor_id: "0x413c",
product_id: "0x2011",
serial_number: generatedSerialNumber,
manufacturer: "Dell Inc.",
product: "Multimedia Pro Keyboard",
},
}),
[deviceId],
);
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
// If the user has set the display to dim after it turns off, set the dim_after
// value to never.
if (settings.dim_after > settings.off_after && settings.off_after != 0) {
settings.dim_after = 0;
}
setBacklightSettings(settings);
handleBacklightSettingsSave();
};
const handleBacklightSettingsSave = () => {
send("setBacklightSettings", { params: settings.backlightSettings }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Backlight settings updated successfully");
});
};
const syncUsbConfigProduct = useCallback(() => {
send("getUsbConfig", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
notifications.error(
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
);
} else {
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
const usbConfigState = resp.result as UsbConfigState;
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
? usbConfigState.product
: "custom";
setUsbConfigProduct(product);
}
});
}, [send]);
const handleUsbConfigChange = useCallback(
(usbConfig: USBConfig) => {
send("setUsbConfig", { usbConfig }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
);
return;
}
// setUsbConfigProduct(usbConfig.product);
notifications.success(
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`,
);
syncUsbConfigProduct();
});
},
[send, syncUsbConfigProduct],
);
useEffect(() => {
send("getBacklightSettings", {}, resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
);
}
const result = resp.result as BacklightSettings;
setBacklightSettings(result);
});
send("getDeviceID", {}, async resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
);
}
setDeviceId(resp.result as string);
});
syncUsbConfigProduct();
}, [send, setBacklightSettings, syncUsbConfigProduct]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Hardware"
description="Configure display settings and hardware options for your JetKVM device"
/>
<div className="space-y-4">
<SettingsItem
title="Display Brightness"
description="Set the brightness of the display"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
{ value: "35", label: "Medium" },
{ value: "64", label: "High" },
]}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
{settings.backlightSettings.max_brightness != 0 && (
<>
<SettingsItem
title="Dim Display After"
description="Set how long to wait before dimming the display"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.dim_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "60", label: "1 Minute" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.dim_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
<SettingsItem
title="Turn off Display After"
description="Period of inactivity before display automatically turns off"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.off_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.off_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
</>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched.
</p>
</div>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsItem
title="USB Device Emulation"
description="Set a Preconfigured USB Device"
>
<SelectMenuBasic
size="SM"
label=""
className="max-w-[192px]"
value={usbConfigProduct}
onChange={e => {
if (e.target.value === "custom") {
setUsbConfigProduct(e.target.value);
} else {
const usbConfig = usbConfigData[e.target.value];
handleUsbConfigChange(usbConfig);
}
}}
options={[...usbConfigs, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{usbConfigProduct === "custom" && (
<USBConfigDialog
onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)}
onRestoreToDefault={() =>
handleUsbConfigChange(usbConfigData[usbConfigs[0].value])
}
/>
)}
</div>
);
}

View File

@ -0,0 +1,133 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { Checkbox } from "@/components/Checkbox";
import { GridCard } from "@/components/Card";
import PointingFinger from "@/assets/pointing-finger.svg";
import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useSettingsStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useEffect, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { cx } from "../cva.config";
export default function SettingsKeyboardMouseRoute() {
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
const [jiggler, setJiggler] = useState(false);
const [send] = useJsonRpc();
useEffect(() => {
send("getJigglerState", {}, resp => {
if ("error" in resp) return;
setJiggler(resp.result as boolean);
});
}, [send]);
const handleJigglerChange = (enabled: boolean) => {
send("setJigglerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setJiggler(enabled);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="Mouse"
description="Configure cursor behavior and interaction settings for your device"
/>
<div className="space-y-4">
<SettingsItem
title="Hide Cursor"
description="Hide the cursor when sending mouse movements"
>
<Checkbox
checked={hideCursor}
onChange={e => setHideCursor(e.target.checked)}
/>
</SettingsItem>
<SettingsItem
title="Jiggler"
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
>
<Checkbox
checked={jiggler}
onChange={e => handleJigglerChange(e.target.checked)}
/>
</SettingsItem>
<div className="space-y-4">
<SettingsItem title="Modes" description="Choose the mouse input mode" />
<div className="flex flex-col items-center gap-4 md:flex-row">
<button
className="group block w-full grow"
onClick={() => console.log("Absolute mouse mode clicked")}
>
<GridCard>
<div className="group flex items-center gap-x-4 px-4 py-3">
<img
className="w-6 shrink-0 dark:invert"
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex grow items-center justify-between">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Absolute
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most convenient
</p>
</div>
<CheckCircleIcon
className={cx(
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
)}
/>
</div>
</div>
</GridCard>
</button>
<button
className="group block w-full grow cursor-not-allowed opacity-50"
disabled
>
<GridCard>
<div className="group flex items-center gap-x-4 px-4 py-3">
<img
className="w-6 shrink-0 dark:invert"
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex grow items-center justify-between">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Relative
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most Compatible
</p>
</div>
<CheckCircleIcon
className={cx(
"hidden",
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
)}
/>
</div>
</div>
</GridCard>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,254 @@
import { NavLink, Outlet, useLocation } from "react-router-dom";
import Card from "@/components/Card";
import {
LuSettings,
LuKeyboard,
LuVideo,
LuCpu,
LuShieldCheck,
LuWrench,
LuArrowLeft,
LuPalette,
} from "react-icons/lu";
import { LinkButton } from "../components/Button";
import React, { useEffect, useRef, useState } from "react";
import { cx } from "../cva.config";
import { useUiStore } from "../hooks/stores";
import useKeyboard from "../hooks/useKeyboard";
import { useResizeObserver } from "../hooks/useResizeObserver";
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function SettingsRoute() {
const location = useLocation();
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { sendKeyboardEvent } = useKeyboard();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const { width } = useResizeObserver({ ref: scrollContainerRef });
// Handle scroll position to show/hide gradients
const handleScroll = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
// Show left gradient only if scrolled to the right
setShowLeftGradient(scrollLeft > 0);
// Show right gradient only if there's more content to scroll to the right
setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); // -1 for rounding errors
}
};
useEffect(() => {
// Check initial scroll position
handleScroll();
// Add scroll event listener to the container
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll);
}
return () => {
// Clean up event listener
if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll);
}
};
}, [width]);
useEffect(() => {
// disable focus trap
setTimeout(() => {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
(document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
}, 300);
return () => {
setDisableVideoFocusTrap(false);
};
}, [setDisableVideoFocusTrap, sendKeyboardEvent]);
return (
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
<div className="h-full">
<div className="w-full gap-x-8 gap-y-4 space-y-4 md:grid md:grid-cols-8 md:space-y-0">
<div className="w-full select-none space-y-4 md:col-span-2">
<Card className="flex w-full gap-x-4 overflow-hidden p-2 md:flex-col dark:bg-slate-800">
<div className="md:hidden">
<LinkButton
to=".."
size="SM"
theme="blank"
text="Back to KVM"
LeadingIcon={LuArrowLeft}
textAlign="left"
/>
</div>
<div className="hidden md:block">
<LinkButton
to=".."
size="SM"
theme="blank"
text="Back to KVM"
LeadingIcon={LuArrowLeft}
textAlign="left"
fullWidth
/>
</div>
</Card>
<Card className="relative overflow-hidden">
{/* Gradient overlay for left side - only visible on mobile when scrolled */}
<div
className={cx(
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-white to-transparent transition-opacity duration-300 ease-in-out md:hidden dark:from-slate-900",
{
"opacity-0": !showLeftGradient,
"opacity-100": showLeftGradient,
},
)}
></div>
{/* Gradient overlay for right side - only visible on mobile when there's more content */}
<div
className={cx(
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-white to-transparent transition duration-300 ease-in-out md:hidden dark:from-slate-900",
{
"opacity-0": !showRightGradient,
"opacity-100": showRightGradient,
},
)}
></div>
<div
ref={scrollContainerRef}
className="hide-scrollbar relative flex w-full gap-x-4 overflow-x-auto whitespace-nowrap p-2 md:flex-col md:overflow-visible md:whitespace-normal dark:bg-slate-800"
>
<div className="shrink-0">
<NavLink
to="general"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuSettings className="h-4 w-4 shrink-0" />
<h1>General</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="mouse"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuKeyboard className="h-4 w-4 shrink-0" />
<h1>Mouse</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="video"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuVideo className="h-4 w-4 shrink-0" />
<h1>Video</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="hardware"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuCpu className="h-4 w-4 shrink-0" />
<h1>Hardware</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="access"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuShieldCheck className="h-4 w-4 shrink-0" />
<h1>Access</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="appearance"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuPalette className="h-4 w-4 shrink-0" />
<h1>Appearance</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="advanced"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuWrench className="h-4 w-4 shrink-0" />
<h1>Advanced</h1>
</div>
</NavLink>
</div>
</div>
</Card>
</div>
<div className="w-full md:col-span-5">
{/* <AutoHeight> */}
<Card className="dark:bg-slate-800">
<div
className="space-y-4 px-8 py-6"
style={{ animationDuration: "0.7s" }}
key={location.pathname} // This is a workaround to force the animation to run when the route changes
>
<Outlet />
</div>
</Card>
{/* </AutoHeight> */}
</div>
</div>
</div>
</div>
);
}
export function SettingsItem({
title,
description,
children,
className,
}: {
title: string;
description: string | React.ReactNode;
children?: React.ReactNode;
className?: string;
name?: string;
}) {
return (
<label
className={cx(
"flex select-none items-center justify-between gap-x-8 rounded",
className,
)}
>
<div className="space-y-0.5">
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
</div>
{children ? <div>{children}</div> : null}
</label>
);
}

View File

@ -0,0 +1,185 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useState, useEffect } from "react";
import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [
{
value: defaultEdid,
label: "JetKVM Default",
},
{
value:
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
label: "Acer B246WL, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC",
label: "ASUS PA248QV, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F",
label: "DELL D2721H, 1920x1080",
},
];
const streamQualityOptions = [
{ value: "1", label: "High" },
{ value: "0.5", label: "Medium" },
{ value: "0.1", label: "Low" },
];
export default function SettingsVideoRoute() {
const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
useEffect(() => {
send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
});
send("getEDID", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
return;
}
const receivedEdid = resp.result as string;
const matchingEdid = edids.find(
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
);
if (matchingEdid) {
// EDID is stored in uppercase in the UI
setEdid(matchingEdid.value.toUpperCase());
// Reset custom EDID value
setCustomEdidValue(null);
} else {
setEdid("custom");
setCustomEdidValue(receivedEdid);
}
});
}, [send]);
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`);
setStreamQuality(factor);
});
};
const handleEDIDChange = (newEdid: string) => {
send("setEDID", { edid: newEdid }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
return;
}
notifications.success(
`EDID set successfully to ${edids.find(x => x.value === newEdid)?.label}`,
);
// Update the EDID value in the UI
setEdid(newEdid);
});
};
return (
<div className="space-y-3">
<div className="space-y-4">
<SettingsPageHeader
title="Video"
description="Configure display settings and EDID for optimal compatibility"
/>
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title="Stream Quality"
description="Adjust the quality of the video stream"
>
<SelectMenuBasic
size="SM"
label=""
value={streamQuality}
options={streamQualityOptions}
onChange={e => handleStreamQualityChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem
title="EDID"
description="Adjust the EDID settings for the display"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={customEdidValue ? "custom" : edid || "asd"}
onChange={e => {
if (e.target.value === "custom") {
setEdid("custom");
setCustomEdidValue("");
} else {
setCustomEdidValue(null);
handleEDIDChange(e.target.value as string);
}
}}
options={[...edids, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{customEdidValue !== null && (
<>
<SettingsItem
title="Custom EDID"
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
/>
<TextAreaWithLabel
label="EDID File"
placeholder="00F..."
rows={3}
value={customEdidValue}
onChange={e => setCustomEdidValue(e.target.value)}
/>
<div className="flex justify-start gap-x-2">
<Button
size="SM"
theme="primary"
text="Set Custom EDID"
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="SM"
theme="light"
text="Restore to default"
onClick={() => {
setCustomEdidValue(null);
handleEDIDChange(defaultEdid);
}}
/>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { Transition } from "@headlessui/react";
import { import {
HidState, HidState,
UpdateState, UpdateState,
@ -16,27 +15,30 @@ import {
import WebRTCVideo from "@components/WebRTCVideo"; import WebRTCVideo from "@components/WebRTCVideo";
import { import {
LoaderFunctionArgs, LoaderFunctionArgs,
Outlet,
Params, Params,
redirect, redirect,
useLoaderData, useLoaderData,
useLocation,
useNavigate, useNavigate,
useOutlet,
useParams, useParams,
useSearchParams, useSearchParams,
} from "react-router-dom"; } from "react-router-dom";
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import SettingsSidebar from "@/components/sidebar/settings";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
import UpdateDialog from "@components/UpdateDialog";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard"; import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api"; import api from "../api";
import { DeviceStatus } from "./welcome-local"; import { DeviceStatus } from "./welcome-local";
import FocusTrap from "focus-trap-react"; import FocusTrap from "focus-trap-react";
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
import Terminal from "@components/Terminal"; import Terminal from "@components/Terminal";
import { CLOUD_API, DEVICE_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
import Modal from "../components/Modal";
import { motion, AnimatePresence } from "motion/react";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
interface LocalLoaderResp { interface LocalLoaderResp {
authMode: "password" | "noPassword" | null; authMode: "password" | "noPassword" | null;
@ -50,8 +52,9 @@ interface CloudLoaderResp {
} | null; } | null;
} }
export type AuthMode = "password" | "noPassword" | null;
export interface LocalDevice { export interface LocalDevice {
authMode: "password" | "noPassword" | null; authMode: AuthMode;
deviceId: string; deviceId: string;
} }
@ -123,16 +126,7 @@ export default function KvmIdRoute() {
const setTransceiver = useRTCStore(state => state.setTransceiver); const setTransceiver = useRTCStore(state => state.setTransceiver);
const navigate = useNavigate(); const navigate = useNavigate();
const { const { otaState, setOtaState, setModalView } = useUpdateStore();
otaState,
setOtaState,
isUpdateDialogOpen,
setIsUpdateDialogOpen,
setModalView,
} = useUpdateStore();
const [isOtherSessionConnectedModalOpen, setIsOtherSessionConnectedModalOpen] =
useState(false);
const sdp = useCallback( const sdp = useCallback(
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => { async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
@ -243,8 +237,7 @@ export default function KvmIdRoute() {
) { ) {
return; return;
} }
// We don't want to connect if another session is connected if (location.pathname.includes("other-session")) return;
if (isOtherSessionConnectedModalOpen) return;
connectWebRTC(); connectWebRTC();
}, 3000); }, 3000);
@ -330,11 +323,11 @@ export default function KvmIdRoute() {
const setHdmiState = useVideoStore(state => state.setHdmiState); const setHdmiState = useVideoStore(state => state.setHdmiState);
const [hasUpdated, setHasUpdated] = useState(false); const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
function onJsonRpcRequest(resp: JsonRpcRequest) { function onJsonRpcRequest(resp: JsonRpcRequest) {
if (resp.method === "otherSessionConnected") { if (resp.method === "otherSessionConnected") {
console.log("otherSessionConnected", resp.params); navigateTo("/other-session");
setIsOtherSessionConnectedModalOpen(true);
} }
if (resp.method === "usbState") { if (resp.method === "usbState") {
@ -358,7 +351,7 @@ export default function KvmIdRoute() {
if (otaState.error) { if (otaState.error) {
setModalView("error"); setModalView("error");
setIsUpdateDialogOpen(true); navigateTo("/settings/general/update");
return; return;
} }
@ -388,11 +381,9 @@ export default function KvmIdRoute() {
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
if (queryParams.get("updateSuccess")) { if (queryParams.get("updateSuccess")) {
setModalView("updateCompleted"); navigateTo("/settings/general/update", { state: { updateSuccess: true } });
setIsUpdateDialogOpen(true);
setQueryParams({});
} }
}, [queryParams, setIsUpdateDialogOpen, setModalView, setQueryParams]); }, [navigate, navigateTo, queryParams, setModalView, setQueryParams]);
const diskChannel = useRTCStore(state => state.diskChannel)!; const diskChannel = useRTCStore(state => state.diskChannel)!;
const file = useMountMediaStore(state => state.localFile)!; const file = useMountMediaStore(state => state.localFile)!;
@ -445,18 +436,27 @@ export default function KvmIdRoute() {
}; };
}, [kvmTerminal]); }, [kvmTerminal]);
const outlet = useOutlet();
const location = useLocation();
const onModalClose = useCallback(() => {
if (location.pathname !== "/other-session") navigateTo("..");
}, [navigateTo, location.pathname]);
return ( return (
<> <>
<Transition show={!isUpdateDialogOpen && otaState.updating}> {!outlet && otaState.updating && (
<div className="pointer-events-none fixed inset-0 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"> <AnimatePresence>
<div className="transition duration-1000 ease-in data-[closed]:opacity-0"> <motion.div
<UpdateInProgressStatusCard className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
setIsUpdateDialogOpen={setIsUpdateDialogOpen} initial={{ opacity: 0, y: -20 }}
setModalView={setModalView} animate={{ opacity: 1, y: 0 }}
/> exit={{ opacity: 0, y: -20 }}
</div> transition={{ duration: 0.3, ease: "easeInOut" }}
</div> >
</Transition> <UpdateInProgressStatusCard />
</motion.div>
</AnimatePresence>
)}
<div className="relative h-full"> <div className="relative h-full">
<FocusTrap <FocusTrap
paused={disableKeyboardFocusTrap} paused={disableKeyboardFocusTrap}
@ -486,21 +486,24 @@ export default function KvmIdRoute() {
</div> </div>
</div> </div>
</div> </div>
<UpdateDialog open={isUpdateDialogOpen} setOpen={setIsUpdateDialogOpen} />
<OtherSessionConnectedModal
open={isOtherSessionConnectedModalOpen}
setOpen={state => {
if (!state) connectWebRTC().then(r => r);
// It takes some time for the WebRTC connection to be established, so we wait a bit before closing the modal <div
setTimeout(() => { className="isolate"
setIsOtherSessionConnectedModalOpen(state); onKeyUp={e => e.stopPropagation()}
}, 1000); onKeyDown={e => {
e.stopPropagation();
if (e.key === "Escape") navigateTo("/");
}} }}
/> >
<Modal open={outlet !== null} onClose={onModalClose}>
<Outlet context={{ connectWebRTC }} />
</Modal>
</div>
{kvmTerminal && ( {kvmTerminal && (
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" /> <Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
)} )}
{serialConsole && ( {serialConsole && (
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" /> <Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
)} )}
@ -518,16 +521,22 @@ function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
style={{ width: sidebarView ? "493px" : 0 }} style={{ width: sidebarView ? "493px" : 0 }}
> >
<div className="relative w-[493px] shrink-0"> <div className="relative w-[493px] shrink-0">
<Transition show={sidebarView === "system"} unmount={false}> <AnimatePresence>
<div className="absolute inset-0"> {sidebarView === "connection-stats" && (
<SettingsSidebar /> <motion.div
</div> className="absolute inset-0"
</Transition> initial={{ opacity: 0 }}
<Transition show={sidebarView === "connection-stats"} unmount={false}> animate={{ opacity: 1 }}
<div className="absolute inset-0"> exit={{ opacity: 0 }}
<ConnectionStatsSidebar /> transition={{
</div> duration: 0.5,
</Transition> ease: "easeInOut",
}}
>
<ConnectionStatsSidebar />
</motion.div>
)}
</AnimatePresence>
</div> </div>
</div> </div>
); );