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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,7 +60,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
)} )}
> >
{label && <FieldLabel label={label} id={id} as="span" />} {label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0"> <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
<select <select
ref={ref} ref={ref}
name={name} name={name}
@ -69,10 +69,13 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
classes, classes,
// General styling // General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0", "block w-full cursor-pointer transition duration-300 rounded border-none py-0 font-medium shadow-none outline-0",
// Hover // Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900", "hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",
// Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-800",
// Invalid // Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2", "invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
@ -82,9 +85,6 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
// Disabled // Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80", "disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)} )}
value={value} value={value}
id={id} id={id}

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -20,8 +20,7 @@ const appendStatToMap = <T extends { timestamp: number }>(
}; };
// Constants and types // Constants and types
export type AvailableSidebarViews = "system" | "connection-stats"; export type AvailableSidebarViews = "connection-stats";
export type AvailableModalViews = "connection-stats" | "settings";
export type AvailableTerminalTypes = "kvm" | "serial" | "none"; export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export interface User { export interface User {
@ -47,9 +46,6 @@ interface UIState {
toggleSidebarView: (view: AvailableSidebarViews) => void; toggleSidebarView: (view: AvailableSidebarViews) => void;
modalView: AvailableModalViews | null;
setModalView: (view: AvailableModalViews | null) => void;
isAttachedVirtualKeyboardVisible: boolean; isAttachedVirtualKeyboardVisible: boolean;
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void; setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
@ -79,9 +75,6 @@ export const useUiStore = create<UIState>(set => ({
} }
}), }),
modalView: null,
setModalView: view => set({ modalView: view }),
isAttachedVirtualKeyboardVisible: true, isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled => setAttachedVirtualKeyboardVisibility: enabled =>
set({ isAttachedVirtualKeyboardVisible: enabled }), set({ isAttachedVirtualKeyboardVisible: enabled }),

View File

