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,10 +103,10 @@ 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}
@ -119,29 +117,19 @@ export default function DashboardNavbar({
)} )}
/> />
) )
)} }
/> />
</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"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in-out duration-75"
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white"> <div className="space-y-1 p-1 dark:text-white">
{userEmail && ( {userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20"> <div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item> <Menu.Item>
<div className="p-2"> <div className="p-2">
<div className="text-xs font-display"> <div className="font-display text-xs">Logged in as</div>
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold"> <div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail} {userEmail}
</div> </div>
@ -154,7 +142,7 @@ export default function DashboardNavbar({
<div onClick={onLogout}> <div onClick={onLogout}>
<button className="block w-full"> <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"> <div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" /> <ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
<div className="font-display">Log out</div> <div className="font-display">Log out</div>
</div> </div>
</button> </button>
@ -164,7 +152,6 @@ export default function DashboardNavbar({
</div> </div>
</Card> </Card>
</Menu.Items> </Menu.Items>
</Transition>
</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,
ease: "easeInOut"
}}
> >
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1"> <div className="flex flex-col items-center justify-center gap-y-1">
<div className="flex items-center justify-center w-12 h-12 animate"> <div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" /> <LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div> </div>
<p className="text-sm text-center text-slate-700 dark:text-slate-300"> <p className="text-center text-sm text-slate-700 dark:text-slate-300">
Loading video stream... Loading video stream...
</p> </p>
</div> </div>
</OverlayContent> </OverlayContent>
</div> </motion.div>
</Transition> )}
</AnimatePresence>
); );
} }
@ -56,24 +59,26 @@ 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,
ease: "easeInOut"
}}
> >
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2> <h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="pl-4 space-y-2 text-left list-disc"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li> <li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li> <li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li> <li>Ensure your network connection is stable and active</li>
@ -93,8 +98,9 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
</div> </div>
</div> </div>
</OverlayContent> </OverlayContent>
</div> </motion.div>
</Transition> )}
</AnimatePresence>
); );
} }
@ -109,16 +115,18 @@ 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,
ease: "easeInOut"
}}
> >
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
@ -126,7 +134,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2> <h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li> <li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li> <li>Ensure source device is powered on and outputting a signal</li>
<li> <li>
@ -148,26 +156,30 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div> </div>
</div> </div>
</OverlayContent> </OverlayContent>
</div> </motion.div>
</Transition> )}
<Transition </AnimatePresence>
show={show && isOtherError}
enter="transition duration-300" <AnimatePresence>
enterFrom="opacity-0" {show && isOtherError && (
enterTo="opacity-100" <motion.div
leave="transition duration-300 " className="absolute inset-0 aspect-video h-full w-full"
leaveFrom="opacity-100" initial={{ opacity: 0 }}
leaveTo="opacity-0" animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
> >
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2> <h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>A loose or faulty HDMI connection</li> <li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li> <li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li> <li>Issues with the source device&apos;s HDMI output</li>
@ -186,8 +198,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div> </div>
</div> </div>
</OverlayContent> </OverlayContent>
</div> </motion.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,17 +182,17 @@ 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 <div
className={cx( className={cx(
!showAttachedVirtualKeyboard !showAttachedVirtualKeyboard
@ -211,8 +211,8 @@ function KeyboardWrapper() {
"rounded-none": showAttachedVirtualKeyboard, "rounded-none": showAttachedVirtualKeyboard,
})} })}
> >
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20"> <div className="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 flex items-center left-2 gap-x-2"> <div className="absolute left-2 flex items-center gap-x-2">
{showAttachedVirtualKeyboard ? ( {showAttachedVirtualKeyboard ? (
<Button <Button
size="XS" size="XS"
@ -245,7 +245,7 @@ function KeyboardWrapper() {
</div> </div>
<div> <div>
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row"> <div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<Keyboard <Keyboard
baseClass="simple-keyboard-main" baseClass="simple-keyboard-main"
layoutName={layoutName} layoutName={layoutName}
@ -450,8 +450,9 @@ function KeyboardWrapper() {
</div> </div>
</Card> </Card>
</div> </div>
</div> </motion.div>
</Transition> )}
</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,17 +228,66 @@ if (isOnDevice) {
path: "other-session", path: "other-session",
element: <OtherSessionRoute />, element: <OtherSessionRoute />,
}, },
{
path: "mount",
element: <MountRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,
children: [
{
index: true,
loader: SettingsIndexRoute.loader,
},
{
path: "general",
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{ {
path: "update", path: "update",
element: <UpdateRoute />, 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", path: "local-auth",
element: <LocalAuthRoute />, element: <LocalAuthRoute />,
}, },
],
},
{ {
path: "mount", path: "video",
element: <MountRoute />, 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">
{/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */}
<Dialog <Dialog
setOpen={open => { onClose={() => {
if (!open) navigate(".."); // TODO: This wont work in cloud mode
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> </div>
</GridCard>
); );
} }
@ -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 }}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
>
<ConnectionStatsSidebar /> <ConnectionStatsSidebar />
</div> </motion.div>
</Transition> )}
</AnimatePresence>
</div> </div>
</div> </div>
); );