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
This commit is contained in:
Adam Shiervani 2025-02-27 01:41:40 +01:00
parent ee4791d9f7
commit ffc049719a
29 changed files with 2183 additions and 1799 deletions

65
ui/package-lock.json generated
View File

@ -22,6 +22,7 @@
"framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"motion": "^12.4.7",
"react": "^18.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "^18.2.0",
@ -4245,6 +4246,31 @@
"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": {
"version": "11.14.3",
"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",
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View File

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

View File

@ -17,6 +17,7 @@ import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover";
import { useNavigate } from "react-router-dom";
export default function Actionbar({
requestFullscreen,
@ -53,6 +54,8 @@ export default function Actionbar({
[setDisableFocusTrap],
);
const navigate = useNavigate();
return (
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<div
@ -266,9 +269,10 @@ export default function Actionbar({
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => toggleSidebarView("system")}
onClick={() => navigate("settings")}
/>
</div>
<div className="hidden items-center gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button

View File

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

View File

@ -16,9 +16,9 @@ export const GridCard = ({
return (
<Card className={cx("overflow-hidden", cardClassName)}>
<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="h-full isolate">{children}</div>
<div className="isolate h-full">{children}</div>
</div>
</Card>
);
@ -28,7 +28,7 @@ export default function Card({ children, className }: CardPropsType) {
return (
<div
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,
)}
>

View File

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

View File

@ -2,7 +2,7 @@ import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config";
export default function Modal({
const Modal = React.memo(function Modal({
children,
className,
open,
@ -13,20 +13,20 @@ export default function Modal({
open: boolean;
onClose: () => void;
}) {
console.log("Modal", open);
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
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="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-baseline sm:p-4">
<DialogPanel
transition
className={cx(
"pointer-events-none relative w-full sm:my-8",
"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",
"pointer-events-none relative w-full sm:my-8 sm:!mt-[10vh]",
"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,
)}
>
@ -45,4 +45,6 @@ export default function Modal({
</div>
</Dialog>
);
}
});
export default Modal;

View File

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

View File

@ -60,7 +60,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
)}
>
{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
ref={ref}
name={name}
@ -69,10 +69,13 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
classes,
// General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
"block w-full cursor-pointer transition duration-300 rounded border-none py-0 font-medium shadow-none outline-0",
// 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:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
@ -82,9 +85,6 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
// 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",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)}
value={value}
id={id}

View File

@ -3,11 +3,9 @@ import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { useNavigate } from "react-router-dom";
import { useUpdateStore } from "@/hooks/stores";
export default function UpdateInProgressStatusCard() {
const navigate = useNavigate();
const { setModalView } = useUpdateStore();
return (
<div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out">
@ -34,8 +32,8 @@ export default function UpdateInProgressStatusCard() {
theme="light"
text="View Details"
onClick={() => {
setModalView("updating");
navigate("update");
// TODO: this wont work in cloud mode
navigate("/settings/general/update");
}}
/>
</div>

View File

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

View File

@ -5,7 +5,7 @@ import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react";
import { motion, AnimatePresence } from "motion/react";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard";
@ -182,276 +182,277 @@ function KeyboardWrapper() {
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}}
>
<Transition
show={virtualKeyboard}
unmount={false}
enter="transition-all transform-gpu duration-500 ease-in-out"
enterFrom="opacity-0 translate-y-[100%]"
enterTo="opacity-100 translate-y-[0%]"
leave="transition-all duration-500 ease-in-out"
leaveFrom="opacity-100 translate-y-[0%]"
leaveTo="opacity-0 translate-y-[100%]"
>
<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)` }
: {}),
<AnimatePresence>
{virtualKeyboard && (
<motion.div
initial={{ opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }}
exit={{ opacity: 0, y: "100%" }}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
})}
<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)` }
: {}),
}}
>
<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">
<div className="absolute flex items-center left-2 gap-x-2">
{showAttachedVirtualKeyboard ? (
<Card
className={cx("overflow-hidden", {
"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
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
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}
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
</div>
</Card>
</div>
</div>
</Transition>
<div>
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<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>
</Card>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -4,7 +4,6 @@ import {
useMouseStore,
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
@ -15,7 +14,9 @@ import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard";
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() {
// 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 (
<div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]">

View File

@ -1,6 +1,5 @@
import SidebarHeader from "@components/SidebarHeader";
import { GridCard } from "@components/Card";
import { useEffect } from "react";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@components/StatChart";
import { useInterval } from "usehooks-ts";
@ -36,19 +35,7 @@ function createChartArray<T, K extends keyof T>(
});
}
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]);
export default function ConnectionStatsSidebar() {
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
@ -111,9 +98,9 @@ export default function ConnectionStatsSidebar () {
}, 500);
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} />
<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">
{/*
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
export type AvailableSidebarViews = "system" | "connection-stats";
export type AvailableModalViews = "connection-stats" | "settings";
export type AvailableSidebarViews = "connection-stats";
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export interface User {
@ -47,9 +46,6 @@ interface UIState {
toggleSidebarView: (view: AvailableSidebarViews) => void;
modalView: AvailableModalViews | null;
setModalView: (view: AvailableModalViews | null) => void;
isAttachedVirtualKeyboardVisible: boolean;
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
@ -79,9 +75,6 @@ export const useUiStore = create<UIState>(set => ({
}
}),
modalView: null,
setModalView: view => set({ modalView: view }),
isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled =>
set({ isAttachedVirtualKeyboardVisible: enabled }),

View File

@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom/client";
import Root from "./root";
import "./index.css";
@ -9,7 +8,7 @@ import {
RouterProvider,
useRouteError,
} 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 SetupRoute from "@routes/devices.$id.setup";
import LoginRoute from "@routes/login";
@ -25,18 +24,28 @@ import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local";
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 { CLOUD_API } from "./ui.config";
import { CLOUD_API, DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session";
import UpdateRoute from "./routes/devices.$id.update";
import LocalAuthRoute from "./routes/devices.$id.local-auth";
import LocalAuthRoute from "./routes/devices.$id.settings.security.local-auth";
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 SettingsSecurityIndexRoute from "./routes/devices.$id.settings.security._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";
export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice;
export async function checkAuth() {
export async function checkCloudAuth() {
const res = await fetch(`${CLOUD_API}/me`, {
mode: "cors",
credentials: "include",
@ -50,6 +59,27 @@ export async function checkAuth() {
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;
if (isOnDevice) {
router = createBrowserRouter([
@ -84,10 +114,7 @@ if (isOnDevice) {
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "update",
element: <UpdateRoute />,
},
{
path: "local-auth",
element: <LocalAuthRoute />,
@ -96,6 +123,63 @@ if (isOnDevice) {
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: "security",
children: [
{
index: true,
element: <SettingsSecurityIndexRoute.default />,
loader: SettingsSecurityIndexRoute.loader,
},
{
path: "local-auth",
element: <LocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
],
},
],
},
@ -144,18 +228,67 @@ if (isOnDevice) {
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "update",
element: <UpdateRoute />,
},
{
path: "local-auth",
element: <LocalAuthRoute />,
},
{
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: "security",
children: [
{
index: true,
element: <SettingsSecurityIndexRoute.default />,
loader: SettingsSecurityIndexRoute.loader,
},
{
path: "local-auth",
element: <LocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
],
},
],
},
{
@ -180,7 +313,7 @@ if (isOnDevice) {
document.addEventListener("DOMContentLoaded", () => {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<>
<RouterProvider router={router} />
<Notifications
toastOptions={{
@ -189,7 +322,7 @@ document.addEventListener("DOMContentLoaded", () => {
}}
max={2}
/>
</React.StrictMode>,
</>,
);
});

View File

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

View File

@ -0,0 +1,304 @@
import { SettingsItem } from "./devices.$id.settings";
import { SectionHeader } from "../components/SectionHeader";
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 { InputFieldWithLabel } from "../components/InputField";
import { Button } from "../components/Button";
import { useSettingsStore } from "../hooks/stores";
import { GridCard } from "@/components/Card";
export default function SettingsAdvancedRoute() {
const [send] = useJsonRpc();
const [cloudUrl, setCloudUrl] = useState("");
const [sshKey, setSSHKey] = useState<string>("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const settings = useSettingsStore();
useEffect(() => {
send("getCloudUrl", {}, resp => {
if ("error" in resp) return;
setCloudUrl(resp.result as string);
});
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, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [send]);
const getCloudUrl = useCallback(() => {
send("getCloudUrl", {}, resp => {
if ("error" in resp) return;
setCloudUrl(resp.result as string);
});
}, [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 handleCloudUrlChange = useCallback(
(url: string) => {
send("setCloudUrl", { url }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudUrl();
notifications.success("Cloud URL updated successfully");
});
},
[send, getCloudUrl],
);
const handleResetCloudUrl = useCallback(() => {
send("resetCloudUrl", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reset cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudUrl();
notifications.success("Cloud URL reset to default successfully");
});
}, [send, getCloudUrl]);
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 [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
return (
<div className="space-y-4">
<SectionHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
/>
<div className="space-y-4">
<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>
)}
{settings.developerMode && (
<div>
<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>
{isOnDevice && (
<div className="mt-4 space-y-4">
<SettingsItem
title="Cloud API URL"
description="Connect to a custom JetKVM Cloud API"
/>
<InputFieldWithLabel
size="SM"
label="Cloud URL"
value={cloudUrl}
onChange={e => setCloudUrl(e.target.value)}
placeholder="https://api.jetkvm.com"
/>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Save Cloud URL"
onClick={() => handleCloudUrlChange(cloudUrl)}
/>
<Button
size="SM"
theme="light"
text="Restore to default"
onClick={handleResetCloudUrl}
/>
</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>
</>
)}
{settings.debugMode && (
<SettingsItem
title="Reset Configuration"
description="Reset the configuration file to its default state. This will log you out of the device."
>
<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 { SectionHeader } from "../components/SectionHeader";
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">
<SectionHeader
title="Hardware"
description="Configure display settings and hardware options for your JetKVM device"
/>
<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,271 @@
import { SectionHeader } from "../components/SectionHeader";
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 { useNavigate } from "react-router-dom";
import { Button, LinkButton } from "../components/Button";
import { GridCard } from "../components/Card";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { CLOUD_APP } from "../ui.config";
import notifications from "../notifications";
import { isOnDevice } from "../main";
import Checkbox from "../components/Checkbox";
export default function SettingsGeneralRoute() {
const [send] = useJsonRpc();
const navigate = useNavigate();
const [devChannel, setDevChannel] = useState(false);
const [autoUpdate, setAutoUpdate] = useState(true);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [isAdopted, setAdopted] = useState(false);
const [currentVersions, setCurrentVersions] = useState<{
appVersion: string;
systemVersion: string;
} | null>(null);
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 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();
getCloudState();
send("getDeviceID", {}, async resp => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});
send("getAutoUpdateState", {}, resp => {
if ("error" in resp) return;
setAutoUpdate(resp.result as boolean);
});
send("getDevChannelState", {}, resp => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
}, [getCurrentVersions, getCloudState, 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);
});
};
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">
<SectionHeader
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={() => {
// TODO: this wont work in cloud mode
navigate("./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>
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
>
<Checkbox
checked={devChannel}
onChange={e => {
handleDevChannelChange(e.target.checked);
}}
/>
</SettingsItem>
</div>
</div>
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title="JetKVM Cloud"
description="Connect your device to the cloud for secure remote access and management"
/>
<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>
<LinkButton
to={
CLOUD_APP +
"/signup?deviceId=" +
deviceId +
`&returnTo=${location.href}adopt`
}
size="MD"
theme="primary"
text="Adopt KVM to Cloud account"
/>
</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="MD"
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,18 +1,19 @@
import { useNavigate } from "react-router-dom";
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 { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { UpdateState, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "@/components/LoadingSpinner";
export default function UpdateRoute() {
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
const { setModalView } = useUpdateStore();
const location = useLocation();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
@ -20,16 +21,29 @@ export default function UpdateRoute() {
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 (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
{/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */}
<Dialog
setOpen={open => {
if (!open) navigate("..");
}}
onConfirmUpdate={onConfirmUpdate}
/>
</GridCard>
<Dialog
onClose={() => {
// TODO: This wont work in cloud mode
navigate("..");
}}
onConfirmUpdate={onConfirmUpdate}
/>
);
}
@ -41,12 +55,14 @@ export interface SystemVersionInfo {
}
export function Dialog({
setOpen,
onClose,
onConfirmUpdate,
}: {
setOpen: (open: boolean) => void;
onClose: () => void;
onConfirmUpdate: () => void;
}) {
const navigate = useNavigate();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore();
@ -72,27 +88,24 @@ export function Dialog({
}, [setModalView]);
return (
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto">
<div className="p-10">
<div className="pointer-events-auto relative mx-auto text-left">
<div>
{modalView === "error" && (
<UpdateErrorState
errorMessage={otaState.error}
onClose={() => setOpen(false)}
onClose={onClose}
onRetryUpdate={() => setModalView("loading")}
/>
)}
{modalView === "loading" && (
<LoadingState
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
)}
{modalView === "updateAvailable" && (
<UpdateAvailableState
onConfirmUpdate={onConfirmUpdate}
onClose={() => setOpen(false)}
onClose={onClose}
versionInfo={versionInfo!}
/>
)}
@ -101,7 +114,8 @@ export function Dialog({
<UpdatingDeviceState
otaState={otaState}
onMinimizeUpgradeDialog={() => {
setOpen(false);
// TODO: This wont work in cloud mode
navigate("/");
}}
/>
)}
@ -109,15 +123,13 @@ export function Dialog({
{modalView === "upToDate" && (
<SystemUpToDateState
checkUpdate={() => setModalView("loading")}
onClose={() => setOpen(false)}
onClose={onClose}
/>
)}
{modalView === "updateCompleted" && (
<UpdateCompletedState onClose={() => setOpen(false)} />
)}
{modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />}
</div>
</GridCard>
</div>
);
}
@ -155,14 +167,12 @@ function LoadingState({
const animationTimer = setTimeout(() => {
setProgressWidth("100%");
}, 500);
}, 0);
getVersionInfo()
.then(versionInfo => {
if (progressBarRef.current) {
progressBarRef.current?.classList.add("!duration-1000");
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
// Add a small delay to ensure it's not just flickering
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
})
.then(versionInfo => {
if (!signal.aborted) {
@ -182,12 +192,8 @@ function LoadingState({
}, [getVersionInfo, onFinished]);
return (
<div className="flex min-h-[140px] 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="max-w-sm space-y-4">
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Checking for updates...
@ -200,18 +206,11 @@ function LoadingState({
<div
ref={progressBarRef}
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 className="mt-4">
<Button
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
</div>
</div>
</div>
@ -293,11 +292,7 @@ function UpdatingDeviceState({
};
return (
<div className="flex min-h-[140px] 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="flex flex-col items-start justify-start space-y-4 text-left">
<div className="w-full max-w-sm space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
@ -410,11 +405,7 @@ function SystemUpToDateState({
onClose: () => void;
}) {
return (
<div className="flex min-h-[140px] 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="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
System is up to date
@ -424,22 +415,8 @@ function SystemUpToDateState({
</p>
<div className="mt-4 flex gap-x-2">
<Button
size="SM"
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
<Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
<Button size="SM" theme="blank" text="Back" onClick={onClose} />
</div>
</div>
</div>
@ -456,11 +433,7 @@ function UpdateAvailableState({
onClose: () => void;
}) {
return (
<div className="flex min-h-[140px] 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="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Update available
@ -494,11 +467,7 @@ function UpdateAvailableState({
function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return (
<div className="flex min-h-[140px] 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="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Update Completed Successfully
@ -508,7 +477,7 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
features and improvements!
</p>
<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>
@ -525,11 +494,7 @@ function UpdateErrorState({
onRetryUpdate: () => void;
}) {
return (
<div className="flex min-h-[140px] 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="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<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">
@ -541,8 +506,8 @@ function UpdateErrorState({
</p>
)}
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} />
<Button size="SM" theme="light" text="Back" onClick={onClose} />
<Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
</div>
</div>
</div>

View File

@ -0,0 +1,129 @@
import { SectionHeader } from "../components/SectionHeader";
import { SettingsItem } from "./devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "../hooks/stores";
import { useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
export default function SettingsHardwareRoute() {
const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings);
const settings = useSettingsStore();
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 [send] = useJsonRpc();
useEffect(() => {
send("getBacklightSettings", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
const result = resp.result as BacklightSettings;
setBacklightSettings(result);
});
}, [send, setBacklightSettings]);
return (
<div className="space-y-4">
<SectionHeader 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>
);
}

View File

@ -0,0 +1,129 @@
import { SectionHeader } from "@/components/SectionHeader";
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">
<SectionHeader
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 items-center gap-4">
<button
className="group block 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 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(
"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,71 @@
import { SectionHeader } from "@/components/SectionHeader";
import { SettingsItem } from "./devices.$id.settings";
import { useNavigate, useLoaderData } from "react-router-dom";
import { Button } from "../components/Button";
import { DEVICE_API } from "../ui.config";
import api from "../api";
import { LocalDevice } from "./devices.$id";
export const loader = async () => {
const status = await api
.GET(`${DEVICE_API}/device`)
.then(res => res.json() as Promise<LocalDevice>);
return status;
};
export default function SettingsSecurityIndexRoute() {
const { authMode } = useLoaderData() as LocalDevice;
const navigate = useNavigate();
return (
<div className="space-y-4">
<SectionHeader
title="Local Access"
description="Manage the mode of local access to the device"
/>
<div className="space-y-4">
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${authMode === "password" ? "Password protected" : "No password"}`}
>
{authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
onClick={() => {
navigate("local-auth", { state: { init: "deletePassword" } });
}}
/>
) : (
<Button
size="SM"
theme="light"
text="Enable Password"
onClick={() => {
navigate("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={() => {
navigate("local-auth", { state: { init: "updatePassword" } });
}}
/>
</SettingsItem>
)}
</div>
</div>
);
}

View File

@ -1,29 +1,32 @@
import { GridCard } from "@/components/Card";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { InputFieldWithLabel } from "@/components/InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
export default function LocalAuthRoute() {
const navigate = useNavigate();
const { setModalView } = useLocalAuthModalStore();
return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
{/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */}
<Dialog
onClose={open => {
if (!open) navigate("..");
}}
/>
</GridCard>
);
const location = useLocation();
const init = location.state?.init;
useEffect(() => {
if (!init) {
navigate("..");
} else {
setModalView(init);
}
}, [init, navigate, 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={() => navigate("..")} />;
}
export function Dialog({ onClose }: { onClose: (open: boolean) => void }) {
export function Dialog({ onClose }: { onClose: () => void }) {
const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
@ -108,12 +111,12 @@ export function Dialog({ onClose }: { onClose: (open: boolean) => void }) {
};
return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div className="p-10">
<div>
<div>
{modalView === "createPassword" && (
<CreatePasswordModal
onSetPassword={handleCreatePassword}
onCancel={() => onClose(false)}
onCancel={onClose}
error={error}
/>
)}
@ -121,7 +124,7 @@ export function Dialog({ onClose }: { onClose: (open: boolean) => void }) {
{modalView === "deletePassword" && (
<DeletePasswordModal
onDeletePassword={handleDeletePassword}
onCancel={() => onClose(false)}
onCancel={onClose}
error={error}
/>
)}
@ -129,7 +132,7 @@ export function Dialog({ onClose }: { onClose: (open: boolean) => void }) {
{modalView === "updatePassword" && (
<UpdatePasswordModal
onUpdatePassword={handleUpdatePassword}
onCancel={() => onClose(false)}
onCancel={onClose}
error={error}
/>
)}
@ -138,7 +141,7 @@ export function Dialog({ onClose }: { onClose: (open: boolean) => void }) {
<SuccessModal
headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
onClose={() => onClose(false)}
onClose={onClose}
/>
)}
@ -146,7 +149,7 @@ export function Dialog({ onClose }: { onClose: (open: boolean) => void }) {
<SuccessModal
headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
onClose={() => onClose(false)}
onClose={onClose}
/>
)}
@ -154,11 +157,11 @@ export function Dialog({ onClose }: { onClose: (open: boolean) => void }) {
<SuccessModal
headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
onClose={() => onClose(false)}
onClose={onClose}
/>
)}
</div>
</GridCard>
</div>
);
}
@ -176,11 +179,12 @@ function CreatePasswordModal({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<form
className="space-y-4"
onSubmit={e => {
e.preventDefault();
}}
>
<div>
<h2 className="text-lg font-semibold dark:text-white">
Local Device Protection
@ -215,7 +219,7 @@ function CreatePasswordModal({
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</form>
</div>
);
}
@ -233,10 +237,6 @@ function DeletePasswordModal({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] 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">
@ -287,11 +287,12 @@ function UpdatePasswordModal({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<form
className="space-y-4"
onSubmit={e => {
e.preventDefault();
}}
>
<div>
<h2 className="text-lg font-semibold dark:text-white">
Change Local Device Password
@ -332,7 +333,7 @@ function UpdatePasswordModal({
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</form>
</div>
);
}
@ -348,10 +349,6 @@ function SuccessModal({
}) {
return (
<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="hidden h-[24px] 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>

View File

@ -0,0 +1,184 @@
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 } from "react";
import { cx } from "../cva.config";
import { useUiStore } from "../hooks/stores";
import useKeyboard from "../hooks/useKeyboard";
/* 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();
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="grid w-full gap-x-8 gap-y-4 md:grid-cols-8">
<div className="w-full select-none space-y-4 md:col-span-2">
<Card className="flex w-full gap-x-4 p-2 md:flex-col dark:bg-slate-800">
<LinkButton
to=".."
size="SM"
theme="blank"
text="Back to KVM"
LeadingIcon={LuArrowLeft}
textAlign="left"
fullWidth
/>
</Card>
<Card className="flex w-full gap-x-4 p-2 md:flex-col dark:bg-slate-800">
<div>
<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>
<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>
<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>
<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>
<NavLink
to="security"
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>Security</h1>
</div>
</NavLink>
</div>
<div>
<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>
<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>
</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 { SectionHeader } from "@/components/SectionHeader";
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">
<SectionHeader
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="MD"
theme="primary"
text="Set Custom EDID"
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="MD"
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 { cx } from "@/cva.config";
import { Transition } from "@headlessui/react";
import {
HidState,
UpdateState,
@ -20,6 +19,7 @@ import {
Params,
redirect,
useLoaderData,
useLocation,
useNavigate,
useOutlet,
useParams,
@ -28,7 +28,6 @@ import {
import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header";
import { useInterval } from "usehooks-ts";
import SettingsSidebar from "@/components/sidebar/settings";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
@ -38,6 +37,7 @@ import FocusTrap from "focus-trap-react";
import Terminal from "@components/Terminal";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import Modal from "../components/Modal";
import { motion, AnimatePresence } from "motion/react";
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
@ -51,8 +51,9 @@ interface CloudLoaderResp {
} | null;
}
export type AuthMode = "password" | "noPassword" | null;
export interface LocalDevice {
authMode: "password" | "noPassword" | null;
authMode: AuthMode;
deviceId: string;
}
@ -349,6 +350,7 @@ export default function KvmIdRoute() {
if (otaState.error) {
setModalView("error");
// TODO: this wont work in cloud mode
navigate("update");
return;
}
@ -379,9 +381,10 @@ export default function KvmIdRoute() {
// When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => {
if (queryParams.get("updateSuccess")) {
setModalView("updateCompleted");
navigate("update");
setQueryParams({});
// TODO: this wont work in cloud mode
navigate("./settings/general/update", {
state: { updateSuccess: true },
});
}
}, [navigate, queryParams, setModalView, setQueryParams]);
@ -437,16 +440,28 @@ export default function KvmIdRoute() {
}, [kvmTerminal]);
const outlet = useOutlet();
const isUpdateDialogOpen = location.pathname.includes("/update");
const location = useLocation();
const onModalClose = useCallback(() => {
if (location.pathname !== "/other-session") {
navigate("..");
}
}, [navigate, location.pathname]);
return (
<>
<Transition show={!isUpdateDialogOpen && 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">
<div className="transition duration-1000 ease-in data-[closed]:opacity-0">
{!outlet && otaState.updating && (
<AnimatePresence>
<motion.div
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"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<UpdateInProgressStatusCard />
</div>
</div>
</Transition>
</motion.div>
</AnimatePresence>
)}
<div className="relative h-full">
<FocusTrap
paused={disableKeyboardFocusTrap}
@ -478,16 +493,14 @@ export default function KvmIdRoute() {
</div>
<div
className="isolate"
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Escape") navigate("..");
if (e.key === "Escape") navigate("./");
}}
>
<Modal
open={outlet !== null}
onClose={() => location.pathname !== "/other-session" && navigate("..")}
>
<Modal open={outlet !== null} onClose={onModalClose}>
<Outlet context={{ connectWebRTC }} />
</Modal>
</div>
@ -513,16 +526,22 @@ function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
style={{ width: sidebarView ? "493px" : 0 }}
>
<div className="relative w-[493px] shrink-0">
<Transition show={sidebarView === "system"} unmount={false}>
<div className="absolute inset-0">
<SettingsSidebar />
</div>
</Transition>
<Transition show={sidebarView === "connection-stats"} unmount={false}>
<div className="absolute inset-0">
<ConnectionStatsSidebar />
</div>
</Transition>
<AnimatePresence>
{sidebarView === "connection-stats" && (
<motion.div
className="absolute inset-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
>
<ConnectionStatsSidebar />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);