@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import Root from "./root"; import Root from "./root";
import "./index.css"; import "./index.css";
@ -9,7 +8,7 @@ import {
RouterProvider, RouterProvider,
useRouteError, useRouteError,
} from "react-router-dom"; } from "react-router-dom";
import DeviceRoute from "@routes/devices.$id"; import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices"; import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
import SetupRoute from "@routes/devices.$id.setup"; import SetupRoute from "@routes/devices.$id.setup";
import LoginRoute from "@routes/login"; import LoginRoute from "@routes/login";
@ -25,18 +24,28 @@ import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Notifications from "./notifications"; import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local"; import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode"; import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
import WelcomeRoute from "./routes/welcome-local"; import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local";
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password"; import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { CLOUD_API } from "./ui.config"; import { CLOUD_API, DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session"; import OtherSessionRoute from "./routes/devices.$id.other-session";
import UpdateRoute from "./routes/devices.$id.update"; import LocalAuthRoute from "./routes/devices.$id.settings.security.local-auth";
import LocalAuthRoute from "./routes/devices.$id.local-auth";
import MountRoute from "./routes/devices.$id.mount"; 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 isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice; export const isInCloud = !isOnDevice;
export async function checkAuth() { export async function checkCloudAuth() {
const res = await fetch(`${CLOUD_API}/me`, { const res = await fetch(`${CLOUD_API}/me`, {
mode: "cors", mode: "cors",
credentials: "include", credentials: "include",
@ -50,6 +59,27 @@ export async function checkAuth() {
return await res.json(); return await res.json();
} }
export async function checkDeviceAuth() {
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode };
}
throw new Error("Error fetching device");
}
export async function checkAuth() {
return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth();
}
let router; let router;
if (isOnDevice) { if (isOnDevice) {
router = createBrowserRouter([ router = createBrowserRouter([
@ -84,10 +114,7 @@ if (isOnDevice) {
path: "other-session", path: "other-session",
element: <OtherSessionRoute />, element: <OtherSessionRoute />,
}, },
{
path: "update",
element: <UpdateRoute />,
},
{ {
path: "local-auth", path: "local-auth",
element: <LocalAuthRoute />, element: <LocalAuthRoute />,
@ -96,6 +123,63 @@ if (isOnDevice) {
path: "mount", path: "mount",
element: <MountRoute />, 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", path: "other-session",
element: <OtherSessionRoute />, element: <OtherSessionRoute />,
}, },
{
path: "update",
element: <UpdateRoute />,
},
{
path: "local-auth",
element: <LocalAuthRoute />,
},
{ {
path: "mount", path: "mount",
element: <MountRoute />, 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", () => { document.addEventListener("DOMContentLoaded", () => {
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Notifications <Notifications
toastOptions={{ toastOptions={{
@ -189,7 +322,7 @@ document.addEventListener("DOMContentLoaded", () => {
}} }}
max={2} 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 { useLocation, useNavigate } from "react-router-dom";
import Card, { GridCard } from "@/components/Card"; import Card from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { UpdateState, useUpdateStore } from "@/hooks/stores"; import { UpdateState, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
export default function UpdateRoute() { export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setModalView } = useUpdateStore(); const location = useLocation();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => { const onConfirmUpdate = useCallback(() => {
@ -20,16 +21,29 @@ export default function UpdateRoute() {
setModalView("updating"); setModalView("updating");
}, [send, setModalView]); }, [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 ( return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto"> <Dialog
{/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */} onClose={() => {
<Dialog // TODO: This wont work in cloud mode
setOpen={open => { navigate("..");
if (!open) navigate(".."); }}
}} onConfirmUpdate={onConfirmUpdate}
onConfirmUpdate={onConfirmUpdate} />
/>
</GridCard>
); );
} }
@ -41,12 +55,14 @@ export interface SystemVersionInfo {
} }
export function Dialog({ export function Dialog({
setOpen, onClose,
onConfirmUpdate, onConfirmUpdate,
}: { }: {
setOpen: (open: boolean) => void; onClose: () => void;
onConfirmUpdate: () => void; onConfirmUpdate: () => void;
}) { }) {
const navigate = useNavigate();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null); const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore(); const { modalView, setModalView, otaState } = useUpdateStore();
@ -72,27 +88,24 @@ export function Dialog({
}, [setModalView]); }, [setModalView]);
return ( return (
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto"> <div className="pointer-events-auto relative mx-auto text-left">
<div className="p-10"> <div>
{modalView === "error" && ( {modalView === "error" && (
<UpdateErrorState <UpdateErrorState
errorMessage={otaState.error} errorMessage={otaState.error}
onClose={() => setOpen(false)} onClose={onClose}
onRetryUpdate={() => setModalView("loading")} onRetryUpdate={() => setModalView("loading")}
/> />
)} )}
{modalView === "loading" && ( {modalView === "loading" && (
<LoadingState <LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
)} )}
{modalView === "updateAvailable" && ( {modalView === "updateAvailable" && (
<UpdateAvailableState <UpdateAvailableState
onConfirmUpdate={onConfirmUpdate} onConfirmUpdate={onConfirmUpdate}
onClose={() => setOpen(false)} onClose={onClose}
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
)} )}
@ -101,7 +114,8 @@ export function Dialog({
<UpdatingDeviceState <UpdatingDeviceState
otaState={otaState} otaState={otaState}
onMinimizeUpgradeDialog={() => { onMinimizeUpgradeDialog={() => {
setOpen(false); // TODO: This wont work in cloud mode
navigate("/");
}} }}
/> />
)} )}
@ -109,15 +123,13 @@ export function Dialog({
{modalView === "upToDate" && ( {modalView === "upToDate" && (
<SystemUpToDateState <SystemUpToDateState
checkUpdate={() => setModalView("loading")} checkUpdate={() => setModalView("loading")}
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
{modalView === "updateCompleted" && ( {modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />}
<UpdateCompletedState onClose={() => setOpen(false)} />
)}
</div> </div>
</GridCard> </div>
); );
} }
@ -155,14 +167,12 @@ function LoadingState({
const animationTimer = setTimeout(() => { const animationTimer = setTimeout(() => {
setProgressWidth("100%"); setProgressWidth("100%");
}, 500); }, 0);
getVersionInfo() getVersionInfo()
.then(versionInfo => { .then(versionInfo => {
if (progressBarRef.current) { // Add a small delay to ensure it's not just flickering
progressBarRef.current?.classList.add("!duration-1000"); return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
}) })
.then(versionInfo => { .then(versionInfo => {
if (!signal.aborted) { if (!signal.aborted) {
@ -182,12 +192,8 @@ function LoadingState({
}, [getVersionInfo, onFinished]); }, [getVersionInfo, onFinished]);
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div> <div className="space-y-4">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="max-w-sm space-y-4">
<div className="space-y-0"> <div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Checking for updates... Checking for updates...
@ -200,18 +206,11 @@ function LoadingState({
<div <div
ref={progressBarRef} ref={progressBarRef}
style={{ width: progressWidth }} style={{ width: progressWidth }}
className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out" className="h-2.5 bg-blue-700 transition-all duration-1000 ease-in-out"
></div> ></div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Button <Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -293,11 +292,7 @@ function UpdatingDeviceState({
}; };
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="w-full max-w-sm space-y-4"> <div className="w-full max-w-sm space-y-4">
<div className="space-y-0"> <div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
@ -410,11 +405,7 @@ function SystemUpToDateState({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
System is up to date System is up to date
@ -424,22 +415,8 @@ function SystemUpToDateState({
</p> </p>
<div className="mt-4 flex gap-x-2"> <div className="mt-4 flex gap-x-2">
<Button <Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
size="SM" <Button size="SM" theme="blank" text="Back" onClick={onClose} />
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -456,11 +433,7 @@ function UpdateAvailableState({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Update available Update available
@ -494,11 +467,7 @@ function UpdateAvailableState({
function UpdateCompletedState({ onClose }: { onClose: () => void }) { function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white"> <p className="text-base font-semibold dark:text-white">
Update Completed Successfully Update Completed Successfully
@ -508,7 +477,7 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
features and improvements! features and improvements!
</p> </p>
<div className="flex items-center justify-start"> <div className="flex items-center justify-start">
<Button size="SM" theme="primary" text="Close" onClick={onClose} /> <Button size="SM" theme="primary" text="Back" onClick={onClose} />
</div> </div>
</div> </div>
</div> </div>
@ -525,11 +494,7 @@ function UpdateErrorState({
onRetryUpdate: () => void; onRetryUpdate: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white">Update Error</p> <p className="text-base font-semibold dark:text-white">Update Error</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400"> <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
@ -541,8 +506,8 @@ function UpdateErrorState({
</p> </p>
)} )}
<div className="flex items-center justify-start gap-x-2"> <div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Close" onClick={onClose} /> <Button size="SM" theme="light" text="Back" onClick={onClose} />
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} /> <Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@ -0,0 +1,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 { useCallback, useEffect, useRef, useState } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { Transition } from "@headlessui/react";
import { import {
HidState, HidState,
UpdateState, UpdateState,
@ -20,6 +19,7 @@ import {
Params, Params,
redirect, redirect,
useLoaderData, useLoaderData,
useLocation,
useNavigate, useNavigate,
useOutlet, useOutlet,
useParams, useParams,
@ -28,7 +28,6 @@ import {
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import SettingsSidebar from "@/components/sidebar/settings";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard"; import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
@ -38,6 +37,7 @@ import FocusTrap from "focus-trap-react";
import Terminal from "@components/Terminal"; import Terminal from "@components/Terminal";
import { CLOUD_API, DEVICE_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
import Modal from "../components/Modal"; import Modal from "../components/Modal";
import { motion, AnimatePresence } from "motion/react";
interface LocalLoaderResp { interface LocalLoaderResp {
authMode: "password" | "noPassword" | null; authMode: "password" | "noPassword" | null;
@ -51,8 +51,9 @@ interface CloudLoaderResp {
} | null; } | null;
} }
export type AuthMode = "password" | "noPassword" | null;
export interface LocalDevice { export interface LocalDevice {
authMode: "password" | "noPassword" | null; authMode: AuthMode;
deviceId: string; deviceId: string;
} }
@ -349,6 +350,7 @@ export default function KvmIdRoute() {
if (otaState.error) { if (otaState.error) {
setModalView("error"); setModalView("error");
// TODO: this wont work in cloud mode
navigate("update"); navigate("update");
return; 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 // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
if (queryParams.get("updateSuccess")) { if (queryParams.get("updateSuccess")) {
setModalView("updateCompleted"); // TODO: this wont work in cloud mode
navigate("update"); navigate("./settings/general/update", {
setQueryParams({}); state: { updateSuccess: true },
});
} }
}, [navigate, queryParams, setModalView, setQueryParams]); }, [navigate, queryParams, setModalView, setQueryParams]);
@ -437,16 +440,28 @@ export default function KvmIdRoute() {
}, [kvmTerminal]); }, [kvmTerminal]);
const outlet = useOutlet(); 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 ( return (
<> <>
<Transition show={!isUpdateDialogOpen && otaState.updating}> {!outlet && otaState.updating && (
<div className="pointer-events-none fixed inset-0 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"> <AnimatePresence>
<div className="transition duration-1000 ease-in data-[closed]:opacity-0"> <motion.div
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 /> <UpdateInProgressStatusCard />
</div> </motion.div>
</div> </AnimatePresence>
</Transition> )}
<div className="relative h-full"> <div className="relative h-full">
<FocusTrap <FocusTrap
paused={disableKeyboardFocusTrap} paused={disableKeyboardFocusTrap}
@ -478,16 +493,14 @@ export default function KvmIdRoute() {
</div> </div>
<div <div
className="isolate"
onKeyUp={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}
onKeyDown={e => { onKeyDown={e => {
e.stopPropagation(); e.stopPropagation();
if (e.key === "Escape") navigate(".."); if (e.key === "Escape") navigate("./");
}} }}
> >
<Modal <Modal open={outlet !== null} onClose={onModalClose}>
open={outlet !== null}
onClose={() => location.pathname !== "/other-session" && navigate("..")}
>
<Outlet context={{ connectWebRTC }} /> <Outlet context={{ connectWebRTC }} />
</Modal> </Modal>
</div> </div>
@ -513,16 +526,22 @@ function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
style={{ width: sidebarView ? "493px" : 0 }} style={{ width: sidebarView ? "493px" : 0 }}
> >
<div className="relative w-[493px] shrink-0"> <div className="relative w-[493px] shrink-0">
<Transition show={sidebarView === "system"} unmount={false}> <AnimatePresence>
<div className="absolute inset-0"> {sidebarView === "connection-stats" && (
<SettingsSidebar /> <motion.div
</div> className="absolute inset-0"
</Transition> initial={{ opacity: 0 }}
<Transition show={sidebarView === "connection-stats"} unmount={false}> animate={{ opacity: 1 }}
<div className="absolute inset-0"> exit={{ opacity: 0 }}
<ConnectionStatsSidebar /> transition={{
</div> duration: 0.5,
</Transition> ease: "easeInOut",
}}
>
<ConnectionStatsSidebar />
</motion.div>
)}
</AnimatePresence>
</div> </div>
</div> </div>
); );