mirror of https://github.com/jetkvm/kvm.git
Move settings to modals & better modal handling (#194)
* feat(ui): Add other session handling route and modal * feat(ui): Add dedicated update route and refactor update dialog state management * feat(ui): Add local authentication route * refactor(ui): Remove LocalAuthPasswordDialog component and clean up related code * refactor(ui): Remove OtherSessionConnectedModal component * feat(ui): Add dedicated mount route and refactor mount media dialog * refactor(ui): Simplify Escape key navigation in device route * refactor(ui): Add TODO comments for future URL-based state migration * refactor(ui): Migrate settings and update routes to dedicated routes This commit introduces a comprehensive refactoring of the UI routing and state management: - Removed sidebar-based settings view - Replaced global modal state with URL-based routing - Added dedicated routes for settings, including general, security, and update sections - Simplified modal and sidebar interactions - Improved animation and transition handling using motion library - Removed deprecated components and simplified route structure * fix(ui): Add TODO comment for modal session interaction * refactor(ui): Move USB configuration to new settings setup This commit introduces several improvements to the USB configuration workflow: - Refactored USB configuration dialog component - Simplified USB config state management - Moved USB configuration to hardware settings route - Updated JSON-RPC type definitions - Cleaned up unused imports and components - Improved error handling and notifications * refactor(ui): Replace react-router-dom navigation with custom navigation hook This commit introduces a new custom navigation hook `useDeviceUiNavigation` to replace direct usage of `useNavigate` across multiple components: - Removed direct `useNavigate` imports in various components - Added `navigateTo` method from new navigation hook - Updated navigation calls in ActionBar, MountPopover, UpdateInProgressStatusCard, and other routes - Simplified navigation logic and prepared for potential future navigation enhancements - Removed console logs and unnecessary comments * refactor(ui): Remove unused react-router-dom import Clean up unnecessary import of `useNavigate` from react-router-dom in device settings route * feat(ui): Improve mobile navigation and scrolling in device settings * refactor(ui): Reorganize device access and security settings This commit introduces several changes to the device access and security settings: - Renamed "Security" section to "Access" in settings navigation - Moved local authentication routes from security to access - Removed deprecated security settings route - Added new route for device access settings with cloud and local authentication management - Updated cloud URL and adoption logic to be part of the access settings - Simplified routing and component structure for better user experience * fix(ui): Update logout button hover state color * fix(ui): Adjust device de-registration button size to small * fix(ui): Update appearance settings section header and description * refactor(ui): Replace SectionHeader with new SettingsPageHeader and SettingsSectionHeader components This commit introduces two new header components for settings pages: - Created SettingsPageHeader for main page headers - Created SettingsSectionHeader for subsection headers - Replaced all existing SectionHeader imports with new components - Updated styling and type definitions to support more flexible header rendering * feat(ui): Add dev channel toggle to advanced settings Move dev channel update option from general settings to advanced settings - Introduced new state and handler for dev channel toggle - Removed dev channel option from general settings route - Added dev channel toggle in advanced settings with error handling
This commit is contained in:
parent
77263e73f7
commit
4052b3d225
|
@ -53,7 +53,7 @@ var defaultConfig = &Config{
|
|||
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||
SerialNumber: "",
|
||||
Manufacturer: "JetKVM",
|
||||
Product: "JetKVM USB Emulation Device",
|
||||
Product: "USB Emulation Device",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -780,6 +780,10 @@ func rpcResetCloudUrl() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func rpcGetDefaultCloudUrl() (string, error) {
|
||||
return defaultConfig.CloudURL, nil
|
||||
}
|
||||
|
||||
var rpcHandlers = map[string]RPCHandler{
|
||||
"ping": {Func: rpcPing},
|
||||
"getDeviceID": {Func: rpcGetDeviceID},
|
||||
|
@ -841,4 +845,5 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"url"}},
|
||||
"getCloudUrl": {Func: rpcGetCloudUrl},
|
||||
"resetCloudUrl": {Func: rpcResetCloudUrl},
|
||||
"getDefaultCloudUrl": {Func: rpcGetDefaultCloudUrl},
|
||||
}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
# Used in settings page to know where to link to when user wants to adopt a device to the cloud
|
||||
VITE_CLOUD_APP=http://localhost:5173
|
||||
VITE_CLOUD_APP=http://app.jetkvm.com
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
"framer-motion": "^11.15.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mini-svg-data-uri": "^1.4.4",
|
||||
"motion": "^12.4.7",
|
||||
"react": "^18.2.0",
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
|
@ -4245,6 +4246,31 @@
|
|||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.4.7",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.4.7.tgz",
|
||||
"integrity": "sha512-mhegHAbf1r80fr+ytC6OkjKvIUegRNXKLWNPrCN2+GnixlNSPwT03FtKqp9oDny1kNcLWZvwbmEr+JqVryFrcg==",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.4.7",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.14.3",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz",
|
||||
|
@ -4255,6 +4281,45 @@
|
|||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz",
|
||||
"integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="
|
||||
},
|
||||
"node_modules/motion/node_modules/framer-motion": {
|
||||
"version": "12.4.7",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.7.tgz",
|
||||
"integrity": "sha512-VhrcbtcAMXfxlrjeHPpWVu2+mkcoR31e02aNSR7OUS/hZAciKa8q6o3YN2mA1h+jjscRsSyKvX6E1CiY/7OLMw==",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.4.5",
|
||||
"motion-utils": "^12.0.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion/node_modules/motion-dom": {
|
||||
"version": "12.4.5",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.4.5.tgz",
|
||||
"integrity": "sha512-Q2xmhuyYug1CGTo0jdsL05EQ4RhIYXlggFS/yPhQQRNzbrhjKQ1tbjThx5Plv68aX31LsUQRq4uIkuDxdO5vRQ==",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion/node_modules/motion-utils": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz",
|
||||
"integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
"framer-motion": "^11.15.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mini-svg-data-uri": "^1.4.4",
|
||||
"motion": "^12.4.7",
|
||||
"react": "^18.2.0",
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "^18.2.0",
|
||||
|
|
|
@ -17,12 +17,14 @@ import MountPopopover from "./popovers/MountPopover";
|
|||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||
import ExtensionPopover from "./popovers/ExtensionPopover";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
|
||||
export default function Actionbar({
|
||||
requestFullscreen,
|
||||
}: {
|
||||
requestFullscreen: () => Promise<void>;
|
||||
}) {
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
|
||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
|
@ -260,15 +262,16 @@ export default function Actionbar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden xs:block ">
|
||||
<div>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Settings"
|
||||
LeadingIcon={LuSettings}
|
||||
onClick={() => toggleSidebarView("system")}
|
||||
onClick={() => navigateTo("/settings")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-x-2 lg:flex">
|
||||
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
|
||||
<Button
|
||||
|
|
|
@ -22,7 +22,7 @@ const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
|
|||
{...props}
|
||||
height={height}
|
||||
duration={300}
|
||||
contentClassName="auto-content pointer-events-none"
|
||||
contentClassName="h-fit"
|
||||
contentRef={contentDiv}
|
||||
disableDisplayNone
|
||||
>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
type CardPropsType = {
|
||||
|
@ -16,23 +16,28 @@ export const GridCard = ({
|
|||
return (
|
||||
<Card className={cx("overflow-hidden", cardClassName)}>
|
||||
<div className="relative h-full">
|
||||
<div className="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" />
|
||||
<div className="absolute inset-0 z-0 h-full w-full bg-gradient-to-tr from-blue-50/30 to-blue-50/20 transition-colors duration-300 ease-in-out dark:from-slate-800/30 dark:to-slate-800/20" />
|
||||
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
|
||||
<div className="h-full isolate">{children}</div>
|
||||
<div className="isolate h-full">{children}</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Card({ children, className }: CardPropsType) {
|
||||
const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
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,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Card.displayName = "Card";
|
||||
|
||||
export default Card;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Fragment, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import { Menu, MenuButton, Transition } from "@headlessui/react";
|
||||
import { Menu, MenuButton } from "@headlessui/react";
|
||||
import Container from "@/components/Container";
|
||||
import Card from "@/components/Card";
|
||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||
|
@ -37,9 +37,7 @@ export default function DashboardNavbar({
|
|||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const onLogout = useCallback(async () => {
|
||||
const logoutUrl = isOnDevice
|
||||
? `${DEVICE_API}/auth/logout`
|
||||
: `${CLOUD_API}/logout`;
|
||||
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
||||
const res = await api.POST(logoutUrl);
|
||||
if (!res.ok) return;
|
||||
|
||||
|
@ -51,10 +49,10 @@ export default function DashboardNavbar({
|
|||
const usbState = useHidStore(state => state.usbState);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||
<div className="w-full select-none border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||
<Container>
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<div className="flex items-center shrink-0 gap-x-8">
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<div className="flex shrink-0 items-center gap-x-8">
|
||||
<div className="inline-block shrink-0">
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
|
||||
|
@ -75,10 +73,10 @@ export default function DashboardNavbar({
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end w-full gap-x-2">
|
||||
<div className="flex items-center space-x-4 shrink-0">
|
||||
<div className="flex w-full items-center justify-end gap-x-2">
|
||||
<div className="flex shrink-0 items-center space-x-4">
|
||||
{showConnectionStatus && (
|
||||
<div className="items-center hidden gap-x-2 md:flex">
|
||||
<div className="hidden items-center gap-x-2 md:flex">
|
||||
<div className="w-[159px]">
|
||||
<PeerConnectionStatusCard
|
||||
state={peerConnectionState}
|
||||
|
@ -105,10 +103,10 @@ export default function DashboardNavbar({
|
|||
text={
|
||||
<>
|
||||
{picture ? <></> : userEmail}
|
||||
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" />
|
||||
</>
|
||||
}
|
||||
LeadingIcon={({ className }) => (
|
||||
LeadingIcon={({ className }) =>
|
||||
picture && (
|
||||
<img
|
||||
src={picture}
|
||||
|
@ -119,29 +117,19 @@ export default function DashboardNavbar({
|
|||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-in-out duration-75"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition ease-in-out duration-75"
|
||||
leaveFrom="transform opacity-75"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
|
||||
|
||||
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none">
|
||||
<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 && (
|
||||
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
|
||||
<Menu.Item>
|
||||
<div className="p-2">
|
||||
<div className="text-xs font-display">
|
||||
Logged in as
|
||||
</div>
|
||||
<div className="font-display text-xs">Logged in as</div>
|
||||
<div className="w-[200px] truncate font-display text-sm font-semibold">
|
||||
{userEmail}
|
||||
</div>
|
||||
|
@ -153,8 +141,8 @@ export default function DashboardNavbar({
|
|||
<Menu.Item>
|
||||
<div onClick={onLogout}>
|
||||
<button className="block w-full">
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
|
||||
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
|
||||
<div className="font-display">Log out</div>
|
||||
</div>
|
||||
</button>
|
||||
|
@ -164,7 +152,6 @@ export default function DashboardNavbar({
|
|||
</div>
|
||||
</Card>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
|
||||
import { cx } from "@/cva.config";
|
||||
|
||||
export default function Modal({
|
||||
const Modal = React.memo(function Modal({
|
||||
children,
|
||||
className,
|
||||
open,
|
||||
|
@ -17,22 +17,25 @@ export default function Modal({
|
|||
<Dialog open={open} onClose={onClose} className="relative z-10">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
|
||||
{/* TODO: This doesn't work well with other-sessions */}
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className={cx(
|
||||
"pointer-events-none relative w-full sm:my-8",
|
||||
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in",
|
||||
"pointer-events-none relative w-full md:my-8 md:!mt-[10vh]",
|
||||
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="inline-block w-full text-left pointer-events-auto">
|
||||
<div className="pointer-events-auto inline-block w-full text-left">
|
||||
<div className="flex justify-center" onClick={onClose}>
|
||||
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
|
||||
<div
|
||||
className="pointer-events-none w-full"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -42,4 +45,6 @@ export default function Modal({
|
|||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default Modal;
|
||||
|
|
|
@ -19,7 +19,7 @@ type SelectMenuProps = Pick<
|
|||
direction?: "vertical" | "horizontal";
|
||||
error?: string;
|
||||
fullWidth?: boolean;
|
||||
} & React.ComponentProps<typeof FieldLabel>;
|
||||
} & Partial<React.ComponentProps<typeof FieldLabel>>;
|
||||
|
||||
const sizes = {
|
||||
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
||||
|
@ -60,7 +60,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
|||
)}
|
||||
>
|
||||
{label && <FieldLabel label={label} id={id} as="span" />}
|
||||
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
|
||||
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
|
||||
<select
|
||||
ref={ref}
|
||||
name={name}
|
||||
|
@ -69,10 +69,13 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
|||
classes,
|
||||
|
||||
// General styling
|
||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
|
||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||
|
||||
// Hover
|
||||
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900",
|
||||
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",
|
||||
|
||||
// Dark mode
|
||||
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-800",
|
||||
|
||||
// Invalid
|
||||
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
|
||||
|
@ -82,9 +85,6 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
|||
|
||||
// Disabled
|
||||
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
|
||||
|
||||
// Dark mode text
|
||||
"dark:bg-slate-900 dark:text-white"
|
||||
)}
|
||||
value={value}
|
||||
id={id}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export function SectionHeader({
|
||||
export function SettingsPageHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
|
@ -8,8 +8,8 @@ export function SectionHeader({
|
|||
description: string | ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
|
||||
<div className="select-none">
|
||||
<h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2>
|
||||
<div className="text-sm text-black dark:text-slate-300">{description}</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,16 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
export function SettingsSectionHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string | ReactNode;
|
||||
description: string | ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="select-none">
|
||||
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,83 +1,25 @@
|
|||
import { GridCard } from "@/components/Card";
|
||||
import {useCallback, useEffect, useState} from "react";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import { InputFieldWithLabel } from "./InputField";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useUsbConfigModalStore } from "@/hooks/stores";
|
||||
import ExtLink from "@components/ExtLink";
|
||||
import { UsbConfigState } from "@/hooks/stores"
|
||||
import { UsbConfigState } from "@/hooks/stores";
|
||||
import { useEffect, useCallback, useState } from "react";
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { USBConfig } from "../routes/devices.$id.settings.hardware";
|
||||
|
||||
export default function USBConfigDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
const { modalView, setModalView } = useUsbConfigModalStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const handleUsbConfigChange = useCallback((usbConfig: object) => {
|
||||
send("setUsbConfig", { usbConfig }, resp => {
|
||||
if ("error" in resp) {
|
||||
setError(`Failed to update USB Config: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
setModalView("updateUsbConfigSuccess");
|
||||
});
|
||||
}, [send, setModalView]);
|
||||
|
||||
return (
|
||||
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
|
||||
<div className="p-10">
|
||||
{modalView === "updateUsbConfig" && (
|
||||
<UpdateUsbConfigModal
|
||||
onSetUsbConfig={handleUsbConfigChange}
|
||||
onCancel={() => setOpen(false)}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
{modalView === "updateUsbConfigSuccess" && (
|
||||
<SuccessModal
|
||||
headline="USB Configuration Updated Successfully"
|
||||
description="You've successfully updated the USB Configuration"
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateUsbConfigModal({
|
||||
export default function UpdateUsbConfigModal({
|
||||
onSetUsbConfig,
|
||||
onCancel,
|
||||
error,
|
||||
onRestoreToDefault,
|
||||
}: {
|
||||
onSetUsbConfig: (usb_config: object) => void;
|
||||
onCancel: () => void;
|
||||
error: string | null;
|
||||
onSetUsbConfig: (usbConfig: USBConfig) => void;
|
||||
onRestoreToDefault: () => void;
|
||||
}) {
|
||||
const [usbConfigState, setUsbConfigState] = useState<UsbConfigState>({
|
||||
vendor_id: '',
|
||||
product_id: '',
|
||||
serial_number: '',
|
||||
manufacturer: '',
|
||||
product: ''
|
||||
const [usbConfigState, setUsbConfigState] = useState<USBConfig>({
|
||||
vendor_id: "",
|
||||
product_id: "",
|
||||
serial_number: "",
|
||||
manufacturer: "",
|
||||
product: "",
|
||||
});
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const syncUsbConfig = useCallback(() => {
|
||||
|
@ -96,47 +38,28 @@ function UpdateUsbConfigModal({
|
|||
}, [syncUsbConfig]);
|
||||
|
||||
const handleUsbVendorIdChange = (value: string) => {
|
||||
setUsbConfigState({... usbConfigState, vendor_id: value})
|
||||
setUsbConfigState({ ...usbConfigState, vendor_id: value });
|
||||
};
|
||||
|
||||
const handleUsbProductIdChange = (value: string) => {
|
||||
setUsbConfigState({... usbConfigState, product_id: value})
|
||||
setUsbConfigState({ ...usbConfigState, product_id: value });
|
||||
};
|
||||
|
||||
const handleUsbSerialChange = (value: string) => {
|
||||
setUsbConfigState({... usbConfigState, serial_number: value})
|
||||
setUsbConfigState({ ...usbConfigState, serial_number: value });
|
||||
};
|
||||
|
||||
const handleUsbManufacturer = (value: string) => {
|
||||
setUsbConfigState({... usbConfigState, manufacturer: value})
|
||||
setUsbConfigState({ ...usbConfigState, manufacturer: value });
|
||||
};
|
||||
|
||||
const handleUsbProduct = (value: string) => {
|
||||
setUsbConfigState({... usbConfigState, product: value})
|
||||
setUsbConfigState({ ...usbConfigState, product: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">USB Emulation Configuration</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Set custom USB parameters to control how the USB device is emulated.
|
||||
The device will rebind once the parameters are updated.
|
||||
</p>
|
||||
<div className="flex justify-start mt-4 text-xs text-slate-500 dark:text-slate-400">
|
||||
<ExtLink
|
||||
href={`https://the-sz.com/products/usbid/index.php`}
|
||||
className="hover:underline"
|
||||
>
|
||||
Look up USB Device IDs here
|
||||
</ExtLink>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InputFieldWithLabel
|
||||
required
|
||||
label="Vendor ID"
|
||||
|
@ -174,6 +97,7 @@ function UpdateUsbConfigModal({
|
|||
defaultValue={usbConfigState?.product}
|
||||
onChange={e => handleUsbProduct(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
|
@ -181,35 +105,12 @@ function UpdateUsbConfigModal({
|
|||
text="Update USB Config"
|
||||
onClick={() => onSetUsbConfig(usbConfigState)}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessModal({
|
||||
headline,
|
||||
description,
|
||||
onClose,
|
||||
}: {
|
||||
headline: string;
|
||||
description: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Restore to Default"
|
||||
onClick={onRestoreToDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -2,25 +2,19 @@ import { cx } from "@/cva.config";
|
|||
import { Button } from "./Button";
|
||||
import { GridCard } from "./Card";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import { UpdateState } from "@/hooks/stores";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
|
||||
interface UpdateInProgressStatusCardProps {
|
||||
setIsUpdateDialogOpen: (isOpen: boolean) => void;
|
||||
setModalView: (view: UpdateState["modalView"]) => void;
|
||||
}
|
||||
export default function UpdateInProgressStatusCard() {
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
export default function UpdateInProgressStatusCard({
|
||||
setIsUpdateDialogOpen,
|
||||
setModalView,
|
||||
}: UpdateInProgressStatusCardProps) {
|
||||
return (
|
||||
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
|
||||
<div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out">
|
||||
<GridCard cardClassName="!shadow-xl">
|
||||
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold leading-none transition text-ellipsis">
|
||||
<div className="text-ellipsis text-sm font-semibold leading-none transition">
|
||||
Update in Progress
|
||||
</div>
|
||||
<div className="text-sm leading-none">
|
||||
|
@ -37,10 +31,7 @@ export default function UpdateInProgressStatusCard({
|
|||
className="pointer-events-auto"
|
||||
theme="light"
|
||||
text="View Details"
|
||||
onClick={() => {
|
||||
setModalView("updating");
|
||||
setIsUpdateDialogOpen(true);
|
||||
}}
|
||||
onClick={() => navigateTo("/settings/general/update")}
|
||||
/>
|
||||
</div>
|
||||
</GridCard>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React from "react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
|
||||
interface OverlayContentProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -12,7 +12,7 @@ interface OverlayContentProps {
|
|||
function OverlayContent({ children }: OverlayContentProps) {
|
||||
return (
|
||||
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
|
||||
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
|
||||
{children}
|
||||
</div>
|
||||
</GridCard>
|
||||
|
@ -25,28 +25,31 @@ interface LoadingOverlayProps {
|
|||
|
||||
export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
||||
return (
|
||||
<Transition
|
||||
show={show}
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="absolute inset-0 aspect-video h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: show ? 0.3 : 0.1,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-center justify-center gap-y-1">
|
||||
<div className="flex items-center justify-center w-12 h-12 animate">
|
||||
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" />
|
||||
<div className="animate flex h-12 w-12 items-center justify-center">
|
||||
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
|
||||
</div>
|
||||
<p className="text-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...
|
||||
</p>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</div>
|
||||
</Transition>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -56,24 +59,26 @@ interface ConnectionErrorOverlayProps {
|
|||
|
||||
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
|
||||
return (
|
||||
<Transition
|
||||
show={show}
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="absolute inset-0 z-10 aspect-video h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-start gap-y-1">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
||||
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
|
||||
<ul className="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>Check all cable connections for any loose or damaged wires</li>
|
||||
<li>Ensure your network connection is stable and active</li>
|
||||
|
@ -93,8 +98,9 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
|
|||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</div>
|
||||
</Transition>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -109,16 +115,18 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Transition
|
||||
show={show && isNoSignal}
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-all duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<AnimatePresence>
|
||||
{show && isNoSignal && (
|
||||
<motion.div
|
||||
className="absolute inset-0 w-full h-full aspect-video"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-start gap-y-1">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
||||
|
@ -126,7 +134,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
|
||||
<ul className="pl-4 space-y-2 text-left list-disc">
|
||||
<ul className="list-disc space-y-2 pl-4 text-left">
|
||||
<li>Ensure the HDMI cable securely connected at both ends</li>
|
||||
<li>Ensure source device is powered on and outputting a signal</li>
|
||||
<li>
|
||||
|
@ -148,26 +156,30 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
show={show && isOtherError}
|
||||
enter="transition duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition duration-300 "
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{show && isOtherError && (
|
||||
<motion.div
|
||||
className="absolute inset-0 aspect-video h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-start gap-y-1">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
||||
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
|
||||
<ul className="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>Incompatible resolution or refresh rate settings</li>
|
||||
<li>Issues with the source device's HDMI output</li>
|
||||
|
@ -186,8 +198,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
|||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</div>
|
||||
</Transition>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import Card from "@components/Card";
|
|||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||
import "react-simple-keyboard/build/css/index.css";
|
||||
import { useHidStore, useUiStore } from "@/hooks/stores";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { cx } from "@/cva.config";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
|
@ -182,17 +182,17 @@ function KeyboardWrapper() {
|
|||
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
|
||||
}}
|
||||
>
|
||||
<Transition
|
||||
show={virtualKeyboard}
|
||||
unmount={false}
|
||||
enter="transition-all transform-gpu duration-500 ease-in-out"
|
||||
enterFrom="opacity-0 translate-y-[100%]"
|
||||
enterTo="opacity-100 translate-y-[0%]"
|
||||
leave="transition-all duration-500 ease-in-out"
|
||||
leaveFrom="opacity-100 translate-y-[0%]"
|
||||
leaveTo="opacity-0 translate-y-[100%]"
|
||||
<AnimatePresence>
|
||||
{virtualKeyboard && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: "100%" }}
|
||||
animate={{ opacity: 1, y: "0%" }}
|
||||
exit={{ opacity: 0, y: "100%" }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={cx(
|
||||
!showAttachedVirtualKeyboard
|
||||
|
@ -211,8 +211,8 @@ function KeyboardWrapper() {
|
|||
"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="absolute flex items-center left-2 gap-x-2">
|
||||
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
|
||||
<div className="absolute left-2 flex items-center gap-x-2">
|
||||
{showAttachedVirtualKeyboard ? (
|
||||
<Button
|
||||
size="XS"
|
||||
|
@ -245,7 +245,7 @@ function KeyboardWrapper() {
|
|||
</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
|
||||
baseClass="simple-keyboard-main"
|
||||
layoutName={layoutName}
|
||||
|
@ -450,8 +450,9 @@ function KeyboardWrapper() {
|
|||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import {
|
|||
useMouseStore,
|
||||
useRTCStore,
|
||||
useSettingsStore,
|
||||
useUiStore,
|
||||
useVideoStore,
|
||||
} from "@/hooks/stores";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
|
@ -15,7 +14,9 @@ import Actionbar from "@components/ActionBar";
|
|||
import InfoBar from "@components/InfoBar";
|
||||
import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
|
||||
import { HDMIErrorOverlay } from "./VideoOverlay";
|
||||
import { ConnectionErrorOverlay } from "./VideoOverlay";
|
||||
import { LoadingOverlay } from "./VideoOverlay";
|
||||
|
||||
export default function WebRTCVideo() {
|
||||
// Video and stream related refs and states
|
||||
|
@ -402,26 +403,6 @@ export default function WebRTCVideo() {
|
|||
],
|
||||
);
|
||||
|
||||
// Focus trap management
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const sidebarView = useUiStore(state => state.sidebarView);
|
||||
useEffect(() => {
|
||||
setTimeout(function () {
|
||||
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
|
||||
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
|
||||
sendKeyboardEvent([], []);
|
||||
setDisableVideoFocusTrap(true);
|
||||
|
||||
// For some reason, the focus trap is not disabled immediately
|
||||
// so we need to blur the active element
|
||||
// (document.activeElement as HTMLElement)?.blur();
|
||||
console.log("Just disabled focus trap");
|
||||
} else {
|
||||
setDisableVideoFocusTrap(false);
|
||||
}
|
||||
}, 300);
|
||||
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-layout">
|
||||
<div className="min-h-[39.5px]">
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@components/Button";
|
||||
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
|
||||
import Card from "@components/Card";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useEffect, useState } from "react";
|
||||
import notifications from "@/notifications";
|
||||
import { useJsonRpc } from "../../hooks/useJsonRpc";
|
||||
|
@ -95,7 +95,7 @@ export function ATXPowerControl() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
<SettingsPageHeader
|
||||
title="ATX Power Control"
|
||||
description="Control your ATX power settings"
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@components/Button";
|
||||
import { LuPower } from "react-icons/lu";
|
||||
import Card from "@components/Card";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import FieldLabel from "../FieldLabel";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
@ -52,7 +52,7 @@ export function DCPowerControl() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
<SettingsPageHeader
|
||||
title="DC Power Control"
|
||||
description="Control your DC power settings"
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@components/Button";
|
||||
import { LuTerminal } from "react-icons/lu";
|
||||
import Card from "@components/Card";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SelectMenuBasic } from "../SelectMenuBasic";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useEffect, useState } from "react";
|
||||
|
@ -52,7 +52,7 @@ export function SerialConsole() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
<SettingsPageHeader
|
||||
title="Serial Console"
|
||||
description="Configure your serial console settings"
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { Button } from "../Button";
|
||||
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
|
||||
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
|
||||
|
@ -106,7 +106,7 @@ export default function ExtensionPopover() {
|
|||
) : (
|
||||
// Extensions List View
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
<SettingsPageHeader
|
||||
title="Extensions"
|
||||
description="Load and manage your extensions"
|
||||
/>
|
||||
|
|
|
@ -5,7 +5,7 @@ import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
|||
import { useMemo, forwardRef, useEffect, useCallback } from "react";
|
||||
import { formatters } from "@/utils";
|
||||
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import {
|
||||
LuArrowUpFromLine,
|
||||
LuCheckCheck,
|
||||
|
@ -15,19 +15,15 @@ import {
|
|||
} from "react-icons/lu";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import notifications from "../../notifications";
|
||||
import MountMediaModal from "../MountMediaDialog";
|
||||
import { useClose } from "@headlessui/react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
|
||||
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
||||
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
|
||||
const [send] = useJsonRpc();
|
||||
const {
|
||||
remoteVirtualMediaState,
|
||||
isMountMediaDialogOpen,
|
||||
setModalView,
|
||||
setIsMountMediaDialogOpen,
|
||||
setRemoteVirtualMediaState,
|
||||
} = useMountMediaStore();
|
||||
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
|
||||
useMountMediaStore();
|
||||
|
||||
const bytesSentPerSecond = useMemo(() => {
|
||||
if (diskDataChannelStats.size < 2) return null;
|
||||
|
@ -78,7 +74,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
|
||||
<PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-700 dark:text-white" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -103,20 +99,25 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<LuCheckCheck className="h-5 text-green-500" />
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3>
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">
|
||||
Streaming from Browser
|
||||
</h3>
|
||||
</div>
|
||||
<Card className="w-auto px-2 py-1">
|
||||
<div className="w-full text-sm text-black truncate dark:text-white">
|
||||
<div className="w-full truncate text-sm text-black dark:text-white">
|
||||
{formatters.truncateMiddle(filename, 50)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex flex-col items-center my-2 gap-y-2">
|
||||
<div className="my-2 flex flex-col items-center gap-y-2">
|
||||
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{formatters.bytes(size ?? 0)}</span>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} />
|
||||
<LuArrowUpFromLine
|
||||
className="h-4 text-blue-700 dark:text-blue-500"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span>
|
||||
{bytesSentPerSecond !== null
|
||||
? `${formatters.bytes(bytesSentPerSecond)}/s`
|
||||
|
@ -131,33 +132,49 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
case "HTTP":
|
||||
return (
|
||||
<div className="">
|
||||
<div className="inline-block mb-0">
|
||||
<div className="mb-0 inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
|
||||
<LuLink className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-500" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3>
|
||||
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">
|
||||
Streaming from URL
|
||||
</h3>
|
||||
<p className="truncate text-sm text-slate-900 dark:text-slate-100">
|
||||
{formatters.truncateMiddle(url, 55)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">
|
||||
{formatters.truncateMiddle(filename, 30)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">
|
||||
{formatters.bytes(size ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "Storage":
|
||||
return (
|
||||
<div className="">
|
||||
<div className="inline-block mb-0">
|
||||
<div className="mb-0 inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
|
||||
<LuRadioReceiver className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-500" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">
|
||||
Mounted from JetKVM Storage
|
||||
</h3>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">
|
||||
{formatters.truncateMiddle(path, 50)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">
|
||||
{formatters.truncateMiddle(filename, 30)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-900 dark:text-slate-100">
|
||||
{formatters.bytes(size ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
|
@ -165,18 +182,21 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
}
|
||||
};
|
||||
const close = useClose();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
syncRemoteVirtualMediaState();
|
||||
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]);
|
||||
}, [syncRemoteVirtualMediaState, location.pathname]);
|
||||
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="p-4 py-3 space-y-4">
|
||||
<div className="space-y-4 p-4 py-3">
|
||||
<div ref={ref} className="grid h-full grid-rows-headerBody">
|
||||
<div className="h-full space-y-4 ">
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
<SettingsPageHeader
|
||||
title="Virtual Media"
|
||||
description="Mount an image to boot from or install an operating system."
|
||||
/>
|
||||
|
@ -185,7 +205,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
<Card>
|
||||
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
|
||||
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
|
||||
<div className="flex items-center w-full text-black">
|
||||
<div className="flex w-full items-center text-black">
|
||||
<div>Closing this tab will unmount the image</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -193,7 +213,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
) : null}
|
||||
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -203,7 +223,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
<div className="group">
|
||||
<Card>
|
||||
<div className="w-full px-4 py-8">
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
{renderGridCardContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -211,8 +231,8 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
</div>
|
||||
</div>
|
||||
{remoteVirtualMediaState ? (
|
||||
<div className="flex items-center justify-between text-xs select-none">
|
||||
<div className="text-white select-none dark:text-slate-300">
|
||||
<div className="flex select-none items-center justify-between text-xs">
|
||||
<div className="select-none text-white dark:text-slate-300">
|
||||
<span>Mounted as</span>{" "}
|
||||
<span className="font-semibold">
|
||||
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
|
||||
|
@ -244,7 +264,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" />
|
||||
<path
|
||||
d="M10 7.49976H0V9.22453H10V7.49976Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3137_1186">
|
||||
|
@ -261,16 +284,11 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MountMediaModal
|
||||
open={isMountMediaDialogOpen}
|
||||
setOpen={setIsMountMediaDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!remoteVirtualMediaState && (
|
||||
<div
|
||||
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
|
||||
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
@ -290,7 +308,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
|
|||
text="Add New Media"
|
||||
onClick={() => {
|
||||
setModalView("mode");
|
||||
setIsMountMediaDialogOpen(true);
|
||||
navigateTo("/mount");
|
||||
}}
|
||||
LeadingIcon={LuPlus}
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@components/Button";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
|
||||
import notifications from "../../notifications";
|
||||
|
@ -75,7 +75,7 @@ export default function PasteModal() {
|
|||
<div className="grid h-full grid-rows-headerBody">
|
||||
<div className="h-full space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
<SettingsPageHeader
|
||||
title="Paste text"
|
||||
description="Paste text from your client to the remote host"
|
||||
/>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { GridCard } from "@components/Card";
|
||||
import { SectionHeader } from "@components/SectionHeader";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
|
@ -102,7 +102,7 @@ export default function WakeOnLanModal() {
|
|||
<div className="p-4 py-3 space-y-4">
|
||||
<div className="grid h-full grid-rows-headerBody">
|
||||
<div className="space-y-4">
|
||||
<SectionHeader
|
||||
<SettingsPageHeader
|
||||
title="Wake On LAN"
|
||||
description="Send a Magic Packet to wake up a remote device."
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import SidebarHeader from "@components/SidebarHeader";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { useEffect } from "react";
|
||||
import { useRTCStore, useUiStore } from "@/hooks/stores";
|
||||
import StatChart from "@components/StatChart";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
|
@ -36,19 +35,7 @@ function createChartArray<T, K extends keyof T>(
|
|||
});
|
||||
}
|
||||
|
||||
export default function ConnectionStatsSidebar () {
|
||||
const setModalView = useUiStore(state => state.setModalView);
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setModalView(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [setModalView]);
|
||||
|
||||
export default function ConnectionStatsSidebar() {
|
||||
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
|
||||
|
||||
const candidatePairStats = useRTCStore(state => state.candidatePairStats);
|
||||
|
@ -111,9 +98,9 @@ export default function ConnectionStatsSidebar () {
|
|||
}, 500);
|
||||
|
||||
return (
|
||||
<div className="grid h-full shadow-sm grid-rows-headerBody">
|
||||
<div className="grid h-full grid-rows-headerBody shadow-sm">
|
||||
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
|
||||
<div className="h-full px-4 py-2 pb-8 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900">
|
||||
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
|
||||
<div className="space-y-4">
|
||||
{/*
|
||||
The entire sidebar component is always rendered, with a display none when not visible
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -20,8 +20,7 @@ const appendStatToMap = <T extends { timestamp: number }>(
|
|||
};
|
||||
|
||||
// Constants and types
|
||||
export type AvailableSidebarViews = "system" | "connection-stats";
|
||||
export type AvailableModalViews = "connection-stats" | "settings";
|
||||
export type AvailableSidebarViews = "connection-stats";
|
||||
export type AvailableTerminalTypes = "kvm" | "serial" | "none";
|
||||
|
||||
export interface User {
|
||||
|
@ -47,9 +46,6 @@ interface UIState {
|
|||
|
||||
toggleSidebarView: (view: AvailableSidebarViews) => void;
|
||||
|
||||
modalView: AvailableModalViews | null;
|
||||
setModalView: (view: AvailableModalViews | null) => void;
|
||||
|
||||
isAttachedVirtualKeyboardVisible: boolean;
|
||||
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
|
||||
|
||||
|
@ -79,9 +75,6 @@ export const useUiStore = create<UIState>(set => ({
|
|||
}
|
||||
}),
|
||||
|
||||
modalView: null,
|
||||
setModalView: view => set({ modalView: view }),
|
||||
|
||||
isAttachedVirtualKeyboardVisible: true,
|
||||
setAttachedVirtualKeyboardVisibility: enabled =>
|
||||
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
||||
|
@ -303,7 +296,8 @@ export const useSettingsStore = create(
|
|||
dim_after: 10000,
|
||||
off_after: 50000,
|
||||
},
|
||||
setBacklightSettings: (settings: BacklightSettings) => set({ backlightSettings: settings }),
|
||||
setBacklightSettings: (settings: BacklightSettings) =>
|
||||
set({ backlightSettings: settings }),
|
||||
}),
|
||||
{
|
||||
name: "settings",
|
||||
|
@ -484,8 +478,6 @@ export interface UpdateState {
|
|||
| "updateCompleted"
|
||||
| "error";
|
||||
setModalView: (view: UpdateState["modalView"]) => void;
|
||||
isUpdateDialogOpen: boolean;
|
||||
setIsUpdateDialogOpen: (isOpen: boolean) => void;
|
||||
setUpdateErrorMessage: (errorMessage: string) => void;
|
||||
updateErrorMessage: string | null;
|
||||
}
|
||||
|
@ -520,16 +512,12 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
|||
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
|
||||
modalView: "loading",
|
||||
setModalView: view => set({ modalView: view }),
|
||||
isUpdateDialogOpen: false,
|
||||
setIsUpdateDialogOpen: isOpen => set({ isUpdateDialogOpen: isOpen }),
|
||||
updateErrorMessage: null,
|
||||
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
|
||||
}));
|
||||
|
||||
interface UsbConfigModalState {
|
||||
modalView:
|
||||
| "updateUsbConfig"
|
||||
| "updateUsbConfigSuccess";
|
||||
modalView: "updateUsbConfig" | "updateUsbConfigSuccess";
|
||||
errorMessage: string | null;
|
||||
setModalView: (view: UsbConfigModalState["modalView"]) => void;
|
||||
setErrorMessage: (message: string | null) => void;
|
||||
|
@ -558,14 +546,10 @@ interface LocalAuthModalState {
|
|||
| "creationSuccess"
|
||||
| "deleteSuccess"
|
||||
| "updateSuccess";
|
||||
errorMessage: string | null;
|
||||
setModalView: (view: LocalAuthModalState["modalView"]) => void;
|
||||
setErrorMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
|
||||
modalView: "createPassword",
|
||||
errorMessage: null,
|
||||
setModalView: view => set({ modalView: view }),
|
||||
setErrorMessage: message => set({ errorMessage: message }),
|
||||
}));
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
|
||||
import { isOnDevice } from "../main";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Hook that provides context-aware navigation and path generation
|
||||
* that works in both cloud and device modes.
|
||||
*
|
||||
* In cloud mode, paths are prefixed with /devices/:id
|
||||
* In device mode, paths start from the root
|
||||
* Relative paths (starting with . or ..) are preserved in both modes
|
||||
* Supports all React Router navigation options
|
||||
*/
|
||||
export function useDeviceUiNavigation() {
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
|
||||
// Get the device ID from params
|
||||
const deviceId = useMemo(() => params.id, [params.id]);
|
||||
|
||||
// Function to generate the correct path
|
||||
const getPath = useCallback(
|
||||
(path: string): string => {
|
||||
// Check if it's a relative path (starts with . or ..)
|
||||
const isRelativePath = path.startsWith(".") || path === "";
|
||||
|
||||
// If it's a relative path, don't modify it
|
||||
if (isRelativePath) return path;
|
||||
|
||||
// Ensure absolute path starts with a slash
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
|
||||
if (isOnDevice) {
|
||||
return normalizedPath;
|
||||
} else {
|
||||
if (!deviceId) {
|
||||
console.error("No device ID found in params when generating path");
|
||||
throw new Error("No device ID found in params when generating path");
|
||||
}
|
||||
return `/devices/${deviceId}${normalizedPath}`;
|
||||
}
|
||||
},
|
||||
[deviceId],
|
||||
);
|
||||
|
||||
// Function to navigate to the correct path with all options
|
||||
const navigateTo = useCallback(
|
||||
(path: string, options?: NavigateOptions) => {
|
||||
navigate(getPath(path), options);
|
||||
},
|
||||
[getPath, navigate],
|
||||
);
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
getPath,
|
||||
};
|
||||
}
|
|
@ -8,17 +8,25 @@ export interface JsonRpcRequest {
|
|||
id: number | string;
|
||||
}
|
||||
|
||||
type JsonRpcResponse =
|
||||
| {
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
data?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface JsonRpcSuccessResponse {
|
||||
jsonrpc: string;
|
||||
result: boolean | number | object | string | [];
|
||||
id: string | number;
|
||||
}
|
||||
| {
|
||||
}
|
||||
|
||||
export interface JsonRpcErrorResponse {
|
||||
jsonrpc: string;
|
||||
error: { code: number; data?: string; message: string };
|
||||
error: JsonRpcError;
|
||||
id: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
|
||||
|
||||
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
|
||||
let requestCounter = 0;
|
||||
|
|
|
@ -105,7 +105,7 @@ video::-webkit-media-controls {
|
|||
}
|
||||
|
||||
.controlArrows {
|
||||
@apply flex items-center justify-between w-full md:w-1/5;
|
||||
@apply flex w-full items-center justify-between md:w-1/5;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
|
@ -191,3 +191,13 @@ video::-webkit-media-controls {
|
|||
scrollbar-color: theme("colors.gray.900") #002b36;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
|
187
ui/src/main.tsx
187
ui/src/main.tsx
|
@ -1,4 +1,3 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import Root from "./root";
|
||||
import "./index.css";
|
||||
|
@ -9,7 +8,7 @@ import {
|
|||
RouterProvider,
|
||||
useRouteError,
|
||||
} from "react-router-dom";
|
||||
import DeviceRoute from "@routes/devices.$id";
|
||||
import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
|
||||
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
|
||||
import SetupRoute from "@routes/devices.$id.setup";
|
||||
import LoginRoute from "@routes/login";
|
||||
|
@ -25,14 +24,28 @@ import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
|
|||
import Notifications from "./notifications";
|
||||
import LoginLocalRoute from "./routes/login-local";
|
||||
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
|
||||
import WelcomeRoute from "./routes/welcome-local";
|
||||
import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local";
|
||||
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
|
||||
import { CLOUD_API } from "./ui.config";
|
||||
import { CLOUD_API, DEVICE_API } from "./ui.config";
|
||||
import OtherSessionRoute from "./routes/devices.$id.other-session";
|
||||
import MountRoute from "./routes/devices.$id.mount";
|
||||
import * as SettingsRoute from "./routes/devices.$id.settings";
|
||||
import SettingsKeyboardMouseRoute from "./routes/devices.$id.settings.mouse";
|
||||
import api from "./api";
|
||||
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
|
||||
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
|
||||
import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index";
|
||||
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
|
||||
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
|
||||
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
|
||||
import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
|
||||
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
|
||||
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
|
||||
|
||||
export const isOnDevice = import.meta.env.MODE === "device";
|
||||
export const isInCloud = !isOnDevice;
|
||||
|
||||
export async function checkAuth() {
|
||||
export async function checkCloudAuth() {
|
||||
const res = await fetch(`${CLOUD_API}/me`, {
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
|
@ -46,6 +59,27 @@ export async function checkAuth() {
|
|||
return await res.json();
|
||||
}
|
||||
|
||||
export async function checkDeviceAuth() {
|
||||
const res = await api
|
||||
.GET(`${DEVICE_API}/device/status`)
|
||||
.then(res => res.json() as Promise<DeviceStatus>);
|
||||
|
||||
if (!res.isSetup) return redirect("/welcome");
|
||||
|
||||
const deviceRes = await api.GET(`${DEVICE_API}/device`);
|
||||
if (deviceRes.status === 401) return redirect("/login-local");
|
||||
if (deviceRes.ok) {
|
||||
const device = (await deviceRes.json()) as LocalDevice;
|
||||
return { authMode: device.authMode };
|
||||
}
|
||||
|
||||
throw new Error("Error fetching device");
|
||||
}
|
||||
|
||||
export async function checkAuth() {
|
||||
return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth();
|
||||
}
|
||||
|
||||
let router;
|
||||
if (isOnDevice) {
|
||||
router = createBrowserRouter([
|
||||
|
@ -75,7 +109,75 @@ if (isOnDevice) {
|
|||
errorElement: <ErrorBoundary />,
|
||||
element: <DeviceRoute />,
|
||||
loader: DeviceRoute.loader,
|
||||
children: [
|
||||
{
|
||||
path: "other-session",
|
||||
element: <OtherSessionRoute />,
|
||||
},
|
||||
{
|
||||
path: "mount",
|
||||
element: <MountRoute />,
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsRoute.default />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: SettingsIndexRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "general",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsGeneralIndexRoute.default />,
|
||||
},
|
||||
{
|
||||
path: "update",
|
||||
element: <SettingsGeneralUpdateRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "mouse",
|
||||
element: <SettingsKeyboardMouseRoute />,
|
||||
},
|
||||
{
|
||||
path: "advanced",
|
||||
element: <SettingsAdvancedRoute />,
|
||||
},
|
||||
{
|
||||
path: "hardware",
|
||||
element: <SettingsHardwareRoute />,
|
||||
},
|
||||
{
|
||||
path: "access",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsAccessIndexRoute.default />,
|
||||
loader: SettingsAccessIndexRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "local-auth",
|
||||
element: <SecurityAccessLocalAuthRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "video",
|
||||
element: <SettingsVideoRoute />,
|
||||
},
|
||||
{
|
||||
path: "appearance",
|
||||
element: <SettingsAppearanceRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: "/adopt",
|
||||
element: <AdoptRoute />,
|
||||
|
@ -116,6 +218,73 @@ if (isOnDevice) {
|
|||
path: "devices/:id",
|
||||
element: <DeviceRoute />,
|
||||
loader: DeviceRoute.loader,
|
||||
children: [
|
||||
{
|
||||
path: "other-session",
|
||||
element: <OtherSessionRoute />,
|
||||
},
|
||||
{
|
||||
path: "mount",
|
||||
element: <MountRoute />,
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsRoute.default />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: SettingsIndexRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "general",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsGeneralIndexRoute.default />,
|
||||
},
|
||||
{
|
||||
path: "update",
|
||||
element: <SettingsGeneralUpdateRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "mouse",
|
||||
element: <SettingsKeyboardMouseRoute />,
|
||||
},
|
||||
{
|
||||
path: "advanced",
|
||||
element: <SettingsAdvancedRoute />,
|
||||
},
|
||||
{
|
||||
path: "hardware",
|
||||
element: <SettingsHardwareRoute />,
|
||||
},
|
||||
{
|
||||
path: "access",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <SettingsAccessIndexRoute.default />,
|
||||
loader: SettingsAccessIndexRoute.loader,
|
||||
},
|
||||
{
|
||||
path: "local-auth",
|
||||
element: <SecurityAccessLocalAuthRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "video",
|
||||
element: <SettingsVideoRoute />,
|
||||
},
|
||||
{
|
||||
path: "appearance",
|
||||
element: <SettingsAppearanceRoute />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "devices/:id/deregister",
|
||||
|
@ -139,7 +308,7 @@ if (isOnDevice) {
|
|||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<Notifications
|
||||
toastOptions={{
|
||||
|
@ -148,7 +317,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
}}
|
||||
max={2}
|
||||
/>
|
||||
</React.StrictMode>,
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -164,8 +333,8 @@ function ErrorBoundary() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="h-full w-full">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="w-full max-w-2xl">
|
||||
<EmptyCard
|
||||
IconElm={ExclamationTriangleIcon}
|
||||
|
|
|
@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import {
|
||||
MountMediaState,
|
||||
RemoteVirtualMediaState,
|
||||
|
@ -21,8 +20,8 @@ import {
|
|||
} from "react-icons/lu";
|
||||
import { formatters } from "@/utils";
|
||||
import { PlusCircleIcon } from "@heroicons/react/20/solid";
|
||||
import AutoHeight from "./AutoHeight";
|
||||
import { InputFieldWithLabel } from "./InputField";
|
||||
import AutoHeight from "@components/AutoHeight";
|
||||
import { InputFieldWithLabel } from "@/components/InputField";
|
||||
import DebianIcon from "@/assets/debian-icon.png";
|
||||
import UbuntuIcon from "@/assets/ubuntu-icon.png";
|
||||
import FedoraIcon from "@/assets/fedora-icon.png";
|
||||
|
@ -33,34 +32,29 @@ import { TrashIcon } from "@heroicons/react/16/solid";
|
|||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import notifications from "../notifications";
|
||||
import Fieldset from "./Fieldset";
|
||||
import Fieldset from "@/components/Fieldset";
|
||||
import { isOnDevice } from "../main";
|
||||
import { DEVICE_API } from "@/ui.config";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function MountMediaModal({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
export default function MountRoute() {
|
||||
const navigate = useNavigate();
|
||||
{
|
||||
/* 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({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
export function Dialog({ onClose }: { onClose: () => void }) {
|
||||
const {
|
||||
modalView,
|
||||
setModalView,
|
||||
setLocalFile,
|
||||
setIsMountMediaDialogOpen,
|
||||
setRemoteVirtualMediaState,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
} = useMountMediaStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null);
|
||||
const [mountInProgress, setMountInProgress] = useState(false);
|
||||
|
@ -99,9 +93,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
|
||||
clearMountMediaState();
|
||||
syncRemoteVirtualMediaState()
|
||||
.then(() => {
|
||||
setIsMountMediaDialogOpen(false);
|
||||
})
|
||||
.then(() => navigate(".."))
|
||||
.catch(err => {
|
||||
triggerError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
|
@ -109,7 +101,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
setMountInProgress(false);
|
||||
});
|
||||
|
||||
setIsMountMediaDialogOpen(false);
|
||||
navigate("..");
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -123,7 +115,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
clearMountMediaState();
|
||||
syncRemoteVirtualMediaState()
|
||||
.then(() => {
|
||||
setIsMountMediaDialogOpen(false);
|
||||
false;
|
||||
})
|
||||
.catch(err => {
|
||||
triggerError(err instanceof Error ? err.message : String(err));
|
||||
|
@ -156,7 +148,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
// We need to keep the local file in the store so that the browser can
|
||||
// continue to stream the file to the device
|
||||
setLocalFile(file);
|
||||
setIsMountMediaDialogOpen(false);
|
||||
navigate("..");
|
||||
})
|
||||
.catch(err => {
|
||||
triggerError(err instanceof Error ? err.message : String(err));
|
||||
|
@ -188,16 +180,16 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
<img
|
||||
src={LogoBlueIcon}
|
||||
alt="JetKVM Logo"
|
||||
className="h-[24px] dark:hidden block"
|
||||
className="block h-[24px] dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src={LogoWhiteIcon}
|
||||
alt="JetKVM Logo"
|
||||
className="h-[24px] dark:block hidden dark:!mt-0"
|
||||
className="hidden h-[24px] dark:!mt-0 dark:block"
|
||||
/>
|
||||
{modalView === "mode" && (
|
||||
<ModeSelectionView
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={() => onClose()}
|
||||
selectedMode={selectedMode}
|
||||
setSelectedMode={setSelectedMode}
|
||||
/>
|
||||
|
@ -261,7 +253,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
<ErrorView
|
||||
errorMessage={errorMessage}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
onClose();
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
onRetry={() => {
|
||||
|
@ -291,7 +283,7 @@ function ModeSelectionView({
|
|||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="space-y-0 asnimate-fadeIn">
|
||||
<div className="asnimate-fadeIn space-y-0">
|
||||
<h2 className="text-lg font-bold leading-tight dark:text-white">
|
||||
Virtual Media Source
|
||||
</h2>
|
||||
|
@ -345,7 +337,7 @@ function ModeSelectionView({
|
|||
)}
|
||||
>
|
||||
<div
|
||||
className="relative z-50 flex flex-col items-start p-4 select-none"
|
||||
className="relative z-50 flex select-none flex-col items-start p-4"
|
||||
onClick={() =>
|
||||
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
|
||||
}
|
||||
|
@ -353,7 +345,7 @@ function ModeSelectionView({
|
|||
<div>
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<Icon className="w-4 h-4 text-blue-700 shrink-0 dark:text-blue-400" />
|
||||
<Icon className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -373,7 +365,7 @@ function ModeSelectionView({
|
|||
value={mode}
|
||||
disabled={disabled}
|
||||
checked={selectedMode === mode}
|
||||
className="absolute w-4 h-4 text-blue-700 right-4 top-4"
|
||||
className="absolute right-4 top-4 h-4 w-4 text-blue-700"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
@ -381,13 +373,13 @@ function ModeSelectionView({
|
|||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex justify-end opacity-0 animate-fadeIn"
|
||||
className="flex animate-fadeIn justify-end opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
}}
|
||||
>
|
||||
<div className="flex pt-2 gap-x-2">
|
||||
<div className="flex gap-x-2 pt-2">
|
||||
<Button size="MD" theme="blank" onClick={onClose} text="Cancel" />
|
||||
<Button
|
||||
size="MD"
|
||||
|
@ -445,18 +437,18 @@ function BrowserFileView({
|
|||
className="block cursor-pointer select-none"
|
||||
>
|
||||
<div
|
||||
className="opacity-0 group animate-fadeIn"
|
||||
className="group animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
>
|
||||
<Card className="transition-all duration-300 outline-dashed hover:bg-blue-50/50">
|
||||
<Card className="outline-dashed transition-all duration-300 hover:bg-blue-50/50">
|
||||
<div className="w-full px-4 py-12">
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<LuHardDrive className="w-6 h-6 mx-auto text-blue-700" />
|
||||
<LuHardDrive className="mx-auto h-6 w-6 text-blue-700" />
|
||||
<h3 className="text-sm font-semibold leading-none">
|
||||
{formatters.truncateMiddle(selectedFile.name, 40)}
|
||||
</h3>
|
||||
|
@ -467,7 +459,7 @@ function BrowserFileView({
|
|||
</>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<PlusCircleIcon className="w-6 h-6 mx-auto text-blue-700" />
|
||||
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700" />
|
||||
<h3 className="text-sm font-semibold leading-none">
|
||||
Click to select a file
|
||||
</h3>
|
||||
|
@ -491,7 +483,7 @@ function BrowserFileView({
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-end justify-between w-full opacity-0 animate-fadeIn"
|
||||
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -586,7 +578,7 @@ function UrlView({
|
|||
/>
|
||||
|
||||
<div
|
||||
className="opacity-0 animate-fadeIn"
|
||||
className="animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
|
@ -601,7 +593,7 @@ function UrlView({
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-end justify-between w-full opacity-0 animate-fadeIn"
|
||||
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -627,7 +619,7 @@ function UrlView({
|
|||
|
||||
<hr className="border-slate-800/30 dark:border-slate-300/20" />
|
||||
<div
|
||||
className="opacity-0 animate-fadeIn"
|
||||
className="animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.2s",
|
||||
|
@ -636,7 +628,7 @@ function UrlView({
|
|||
<h2 className="mb-2 text-sm font-semibold text-black dark:text-white">
|
||||
Popular images
|
||||
</h2>
|
||||
<Card className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20">
|
||||
<Card className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
|
||||
{popularImages.map((image, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-x-4 p-3.5">
|
||||
<div className="flex items-center gap-x-4">
|
||||
|
@ -805,7 +797,7 @@ function DeviceFileView({
|
|||
description="Select an image to mount from the JetKVM storage"
|
||||
/>
|
||||
<div
|
||||
className="w-full opacity-0 animate-fadeIn"
|
||||
className="w-full animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
|
@ -816,7 +808,7 @@ function DeviceFileView({
|
|||
<div className="flex items-center justify-center py-8 text-center">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<PlusCircleIcon className="w-6 h-6 mx-auto text-blue-700 dark:text-blue-500" />
|
||||
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
|
||||
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
|
||||
No images available
|
||||
</h3>
|
||||
|
@ -835,7 +827,7 @@ function DeviceFileView({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20">
|
||||
<div className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
|
||||
{currentFiles.map((file, index) => (
|
||||
<PreUploadedImageItem
|
||||
key={index}
|
||||
|
@ -888,7 +880,7 @@ function DeviceFileView({
|
|||
|
||||
{onStorageFiles.length > 0 ? (
|
||||
<div
|
||||
className="flex items-end justify-between opacity-0 animate-fadeIn"
|
||||
className="flex animate-fadeIn items-end justify-between opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.15s",
|
||||
|
@ -916,7 +908,7 @@ function DeviceFileView({
|
|||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-end justify-end opacity-0 animate-fadeIn"
|
||||
className="flex animate-fadeIn items-end justify-end opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.15s",
|
||||
|
@ -929,31 +921,39 @@ function DeviceFileView({
|
|||
)}
|
||||
<hr className="border-slate-800/20 dark:border-slate-300/20" />
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.20s",
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-black dark:text-white">Available Storage</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">{percentageUsed}% used</span>
|
||||
<span className="font-medium text-black dark:text-white">
|
||||
Available Storage
|
||||
</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{percentageUsed}% used
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-full transition-all duration-300 ease-in-out bg-blue-700 rounded-sm dark:bg-blue-500"
|
||||
className="h-full rounded-sm bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500"
|
||||
style={{ width: `${percentageUsed}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-600">
|
||||
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesUsed)} used</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesFree)} free</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{formatters.bytes(bytesUsed)} used
|
||||
</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{formatters.bytes(bytesFree)} free
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onStorageFiles.length > 0 && (
|
||||
<div
|
||||
className="w-full opacity-0 animate-fadeIn"
|
||||
className="w-full animate-fadeIn opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.25s",
|
||||
|
@ -1245,7 +1245,7 @@ function UploadFileView({
|
|||
}
|
||||
/>
|
||||
<div
|
||||
className="space-y-2 opacity-0 animate-fadeIn"
|
||||
className="animate-fadeIn space-y-2 opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
}}
|
||||
|
@ -1261,17 +1261,18 @@ function UploadFileView({
|
|||
<div className="group">
|
||||
<Card
|
||||
className={cx("transition-all duration-300", {
|
||||
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50": uploadState === "idle",
|
||||
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50":
|
||||
uploadState === "idle",
|
||||
})}
|
||||
>
|
||||
<div className="h-[186px] w-full px-4">
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
{uploadState === "idle" && (
|
||||
<div className="space-y-1">
|
||||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<PlusCircleIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
<PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -1291,11 +1292,11 @@ function UploadFileView({
|
|||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<LuUpload className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
<LuUpload className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-black leading-non dark:text-white">
|
||||
<h3 className="leading-non text-lg font-semibold text-black dark:text-white">
|
||||
Uploading {formatters.truncateMiddle(uploadedFileName, 30)}
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
|
||||
|
@ -1304,7 +1305,7 @@ function UploadFileView({
|
|||
<div className="w-full space-y-2">
|
||||
<div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700">
|
||||
<div
|
||||
className="h-3.5 rounded-full bg-blue-700 dark:bg-blue-500 transition-all duration-500 ease-linear"
|
||||
className="h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
@ -1325,7 +1326,7 @@ function UploadFileView({
|
|||
<div className="inline-block">
|
||||
<Card>
|
||||
<div className="p-1">
|
||||
<LuCheck className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
|
||||
<LuCheck className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
@ -1350,13 +1351,15 @@ function UploadFileView({
|
|||
className="hidden"
|
||||
accept=".iso, .img"
|
||||
/>
|
||||
{fileError && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>}
|
||||
{fileError && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display upload error if present */}
|
||||
{uploadError && (
|
||||
<div
|
||||
className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn"
|
||||
className="mt-2 animate-fadeIn truncate text-sm text-red-600 opacity-0 dark:text-red-400"
|
||||
style={{ animationDuration: "0.7s" }}
|
||||
>
|
||||
Error: {uploadError}
|
||||
|
@ -1364,13 +1367,13 @@ function UploadFileView({
|
|||
)}
|
||||
|
||||
<div
|
||||
className="flex items-end w-full opacity-0 animate-fadeIn"
|
||||
className="flex w-full animate-fadeIn items-end opacity-0"
|
||||
style={{
|
||||
animationDuration: "0.7s",
|
||||
animationDelay: "0.1s",
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-end w-full space-x-2">
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
{uploadState === "uploading" ? (
|
||||
<Button
|
||||
size="MD"
|
||||
|
@ -1412,7 +1415,7 @@ function ErrorView({
|
|||
<div className="w-full space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-red-600">
|
||||
<ExclamationTriangleIcon className="w-6 h-6" />
|
||||
<ExclamationTriangleIcon className="h-6 w-6" />
|
||||
<h2 className="text-lg font-bold leading-tight">Mount Error</h2>
|
||||
</div>
|
||||
<p className="text-sm leading-snug text-slate-600">
|
||||
|
@ -1420,7 +1423,7 @@ function ErrorView({
|
|||
</p>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<Card className="p-4 border border-red-200 bg-red-50">
|
||||
<Card className="border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm font-medium text-red-800">{errorMessage}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
@ -1480,12 +1483,12 @@ function PreUploadedImageItem({
|
|||
<div className="flex items-center gap-x-1 text-slate-600 dark:text-slate-400">
|
||||
{formatters.date(new Date(uploadedAt), { month: "short" })}
|
||||
</div>
|
||||
<div className="mx-1 h-[10px] w-[1px] bg-slate-300 dark:bg-slate-600 text-slate-300"></div>
|
||||
<div className="mx-1 h-[10px] w-[1px] bg-slate-300 text-slate-300 dark:bg-slate-600"></div>
|
||||
<div className="text-gray-600 dark:text-slate-400">{size}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex items-center select-none gap-x-3">
|
||||
<div className="relative flex select-none items-center gap-x-3">
|
||||
<div
|
||||
className={cx("opacity-0 transition-opacity duration-200", {
|
||||
"w-auto opacity-100": isHovering,
|
||||
|
@ -1509,7 +1512,7 @@ function PreUploadedImageItem({
|
|||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
name={name}
|
||||
className="w-3 h-3 text-blue-700 bg-white dark:bg-slate-800 border-slate-800/30 dark:border-slate-300/20 focus:ring-blue-500 disabled:opacity-30"
|
||||
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 focus:ring-blue-500 disabled:opacity-30 dark:border-slate-300/20 dark:bg-slate-800"
|
||||
onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect
|
||||
/>
|
||||
) : (
|
||||
|
@ -1549,7 +1552,7 @@ function UsbModeSelector({
|
|||
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start space-y-1 select-none">
|
||||
<div className="flex select-none flex-col items-start space-y-1">
|
||||
<label className="text-sm font-semibold text-black dark:text-white">Mount as</label>
|
||||
<div className="flex space-x-4">
|
||||
<label htmlFor="cdrom" className="flex items-center">
|
||||
|
@ -1559,7 +1562,7 @@ function UsbModeSelector({
|
|||
name="mountType"
|
||||
onChange={() => setUsbMode("CDROM")}
|
||||
checked={usbMode === "CDROM"}
|
||||
className="w-3 h-3 text-blue-700 transition-opacity bg-white border-slate-800/30 focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
|
||||
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
|
||||
/>
|
||||
<span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
|
||||
CD/DVD
|
||||
|
@ -1573,10 +1576,10 @@ function UsbModeSelector({
|
|||
disabled
|
||||
checked={usbMode === "Disk"}
|
||||
onChange={() => setUsbMode("Disk")}
|
||||
className="w-3 h-3 text-blue-700 transition-opacity bg-white border-slate-800/30 focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
|
||||
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
|
||||
/>
|
||||
<div className="flex flex-col ml-2 gap-y-0">
|
||||
<span className="text-sm font-medium leading-none opacity-50 text-slate-900 dark:text-white">
|
||||
<div className="ml-2 flex flex-col gap-y-0">
|
||||
<span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
|
||||
Disk
|
||||
</span>
|
||||
<div className="text-[10px] text-slate-500 dark:text-slate-400">
|
|
@ -1,24 +1,23 @@
|
|||
import { useNavigate, useOutletContext } from "react-router-dom";
|
||||
import { GridCard } from "@/components/Card";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlue from "@/assets/logo-blue.svg";
|
||||
import LogoWhite from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
|
||||
export default function OtherSessionConnectedModal({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
interface ContextType {
|
||||
connectWebRTC: () => Promise<void>;
|
||||
}
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
|
||||
export default function OtherSessionRoute() {
|
||||
const outletContext = useOutletContext<ContextType>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Function to handle closing the modal
|
||||
const handleClose = () => {
|
||||
outletContext?.connectWebRTC().then(() => navigate(".."));
|
||||
};
|
||||
|
||||
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
return (
|
||||
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
|
||||
<div className="p-10">
|
||||
|
@ -37,12 +36,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
this session?
|
||||
</p>
|
||||
<div className="flex items-center justify-start space-x-4">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Use Here"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<Button size="SM" theme="primary" text="Use Here" onClick={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "react-router-dom";
|
||||
|
||||
export function loader() {
|
||||
return redirect("/settings/general");
|
||||
}
|
|
@ -0,0 +1,331 @@
|
|||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { useLoaderData } from "react-router-dom";
|
||||
import { Button, LinkButton } from "../components/Button";
|
||||
import { CLOUD_APP, DEVICE_API } from "../ui.config";
|
||||
import api from "../api";
|
||||
import { LocalDevice } from "./devices.$id";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
import { isOnDevice } from "../main";
|
||||
import { GridCard } from "../components/Card";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||
import notifications from "../notifications";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { InputFieldWithLabel } from "../components/InputField";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
|
||||
|
||||
export const loader = async () => {
|
||||
const status = await api
|
||||
.GET(`${DEVICE_API}/device`)
|
||||
.then(res => res.json() as Promise<LocalDevice>);
|
||||
return status;
|
||||
};
|
||||
|
||||
export default function SettingsAccessIndexRoute() {
|
||||
const { authMode } = useLoaderData() as LocalDevice;
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const [isAdopted, setAdopted] = useState(false);
|
||||
const [deviceId, setDeviceId] = useState<string | null>(null);
|
||||
const [cloudUrl, setCloudUrl] = useState("");
|
||||
const [cloudProviders, setCloudProviders] = useState<
|
||||
{ value: string; label: string }[] | null
|
||||
>([{ value: "https://api.jetkvm.com", label: "JetKVM Cloud" }]);
|
||||
|
||||
// The default value is just there so it doesn't flicker while we fetch the default Cloud URL and available providers
|
||||
const [selectedUrlOption, setSelectedUrlOption] = useState<string>(
|
||||
"https://api.jetkvm.com",
|
||||
);
|
||||
|
||||
const [defaultCloudUrl, setDefaultCloudUrl] = useState<string>("");
|
||||
|
||||
const syncCloudUrl = useCallback(() => {
|
||||
send("getCloudUrl", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const url = resp.result as string;
|
||||
setCloudUrl(url);
|
||||
// Check if the URL matches any predefined option
|
||||
if (cloudProviders?.some(provider => provider.value === url)) {
|
||||
setSelectedUrlOption(url);
|
||||
} else {
|
||||
setSelectedUrlOption("custom");
|
||||
// setCustomCloudUrl(url);
|
||||
}
|
||||
});
|
||||
}, [cloudProviders, send]);
|
||||
|
||||
const getCloudState = useCallback(() => {
|
||||
send("getCloudState", {}, resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
const cloudState = resp.result as { connected: boolean };
|
||||
setAdopted(cloudState.connected);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const deregisterDevice = async () => {
|
||||
send("deregisterDevice", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
getCloudState();
|
||||
return;
|
||||
});
|
||||
};
|
||||
|
||||
const onCloudAdoptClick = useCallback(
|
||||
(url: string) => {
|
||||
if (!deviceId) {
|
||||
notifications.error("No device ID available");
|
||||
return;
|
||||
}
|
||||
|
||||
send("setCloudUrl", { url }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
syncCloudUrl();
|
||||
notifications.success("Cloud URL updated successfully");
|
||||
|
||||
const returnTo = new URL(window.location.href);
|
||||
returnTo.pathname = "/adopt";
|
||||
returnTo.search = "";
|
||||
returnTo.hash = "";
|
||||
window.location.href =
|
||||
CLOUD_APP + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`;
|
||||
});
|
||||
},
|
||||
[deviceId, syncCloudUrl, send],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultCloudUrl) return;
|
||||
setSelectedUrlOption(defaultCloudUrl);
|
||||
setCloudProviders([
|
||||
{ value: defaultCloudUrl, label: "JetKVM Cloud" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
]);
|
||||
}, [defaultCloudUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
getCloudState();
|
||||
|
||||
send("getDeviceID", {}, async resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
setDeviceId(resp.result as string);
|
||||
});
|
||||
}, [send, getCloudState]);
|
||||
|
||||
useEffect(() => {
|
||||
send("getDefaultCloudUrl", {}, resp => {
|
||||
if ("error" in resp) return console.error(resp.error);
|
||||
setDefaultCloudUrl(resp.result as string);
|
||||
});
|
||||
}, [cloudProviders, syncCloudUrl, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cloudProviders?.length) return;
|
||||
syncCloudUrl();
|
||||
}, [cloudProviders, syncCloudUrl]);
|
||||
|
||||
console.log("is adopted:", isAdopted);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Access"
|
||||
description="Manage the Access Control of the device"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsSectionHeader
|
||||
title="Local"
|
||||
description="Manage the mode of local access to the device"
|
||||
/>
|
||||
<SettingsItem
|
||||
title="Authentication Mode"
|
||||
description={`Current mode: ${authMode === "password" ? "Password protected" : "No password"}`}
|
||||
>
|
||||
{authMode === "password" ? (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Disable Protection"
|
||||
onClick={() => {
|
||||
navigateTo("./local-auth", { state: { init: "deletePassword" } });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Enable Password"
|
||||
onClick={() => {
|
||||
navigateTo("./local-auth", { state: { init: "createPassword" } });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingsItem>
|
||||
|
||||
{authMode === "password" && (
|
||||
<SettingsItem
|
||||
title="Change Password"
|
||||
description="Update your device access password"
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Change Password"
|
||||
onClick={() => {
|
||||
navigateTo("./local-auth", { state: { init: "updatePassword" } });
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
<div className="space-y-4">
|
||||
<SettingsSectionHeader
|
||||
title="Remote"
|
||||
description="Manage the mode of Remote access to the device"
|
||||
/>
|
||||
|
||||
{isOnDevice && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{!isAdopted && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Cloud Provider"
|
||||
description="Select the cloud provider for your device"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={selectedUrlOption}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setSelectedUrlOption(value);
|
||||
}}
|
||||
options={cloudProviders ?? []}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{selectedUrlOption === "custom" && (
|
||||
<div className="mt-4 flex items-end gap-x-2 space-y-4">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
label="Custom Cloud URL"
|
||||
value={cloudUrl}
|
||||
onChange={e => setCloudUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/*
|
||||
We do the harcoding here to avoid flickering when the default Cloud URL being fetched.
|
||||
I've tried to avoid harcoding api.jetkvm.com, but it's the only reasonable way I could think of to avoid flickering for now.
|
||||
*/}
|
||||
{selectedUrlOption === (defaultCloudUrl || "https://api.jetkvm.com") && (
|
||||
<GridCard>
|
||||
<div className="flex items-start gap-x-4 p-4">
|
||||
<ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
Cloud Security
|
||||
</h3>
|
||||
<div>
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
|
||||
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
|
||||
<li>Zero Trust security model</li>
|
||||
<li>OIDC (OpenID Connect) authentication</li>
|
||||
<li>All streams encrypted in transit</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300">
|
||||
All cloud components are open-source and available on{" "}
|
||||
<a
|
||||
href="https://github.com/jetkvm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<hr className="block w-full dark:border-slate-600" />
|
||||
|
||||
<div>
|
||||
<LinkButton
|
||||
to="https://jetkvm.com/docs/networking/remote-access"
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Learn about our cloud security"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
|
||||
{!isAdopted ? (
|
||||
<div className="flex items-end gap-x-2">
|
||||
<Button
|
||||
onClick={() => onCloudAdoptClick(cloudUrl)}
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Adopt KVM to Cloud"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Your device is adopted to JetKVM Cloud
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="De-register from Cloud"
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
if (deviceId) {
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to de-register this device?",
|
||||
)
|
||||
) {
|
||||
deregisterDevice();
|
||||
}
|
||||
} else {
|
||||
notifications.error("No device ID available");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,30 +1,35 @@
|
|||
import { GridCard } from "@/components/Card";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import { InputFieldWithLabel } from "./InputField";
|
||||
import { InputFieldWithLabel } from "@/components/InputField";
|
||||
import api from "@/api";
|
||||
import { useLocalAuthModalStore } from "@/hooks/stores";
|
||||
import { useLocation, useRevalidator } from "react-router-dom";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
|
||||
export default function LocalAuthPasswordDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
export default function SecurityAccessLocalAuthRoute() {
|
||||
const { setModalView } = useLocalAuthModalStore();
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const location = useLocation();
|
||||
const init = location.state?.init;
|
||||
|
||||
useEffect(() => {
|
||||
if (!init) {
|
||||
navigateTo("..");
|
||||
} else {
|
||||
setModalView(init);
|
||||
}
|
||||
}, [init, navigateTo, setModalView]);
|
||||
|
||||
{
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
}
|
||||
return <Dialog onClose={() => navigateTo("..")} />;
|
||||
}
|
||||
|
||||
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
export function Dialog({ onClose }: { onClose: () => void }) {
|
||||
const { modalView, setModalView } = useLocalAuthModalStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const handleCreatePassword = async (password: string, confirmPassword: string) => {
|
||||
if (password === "") {
|
||||
|
@ -41,6 +46,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
const res = await api.POST("/auth/password-local", { password });
|
||||
if (res.ok) {
|
||||
setModalView("creationSuccess");
|
||||
// The rest of the app needs to revalidate the device authMode
|
||||
revalidator.revalidate();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while setting the password");
|
||||
|
@ -78,6 +85,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
|
||||
if (res.ok) {
|
||||
setModalView("updateSuccess");
|
||||
// The rest of the app needs to revalidate the device authMode
|
||||
revalidator.revalidate();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while changing the password");
|
||||
|
@ -97,6 +106,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
const res = await api.DELETE("/auth/local-password", { password });
|
||||
if (res.ok) {
|
||||
setModalView("deleteSuccess");
|
||||
// The rest of the app needs to revalidate the device authMode
|
||||
revalidator.revalidate();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || "An error occurred while disabling the password");
|
||||
|
@ -107,12 +118,12 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
|
||||
<div className="p-10">
|
||||
<div>
|
||||
<div>
|
||||
{modalView === "createPassword" && (
|
||||
<CreatePasswordModal
|
||||
onSetPassword={handleCreatePassword}
|
||||
onCancel={() => setOpen(false)}
|
||||
onCancel={onClose}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
@ -120,7 +131,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
{modalView === "deletePassword" && (
|
||||
<DeletePasswordModal
|
||||
onDeletePassword={handleDeletePassword}
|
||||
onCancel={() => setOpen(false)}
|
||||
onCancel={onClose}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
@ -128,7 +139,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
{modalView === "updatePassword" && (
|
||||
<UpdatePasswordModal
|
||||
onUpdatePassword={handleUpdatePassword}
|
||||
onCancel={() => setOpen(false)}
|
||||
onCancel={onClose}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
@ -137,7 +148,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
<SuccessModal
|
||||
headline="Password Set Successfully"
|
||||
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -145,7 +156,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
<SuccessModal
|
||||
headline="Password Protection Disabled"
|
||||
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -153,11 +164,11 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
|||
<SuccessModal
|
||||
headline="Password Updated Successfully"
|
||||
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -175,13 +186,16 @@ function CreatePasswordModal({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
|
||||
<h2 className="text-lg font-semibold dark:text-white">
|
||||
Local Device Protection
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Create a password to protect your device from unauthorized local access.
|
||||
</p>
|
||||
|
@ -191,6 +205,7 @@ function CreatePasswordModal({
|
|||
type="password"
|
||||
placeholder="Enter a strong password"
|
||||
value={password}
|
||||
autoFocus
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
|
@ -211,7 +226,7 @@ function CreatePasswordModal({
|
|||
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -229,13 +244,11 @@ function DeletePasswordModal({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
|
||||
<h2 className="text-lg font-semibold dark:text-white">
|
||||
Disable Local Device Protection
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Enter your current password to disable local device protection.
|
||||
</p>
|
||||
|
@ -281,13 +294,16 @@ function UpdatePasswordModal({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
|
||||
<h2 className="text-lg font-semibold dark:text-white">
|
||||
Change Local Device Password
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Enter your current password and a new password to update your local device
|
||||
protection.
|
||||
|
@ -324,7 +340,7 @@ function UpdatePasswordModal({
|
|||
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -339,11 +355,7 @@ function SuccessModal({
|
|||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
|
|
@ -0,0 +1,263 @@
|
|||
import { SettingsItem } from "./devices.$id.settings";
|
||||
|
||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
import Checkbox from "../components/Checkbox";
|
||||
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import notifications from "../notifications";
|
||||
import { TextAreaWithLabel } from "../components/TextArea";
|
||||
import { isOnDevice } from "../main";
|
||||
import { Button } from "../components/Button";
|
||||
import { useSettingsStore } from "../hooks/stores";
|
||||
import { GridCard } from "@components/Card";
|
||||
|
||||
export default function SettingsAdvancedRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const [sshKey, setSSHKey] = useState<string>("");
|
||||
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
|
||||
const [devChannel, setDevChannel] = useState(false);
|
||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
send("getDevModeState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const result = resp.result as { enabled: boolean };
|
||||
setDeveloperMode(result.enabled);
|
||||
});
|
||||
|
||||
send("getSSHKeyState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setSSHKey(resp.result as string);
|
||||
});
|
||||
|
||||
send("getUsbEmulationState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setUsbEmulationEnabled(resp.result as boolean);
|
||||
});
|
||||
|
||||
send("getDevChannelState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setDevChannel(resp.result as boolean);
|
||||
});
|
||||
}, [send, setDeveloperMode]);
|
||||
|
||||
const getUsbEmulationState = useCallback(() => {
|
||||
send("getUsbEmulationState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setUsbEmulationEnabled(resp.result as boolean);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleUsbEmulationToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
send("setUsbEmulationState", { enabled: enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUsbEmulationEnabled(enabled);
|
||||
getUsbEmulationState();
|
||||
});
|
||||
},
|
||||
[getUsbEmulationState, send],
|
||||
);
|
||||
|
||||
const handleResetConfig = useCallback(() => {
|
||||
send("resetConfig", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("Configuration reset to default successfully");
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleUpdateSSHKey = useCallback(() => {
|
||||
send("setSSHKeyState", { sshKey }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("SSH key updated successfully");
|
||||
});
|
||||
}, [send, sshKey]);
|
||||
|
||||
const handleDevModeChange = useCallback(
|
||||
(developerMode: boolean) => {
|
||||
send("setDevModeState", { enabled: developerMode }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDeveloperMode(developerMode);
|
||||
});
|
||||
},
|
||||
[send, setDeveloperMode],
|
||||
);
|
||||
|
||||
const handleDevChannelChange = (enabled: boolean) => {
|
||||
send("setDevChannelState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDevChannel(enabled);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Advanced"
|
||||
description="Access additional settings for troubleshooting and customization"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Dev Channel Updates"
|
||||
description="Receive early updates from the development channel"
|
||||
>
|
||||
<Checkbox
|
||||
checked={devChannel}
|
||||
onChange={e => {
|
||||
handleDevChannelChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Developer Mode"
|
||||
description="Enable advanced features for developers"
|
||||
>
|
||||
<Checkbox
|
||||
checked={settings.developerMode}
|
||||
onChange={e => handleDevModeChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.developerMode && (
|
||||
<GridCard>
|
||||
<div className="flex select-none items-start gap-x-4 p-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
Developer Mode Enabled
|
||||
</h3>
|
||||
<div>
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
|
||||
<li>Security is weakened while active</li>
|
||||
<li>Only use if you understand the risks</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300">
|
||||
For advanced users only. Not for production use.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
|
||||
{isOnDevice && settings.developerMode && (
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="SSH Access"
|
||||
description="Add your SSH public key to enable secure remote access to the device"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label="SSH Public Key"
|
||||
value={sshKey || ""}
|
||||
rows={3}
|
||||
onChange={e => setSSHKey(e.target.value)}
|
||||
placeholder="Enter your SSH public key"
|
||||
/>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
The default SSH user is <strong>root</strong>.
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Update SSH Key"
|
||||
onClick={handleUpdateSSHKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsItem
|
||||
title="Troubleshooting Mode"
|
||||
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
|
||||
>
|
||||
<Checkbox
|
||||
defaultChecked={settings.debugMode}
|
||||
onChange={e => {
|
||||
settings.setDebugMode(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.debugMode && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="USB Emulation"
|
||||
description="Control the USB emulation state"
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={
|
||||
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
|
||||
}
|
||||
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title="Reset Configuration"
|
||||
description="Reset configuration to default. This will log you out."
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Reset Config"
|
||||
onClick={() => {
|
||||
handleResetConfig();
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
|
||||
export default function SettingsAppearanceRoute() {
|
||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||
return localStorage.theme || "system";
|
||||
});
|
||||
|
||||
const handleThemeChange = useCallback((value: string) => {
|
||||
const root = document.documentElement;
|
||||
|
||||
if (value === "system") {
|
||||
localStorage.removeItem("theme");
|
||||
// Check system preference
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(systemTheme);
|
||||
} else {
|
||||
localStorage.theme = value;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Appearance"
|
||||
description="Customize the look and feel of your JetKVM interface"
|
||||
/>
|
||||
<SettingsItem title="Theme" description="Choose your preferred color theme">
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={currentTheme}
|
||||
options={[
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
]}
|
||||
onChange={e => {
|
||||
setCurrentTheme(e.target.value);
|
||||
handleThemeChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { Button } from "../components/Button";
|
||||
import notifications from "../notifications";
|
||||
import Checkbox from "../components/Checkbox";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
|
||||
export default function SettingsGeneralRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
const [currentVersions, setCurrentVersions] = useState<{
|
||||
appVersion: string;
|
||||
systemVersion: string;
|
||||
} | null>(null);
|
||||
|
||||
const getCurrentVersions = useCallback(() => {
|
||||
send("getUpdateStatus", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
const result = resp.result as SystemVersionInfo;
|
||||
setCurrentVersions({
|
||||
appVersion: result.local.appVersion,
|
||||
systemVersion: result.local.systemVersion,
|
||||
});
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentVersions();
|
||||
send("getAutoUpdateState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setAutoUpdate(resp.result as boolean);
|
||||
});
|
||||
}, [getCurrentVersions, send]);
|
||||
|
||||
const handleAutoUpdateChange = (enabled: boolean) => {
|
||||
send("setAutoUpdateState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAutoUpdate(enabled);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="General"
|
||||
description="Configure device settings and update preferences"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="mt-2 flex items-center justify-between gap-x-2">
|
||||
<SettingsItem
|
||||
title="Check for Updates"
|
||||
description={
|
||||
currentVersions ? (
|
||||
<>
|
||||
App: {currentVersions.appVersion}
|
||||
<br />
|
||||
System: {currentVersions.systemVersion}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
App: Loading...
|
||||
<br />
|
||||
System: Loading...
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Check for Updates"
|
||||
onClick={() => navigateTo("./update")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Auto Update"
|
||||
description="Automatically update the device to the latest version"
|
||||
>
|
||||
<Checkbox
|
||||
checked={autoUpdate}
|
||||
onChange={e => {
|
||||
handleAutoUpdateChange(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,14 +1,44 @@
|
|||
import Card, { GridCard } from "@/components/Card";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import Card from "@/components/Card";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import { UpdateState, useUpdateStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
|
||||
export default function SettingsGeneralUpdateRoute() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { updateSuccess } = location.state || {};
|
||||
|
||||
const { setModalView, otaState } = useUpdateStore();
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const onConfirmUpdate = useCallback(() => {
|
||||
send("tryUpdate", {});
|
||||
setModalView("updating");
|
||||
}, [send, setModalView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (otaState.updating) {
|
||||
setModalView("updating");
|
||||
} else if (otaState.error) {
|
||||
setModalView("error");
|
||||
} else if (updateSuccess) {
|
||||
setModalView("updateCompleted");
|
||||
} else {
|
||||
setModalView("loading");
|
||||
}
|
||||
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
|
||||
|
||||
{
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
}
|
||||
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
local: { appVersion: string; systemVersion: string };
|
||||
|
@ -17,37 +47,15 @@ export interface SystemVersionInfo {
|
|||
appUpdateAvailable: boolean;
|
||||
}
|
||||
|
||||
export default function UpdateDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
// We need to keep track of the update state in the dialog even if the dialog is minimized
|
||||
const { setModalView } = useUpdateStore();
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const onConfirmUpdate = useCallback(() => {
|
||||
send("tryUpdate", {});
|
||||
setModalView("updating");
|
||||
}, [send, setModalView]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} onConfirmUpdate={onConfirmUpdate} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
setOpen,
|
||||
onClose,
|
||||
onConfirmUpdate,
|
||||
}: {
|
||||
setOpen: (open: boolean) => void;
|
||||
onClose: () => void;
|
||||
onConfirmUpdate: () => void;
|
||||
}) {
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
||||
const { modalView, setModalView, otaState } = useUpdateStore();
|
||||
|
||||
|
@ -73,27 +81,24 @@ export function Dialog({
|
|||
}, [setModalView]);
|
||||
|
||||
return (
|
||||
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto">
|
||||
<div className="p-10">
|
||||
<div className="pointer-events-auto relative mx-auto text-left">
|
||||
<div>
|
||||
{modalView === "error" && (
|
||||
<UpdateErrorState
|
||||
errorMessage={otaState.error}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={onClose}
|
||||
onRetryUpdate={() => setModalView("loading")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "loading" && (
|
||||
<LoadingState
|
||||
onFinished={onFinishedLoading}
|
||||
onCancelCheck={() => setOpen(false)}
|
||||
/>
|
||||
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
|
||||
)}
|
||||
|
||||
{modalView === "updateAvailable" && (
|
||||
<UpdateAvailableState
|
||||
onConfirmUpdate={onConfirmUpdate}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={onClose}
|
||||
versionInfo={versionInfo!}
|
||||
/>
|
||||
)}
|
||||
|
@ -101,24 +106,20 @@ export function Dialog({
|
|||
{modalView === "updating" && (
|
||||
<UpdatingDeviceState
|
||||
otaState={otaState}
|
||||
onMinimizeUpgradeDialog={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onMinimizeUpgradeDialog={() => navigateTo("/")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "upToDate" && (
|
||||
<SystemUpToDateState
|
||||
checkUpdate={() => setModalView("loading")}
|
||||
onClose={() => setOpen(false)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updateCompleted" && (
|
||||
<UpdateCompletedState onClose={() => setOpen(false)} />
|
||||
)}
|
||||
{modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />}
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -156,14 +157,12 @@ function LoadingState({
|
|||
|
||||
const animationTimer = setTimeout(() => {
|
||||
setProgressWidth("100%");
|
||||
}, 500);
|
||||
}, 0);
|
||||
|
||||
getVersionInfo()
|
||||
.then(versionInfo => {
|
||||
if (progressBarRef.current) {
|
||||
progressBarRef.current?.classList.add("!duration-1000");
|
||||
}
|
||||
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
|
||||
// Add a small delay to ensure it's not just flickering
|
||||
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
|
||||
})
|
||||
.then(versionInfo => {
|
||||
if (!signal.aborted) {
|
||||
|
@ -183,12 +182,8 @@ function LoadingState({
|
|||
}, [getVersionInfo, onFinished]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="max-w-sm space-y-4">
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-0">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
Checking for updates...
|
||||
|
@ -201,18 +196,11 @@ function LoadingState({
|
|||
<div
|
||||
ref={progressBarRef}
|
||||
style={{ width: progressWidth }}
|
||||
className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out"
|
||||
className="h-2.5 bg-blue-700 transition-all duration-1000 ease-in-out"
|
||||
></div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Cancel"
|
||||
onClick={() => {
|
||||
onCancelCheck();
|
||||
}}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -294,11 +282,7 @@ function UpdatingDeviceState({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="w-full max-w-sm space-y-4">
|
||||
<div className="space-y-0">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
|
@ -308,10 +292,10 @@ function UpdatingDeviceState({
|
|||
Please don{"'"}t turn off your device. This process may take a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
<Card className="p-4 space-y-4">
|
||||
<Card className="space-y-4 p-4">
|
||||
{areAllUpdatesComplete() ? (
|
||||
<div className="flex flex-col items-center my-2 space-y-2 text-center">
|
||||
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
|
||||
<div className="my-2 flex flex-col items-center space-y-2 text-center">
|
||||
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
|
||||
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
|
||||
<span className="font-medium text-black dark:text-white">
|
||||
Rebooting to complete the update...
|
||||
|
@ -321,8 +305,8 @@ function UpdatingDeviceState({
|
|||
) : (
|
||||
<>
|
||||
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
|
||||
<div className="flex flex-col items-center my-2 space-y-2 text-center">
|
||||
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
|
||||
<div className="my-2 flex flex-col items-center space-y-2 text-center">
|
||||
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -333,9 +317,9 @@ function UpdatingDeviceState({
|
|||
Linux System Update
|
||||
</p>
|
||||
{calculateOverallProgress("system") < 100 ? (
|
||||
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
) : (
|
||||
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
<CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
|
||||
|
@ -365,9 +349,9 @@ function UpdatingDeviceState({
|
|||
App Update
|
||||
</p>
|
||||
{calculateOverallProgress("app") < 100 ? (
|
||||
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
) : (
|
||||
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
|
||||
<CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
|
||||
|
@ -390,7 +374,7 @@ function UpdatingDeviceState({
|
|||
</>
|
||||
)}
|
||||
</Card>
|
||||
<div className="flex justify-start mt-4 text-white gap-x-2">
|
||||
<div className="mt-4 flex justify-start gap-x-2 text-white">
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
|
@ -411,11 +395,7 @@ function SystemUpToDateState({
|
|||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
System is up to date
|
||||
|
@ -424,23 +404,9 @@ function SystemUpToDateState({
|
|||
Your system is running the latest version. No updates are currently available.
|
||||
</p>
|
||||
|
||||
<div className="flex mt-4 gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Check Again"
|
||||
onClick={() => {
|
||||
checkUpdate();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Close"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 flex gap-x-2">
|
||||
<Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
|
||||
<Button size="SM" theme="blank" text="Back" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -457,11 +423,7 @@ function UpdateAvailableState({
|
|||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold text-black dark:text-white">
|
||||
Update available
|
||||
|
@ -495,11 +457,7 @@ function UpdateAvailableState({
|
|||
|
||||
function UpdateCompletedState({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold dark:text-white">
|
||||
Update Completed Successfully
|
||||
|
@ -509,7 +467,7 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
|
|||
features and improvements!
|
||||
</p>
|
||||
<div className="flex items-center justify-start">
|
||||
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
|
||||
<Button size="SM" theme="primary" text="Back" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -526,11 +484,7 @@ function UpdateErrorState({
|
|||
onRetryUpdate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div className="text-left">
|
||||
<p className="text-base font-semibold dark:text-white">Update Error</p>
|
||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
|
@ -542,8 +496,8 @@ function UpdateErrorState({
|
|||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
|
||||
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} />
|
||||
<Button size="SM" theme="light" text="Back" onClick={onClose} />
|
||||
<Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,288 @@
|
|||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "@routes/devices.$id.settings";
|
||||
import { BacklightSettings, UsbConfigState, useSettingsStore } from "@/hooks/stores";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
|
||||
import notifications from "../notifications";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import USBConfigDialog from "@components/USBConfigDialog";
|
||||
|
||||
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&");
|
||||
|
||||
function generateNumber(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function generateHex(min: number, max: number) {
|
||||
const len = generateNumber(min, max);
|
||||
const n = (Math.random() * 0xfffff * 1000000).toString(16);
|
||||
return n.slice(0, len);
|
||||
}
|
||||
|
||||
export interface USBConfig {
|
||||
vendor_id: string;
|
||||
product_id: string;
|
||||
serial_number: string;
|
||||
manufacturer: string;
|
||||
product: string;
|
||||
}
|
||||
|
||||
const usbConfigs = [
|
||||
{
|
||||
label: "JetKVM Default",
|
||||
value: "USB Emulation Device",
|
||||
},
|
||||
{
|
||||
label: "Logitech Universal Adapter",
|
||||
value: "Logitech USB Input Device",
|
||||
},
|
||||
{
|
||||
label: "Microsoft Wireless MultiMedia Keyboard",
|
||||
value: "Wireless MultiMedia Keyboard",
|
||||
},
|
||||
{
|
||||
label: "Dell Multimedia Pro Keyboard",
|
||||
value: "Multimedia Pro Keyboard",
|
||||
},
|
||||
];
|
||||
|
||||
type UsbConfigMap = Record<string, USBConfig>;
|
||||
|
||||
export default function SettingsHardwareRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const [usbConfigProduct, setUsbConfigProduct] = useState("");
|
||||
const [deviceId, setDeviceId] = useState("");
|
||||
|
||||
const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings);
|
||||
|
||||
const usbConfigData: UsbConfigMap = useMemo(
|
||||
() => ({
|
||||
"USB Emulation Device": {
|
||||
vendor_id: "0x1d6b",
|
||||
product_id: "0x0104",
|
||||
serial_number: deviceId,
|
||||
manufacturer: "JetKVM",
|
||||
product: "USB Emulation Device",
|
||||
},
|
||||
"Logitech USB Input Device": {
|
||||
vendor_id: "0x046d",
|
||||
product_id: "0xc52b",
|
||||
serial_number: generatedSerialNumber,
|
||||
manufacturer: "Logitech (x64)",
|
||||
product: "Logitech USB Input Device",
|
||||
},
|
||||
"Wireless MultiMedia Keyboard": {
|
||||
vendor_id: "0x045e",
|
||||
product_id: "0x005f",
|
||||
serial_number: generatedSerialNumber,
|
||||
manufacturer: "Microsoft",
|
||||
product: "Wireless MultiMedia Keyboard",
|
||||
},
|
||||
"Multimedia Pro Keyboard": {
|
||||
vendor_id: "0x413c",
|
||||
product_id: "0x2011",
|
||||
serial_number: generatedSerialNumber,
|
||||
manufacturer: "Dell Inc.",
|
||||
product: "Multimedia Pro Keyboard",
|
||||
},
|
||||
}),
|
||||
[deviceId],
|
||||
);
|
||||
|
||||
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
|
||||
// If the user has set the display to dim after it turns off, set the dim_after
|
||||
// value to never.
|
||||
if (settings.dim_after > settings.off_after && settings.off_after != 0) {
|
||||
settings.dim_after = 0;
|
||||
}
|
||||
|
||||
setBacklightSettings(settings);
|
||||
handleBacklightSettingsSave();
|
||||
};
|
||||
|
||||
const handleBacklightSettingsSave = () => {
|
||||
send("setBacklightSettings", { params: settings.backlightSettings }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("Backlight settings updated successfully");
|
||||
});
|
||||
};
|
||||
const syncUsbConfigProduct = useCallback(() => {
|
||||
send("getUsbConfig", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
console.error("Failed to load USB Config:", resp.error);
|
||||
notifications.error(
|
||||
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
} else {
|
||||
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
|
||||
const usbConfigState = resp.result as UsbConfigState;
|
||||
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
|
||||
? usbConfigState.product
|
||||
: "custom";
|
||||
setUsbConfigProduct(product);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleUsbConfigChange = useCallback(
|
||||
(usbConfig: USBConfig) => {
|
||||
send("setUsbConfig", { usbConfig }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// setUsbConfigProduct(usbConfig.product);
|
||||
notifications.success(
|
||||
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`,
|
||||
);
|
||||
syncUsbConfigProduct();
|
||||
});
|
||||
},
|
||||
[send, syncUsbConfigProduct],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
send("getBacklightSettings", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
return notifications.error(
|
||||
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
const result = resp.result as BacklightSettings;
|
||||
setBacklightSettings(result);
|
||||
});
|
||||
|
||||
send("getDeviceID", {}, async resp => {
|
||||
if ("error" in resp) {
|
||||
return notifications.error(
|
||||
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
setDeviceId(resp.result as string);
|
||||
});
|
||||
|
||||
syncUsbConfigProduct();
|
||||
}, [send, setBacklightSettings, syncUsbConfigProduct]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Hardware"
|
||||
description="Configure display settings and hardware options for your JetKVM device"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Display Brightness"
|
||||
description="Set the brightness of the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.backlightSettings.max_brightness.toString()}
|
||||
options={[
|
||||
{ value: "0", label: "Off" },
|
||||
{ value: "10", label: "Low" },
|
||||
{ value: "35", label: "Medium" },
|
||||
{ value: "64", label: "High" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.backlightSettings.max_brightness = parseInt(e.target.value);
|
||||
handleBacklightSettingsChange(settings.backlightSettings);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{settings.backlightSettings.max_brightness != 0 && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Dim Display After"
|
||||
description="Set how long to wait before dimming the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.backlightSettings.dim_after.toString()}
|
||||
options={[
|
||||
{ value: "0", label: "Never" },
|
||||
{ value: "60", label: "1 Minute" },
|
||||
{ value: "300", label: "5 Minutes" },
|
||||
{ value: "600", label: "10 Minutes" },
|
||||
{ value: "1800", label: "30 Minutes" },
|
||||
{ value: "3600", label: "1 Hour" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.backlightSettings.dim_after = parseInt(e.target.value);
|
||||
handleBacklightSettingsChange(settings.backlightSettings);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Turn off Display After"
|
||||
description="Period of inactivity before display automatically turns off"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={settings.backlightSettings.off_after.toString()}
|
||||
options={[
|
||||
{ value: "0", label: "Never" },
|
||||
{ value: "300", label: "5 Minutes" },
|
||||
{ value: "600", label: "10 Minutes" },
|
||||
{ value: "1800", label: "30 Minutes" },
|
||||
{ value: "3600", label: "1 Hour" },
|
||||
]}
|
||||
onChange={e => {
|
||||
settings.backlightSettings.off_after = parseInt(e.target.value);
|
||||
handleBacklightSettingsChange(settings.backlightSettings);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
The display will wake up when the connection state changes, or when touched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<SettingsItem
|
||||
title="USB Device Emulation"
|
||||
description="Set a Preconfigured USB Device"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
className="max-w-[192px]"
|
||||
value={usbConfigProduct}
|
||||
onChange={e => {
|
||||
if (e.target.value === "custom") {
|
||||
setUsbConfigProduct(e.target.value);
|
||||
} else {
|
||||
const usbConfig = usbConfigData[e.target.value];
|
||||
handleUsbConfigChange(usbConfig);
|
||||
}
|
||||
}}
|
||||
options={[...usbConfigs, { value: "custom", label: "Custom" }]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{usbConfigProduct === "custom" && (
|
||||
<USBConfigDialog
|
||||
onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)}
|
||||
onRestoreToDefault={() =>
|
||||
handleUsbConfigChange(usbConfigData[usbConfigs[0].value])
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { Checkbox } from "@/components/Checkbox";
|
||||
import { GridCard } from "@/components/Card";
|
||||
import PointingFinger from "@/assets/pointing-finger.svg";
|
||||
import { CheckCircleIcon } from "@heroicons/react/16/solid";
|
||||
import { useSettingsStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { cx } from "../cva.config";
|
||||
|
||||
export default function SettingsKeyboardMouseRoute() {
|
||||
const hideCursor = useSettingsStore(state => state.isCursorHidden);
|
||||
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
|
||||
|
||||
const [jiggler, setJiggler] = useState(false);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
useEffect(() => {
|
||||
send("getJigglerState", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setJiggler(resp.result as boolean);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleJigglerChange = (enabled: boolean) => {
|
||||
send("setJigglerState", { enabled }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setJiggler(enabled);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Mouse"
|
||||
description="Configure cursor behavior and interaction settings for your device"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Hide Cursor"
|
||||
description="Hide the cursor when sending mouse movements"
|
||||
>
|
||||
<Checkbox
|
||||
checked={hideCursor}
|
||||
onChange={e => setHideCursor(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="Jiggler"
|
||||
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
|
||||
>
|
||||
<Checkbox
|
||||
checked={jiggler}
|
||||
onChange={e => handleJigglerChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title="Modes" description="Choose the mouse input mode" />
|
||||
<div className="flex flex-col items-center gap-4 md:flex-row">
|
||||
<button
|
||||
className="group block w-full grow"
|
||||
onClick={() => console.log("Absolute mouse mode clicked")}
|
||||
>
|
||||
<GridCard>
|
||||
<div className="group flex items-center gap-x-4 px-4 py-3">
|
||||
<img
|
||||
className="w-6 shrink-0 dark:invert"
|
||||
src={PointingFinger}
|
||||
alt="Finger touching a screen"
|
||||
/>
|
||||
<div className="flex grow items-center justify-between">
|
||||
<div className="text-left">
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">
|
||||
Absolute
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
|
||||
Most convenient
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircleIcon
|
||||
className={cx(
|
||||
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</button>
|
||||
<button
|
||||
className="group block w-full grow cursor-not-allowed opacity-50"
|
||||
disabled
|
||||
>
|
||||
<GridCard>
|
||||
<div className="group flex items-center gap-x-4 px-4 py-3">
|
||||
<img
|
||||
className="w-6 shrink-0 dark:invert"
|
||||
src={PointingFinger}
|
||||
alt="Finger touching a screen"
|
||||
/>
|
||||
<div className="flex grow items-center justify-between">
|
||||
<div className="text-left">
|
||||
<h3 className="text-sm font-semibold text-black dark:text-white">
|
||||
Relative
|
||||
</h3>
|
||||
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
|
||||
Most Compatible
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircleIcon
|
||||
className={cx(
|
||||
"hidden",
|
||||
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import Card from "@/components/Card";
|
||||
import {
|
||||
LuSettings,
|
||||
LuKeyboard,
|
||||
LuVideo,
|
||||
LuCpu,
|
||||
LuShieldCheck,
|
||||
LuWrench,
|
||||
LuArrowLeft,
|
||||
LuPalette,
|
||||
} from "react-icons/lu";
|
||||
import { LinkButton } from "../components/Button";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { cx } from "../cva.config";
|
||||
import { useUiStore } from "../hooks/stores";
|
||||
import useKeyboard from "../hooks/useKeyboard";
|
||||
import { useResizeObserver } from "../hooks/useResizeObserver";
|
||||
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
export default function SettingsRoute() {
|
||||
const location = useLocation();
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const { sendKeyboardEvent } = useKeyboard();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
const { width } = useResizeObserver({ ref: scrollContainerRef });
|
||||
|
||||
// Handle scroll position to show/hide gradients
|
||||
const handleScroll = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
||||
// Show left gradient only if scrolled to the right
|
||||
setShowLeftGradient(scrollLeft > 0);
|
||||
// Show right gradient only if there's more content to scroll to the right
|
||||
setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); // -1 for rounding errors
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial scroll position
|
||||
handleScroll();
|
||||
|
||||
// Add scroll event listener to the container
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer) {
|
||||
scrollContainer.addEventListener("scroll", handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up event listener
|
||||
if (scrollContainer) {
|
||||
scrollContainer.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
};
|
||||
}, [width]);
|
||||
|
||||
useEffect(() => {
|
||||
// disable focus trap
|
||||
setTimeout(() => {
|
||||
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
|
||||
sendKeyboardEvent([], []);
|
||||
setDisableVideoFocusTrap(true);
|
||||
// For some reason, the focus trap is not disabled immediately
|
||||
// so we need to blur the active element
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
console.log("Just disabled focus trap");
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
setDisableVideoFocusTrap(false);
|
||||
};
|
||||
}, [setDisableVideoFocusTrap, sendKeyboardEvent]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
|
||||
<div className="h-full">
|
||||
<div className="w-full gap-x-8 gap-y-4 space-y-4 md:grid md:grid-cols-8 md:space-y-0">
|
||||
<div className="w-full select-none space-y-4 md:col-span-2">
|
||||
<Card className="flex w-full gap-x-4 overflow-hidden p-2 md:flex-col dark:bg-slate-800">
|
||||
<div className="md:hidden">
|
||||
<LinkButton
|
||||
to=".."
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Back to KVM"
|
||||
LeadingIcon={LuArrowLeft}
|
||||
textAlign="left"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<LinkButton
|
||||
to=".."
|
||||
size="SM"
|
||||
theme="blank"
|
||||
text="Back to KVM"
|
||||
LeadingIcon={LuArrowLeft}
|
||||
textAlign="left"
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="relative overflow-hidden">
|
||||
{/* Gradient overlay for left side - only visible on mobile when scrolled */}
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-white to-transparent transition-opacity duration-300 ease-in-out md:hidden dark:from-slate-900",
|
||||
{
|
||||
"opacity-0": !showLeftGradient,
|
||||
"opacity-100": showLeftGradient,
|
||||
},
|
||||
)}
|
||||
></div>
|
||||
{/* Gradient overlay for right side - only visible on mobile when there's more content */}
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-white to-transparent transition duration-300 ease-in-out md:hidden dark:from-slate-900",
|
||||
{
|
||||
"opacity-0": !showRightGradient,
|
||||
"opacity-100": showRightGradient,
|
||||
},
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="hide-scrollbar relative flex w-full gap-x-4 overflow-x-auto whitespace-nowrap p-2 md:flex-col md:overflow-visible md:whitespace-normal dark:bg-slate-800"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="general"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuSettings className="h-4 w-4 shrink-0" />
|
||||
<h1>General</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="mouse"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuKeyboard className="h-4 w-4 shrink-0" />
|
||||
<h1>Mouse</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="video"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuVideo className="h-4 w-4 shrink-0" />
|
||||
<h1>Video</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="hardware"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuCpu className="h-4 w-4 shrink-0" />
|
||||
<h1>Hardware</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="access"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuShieldCheck className="h-4 w-4 shrink-0" />
|
||||
<h1>Access</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="appearance"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuPalette className="h-4 w-4 shrink-0" />
|
||||
<h1>Appearance</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="advanced"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
|
||||
<LuWrench className="h-4 w-4 shrink-0" />
|
||||
<h1>Advanced</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="w-full md:col-span-5">
|
||||
{/* <AutoHeight> */}
|
||||
<Card className="dark:bg-slate-800">
|
||||
<div
|
||||
className="space-y-4 px-8 py-6"
|
||||
style={{ animationDuration: "0.7s" }}
|
||||
key={location.pathname} // This is a workaround to force the animation to run when the route changes
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Card>
|
||||
{/* </AutoHeight> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsItem({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description: string | React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={cx(
|
||||
"flex select-none items-center justify-between gap-x-8 rounded",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
|
||||
</div>
|
||||
{children ? <div>{children}</div> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,185 @@
|
|||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import { Button } from "@/components/Button";
|
||||
import { TextAreaWithLabel } from "@/components/TextArea";
|
||||
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useState, useEffect } from "react";
|
||||
import notifications from "../notifications";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
const defaultEdid =
|
||||
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
|
||||
const edids = [
|
||||
{
|
||||
value: defaultEdid,
|
||||
label: "JetKVM Default",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
|
||||
label: "Acer B246WL, 1920x1200",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC",
|
||||
label: "ASUS PA248QV, 1920x1200",
|
||||
},
|
||||
{
|
||||
value:
|
||||
"00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F",
|
||||
label: "DELL D2721H, 1920x1080",
|
||||
},
|
||||
];
|
||||
|
||||
const streamQualityOptions = [
|
||||
{ value: "1", label: "High" },
|
||||
{ value: "0.5", label: "Medium" },
|
||||
{ value: "0.1", label: "Low" },
|
||||
];
|
||||
|
||||
export default function SettingsVideoRoute() {
|
||||
const [send] = useJsonRpc();
|
||||
const [streamQuality, setStreamQuality] = useState("1");
|
||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||
const [edid, setEdid] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
send("getStreamQualityFactor", {}, resp => {
|
||||
if ("error" in resp) return;
|
||||
setStreamQuality(String(resp.result));
|
||||
});
|
||||
|
||||
send("getEDID", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const receivedEdid = resp.result as string;
|
||||
|
||||
const matchingEdid = edids.find(
|
||||
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
|
||||
);
|
||||
|
||||
if (matchingEdid) {
|
||||
// EDID is stored in uppercase in the UI
|
||||
setEdid(matchingEdid.value.toUpperCase());
|
||||
// Reset custom EDID value
|
||||
setCustomEdidValue(null);
|
||||
} else {
|
||||
setEdid("custom");
|
||||
setCustomEdidValue(receivedEdid);
|
||||
}
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handleStreamQualityChange = (factor: string) => {
|
||||
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`);
|
||||
setStreamQuality(factor);
|
||||
});
|
||||
};
|
||||
|
||||
const handleEDIDChange = (newEdid: string) => {
|
||||
send("setEDID", { edid: newEdid }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(
|
||||
`EDID set successfully to ${edids.find(x => x.value === newEdid)?.label}`,
|
||||
);
|
||||
// Update the EDID value in the UI
|
||||
setEdid(newEdid);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="Video"
|
||||
description="Configure display settings and EDID for optimal compatibility"
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title="Stream Quality"
|
||||
description="Adjust the quality of the video stream"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
value={streamQuality}
|
||||
options={streamQualityOptions}
|
||||
onChange={e => handleStreamQualityChange(e.target.value)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
title="EDID"
|
||||
description="Adjust the EDID settings for the display"
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
label=""
|
||||
fullWidth
|
||||
value={customEdidValue ? "custom" : edid || "asd"}
|
||||
onChange={e => {
|
||||
if (e.target.value === "custom") {
|
||||
setEdid("custom");
|
||||
setCustomEdidValue("");
|
||||
} else {
|
||||
setCustomEdidValue(null);
|
||||
handleEDIDChange(e.target.value as string);
|
||||
}
|
||||
}}
|
||||
options={[...edids, { value: "custom", label: "Custom" }]}
|
||||
/>
|
||||
</SettingsItem>
|
||||
{customEdidValue !== null && (
|
||||
<>
|
||||
<SettingsItem
|
||||
title="Custom EDID"
|
||||
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
|
||||
/>
|
||||
<TextAreaWithLabel
|
||||
label="EDID File"
|
||||
placeholder="00F..."
|
||||
rows={3}
|
||||
value={customEdidValue}
|
||||
onChange={e => setCustomEdidValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-start gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Set Custom EDID"
|
||||
onClick={() => handleEDIDChange(customEdidValue)}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Restore to default"
|
||||
onClick={() => {
|
||||
setCustomEdidValue(null);
|
||||
handleEDIDChange(defaultEdid);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cx } from "@/cva.config";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import {
|
||||
HidState,
|
||||
UpdateState,
|
||||
|
@ -16,27 +15,30 @@ import {
|
|||
import WebRTCVideo from "@components/WebRTCVideo";
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Outlet,
|
||||
Params,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useOutlet,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { checkAuth, isInCloud, isOnDevice } from "@/main";
|
||||
import DashboardNavbar from "@components/Header";
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import SettingsSidebar from "@/components/sidebar/settings";
|
||||
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
|
||||
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import UpdateDialog from "@components/UpdateDialog";
|
||||
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
|
||||
import api from "../api";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
|
||||
import Terminal from "@components/Terminal";
|
||||
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||
import Modal from "../components/Modal";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
|
||||
interface LocalLoaderResp {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
|
@ -50,8 +52,9 @@ interface CloudLoaderResp {
|
|||
} | null;
|
||||
}
|
||||
|
||||
export type AuthMode = "password" | "noPassword" | null;
|
||||
export interface LocalDevice {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
authMode: AuthMode;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
|
@ -123,16 +126,7 @@ export default function KvmIdRoute() {
|
|||
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
otaState,
|
||||
setOtaState,
|
||||
isUpdateDialogOpen,
|
||||
setIsUpdateDialogOpen,
|
||||
setModalView,
|
||||
} = useUpdateStore();
|
||||
|
||||
const [isOtherSessionConnectedModalOpen, setIsOtherSessionConnectedModalOpen] =
|
||||
useState(false);
|
||||
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
||||
|
||||
const sdp = useCallback(
|
||||
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
|
||||
|
@ -243,8 +237,7 @@ export default function KvmIdRoute() {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
// We don't want to connect if another session is connected
|
||||
if (isOtherSessionConnectedModalOpen) return;
|
||||
if (location.pathname.includes("other-session")) return;
|
||||
connectWebRTC();
|
||||
}, 3000);
|
||||
|
||||
|
@ -330,11 +323,11 @@ export default function KvmIdRoute() {
|
|||
const setHdmiState = useVideoStore(state => state.setHdmiState);
|
||||
|
||||
const [hasUpdated, setHasUpdated] = useState(false);
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
function onJsonRpcRequest(resp: JsonRpcRequest) {
|
||||
if (resp.method === "otherSessionConnected") {
|
||||
console.log("otherSessionConnected", resp.params);
|
||||
setIsOtherSessionConnectedModalOpen(true);
|
||||
navigateTo("/other-session");
|
||||
}
|
||||
|
||||
if (resp.method === "usbState") {
|
||||
|
@ -358,7 +351,7 @@ export default function KvmIdRoute() {
|
|||
|
||||
if (otaState.error) {
|
||||
setModalView("error");
|
||||
setIsUpdateDialogOpen(true);
|
||||
navigateTo("/settings/general/update");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -388,11 +381,9 @@ export default function KvmIdRoute() {
|
|||
// When the update is successful, we need to refresh the client javascript and show a success modal
|
||||
useEffect(() => {
|
||||
if (queryParams.get("updateSuccess")) {
|
||||
setModalView("updateCompleted");
|
||||
setIsUpdateDialogOpen(true);
|
||||
setQueryParams({});
|
||||
navigateTo("/settings/general/update", { state: { updateSuccess: true } });
|
||||
}
|
||||
}, [queryParams, setIsUpdateDialogOpen, setModalView, setQueryParams]);
|
||||
}, [navigate, navigateTo, queryParams, setModalView, setQueryParams]);
|
||||
|
||||
const diskChannel = useRTCStore(state => state.diskChannel)!;
|
||||
const file = useMountMediaStore(state => state.localFile)!;
|
||||
|
@ -445,18 +436,27 @@ export default function KvmIdRoute() {
|
|||
};
|
||||
}, [kvmTerminal]);
|
||||
|
||||
const outlet = useOutlet();
|
||||
const location = useLocation();
|
||||
const onModalClose = useCallback(() => {
|
||||
if (location.pathname !== "/other-session") navigateTo("..");
|
||||
}, [navigateTo, location.pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition show={!isUpdateDialogOpen && otaState.updating}>
|
||||
<div className="pointer-events-none fixed inset-0 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center">
|
||||
<div className="transition duration-1000 ease-in data-[closed]:opacity-0">
|
||||
<UpdateInProgressStatusCard
|
||||
setIsUpdateDialogOpen={setIsUpdateDialogOpen}
|
||||
setModalView={setModalView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
{!outlet && otaState.updating && (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<UpdateInProgressStatusCard />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
<div className="relative h-full">
|
||||
<FocusTrap
|
||||
paused={disableKeyboardFocusTrap}
|
||||
|
@ -486,21 +486,24 @@ export default function KvmIdRoute() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateDialog open={isUpdateDialogOpen} setOpen={setIsUpdateDialogOpen} />
|
||||
<OtherSessionConnectedModal
|
||||
open={isOtherSessionConnectedModalOpen}
|
||||
setOpen={state => {
|
||||
if (!state) connectWebRTC().then(r => r);
|
||||
|
||||
// It takes some time for the WebRTC connection to be established, so we wait a bit before closing the modal
|
||||
setTimeout(() => {
|
||||
setIsOtherSessionConnectedModalOpen(state);
|
||||
}, 1000);
|
||||
<div
|
||||
className="isolate"
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") navigateTo("/");
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Modal open={outlet !== null} onClose={onModalClose}>
|
||||
<Outlet context={{ connectWebRTC }} />
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{kvmTerminal && (
|
||||
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
|
||||
)}
|
||||
|
||||
{serialConsole && (
|
||||
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
|
||||
)}
|
||||
|
@ -518,16 +521,22 @@ function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
|
|||
style={{ width: sidebarView ? "493px" : 0 }}
|
||||
>
|
||||
<div className="relative w-[493px] shrink-0">
|
||||
<Transition show={sidebarView === "system"} unmount={false}>
|
||||
<div className="absolute inset-0">
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition show={sidebarView === "connection-stats"} unmount={false}>
|
||||
<div className="absolute inset-0">
|
||||
<AnimatePresence>
|
||||
{sidebarView === "connection-stats" && (
|
||||
<motion.div
|
||||
className="absolute inset-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<ConnectionStatsSidebar />
|
||||
</div>
|
||||
</Transition>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue