Compare commits

..

32 Commits

Author SHA1 Message Date
Daniel Lorch 0d6899aa59
Merge 88b22a4378 into d54568642b 2025-05-16 16:48:45 +02:00
Daniel Lorch 88b22a4378 Move hold key handling into Go backend analogous to https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt 2025-05-16 16:48:18 +02:00
Marc Brooks 9ed19a451d Change the locale names to their native language
German->Deutsch et. al.
2025-05-16 16:48:18 +02:00
Daniel Lorch 057f6c9d57 Move language name definitions into the keyboard layout files 2025-05-16 16:48:18 +02:00
Daniel Lorch 8d1475f29a Move guard statements outside of loop 2025-05-16 16:48:18 +02:00
Daniel Lorch 9056dea38b Add Czech 2025-05-16 16:48:17 +02:00
Daniel Lorch d8d11a0020 Add Italian 2025-05-16 16:48:17 +02:00
Daniel Lorch 0d28a5d914 Operator precedence 🤦 2025-05-16 16:48:17 +02:00
Daniel Lorch ac42be96e7 Add Norwegian 2025-05-16 16:48:17 +02:00
Daniel Lorch 2feef185c4 Remove default value shift: false 2025-05-16 16:48:15 +02:00
Daniel Lorch 4cbde51ce7 Add more keys to Spanish 2025-05-16 16:47:36 +02:00
Daniel Lorch e4ec2c1d8d Fix fr_FR special characters 2025-05-16 16:47:36 +02:00
Daniel Lorch cb1937aac6 Add Spanish 2025-05-16 16:47:36 +02:00
Daniel Lorch af9c8fad09 Add Swedish 2025-05-16 16:47:36 +02:00
Daniel Lorch dd5eee8179 Add English (UK) 2025-05-16 16:47:36 +02:00
Daniel Lorch 58d59eca47 Add French (France) 2025-05-16 16:47:36 +02:00
Daniel Lorch 66f8d7bcd6 Fix whitespace 2025-05-16 16:47:36 +02:00
Daniel Lorch 926e0b8a06 Change line ordering 2025-05-16 16:47:36 +02:00
Daniel Lorch 384025ecf5 Add Swiss French 2025-05-16 16:47:36 +02:00
Daniel Lorch 768a9f7604 Remove obscure Alt-Gr keys, unsure if they are supported everywhere 2025-05-16 16:47:36 +02:00
Daniel Lorch a255680b8a Improve accent handling 2025-05-16 16:47:32 +02:00
Daniel Lorch 0ae4639275 Fix default value 2025-05-16 16:46:11 +02:00
Daniel Lorch 85e3b22660 Improve error handling and pre-loading 2025-05-16 16:45:32 +02:00
Daniel Lorch c550948bef Trema is the more robust method for capital umlauts 2025-05-16 16:45:32 +02:00
Daniel Lorch 949be95cd5 Enable multiple keyboard layouts for paste text from host 2025-05-16 16:45:21 +02:00
Marc Brooks d54568642b
fix(ui): Fix regression on Shift-Backspace not being handled (#454)
This keystroke is valid and means "delete to the right" on MacOS.
2025-05-16 12:38:56 +02:00
Marc Brooks c9068af568
Update devcontainer.json to match ui package.json (#457)
Missed that we upgraded the ui's package.json, need to _also_ update the devcontainer.json to matching verison 22.15.0
2025-05-16 12:37:54 +02:00
Adam Shiervani 033bdcd645
fix(ui): Adjust EmptyCard icon size and tweak SettingsMacros (#452) 2025-05-15 17:31:20 +02:00
Adam Shiervani baf85dcbec
refactor: Migrate from tailwind.js config to Tailwind CSS config (#451)
* refactor: Migrate from tailwind.js config to Tailwind CSS configuration and improve component styling

- Removed extensive theme and animation configurations from tailwind.config.js, migrating them to index.css for better organization.
- Updated components to utilize CSS variables for grid layouts and animations, enhancing maintainability.
- Adjusted various components to reflect the new CSS structure, ensuring consistent styling across the application.
- Improved accessibility and responsiveness in several UI components, including headers and popovers.
- Fixed minor styling issues and optimized class usage for better performance.

* style: use default tailwindcss/forms options

* refactor(Header): remove unused LuUser icon import
2025-05-15 17:13:16 +02:00
Marc Brooks c9dd3cd926
feat(ui): Enhance Virtual Keyboard for US (#449)
* feat(ui): Add Ctrl+Alt-Backspace combination key to Virtual Keyboard

Fixes #445 (somewhat)

* fix(ui): Correct virtual keyboard display when shift key is down.

Somewhere along the way, the handling of the shift-key state for letters and numbers was lost and we stopped displaying the capital/symbol for the key.
Also update page up and page down to have the space in the on-screen key.

* feat(ui): Add missing keys for virtual keyboard

Enable insert, delete, numpad equal, print scree, scroll lock, pause, system request, break keys.
2025-05-15 17:05:53 +02:00
Marc Brooks 7ccb8e617c
chore: Upgrade UI vite and tailwind packages (#443)
* chore: Upgrade UI vite and tailwind packages

Vite 5.2.0 -> 6.3.5
@vitejs/plugin-basic-ssl 1.2.0 -> 2.0.0
cva: 1.0.0-beta.1 -> 1.0.0-beta.3
focus-trap-react 10.2.3 -> 11.0.3
framer-motion 11.15.0 -> 12.11.0
@tailwindcss/postcss 4.1.6
@tailwindcss/vite 4.1.6
tailwind 3.4.17 -> 4.1.6
tailwind-merge 2.5.5 -> 3.3.0

Minor updates:
@headlessui/react 2.2.2 -> 2.2.3
@types/react 19.1.3 -> 19.1.4
@types/react-dom 19.1.3 -> 19.1.5
@typescript-eslint/eslint-plugin 8.32.0 -> 8.32.1
@typescript-eslint/parser 8.32.0 -> 8.32.1
react-simple-keyboard 3.8.71 -> 3.8.72

The new version of vite required an Node 22.15 (since that's current LTS and node 21.x is EOL)

The changes to css due to the tailwind 3 to 4 upgrade were done following [the upgrade guide](https://tailwindcss.com/docs/upgrade-guide#changes-from-v3)

Done in this order (important):
`shadow-sm` -> `shadow-xs`
`shadow` -> `shadown-sm`
`rounded` -> `rounded-sm`
`outline-none` -> `outline-hidden`
`32rem_32rem_at_center` -> `center_at_32rem_32rem` (revised order of gradient props)
`ring-1 ring-black ring-opacity-5` -> `ring-1 ring-black/50`
`flex-shrink-0` -> `shrink-0`
`flex-grow-0` -> `grow-0`
`outline outline-1` -> `outline-1`

ALSO removed the **extra** `opacity-0` on the video element (trips up latest tailwind causing the video to be invisible)

FocusTrap is now not exported as the default, so change those imports

headlessui's Menu completely changed, so upgrade to the new syntax which necessitated a reorganization of the Header.tsx to enable the "menu" to still work

* Update eslint config and fix errors
2025-05-15 14:21:03 +02:00
Adam Shiervani 340babac24
feat(network): enhance network settings UI (#364)
* feat(network): enhance network settings UI with domain management and improved layout

- Added custom domain input and selection options for DHCP and local domains.
- Improved layout for displaying network settings, including DHCP lease information and IPv6 addresses.
- Refactored state management for network settings and added handlers for hostname and domain changes.
- Updated the display of network settings to enhance user experience and accessibility.

* Re-add save button

* fix: add ConfirmDialog for renewing DHCP lease and improve network settings layout

- Integrated ConfirmDialog component to confirm DHCP lease renewal.
- Enhanced the layout of network settings, including better organization of IPv4 and IPv6 information.
- Updated state management for displaying network settings and lease information.
- Improved user experience with clearer descriptions and structured UI elements.

* Fix lint errors

* fix: useRef TS2554

---------

Co-authored-by: Siyuan Miao <i@xswan.net>
2025-05-14 17:25:56 +02:00
59 changed files with 3300 additions and 2166 deletions

View File

@ -4,7 +4,7 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json // Should match what is defined in ui/package.json
"version": "21.1.0" "version": "22.15.0"
} }
}, },
"mounts": [ "mounts": [

View File

@ -109,8 +109,8 @@ var defaultConfig = &Config{
AutoUpdateEnabled: true, // Set a default value AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "", ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{}, KeyboardMacros: []KeyboardMacro{},
KeyboardLayout: "en_US",
DisplayRotation: "270", DisplayRotation: "270",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64, DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes DisplayOffAfterSec: 1800, // 30 minutes

View File

@ -1,66 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: __dirname,
},
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"import/order": [
"error",
{
/**
* @description
*
* This keeps imports separate from one another, ensuring that imports are separated
* by their relative groups. As you move through the groups, imports become closer
* to the current file.
*
* @example
* ```
* import fs from 'fs';
*
* import package from 'npm-package';
*
* import xyz from '~/project-file';
*
* import index from '../';
*
* import sibling from './foo';
* ```
*/
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
},
],
},
settings: {
"import/resolver": {
alias: {
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@assets", "./src/assets"],
["@", "./src"],
],
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
},
},
};

93
ui/eslint.config.cjs Normal file
View File

@ -0,0 +1,93 @@
const {
defineConfig,
globalIgnores,
} = require("eslint/config");
const globals = require("globals");
const {
fixupConfigRules,
} = require("@eslint/compat");
const tsParser = require("@typescript-eslint/parser");
const reactRefresh = require("eslint-plugin-react-refresh");
const js = require("@eslint/js");
const {
FlatCompat,
} = require("@eslint/eslintrc");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
module.exports = defineConfig([{
languageOptions: {
globals: {
...globals.browser,
},
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: __dirname,
ecmaFeatures: {
jsx: true
}
},
},
extends: fixupConfigRules(compat.extends(
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
)),
plugins: {
"react-refresh": reactRefresh,
},
rules: {
"react-refresh/only-export-components": ["warn", {
allowConstantExport: true,
}],
"import/order": ["error", {
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
}],
},
settings: {
"react": {
"version": "detect"
},
"import/resolver": {
alias: {
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@assets", "./src/assets"],
["@", "./src"],
],
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
},
},
}, globalIgnores([
"**/dist",
"**/.eslintrc.cjs",
"**/tailwind.config.js",
"**/postcss.config.js",
])]);

2790
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "21.1.0" "node": "22.15.0"
}, },
"scripts": { "scripts": {
"dev": "./dev_device.sh", "dev": "./dev_device.sh",
@ -19,21 +19,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.2", "@headlessui/react": "^2.2.3",
"@headlessui/tailwindcss": "^0.2.2", "@headlessui/tailwindcss": "^0.2.2",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@vitejs/plugin-basic-ssl": "^1.2.0", "@vitejs/plugin-basic-ssl": "^2.0.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1", "cva": "^1.0.0-beta.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^10.2.3", "focus-trap-react": "^11.0.3",
"framer-motion": "^11.15.0", "framer-motion": "^12.11.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"react": "^19.1.0", "react": "^19.1.0",
@ -42,24 +42,29 @@
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.71", "react-simple-keyboard": "^3.8.72",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^3.3.0",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"validator": "^13.15.0", "validator": "^13.15.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.6",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/react": "^19.1.3", "@tailwindcss/vite": "^4.1.6",
"@types/react-dom": "^19.1.3", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/validator": "^13.15.0", "@types/validator": "^13.15.0",
"@typescript-eslint/eslint-plugin": "^8.32.0", "@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.0", "@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-swc": "^3.9.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.26.0", "eslint": "^9.26.0",
@ -68,12 +73,13 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17", "tailwindcss": "^4.1.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^5.2.0", "vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }

View File

@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} }

View File

@ -37,7 +37,7 @@ export default function AuthLayout({
<> <>
<GridBackground /> <GridBackground />
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<SimpleNavbar <SimpleNavbar
logoHref="/" logoHref="/"
actionElement={ actionElement={

View File

@ -16,7 +16,7 @@ const sizes = {
const themes = { const themes = {
primary: cx( primary: cx(
// Base styles // Base styles
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow", "bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow-sm",
// Hover states // Hover states
"group-hover:bg-blue-800", "group-hover:bg-blue-800",
// Active states // Active states
@ -24,7 +24,7 @@ const themes = {
), ),
danger: cx( danger: cx(
// Base styles // Base styles
"bg-red-600 text-white border-red-700 shadow-sm shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20", "bg-red-600 text-white border-red-700 shadow-xs shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
// Hover states // Hover states
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600", "group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
// Active states // Active states
@ -34,7 +34,7 @@ const themes = {
), ),
light: cx( light: cx(
// Base styles // Base styles
"bg-white text-black border-slate-800/30 shadow dark:bg-slate-800 dark:border-slate-300/20 dark:text-white", "bg-white text-black border-slate-800/30 shadow-xs dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
// Hover states // Hover states
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700", "group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
// Active states // Active states
@ -44,7 +44,7 @@ const themes = {
), ),
lightDanger: cx( lightDanger: cx(
// Base styles // Base styles
"bg-white text-black border-red-400/60 shadow-sm", "bg-white text-black border-red-400/60 shadow-xs",
// Hover states // Hover states
"group-hover:bg-red-50/80", "group-hover:bg-red-50/80",
// Active states // Active states
@ -56,7 +56,7 @@ const themes = {
// Base styles // Base styles
"bg-white/0 text-black border-transparent dark:text-white", "bg-white/0 text-black border-transparent dark:text-white",
// Hover states // Hover states
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600", "group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow-sm dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
// Active states // Active states
"group-active:bg-slate-100/80", "group-active:bg-slate-100/80",
), ),
@ -65,15 +65,15 @@ const themes = {
const btnVariants = cva({ const btnVariants = cva({
base: cx( base: cx(
// Base styles // Base styles
"border rounded select-none", "border rounded-sm select-none",
// Size classes // Size classes
"justify-center items-center shrink-0", "justify-center items-center shrink-0",
// Transition classes // Transition classes
"outline-none transition-all duration-200", "outline-hidden transition-all duration-200",
// Text classes // Text classes
"font-display text-center font-medium leading-tight", "font-display text-center font-medium leading-tight",
// States // States
"group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700", "group-focus:outline-hidden group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
"group-disabled:opacity-50 group-disabled:pointer-events-none", "group-disabled:opacity-50 group-disabled:pointer-events-none",
), ),
@ -175,7 +175,7 @@ type ButtonPropsType = Pick<
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>( export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => { ({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
const classes = cx( const classes = cx(
"group outline-none", "group outline-hidden",
props.fullWidth ? "w-full" : "", props.fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "", loading ? "pointer-events-none" : "",
); );
@ -215,7 +215,7 @@ type LinkPropsType = Pick<LinkProps, "to"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean }; React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LinkButton = ({ to, ...props }: LinkPropsType) => { export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const classes = cx( const classes = cx(
"group outline-none", "group outline-hidden",
props.disabled ? "pointer-events-none !opacity-70" : "", props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "", props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "", props.loading ? "pointer-events-none" : "",
@ -241,7 +241,7 @@ type LabelPropsType = Pick<HTMLLabelElement, "htmlFor"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean }; React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => { export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => {
const classes = cx( const classes = cx(
"group outline-none block cursor-pointer", "group outline-hidden block cursor-pointer",
props.disabled ? "pointer-events-none !opacity-70" : "", props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "", props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "", props.loading ? "pointer-events-none" : "",

View File

@ -30,7 +30,7 @@ const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className },
<div <div
ref={ref} ref={ref}
className={cx( className={cx(
"w-full rounded border-none bg-white shadow outline outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20", "w-full rounded-sm border-none bg-white shadow-xs outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20",
className, className,
)} )}
> >

View File

@ -15,7 +15,7 @@ const checkboxVariants = cva({
"block rounded", "block rounded",
// Colors // Colors
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-blue-700 dark:text-blue-500 transition-colors", "border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 checked:accent-blue-700 checked:dark:accent-blue-500 transition-colors",
// Hover // Hover
"hover:bg-slate-200/50 dark:hover:bg-slate-700/50", "hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
@ -24,7 +24,7 @@ const checkboxVariants = cva({
"active:bg-slate-200 dark:active:bg-slate-700", "active:bg-slate-200 dark:active:bg-slate-700",
// Focus // Focus
"focus:border-slate-300 dark:focus:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900", "focus:border-slate-300 dark:focus:border-slate-600 focus:outline-hidden focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900",
// Disabled // Disabled
"disabled:pointer-events-none disabled:opacity-30", "disabled:pointer-events-none disabled:opacity-30",
@ -41,7 +41,9 @@ const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
ref, ref,
) { ) {
const classes = checkboxVariants({ size }); const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />; return (
<input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />
);
}); });
Checkbox.displayName = "Checkbox"; Checkbox.displayName = "Checkbox";

View File

@ -1,7 +1,14 @@
import { useRef } from "react"; import { useRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; import {
Combobox as HeadlessCombobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/react";
import { cva } from "@/cva.config"; import { cva } from "@/cva.config";
import Card from "./Card"; import Card from "./Card";
export interface ComboboxOption { export interface ComboboxOption {
@ -22,7 +29,7 @@ const comboboxVariants = cva({
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>; type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> { interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
displayValue: (option: ComboboxOption) => string; displayValue: (option: ComboboxOption) => string;
onInputChange: (option: string) => void; onInputChange: (option: string) => void;
options: () => ComboboxOption[]; options: () => ComboboxOption[];
@ -48,56 +55,54 @@ export function Combobox({
const classes = comboboxVariants({ size }); const classes = comboboxVariants({ size });
return ( return (
<HeadlessCombobox <HeadlessCombobox onChange={onChange} {...otherProps}>
onChange={onChange}
{...otherProps}
>
{() => ( {() => (
<> <>
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30"> <Card className="w-auto !border border-solid !border-slate-800/30 shadow-xs outline-0 dark:!border-slate-300/30">
<ComboboxInput <ComboboxInput
ref={inputRef} ref={inputRef}
className={clsx( className={clsx(
classes, classes,
// General styling // General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300", "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
// Hover // Hover
"hover:bg-blue-50/80 active:bg-blue-100/60", "hover:bg-blue-50/80 active:bg-blue-100/60",
// Dark mode // Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60", "dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
// Focus // Focus
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500", "focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
// Disabled // Disabled
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800" disabled &&
)} "pointer-events-none select-none bg-slate-50 text-slate-500/80 disabled:hover:bg-white dark:bg-slate-800 dark:text-slate-400/80 dark:disabled:hover:bg-slate-800",
placeholder={disabled ? disabledMessage : placeholder} )}
displayValue={displayValue} placeholder={disabled ? disabledMessage : placeholder}
onChange={(event) => onInputChange(event.target.value)} displayValue={displayValue}
disabled={disabled} onChange={event => onInputChange(event.target.value)}
disabled={disabled}
/> />
</Card> </Card>
{options().length > 0 && ( {options().length > 0 && (
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar"> <ComboboxOptions className="hide-scrollbar absolute left-0 z-[100] mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700">
{options().map((option) => ( {options().map(option => (
<ComboboxOption <ComboboxOption
key={option.value} key={option.value}
value={option} value={option}
className={clsx( className={clsx(
// General styling // General styling
"cursor-default select-none py-2 px-4", "cursor-default select-none px-4 py-2",
// Hover and active states // Hover and active states
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900", "hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
// Dark mode // Dark mode
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200" "dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200",
)} )}
> >
{option.label} {option.label}
</ComboboxOption> </ComboboxOption>
@ -106,10 +111,8 @@ export function Combobox({
)} )}
{options().length === 0 && inputRef.current?.value && ( {options().length === 0 && inputRef.current?.value && (
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700"> <div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white px-4 py-2 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700">
<div className="text-slate-500 dark:text-slate-400"> <div className="text-slate-500 dark:text-slate-400">{emptyMessage}</div>
{emptyMessage}
</div>
</div> </div>
)} )}
</> </>

View File

@ -1,4 +1,9 @@
import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; import {
ExclamationTriangleIcon,
CheckCircleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
@ -42,12 +47,15 @@ const variantConfig = {
iconBgClass: "bg-blue-100", iconBgClass: "bg-blue-100",
buttonTheme: "primary", buttonTheme: "primary",
}, },
} as Record<Variant, { } as Record<
Variant,
{
icon: React.ElementType; icon: React.ElementType;
iconClass: string; iconClass: string;
iconBgClass: string; iconBgClass: string;
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger"; buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
}>; }
>;
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
@ -65,13 +73,18 @@ export function ConfirmDialog({
return ( return (
<Modal open={open} onClose={onClose}> <Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out"> <div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto"> <div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
<div className="space-y-4"> <div className="space-y-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}> <div
className={cx(
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
iconBgClass,
)}
>
<Icon aria-hidden="true" className={cx("size-6", iconClass)} /> <Icon aria-hidden="true" className={cx("size-6", iconClass)} />
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h2 className="text-lg font-bold leading-tight text-black dark:text-white"> <h2 className="text-lg font-bold leading-tight text-black dark:text-white">
{title} {title}
</h2> </h2>
@ -83,12 +96,7 @@ export function ConfirmDialog({
<div className="flex justify-end gap-x-2"> <div className="flex justify-end gap-x-2">
{cancelText && ( {cancelText && (
<Button <Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
size="SM"
theme="blank"
text={cancelText}
onClick={onClose}
/>
)} )}
<Button <Button
size="SM" size="SM"

View File

@ -30,7 +30,7 @@ export default function EmptyCard({
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]"> <div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2"> <div className="space-y-2">
{IconElm && ( {IconElm && (
<IconElm className="mx-auto h-6 w-6 text-blue-600 dark:text-blue-400" /> <IconElm className="mx-auto h-5 w-5 text-blue-600 dark:text-blue-600" />
)} )}
<h4 className="text-base font-bold leading-none text-black dark:text-white"> <h4 className="text-base font-bold leading-none text-black dark:text-white">
{headline} {headline}

View File

@ -1,8 +1,8 @@
export default function GridBackground() { export default function GridBackground() {
return ( return (
<div className="absolute w-screen h-screen overflow-hidden isolate opacity-60"> <div className="absolute isolate h-screen w-screen overflow-hidden opacity-60">
<svg <svg
className="absolute inset-x-0 top-0 -z-10 h-[64rem] w-full stroke-gray-300 [mask-image:radial-gradient(32rem_32rem_at_center,white,transparent)] dark:stroke-slate-300/20" className="absolute inset-x-0 top-0 -z-10 h-full w-full mask-radial-[32rem_32rem] mask-radial-from-white mask-radial-to-transparent mask-radial-at-center stroke-gray-300 dark:stroke-slate-300/20"
aria-hidden="true" aria-hidden="true"
> >
<defs> <defs>

View File

@ -1,12 +1,11 @@
import { Fragment, useCallback } from "react"; import { useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid"; import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton } from "@headlessui/react"; import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu"; import { LuMonitorSmartphone } from "react-icons/lu";
import Container from "@/components/Container"; import Container from "@/components/Container";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
@ -17,7 +16,7 @@ import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "../api"; import api from "../api";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button"; import { LinkButton } from "./Button";
interface NavbarProps { interface NavbarProps {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -51,8 +50,12 @@ export default function DashboardNavbar({
const usbState = useHidStore(state => state.usbState); const usbState = useHidStore(state => state.usbState);
// for testing
//userEmail = "user@example.org";
//picture = "https://placehold.co/32x32"
return ( return (
<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"> <div className="w-full border-b border-b-slate-800/20 bg-white select-none dark:border-b-slate-300/20 dark:bg-slate-900">
<Container> <Container>
<div className="flex h-14 items-center justify-between"> <div className="flex h-14 items-center justify-between">
<div className="flex shrink-0 items-center gap-x-8"> <div className="flex shrink-0 items-center gap-x-8">
@ -78,86 +81,82 @@ export default function DashboardNavbar({
</div> </div>
<div className="flex w-full items-center justify-end gap-x-2"> <div className="flex w-full items-center justify-end gap-x-2">
<div className="flex shrink-0 items-center space-x-4"> <div className="flex shrink-0 items-center space-x-4">
{showConnectionStatus && ( <div className="hidden items-stretch gap-x-2 md:flex">
<div className="hidden items-center gap-x-2 md:flex"> {showConnectionStatus && (
<div className="w-[159px]"> <>
<PeerConnectionStatusCard <div className="w-[159px]">
state={peerConnectionState} <PeerConnectionStatusCard
title={kvmName} state={peerConnectionState}
/> title={kvmName}
</div> />
<div className="hidden w-[159px] md:block"> </div>
<USBStateStatus <div className="hidden w-[159px] md:block">
state={usbState} <USBStateStatus
state={usbState}
peerConnectionState={peerConnectionState} peerConnectionState={peerConnectionState}
/> />
</div> </div>
</div> </>
)} )}
{isLoggedIn ? ( {isLoggedIn ? (
<> <>
<hr className="h-[20px] w-[1px] border-none bg-slate-800/20 dark:bg-slate-300/20" /> <hr className="h-[20px] w-[1px] self-center border-none bg-slate-800/20 dark:bg-slate-300/20" />
<Menu as="div" className="relative inline-block text-left"> <div className="relative inline-block text-left">
<div> <Menu>
<MenuButton as={Fragment}> <MenuButton className="h-full">
<Button <Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white">
theme="blank" {picture ? (
size="SM"
text={
<>
{picture ? <></> : userEmail}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" />
</>
}
LeadingIcon={({ className }) =>
picture && (
<img <img
src={picture} src={picture}
alt="Avatar" alt="Avatar"
className={cx( className="size-6 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700"
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
/> />
) ) : userEmail ? (
} <span className="font-display max-w-[200px] truncate text-sm/6 font-semibold">
/> {userEmail}
</MenuButton> </span>
</div> ) : null}
<ChevronDownIcon className="size-4 shrink-0 text-slate-900 dark:text-white" />
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none"> </Button>
<Card className="overflow-hidden"> </MenuButton>
<div className="space-y-1 p-1 dark:text-white"> <MenuItems
{userEmail && ( transition
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20"> anchor="bottom end"
<Menu.Item> className="right-0 mt-1 w-56 origin-top-right p-px focus:outline-hidden data-closed:opacity-0"
<div className="p-2"> >
<div className="font-display text-xs">Logged in as</div> <MenuItem>
<div className="w-[200px] truncate font-display text-sm font-semibold"> <Card className="overflow-hidden">
{userEmail} {userEmail && (
<div className="space-y-1 p-1 dark:text-white">
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<div className="p-2">
<div className="font-display text-xs">
Logged in as
</div>
<div className="font-display max-w-[200px] truncate text-sm font-semibold">
{userEmail}
</div>
</div>
</div> </div>
</div> </div>
</Menu.Item> )}
</div> <div
)} className="space-y-1 p-1 dark:text-white"
<div> onClick={onLogout}
<Menu.Item> >
<div onClick={onLogout}> <button className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
<button className="block w-full"> <ArrowLeftEndOnRectangleIcon className="size-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"> <div className="font-display">Log out</div>
<ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
<div className="font-display">Log out</div>
</div>
</button> </button>
</div> </div>
</Menu.Item> </Card>
</div> </MenuItem>
</div> </MenuItems>
</Card> </Menu>
</Menu.Items> </div>
</Menu> </>
</> ) : null}
) : null} </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -44,7 +44,7 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
"[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2", "[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2",
// Focus Within // Focus Within
"focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2", "focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-hidden focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2",
// Disabled Within // Disabled Within
"disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80", "disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80",

View File

@ -113,7 +113,7 @@ export default function KvmCard({
transition transition
className="data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in" className="data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in"
> >
<Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black ring-opacity-5 focus:outline-none"> <Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black/50 focus:outline-hidden">
<div className="divide-y divide-slate-800/20 dark:divide-slate-300/20"> <div className="divide-y divide-slate-800/20 dark:divide-slate-300/20">
<MenuItem> <MenuItem>
<div> <div>

View File

@ -7,7 +7,7 @@ export default function LoadingSpinner({
}) { }) {
return ( return (
<svg <svg
className={clsx(className, "flex-shrink-0 animate-spin p-[2px]")} className={clsx(className, "shrink-0 animate-spin p-[2px]")}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores"; import { KeySequence } from "@/hooks/stores";
@ -7,16 +6,23 @@ import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField"; import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import Fieldset from "@/components/Fieldset"; import Fieldset from "@/components/Fieldset";
import { MacroStepCard } from "@/components/MacroStepCard"; import { MacroStepCard } from "@/components/MacroStepCard";
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros"; import {
DEFAULT_DELAY,
MAX_STEPS_PER_MACRO,
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
interface ValidationErrors { interface ValidationErrors {
name?: string; name?: string;
steps?: Record<number, { steps?: Record<
keys?: string; number,
modifiers?: string; {
delay?: string; keys?: string;
}>; modifiers?: string;
delay?: string;
}
>;
} }
interface MacroFormProps { interface MacroFormProps {
@ -57,12 +63,14 @@ export function MacroForm({
if (!macro.steps?.length) { if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } }; newErrors.steps = { 0: { keys: "At least one step is required" } };
} else { } else {
const hasKeyOrModifier = macro.steps.some(step => const hasKeyOrModifier = macro.steps.some(
(step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0 step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
); );
if (!hasKeyOrModifier) { if (!hasKeyOrModifier) {
newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } }; newErrors.steps = {
0: { keys: "At least one step must have keys or modifiers" },
};
} }
} }
@ -87,7 +95,10 @@ export function MacroForm({
} }
}; };
const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => { const handleKeySelect = (
stepIndex: number,
option: { value: string | null; keys?: string[] },
) => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
if (!newSteps[stepIndex]) return; if (!newSteps[stepIndex]) return;
@ -97,7 +108,9 @@ export function MacroForm({
if (!newSteps[stepIndex].keys) { if (!newSteps[stepIndex].keys) {
newSteps[stepIndex].keys = []; newSteps[stepIndex].keys = [];
} }
const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : []; const keysArray = Array.isArray(newSteps[stepIndex].keys)
? newSteps[stepIndex].keys
: [];
if (keysArray.length >= MAX_KEYS_PER_STEP) { if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`); showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
return; return;
@ -148,9 +161,9 @@ export function MacroForm({
setMacro({ ...macro, steps: newSteps }); setMacro({ ...macro, steps: newSteps });
}; };
const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => { const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1; const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]]; [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
setMacro({ ...macro, steps: newSteps }); setMacro({ ...macro, steps: newSteps });
}; };
@ -181,7 +194,10 @@ export function MacroForm({
<div> <div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} /> <FieldLabel
label="Steps"
description={`Keys/modifiers executed in sequence with a delay between each step.`}
/>
</div> </div>
<span className="text-slate-500 dark:text-slate-400"> <span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
@ -199,18 +215,24 @@ export function MacroForm({
key={stepIndex} key={stepIndex}
step={step} step={step}
stepIndex={stepIndex} stepIndex={stepIndex}
onDelete={macro.steps && macro.steps.length > 1 ? () => { onDelete={
const newSteps = [...(macro.steps || [])]; macro.steps && macro.steps.length > 1
newSteps.splice(stepIndex, 1); ? () => {
setMacro(prev => ({ ...prev, steps: newSteps })); const newSteps = [...(macro.steps || [])];
} : undefined} newSteps.splice(stepIndex, 1);
onMoveUp={() => handleStepMove(stepIndex, 'up')} setMacro(prev => ({ ...prev, steps: newSteps }));
onMoveDown={() => handleStepMove(stepIndex, 'down')} }
onKeySelect={(option) => handleKeySelect(stepIndex, option)} : undefined
onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)} }
keyQuery={keyQueries[stepIndex] || ''} onMoveUp={() => handleStepMove(stepIndex, "up")}
onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)} onMoveDown={() => handleStepMove(stepIndex, "down")}
onDelayChange={(delay) => handleDelayChange(stepIndex, delay)} onKeySelect={option => handleKeySelect(stepIndex, option)}
onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ""}
onModifierChange={modifiers =>
handleModifierChange(stepIndex, modifiers)
}
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1} isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
/> />
))} ))}
@ -223,10 +245,12 @@ export function MacroForm({
theme="light" theme="light"
fullWidth fullWidth
LeadingIcon={LuPlus} LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`} text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ""}`}
onClick={() => { onClick={() => {
if (isMaxStepsReached) { if (isMaxStepsReached) {
showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`); showTemporaryError(
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
);
return; return;
} }
@ -234,7 +258,7 @@ export function MacroForm({
...prev, ...prev,
steps: [ steps: [
...(prev.steps || []), ...(prev.steps || []),
{ keys: [], modifiers: [], delay: DEFAULT_DELAY } { keys: [], modifiers: [], delay: DEFAULT_DELAY },
], ],
})); }));
setErrors({}); setErrors({});
@ -257,12 +281,7 @@ export function MacroForm({
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<Button <Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
size="SM"
theme="light"
text="Cancel"
onClick={onCancel}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -63,7 +63,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
)} )}
> >
{label && <FieldLabel label={label} id={id} as="span" />} {label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30"> <Card className="w-auto !border border-solid !border-slate-800/30 shadow-xs outline-0 dark:!border-slate-300/30">
<select <select
ref={ref} ref={ref}
name={name} name={name}
@ -72,7 +72,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
classes, classes,
// General styling // General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300", "block w-full cursor-pointer rounded-sm border-none py-0 font-medium shadow-none outline-0 transition duration-300",
// Hover // Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white", "hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",

View File

@ -44,7 +44,7 @@ export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props)
return ( return (
<div <div
className={cx( className={cx(
"rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-sm dark:border-blue-300", "rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-xs dark:border-blue-300",
textStyle, textStyle,
)} )}
key={`${i}-${currStepIdx}`} key={`${i}-${currStepIdx}`}

View File

@ -17,7 +17,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
className={cx( className={cx(
"relative w-full", "relative w-full",
"invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2", "invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2",
"focus-within:border-slate-300 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600", "focus-within:border-slate-300 focus-within:outline-hidden focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600",
)} )}
> >
<textarea <textarea
@ -25,7 +25,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
{...props} {...props}
id="asd" id="asd"
className={clsx( className={clsx(
"block w-full rounded border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm", "block w-full rounded-sm border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm",
props.className, props.className,
)} )}
/> />

View File

@ -3,18 +3,18 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu"; import { LuPlay } from "react-icons/lu";
import { BsMouseFill } from "react-icons/bs";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { BsMouseFill } from "react-icons/bs";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; children: React.ReactNode;
} }
function OverlayContent({ children }: OverlayContentProps) { function OverlayContent({ children }: OverlayContentProps) {
return ( return (
<GridCard cardClassName="h-full pointer-events-auto !outline-none"> <GridCard cardClassName="h-full pointer-events-auto !outline-hidden">
<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"> <div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
{children} {children}
</div> </div>
@ -242,8 +242,8 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
Ensure source device is powered on and outputting a signal Ensure source device is powered on and outputting a signal
</li> </li>
<li> <li>
If using an adapter, ensure it&apos;s compatible and If using an adapter, ensure it&apos;s compatible and functioning
functioning correctly correctly
</li> </li>
</ul> </ul>
</div> </div>
@ -377,7 +377,7 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
> >
<div> <div>
<Card className="rounded-b-none shadow-none !outline-0"> <Card className="rounded-b-none shadow-none !outline-0">
<div className="flex items-center justify-between border border-slate-800/50 px-4 py-2 outline-0 backdrop-blur-sm dark:border-slate-300/20 dark:bg-slate-800"> <div className="flex items-center justify-between border border-slate-800/50 px-4 py-2 outline-0 backdrop-blur-xs dark:border-slate-300/20 dark:bg-slate-800">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" /> <BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" />
<span className="text-sm text-black dark:text-white"> <span className="text-sm text-black dark:text-white">

View File

@ -143,6 +143,16 @@ function KeyboardWrapper() {
return; return;
} }
if (key === "CtrlAltBackspace") {
sendKeyboardEvent(
[keys["Backspace"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (isKeyShift || isKeyCaps) { if (isKeyShift || isKeyCaps) {
toggleLayout(); toggleLayout();
@ -257,13 +267,13 @@ function KeyboardWrapper() {
buttonTheme={[ buttonTheme={[
{ {
class: "combination-key", class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape", buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
}, },
]} ]}
display={keyDisplayMap} display={keyDisplayMap}
layout={{ layout={{
default: [ default: [
"CtrlAltDelete AltMetaEscape", "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace", "Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash", "Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
@ -272,7 +282,7 @@ function KeyboardWrapper() {
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight", "ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
], ],
shift: [ shift: [
"CtrlAltDelete AltMetaEscape", "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)", "(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)", "Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
@ -282,7 +292,7 @@ function KeyboardWrapper() {
], ],
}} }}
disableButtonHold={true} disableButtonHold={true}
mergeDisplay={true} syncInstanceInputs={true}
debug={false} debug={false}
/> />
@ -290,34 +300,25 @@ function KeyboardWrapper() {
<Keyboard <Keyboard
baseClass="simple-keyboard-control" baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default" theme="simple-keyboard hg-theme-default hg-layout-default"
layoutName={layoutName}
onKeyPress={onKeyDown}
display={keyDisplayMap}
layout={{ layout={{
default: ["Home Pageup", "Delete End Pagedown"], default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
}} shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}} }}
syncInstanceInputs={true} syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false} debug={false}
/> />
<Keyboard <Keyboard
baseClass="simple-keyboard-arrows" baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default" theme="simple-keyboard hg-theme-default hg-layout-default"
display={{ onKeyPress={onKeyDown}
ArrowLeft: "←", display={keyDisplayMap}
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{ layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"], default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}} }}
onKeyPress={onKeyDown} syncInstanceInputs={true}
debug={false} debug={false}
/> />
</div> </div>

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useResizeObserver } from "usehooks-ts";
import { import {
useDeviceSettingsStore, useDeviceSettingsStore,
@ -10,7 +11,6 @@ import {
useVideoStore, useVideoStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { useResizeObserver } from "usehooks-ts";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import VirtualKeyboard from "@components/VirtualKeyboard"; import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar"; import Actionbar from "@components/ActionBar";
@ -151,7 +151,7 @@ export default function WebRTCVideo() {
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted) { if (isKeyboardLockGranted) {
if ("keyboard" in navigator) { if ("keyboard" in navigator) {
// @ts-ignore // @ts-expect-error - keyboard lock is not supported in all browsers
await navigator.keyboard.lock(); await navigator.keyboard.lock();
} }
} }
@ -673,7 +673,7 @@ export default function WebRTCVideo() {
]); ]);
return ( return (
<div className="grid h-full w-full grid-rows-layout"> <div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col"> <div className="flex min-h-[39.5px] flex-col">
<div className="flex flex-col"> <div className="flex flex-col">
<fieldset <fieldset
@ -699,7 +699,7 @@ export default function WebRTCVideo() {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="relative flex-grow overflow-hidden"> <div className="relative flex-grow overflow-hidden">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="grid flex-grow grid-rows-bodyFooter overflow-hidden"> <div className="grid flex-grow grid-rows-(--grid-bodyFooter) overflow-hidden">
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden"> <div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex h-full w-full items-center justify-center">
<div className="relative inline-block"> <div className="relative inline-block">
@ -724,7 +724,7 @@ export default function WebRTCVideo() {
hdmiError || hdmiError ||
peerConnectionState !== "connected", peerConnectionState !== "connected",
"!opacity-60": showPointerLockBar, "!opacity-60": showPointerLockBar,
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": "animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
isPlaying, isPlaying,
}, },
)} )}
@ -732,7 +732,7 @@ export default function WebRTCVideo() {
{peerConnection?.connectionState == "connected" && ( {peerConnection?.connectionState == "connected" && (
<div <div
style={{ animationDuration: "500ms" }} style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0" className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center"
> >
<div className="relative h-full w-full rounded-md"> <div className="relative h-full w-full rounded-md">
<LoadingVideoOverlay show={isVideoLoading} /> <LoadingVideoOverlay show={isVideoLoading} />

View File

@ -107,7 +107,7 @@ export function ATXPowerControl() {
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" /> <LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card> </Card>
) : ( ) : (
<Card className="h-[120px] animate-fadeIn opacity-0"> <Card className="h-[120px] animate-fadeIn">
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
{/* Control Buttons */} {/* Control Buttons */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">

View File

@ -63,7 +63,7 @@ export function DCPowerControl() {
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" /> <LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card> </Card>
) : ( ) : (
<Card className="h-[160px] animate-fadeIn opacity-0"> <Card className="h-[160px] animate-fadeIn">
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
{/* Power Controls */} {/* Power Controls */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">

View File

@ -58,7 +58,7 @@ export function SerialConsole() {
description="Configure your serial console settings" description="Configure your serial console settings"
/> />
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn">
<div className="space-y-4 p-3"> <div className="space-y-4 p-3">
{/* Open Console Button */} {/* Open Console Button */}
<div className="flex items-center"> <div className="flex items-center">

View File

@ -84,7 +84,7 @@ export default function ExtensionPopover() {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="space-y-4"> <div className="space-y-4">
{activeExtension ? ( {activeExtension ? (
// Extension Control View // Extension Control View
@ -92,7 +92,7 @@ export default function ExtensionPopover() {
{renderActiveExtension()} {renderActiveExtension()}
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -113,7 +113,7 @@ export default function ExtensionPopover() {
title="Extensions" title="Extensions"
description="Load and manage your extensions" description="Load and manage your extensions"
/> />
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30"> <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => ( {AVAILABLE_EXTENSIONS.map(extension => (
<div <div

View File

@ -194,7 +194,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div ref={ref} className="grid h-full grid-rows-headerBody"> <div ref={ref} className="grid h-full grid-rows-(--grid-headerBody)">
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -214,7 +214,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
) : null} ) : null}
<div <div
className="animate-fadeIn space-y-2 opacity-0" className="animate-fadeIn space-y-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -289,7 +289,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
{!remoteVirtualMediaState && ( {!remoteVirtualMediaState && (
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -106,7 +106,7 @@ export default function PasteModal() {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -115,7 +115,7 @@ export default function PasteModal() {
/> />
<div <div
className="animate-fadeIn space-y-2 opacity-0" className="animate-fadeIn space-y-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -174,7 +174,7 @@ export default function PasteModal() {
</div> </div>
</div> </div>
<div <div
className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0" className="flex animate-fadeIn items-center justify-end gap-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -26,7 +26,7 @@ export default function AddDeviceForm({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div <div
className="animate-fadeIn space-y-4 opacity-0" className="animate-fadeIn space-y-4"
style={{ style={{
animationDuration: "0.5s", animationDuration: "0.5s",
animationFillMode: "forwards", animationFillMode: "forwards",
@ -73,7 +73,7 @@ export default function AddDeviceForm({
/> />
</div> </div>
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -28,7 +28,7 @@ export default function DeviceList({
}: DeviceListProps) { }: DeviceListProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30"> <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => ( {storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between gap-x-2 p-3"> <div key={index} className="flex items-center justify-between gap-x-2 p-3">
@ -63,7 +63,7 @@ export default function DeviceList({
</div> </div>
</Card> </Card>
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -13,7 +13,7 @@ export default function EmptyStateCard({
}) { }) {
return ( return (
<div className="select-none space-y-4"> <div className="select-none space-y-4">
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn">
<div className="flex items-center justify-center py-8 text-center"> <div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1"> <div className="space-y-1">
@ -35,7 +35,7 @@ export default function EmptyStateCard({
</div> </div>
</Card> </Card>
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -102,7 +102,7 @@ export default function WakeOnLanModal() {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Wake On LAN" title="Wake On LAN"

View File

@ -99,7 +99,7 @@ export default function ConnectionStatsSidebar() {
}, 500); }, 500);
return ( return (
<div className="grid h-full grid-rows-headerBody shadow-sm"> <div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} /> <SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900"> <div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4"> <div className="space-y-4">

View File

@ -1,6 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
import {
MAX_STEPS_PER_MACRO,
MAX_TOTAL_MACROS,
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
// Define the JsonRpc types for better type checking // Define the JsonRpc types for better type checking
interface JsonRpcResponse { interface JsonRpcResponse {
@ -576,12 +581,12 @@ export interface UpdateState {
setOtaState: (state: UpdateState["otaState"]) => void; setOtaState: (state: UpdateState["otaState"]) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
modalView: modalView:
| "loading" | "loading"
| "updating" | "updating"
| "upToDate" | "upToDate"
| "updateAvailable" | "updateAvailable"
| "updateCompleted" | "updateCompleted"
| "error"; | "error";
setModalView: (view: UpdateState["modalView"]) => void; setModalView: (view: UpdateState["modalView"]) => void;
setUpdateErrorMessage: (errorMessage: string) => void; setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null; updateErrorMessage: string | null;
@ -645,12 +650,12 @@ export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
interface LocalAuthModalState { interface LocalAuthModalState {
modalView: modalView:
| "createPassword" | "createPassword"
| "deletePassword" | "deletePassword"
| "updatePassword" | "updatePassword"
| "creationSuccess" | "creationSuccess"
| "deleteSuccess" | "deleteSuccess"
| "updateSuccess"; | "updateSuccess";
setModalView: (view: LocalAuthModalState["modalView"]) => void; setModalView: (view: LocalAuthModalState["modalView"]) => void;
} }
@ -731,12 +736,23 @@ export interface NetworkState {
setDhcpLeaseExpiry: (expiry: Date) => void; setDhcpLeaseExpiry: (expiry: Date) => void;
} }
export type IPv6Mode =
export type IPv6Mode = "disabled" | "slaac" | "dhcpv6" | "slaac_and_dhcpv6" | "static" | "link_local" | "unknown"; | "disabled"
| "slaac"
| "dhcpv6"
| "slaac_and_dhcpv6"
| "static"
| "link_local"
| "unknown";
export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown"; export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown";
export type LLDPMode = "disabled" | "basic" | "all" | "unknown"; export type LLDPMode = "disabled" | "basic" | "all" | "unknown";
export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown"; export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown";
export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown"; export type TimeSyncMode =
| "ntp_only"
| "ntp_and_http"
| "http_only"
| "custom"
| "unknown";
export interface NetworkSettings { export interface NetworkSettings {
hostname: string; hostname: string;
@ -761,7 +777,7 @@ export const useNetworkStateStore = create<NetworkState>((set, get) => ({
lease.lease_expiry = expiry; lease.lease_expiry = expiry;
set({ dhcp_lease: lease }); set({ dhcp_lease: lease });
} },
})); }));
export interface KeySequenceStep { export interface KeySequenceStep {
@ -783,8 +799,20 @@ export interface MacrosState {
initialized: boolean; initialized: boolean;
loadMacros: () => Promise<void>; loadMacros: () => Promise<void>;
saveMacros: (macros: KeySequence[]) => Promise<void>; saveMacros: (macros: KeySequence[]) => Promise<void>;
sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void) | null; sendFn:
setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void; | ((
method: string,
params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void)
| null;
setSendFn: (
sendFn: (
method: string,
params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void,
) => void;
} }
export const generateMacroId = () => { export const generateMacroId = () => {
@ -797,7 +825,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
initialized: false, initialized: false,
sendFn: null, sendFn: null,
setSendFn: (sendFn) => { setSendFn: sendFn => {
set({ sendFn }); set({ sendFn });
}, },
@ -814,7 +842,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sendFn("getKeyboardMacros", {}, (response) => { sendFn("getKeyboardMacros", {}, response => {
if (response.error) { if (response.error) {
console.error("Error loading macros:", response.error); console.error("Error loading macros:", response.error);
reject(new Error(response.error.message)); reject(new Error(response.error.message));
@ -834,7 +862,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
set({ set({
macros: sortedMacros, macros: sortedMacros,
initialized: true initialized: true,
}); });
resolve(); resolve();
@ -861,15 +889,23 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
for (const macro of macros) { for (const macro of macros) {
if (macro.steps.length > MAX_STEPS_PER_MACRO) { if (macro.steps.length > MAX_STEPS_PER_MACRO) {
console.error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`); console.error(
throw new Error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`); `Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
);
throw new Error(
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
);
} }
for (let i = 0; i < macro.steps.length; i++) { for (let i = 0; i < macro.steps.length; i++) {
const step = macro.steps[i]; const step = macro.steps[i];
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) { if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
console.error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); console.error(
throw new Error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); `Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
);
throw new Error(
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
);
} }
} }
} }
@ -879,20 +915,25 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try { try {
const macrosWithSortOrder = macros.map((macro, index) => ({ const macrosWithSortOrder = macros.map((macro, index) => ({
...macro, ...macro,
sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index,
})); }));
const response = await new Promise<JsonRpcResponse>((resolve) => { const response = await new Promise<JsonRpcResponse>(resolve => {
sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => { sendFn(
resolve(response); "setKeyboardMacros",
}); { params: { macros: macrosWithSortOrder } },
response => {
resolve(response);
},
);
}); });
if (response.error) { if (response.error) {
console.error("Error saving macros:", response.error); console.error("Error saving macros:", response.error);
const errorMessage = typeof response.error.data === 'string' const errorMessage =
? response.error.data typeof response.error.data === "string"
: response.error.message || "Failed to save macros"; ? response.error.data
: response.error.message || "Failed to save macros";
throw new Error(errorMessage); throw new Error(errorMessage);
} }
@ -904,5 +945,6 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
},
} }
})); }));

View File

@ -1,6 +1,11 @@
@tailwind base; @import "tailwindcss";
@tailwind components; @config "../tailwind.config.js";
@tailwind utilities; @plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "@headlessui/tailwindcss";
/* Dark mode uses CSS selector instead of prefers-color-scheme */
@custom-variant dark (&:where(.dark, .dark *));
html { html {
@apply scroll-smooth; @apply scroll-smooth;
@ -13,6 +18,128 @@ body {
overflow: auto; overflow: auto;
} }
@theme {
--font-sans: "Circular", sans-serif;
--font-display: "Circular", sans-serif;
--font-serif: "Circular", serif;
--font-mono: "Source Code Pro Variable", monospace;
--grid-layout: auto 1fr auto;
--grid-headerBody: auto 1fr;
--grid-bodyFooter: 1fr auto;
--grid-sidebar: 1fr minmax(360px, 25%);
--breakpoint-xs: 480px;
--breakpoint-2xl: 1440px;
--breakpoint-3xl: 1920px;
--breakpoint-4xl: 2560px;
/* Migrated animations */
--animate-enter: enter 0.2s ease-out;
--animate-leave: leave 0.15s ease-in forwards;
--animate-fadeInScale: fadeInScale 1s ease-out forwards;
--animate-fadeInScaleFloat:
fadeInScaleFloat 1s ease-out forwards, float 3s ease-in-out infinite;
--animate-fadeIn: fadeIn 1s ease-out forwards;
--animate-slideUpFade: slideUpFade 1s ease-out forwards;
--container-8xl: 88rem;
--container-9xl: 96rem;
--container-10xl: 104rem;
--container-11xl: 112rem;
--container-12xl: 120rem;
/* Migrated keyframes */
@keyframes enter {
0% {
opacity: 0;
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes leave {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.9);
}
}
@keyframes fadeInScale {
0% {
opacity: 0;
transform: scale(0.98);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeInScaleFloat {
0% {
opacity: 0;
transform: scale(0.98) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(10px);
}
70% {
opacity: 0.8;
transform: translateY(1px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUpFade {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
@utility max-width-* {
max-width: --modifier(--container- *, [length], [ *]);
}
/* Ensure there is not a `ms` and ms -> `...)ms` */
@utility animation-delay-* {
animation-delay: --value(integer) ms;
}
@property --grid-color-start { @property --grid-color-start {
syntax: "<color>"; syntax: "<color>";
initial-value: theme("colors.blue.50/10"); initial-value: theme("colors.blue.50/10");
@ -50,7 +177,7 @@ video::-webkit-media-controls {
} }
.hg-theme-default .hg-button { .hg-theme-default .hg-button {
@apply border !border-b border-slate-800/25 !border-b-slate-800/25 !shadow-sm; @apply border !border-b border-slate-800/25 !border-b-slate-800/25 !shadow-xs;
} }
.hg-theme-default .hg-button span { .hg-theme-default .hg-button span {
@ -174,7 +301,7 @@ video::-webkit-media-controls {
} }
.hg-theme-default .hg-row .combination-key { .hg-theme-default .hg-row .combination-key {
@apply inline-flex !h-auto !w-auto flex-grow-0 py-1 text-xs; @apply inline-flex !h-auto !w-auto grow-0 py-1 text-xs;
} }
.hg-theme-default .hg-row:has(.combination-key) { .hg-theme-default .hg-row:has(.combination-key) {

View File

@ -99,8 +99,15 @@ export const chars = {
"~": { key: "Backquote", shift: true }, "~": { key: "Backquote", shift: true },
"§": { key: "IntlBackslash" }, "§": { key: "IntlBackslash" },
"±": { key: "IntlBackslash", shift: true }, "±": { key: "IntlBackslash", shift: true },
" ": { key: "Space" }, " ": { key: "Space", shift: false },
"\n": { key: "Enter" }, "\n": { key: "Enter", shift: false },
Enter: { key: "Enter" }, Enter: { key: "Enter", shift: false },
Tab: { key: "Tab" }, Tab: { key: "Tab", shift: false },
PrintScreen: { key: "Prt Sc", shift: false },
SystemRequest: { key: "Prt Sc", shift: true },
ScrollLock: { key: "ScrollLock", shift: false},
Pause: { key: "Pause", shift: false },
Break: { key: "Pause", shift: true },
Insert: { key: "Insert", shift: false },
Delete: { key: "Delete", shift: false },
} as Record<string, KeyCombo> } as Record<string, KeyCombo>

View File

@ -86,16 +86,21 @@ export const keys = {
NumpadAdd: 0x57, NumpadAdd: 0x57,
NumpadDivide: 0x54, NumpadDivide: 0x54,
NumpadEnter: 0x58, NumpadEnter: 0x58,
NumpadEqual: 0x67,
NumpadMultiply: 0x55, NumpadMultiply: 0x55,
NumpadSubtract: 0x56, NumpadSubtract: 0x56,
NumpadDecimal: 0x63, NumpadDecimal: 0x63,
PageDown: 0x4e, PageDown: 0x4e,
PageUp: 0x4b, PageUp: 0x4b,
Period: 0x37, Period: 0x37,
PrintScreen: 0x46,
Pause: 0x48,
Quote: 0x34, Quote: 0x34,
ScrollLock: 0x47,
Semicolon: 0x33, Semicolon: 0x33,
Slash: 0x38, Slash: 0x38,
Space: 0x2c, Space: 0x2c,
SystemRequest: 0x9a,
Tab: 0x2b, Tab: 0x2b,
} as Record<string, number>; } as Record<string, number>;
@ -124,9 +129,11 @@ export const modifierDisplayMap: Record<string, string> = {
export const keyDisplayMap: Record<string, string> = { export const keyDisplayMap: Record<string, string> = {
CtrlAltDelete: "Ctrl + Alt + Delete", CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape", AltMetaEscape: "Alt + Meta + Escape",
CtrlAltBackspace: "Ctrl + Alt + Backspace",
Escape: "esc", Escape: "esc",
Tab: "tab", Tab: "tab",
Backspace: "backspace", Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter", Enter: "enter",
CapsLock: "caps lock", CapsLock: "caps lock",
ShiftLeft: "shift", ShiftLeft: "shift",
@ -137,11 +144,12 @@ export const keyDisplayMap: Record<string, string> = {
MetaLeft: "meta", MetaLeft: "meta",
MetaRight: "meta", MetaRight: "meta",
Space: " ", Space: " ",
Insert: "insert",
Home: "home", Home: "home",
PageUp: "pageup", PageUp: "page up",
Delete: "delete", Delete: "delete",
End: "end", End: "end",
PageDown: "pagedown", PageDown: "page down",
ArrowLeft: "←", ArrowLeft: "←",
ArrowRight: "→", ArrowRight: "→",
ArrowUp: "↑", ArrowUp: "↑",
@ -155,22 +163,45 @@ export const keyDisplayMap: Record<string, string> = {
KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y", KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y",
KeyZ: "z", KeyZ: "z",
// Capital letters
"(KeyA)": "A", "(KeyB)": "B", "(KeyC)": "C", "(KeyD)": "D", "(KeyE)": "E",
"(KeyF)": "F", "(KeyG)": "G", "(KeyH)": "H", "(KeyI)": "I", "(KeyJ)": "J",
"(KeyK)": "K", "(KeyL)": "L", "(KeyM)": "M", "(KeyN)": "N", "(KeyO)": "O",
"(KeyP)": "P", "(KeyQ)": "Q", "(KeyR)": "R", "(KeyS)": "S", "(KeyT)": "T",
"(KeyU)": "U", "(KeyV)": "V", "(KeyW)": "W", "(KeyX)": "X", "(KeyY)": "Y",
"(KeyZ)": "Z",
// Numbers // Numbers
Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5", Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5",
Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0", Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0",
// Shifted Numbers
"(Digit1)": "!", "(Digit2)": "@", "(Digit3)": "#", "(Digit4)": "$", "(Digit5)": "%",
"(Digit6)": "^", "(Digit7)": "&", "(Digit8)": "*", "(Digit9)": "(", "(Digit0)": ")",
// Symbols // Symbols
Minus: "-", Minus: "-",
"(Minus)": "_",
Equal: "=", Equal: "=",
"(Equal)": "+",
BracketLeft: "[", BracketLeft: "[",
"(BracketLeft)": "{",
BracketRight: "]", BracketRight: "]",
"(BracketRight)": "}",
Backslash: "\\", Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";", Semicolon: ";",
"(Semicolon)": ":",
Quote: "'", Quote: "'",
"(Quote)": "\"",
Comma: ",", Comma: ",",
"(Comma)": "<",
Period: ".", Period: ".",
"(Period)": ">",
Slash: "/", Slash: "/",
"(Slash)": "?",
Backquote: "`", Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\", IntlBackslash: "\\",
// Function keys // Function keys
@ -184,5 +215,11 @@ export const keyDisplayMap: Record<string, string> = {
Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8", Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8",
Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -", Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -",
NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .", NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .",
NumpadEnter: "Num Enter" NumpadEqual: "Num =", NumpadEnter: "Num Enter",
NumLock: "Num Lock",
// Modals
PrintScreen: "prt sc", ScrollLock: "scr lk", Pause: "pause",
"(PrintScreen)": "sys rq", "(Pause)": "break",
SystemRequest: "sys rq", Break: "break"
}; };

View File

@ -17,7 +17,7 @@ import AdoptRoute from "@routes/adopt";
import SignupRoute from "@routes/signup"; import SignupRoute from "@routes/signup";
import LoginRoute from "@routes/login"; import LoginRoute from "@routes/login";
import SetupRoute from "@routes/devices.$id.setup"; import SetupRoute from "@routes/devices.$id.setup";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices"; import DevicesRoute from "@routes/devices";
import DeviceRoute, { LocalDevice } from "@routes/devices.$id"; import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import Card from "@components/Card"; import Card from "@components/Card";
import DevicesAlreadyAdopted from "@routes/devices.already-adopted"; import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
@ -37,7 +37,7 @@ import SettingsKeyboardRoute from "./routes/devices.$id.settings.keyboard";
import api from "./api"; import api from "./api";
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index"; import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced"; import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index"; import SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index";
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware"; import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
import SettingsVideoRoute from "./routes/devices.$id.settings.video"; import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance"; import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
@ -171,7 +171,7 @@ if (isOnDevice) {
children: [ children: [
{ {
index: true, index: true,
element: <SettingsAccessIndexRoute.default />, element: <SettingsAccessIndexRoute />,
loader: SettingsAccessIndexRoute.loader, loader: SettingsAccessIndexRoute.loader,
}, },
{ {
@ -300,7 +300,7 @@ if (isOnDevice) {
children: [ children: [
{ {
index: true, index: true,
element: <SettingsAccessIndexRoute.default />, element: <SettingsAccessIndexRoute />,
loader: SettingsAccessIndexRoute.loader, loader: SettingsAccessIndexRoute.loader,
}, },
{ {
@ -350,7 +350,10 @@ if (isOnDevice) {
loader: DeviceIdRename.loader, loader: DeviceIdRename.loader,
action: DeviceIdRename.action, action: DeviceIdRename.action,
}, },
{ path: "devices", element: <DevicesRoute />, loader: DeviceListLoader }, {
path: "devices",
element: <DevicesRoute />,
loader: DevicesRoute.loader },
], ],
}, },
], ],
@ -365,7 +368,7 @@ document.addEventListener("DOMContentLoaded", () => {
<Notifications <Notifications
toastOptions={{ toastOptions={{
className: className:
"rounded border-none bg-white text-black shadow outline outline-1 outline-slate-800/30", "rounded-sm border-none bg-white text-black shadow-sm outline-1 outline-slate-800/30",
}} }}
max={2} max={2}
/> />

View File

@ -71,12 +71,13 @@ export default function DevicesIdDeregister() {
const error = useActionData() as { message: string }; const error = useActionData() as { message: string };
return ( return (
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<DashboardNavbar <DashboardNavbar
isLoggedIn={!!user} isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]} primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
userEmail={user?.email} userEmail={user?.email}
picture={user?.picture} picture={user?.picture}
kvmName={device?.name}
/> />
<div className="w-full h-full"> <div className="w-full h-full">

View File

@ -320,7 +320,7 @@ function ModeSelectionView({
].map(({ label, description, value: mode, icon: Icon, tag, disabled }, index) => ( ].map(({ label, description, value: mode, icon: Icon, tag, disabled }, index) => (
<div <div
key={label} key={label}
className={cx("animate-fadeIn opacity-0")} className={cx("animate-fadeIn")}
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: `${25 * (index * 5)}ms`, animationDelay: `${25 * (index * 5)}ms`,
@ -328,7 +328,7 @@ function ModeSelectionView({
> >
<Card <Card
className={cx( className={cx(
"w-full min-w-[250px] cursor-pointer bg-white shadow-sm transition-all duration-100 hover:shadow-md dark:bg-slate-800", "w-full min-w-[250px] cursor-pointer bg-white shadow-xs transition-all duration-100 hover:shadow-md dark:bg-slate-800",
{ {
"ring-2 ring-blue-700": selectedMode === mode, "ring-2 ring-blue-700": selectedMode === mode,
"hover:ring-2 hover:ring-blue-500": selectedMode !== mode && !disabled, "hover:ring-2 hover:ring-blue-500": selectedMode !== mode && !disabled,
@ -373,7 +373,7 @@ function ModeSelectionView({
))} ))}
</div> </div>
<div <div
className="flex animate-fadeIn justify-end opacity-0" className="flex animate-fadeIn justify-end"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -437,7 +437,7 @@ function BrowserFileView({
className="block cursor-pointer select-none" className="block cursor-pointer select-none"
> >
<div <div
className="group animate-fadeIn opacity-0" className="group animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -483,7 +483,7 @@ function BrowserFileView({
</div> </div>
<div <div
className="flex w-full animate-fadeIn items-end justify-between opacity-0" className="flex w-full animate-fadeIn items-end justify-between"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -578,7 +578,7 @@ function UrlView({
/> />
<div <div
className="animate-fadeIn opacity-0" className="animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -593,7 +593,7 @@ function UrlView({
/> />
</div> </div>
<div <div
className="flex w-full animate-fadeIn items-end justify-between opacity-0" className="flex w-full animate-fadeIn items-end justify-between"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -619,7 +619,7 @@ function UrlView({
<hr className="border-slate-800/30 dark:border-slate-300/20" /> <hr className="border-slate-800/30 dark:border-slate-300/20" />
<div <div
className="animate-fadeIn opacity-0" className="animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -797,7 +797,7 @@ function DeviceFileView({
description="Select an image to mount from the JetKVM storage" description="Select an image to mount from the JetKVM storage"
/> />
<div <div
className="w-full animate-fadeIn opacity-0" className="w-full animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -886,7 +886,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 ? ( {onStorageFiles.length > 0 ? (
<div <div
className="flex animate-fadeIn items-end justify-between opacity-0" className="flex animate-fadeIn items-end justify-between"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.15s", animationDelay: "0.15s",
@ -914,7 +914,7 @@ function DeviceFileView({
</div> </div>
) : ( ) : (
<div <div
className="flex animate-fadeIn items-end justify-end opacity-0" className="flex animate-fadeIn items-end justify-end"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.15s", animationDelay: "0.15s",
@ -927,7 +927,7 @@ function DeviceFileView({
)} )}
<hr className="border-slate-800/20 dark:border-slate-300/20" /> <hr className="border-slate-800/20 dark:border-slate-300/20" />
<div <div
className="animate-fadeIn space-y-2 opacity-0" className="animate-fadeIn space-y-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.20s", animationDelay: "0.20s",
@ -941,9 +941,9 @@ function DeviceFileView({
{percentageUsed}% used {percentageUsed}% used
</span> </span>
</div> </div>
<div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700"> <div className="h-3.5 w-full overflow-hidden rounded-xs bg-slate-200 dark:bg-slate-700">
<div <div
className="h-full rounded-sm bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500" className="h-full rounded-xs bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500"
style={{ width: `${percentageUsed}%` }} style={{ width: `${percentageUsed}%` }}
></div> ></div>
</div> </div>
@ -959,7 +959,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 && ( {onStorageFiles.length > 0 && (
<div <div
className="w-full animate-fadeIn opacity-0" className="w-full animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.25s", animationDelay: "0.25s",
@ -1251,7 +1251,7 @@ function UploadFileView({
} }
/> />
<div <div
className="animate-fadeIn space-y-2 opacity-0" className="animate-fadeIn space-y-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -1365,7 +1365,7 @@ function UploadFileView({
{/* Display upload error if present */} {/* Display upload error if present */}
{uploadError && ( {uploadError && (
<div <div
className="mt-2 animate-fadeIn truncate text-sm text-red-600 opacity-0 dark:text-red-400" className="mt-2 animate-fadeIn truncate text-sm text-red-600 dark:text-red-400"
style={{ animationDuration: "0.7s" }} style={{ animationDuration: "0.7s" }}
> >
Error: {uploadError} Error: {uploadError}
@ -1373,7 +1373,7 @@ function UploadFileView({
)} )}
<div <div
className="flex w-full animate-fadeIn items-end opacity-0" className="flex w-full animate-fadeIn items-end"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -1496,7 +1496,7 @@ function PreUploadedImageItem({
</div> </div>
<div className="relative flex select-none items-center gap-x-3"> <div className="relative flex select-none items-center gap-x-3">
<div <div
className={cx("opacity-0 transition-opacity duration-200", { className={cx("opacity-0 transition-opacity duration-200", {
"w-auto opacity-100": isHovering, "w-auto opacity-100": isHovering,
})} })}
> >

View File

@ -75,12 +75,13 @@ export default function DeviceIdRename() {
const error = useActionData() as { message: string }; const error = useActionData() as { message: string };
return ( return (
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<DashboardNavbar <DashboardNavbar
isLoggedIn={!!user} isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]} primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
userEmail={user?.email} userEmail={user?.email}
picture={user?.picture} picture={user?.picture}
kvmName={device?.name}
/> />
<div className="h-full w-full"> <div className="h-full w-full">

View File

@ -26,7 +26,7 @@ export interface TLSState {
privateKey?: string; privateKey?: string;
} }
export const loader = async () => { const loader = async () => {
if (isOnDevice) { if (isOnDevice) {
const status = await api const status = await api
.GET(`${DEVICE_API}/device`) .GET(`${DEVICE_API}/device`)
@ -468,3 +468,5 @@ export default function SettingsAccessIndexRoute() {
</div> </div>
); );
} }
SettingsAccessIndexRoute.loader = loader;

View File

@ -1,6 +1,15 @@
import { useEffect, Fragment, useMemo, useState, useCallback } from "react"; import { useEffect, Fragment, useMemo, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { LuPenLine, LuCopy, LuMoveRight, LuCornerDownRight, LuArrowUp, LuArrowDown, LuTrash2, LuCommand } from "react-icons/lu"; import {
LuPenLine,
LuCopy,
LuMoveRight,
LuCornerDownRight,
LuArrowUp,
LuArrowDown,
LuTrash2,
LuCommand,
} from "react-icons/lu";
import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores"; import { KeySequence, useMacrosStore, generateMacroId } from "@/hooks/stores";
import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { SettingsPageHeader } from "@/components/SettingsPageheader";
@ -27,9 +36,9 @@ export default function SettingsMacrosRoute() {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null); const [macroToDelete, setMacroToDelete] = useState<KeySequence | null>(null);
const isMaxMacrosReached = useMemo(() => const isMaxMacrosReached = useMemo(
macros.length >= MAX_TOTAL_MACROS, () => macros.length >= MAX_TOTAL_MACROS,
[macros.length] [macros.length],
); );
useEffect(() => { useEffect(() => {
@ -38,75 +47,83 @@ export default function SettingsMacrosRoute() {
} }
}, [initialized, loadMacros]); }, [initialized, loadMacros]);
const handleDuplicateMacro = useCallback(async (macro: KeySequence) => { const handleDuplicateMacro = useCallback(
if (!macro?.id || !macro?.name) { async (macro: KeySequence) => {
notifications.error("Invalid macro data"); if (!macro?.id || !macro?.name) {
return; notifications.error("Invalid macro data");
} return;
if (isMaxMacrosReached) {
notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
return;
}
setActionLoadingId(macro.id);
const newMacroCopy: KeySequence = {
...JSON.parse(JSON.stringify(macro)),
id: generateMacroId(),
name: `${macro.name} ${COPY_SUFFIX}`,
sortOrder: macros.length + 1,
};
try {
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to duplicate macro: ${error.message}`);
} else {
notifications.error("Failed to duplicate macro");
} }
} finally {
setActionLoadingId(null);
}
}, [isMaxMacrosReached, macros, saveMacros, setActionLoadingId]);
const handleMoveMacro = useCallback(async (index: number, direction: 'up' | 'down', macroId: string) => { if (isMaxMacrosReached) {
if (!Array.isArray(macros) || macros.length === 0) { notifications.error(`Maximum of ${MAX_TOTAL_MACROS} macros allowed`);
notifications.error("No macros available"); return;
return;
}
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= macros.length) return;
setActionLoadingId(macroId);
try {
const newMacros = [...macros];
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
const updatedMacros = normalizeSortOrders(newMacros);
await saveMacros(updatedMacros);
notifications.success("Macro order updated successfully");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to reorder macros: ${error.message}`);
} else {
notifications.error("Failed to reorder macros");
} }
} finally {
setActionLoadingId(null); setActionLoadingId(macro.id);
}
}, [macros, saveMacros, setActionLoadingId]); const newMacroCopy: KeySequence = {
...JSON.parse(JSON.stringify(macro)),
id: generateMacroId(),
name: `${macro.name} ${COPY_SUFFIX}`,
sortOrder: macros.length + 1,
};
try {
await saveMacros(normalizeSortOrders([...macros, newMacroCopy]));
notifications.success(`Macro "${newMacroCopy.name}" duplicated successfully`);
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to duplicate macro: ${error.message}`);
} else {
notifications.error("Failed to duplicate macro");
}
} finally {
setActionLoadingId(null);
}
},
[isMaxMacrosReached, macros, saveMacros, setActionLoadingId],
);
const handleMoveMacro = useCallback(
async (index: number, direction: "up" | "down", macroId: string) => {
if (!Array.isArray(macros) || macros.length === 0) {
notifications.error("No macros available");
return;
}
const newIndex = direction === "up" ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= macros.length) return;
setActionLoadingId(macroId);
try {
const newMacros = [...macros];
[newMacros[index], newMacros[newIndex]] = [newMacros[newIndex], newMacros[index]];
const updatedMacros = normalizeSortOrders(newMacros);
await saveMacros(updatedMacros);
notifications.success("Macro order updated successfully");
} catch (error: unknown) {
if (error instanceof Error) {
notifications.error(`Failed to reorder macros: ${error.message}`);
} else {
notifications.error("Failed to reorder macros");
}
} finally {
setActionLoadingId(null);
}
},
[macros, saveMacros, setActionLoadingId],
);
const handleDeleteMacro = useCallback(async () => { const handleDeleteMacro = useCallback(async () => {
if (!macroToDelete?.id) return; if (!macroToDelete?.id) return;
setActionLoadingId(macroToDelete.id); setActionLoadingId(macroToDelete.id);
try { try {
const updatedMacros = normalizeSortOrders(macros.filter(m => m.id !== macroToDelete.id)); const updatedMacros = normalizeSortOrders(
macros.filter(m => m.id !== macroToDelete.id),
);
await saveMacros(updatedMacros); await saveMacros(updatedMacros);
notifications.success(`Macro "${macroToDelete.name}" deleted successfully`); notifications.success(`Macro "${macroToDelete.name}" deleted successfully`);
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
@ -122,135 +139,168 @@ export default function SettingsMacrosRoute() {
} }
}, [macroToDelete, macros, saveMacros]); }, [macroToDelete, macros, saveMacros]);
const MacroList = useMemo(() => ( const MacroList = useMemo(
<div className="space-y-2"> () => (
{macros.map((macro, index) => ( <div className="space-y-2">
<Card key={macro.id} className="p-2 bg-white dark:bg-slate-800"> {macros.map((macro, index) => (
<div className="flex items-center justify-between"> <Card key={macro.id} className="bg-white p-2 dark:bg-slate-800">
<div className="flex flex-col gap-1 px-2"> <div className="flex items-center justify-between">
<Button <div className="flex flex-col gap-1 px-2">
size="XS" <Button
theme="light" size="XS"
onClick={() => handleMoveMacro(index, 'up', macro.id)} theme="light"
disabled={index === 0 || actionLoadingId === macro.id} onClick={() => handleMoveMacro(index, "up", macro.id)}
LeadingIcon={LuArrowUp} disabled={index === 0 || actionLoadingId === macro.id}
aria-label={`Move ${macro.name} up`} LeadingIcon={LuArrowUp}
/> aria-label={`Move ${macro.name} up`}
<Button />
size="XS" <Button
theme="light" size="XS"
onClick={() => handleMoveMacro(index, 'down', macro.id)} theme="light"
disabled={index === macros.length - 1 || actionLoadingId === macro.id} onClick={() => handleMoveMacro(index, "down", macro.id)}
LeadingIcon={LuArrowDown} disabled={index === macros.length - 1 || actionLoadingId === macro.id}
aria-label={`Move ${macro.name} down`} LeadingIcon={LuArrowDown}
/> aria-label={`Move ${macro.name} down`}
</div> />
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center ml-2"> <div className="ml-2 flex min-w-0 flex-1 flex-col justify-center">
<h3 className="truncate text-sm font-semibold text-black dark:text-white"> <h3 className="truncate text-sm font-semibold text-black dark:text-white">
{macro.name} {macro.name}
</h3> </h3>
<p className="mt-1 ml-4 text-xs text-slate-500 dark:text-slate-400 overflow-hidden"> <p className="mt-1 ml-4 overflow-hidden text-xs text-slate-500 dark:text-slate-400">
<span className="flex flex-col items-start gap-1"> <span className="flex flex-col items-start gap-1">
{macro.steps.map((step, stepIndex) => { {macro.steps.map((step, stepIndex) => {
const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight; const StepIcon = stepIndex === 0 ? LuMoveRight : LuCornerDownRight;
return ( return (
<span key={stepIndex} className="inline-flex items-center"> <span key={stepIndex} className="inline-flex items-center">
<StepIcon className="mr-1 text-slate-400 dark:text-slate-500 h-3 w-3 flex-shrink-0" /> <StepIcon className="mr-1 h-3 w-3 shrink-0 text-slate-400 dark:text-slate-500" />
<span className="bg-slate-50 dark:bg-slate-800 px-2 py-0.5 rounded-md border border-slate-200/50 dark:border-slate-700/50"> <span className="rounded-md border border-slate-200/50 bg-slate-50 px-2 py-0.5 dark:border-slate-700/50 dark:bg-slate-800">
{(Array.isArray(step.modifiers) && step.modifiers.length > 0) || (Array.isArray(step.keys) && step.keys.length > 0) ? ( {(Array.isArray(step.modifiers) &&
<> step.modifiers.length > 0) ||
{Array.isArray(step.modifiers) && step.modifiers.map((modifier, idx) => ( (Array.isArray(step.keys) && step.keys.length > 0) ? (
<Fragment key={`mod-${idx}`}> <>
<span className="font-medium text-slate-600 dark:text-slate-200"> {Array.isArray(step.modifiers) &&
{modifierDisplayMap[modifier] || modifier} step.modifiers.map((modifier, idx) => (
</span> <Fragment key={`mod-${idx}`}>
{idx < step.modifiers.length - 1 && ( <span className="font-medium text-slate-600 dark:text-slate-200">
<span className="text-slate-400 dark:text-slate-600"> + </span> {modifierDisplayMap[modifier] || modifier}
</span>
{idx < step.modifiers.length - 1 && (
<span className="text-slate-400 dark:text-slate-600">
{" "}
+{" "}
</span>
)}
</Fragment>
))}
{Array.isArray(step.modifiers) &&
step.modifiers.length > 0 &&
Array.isArray(step.keys) &&
step.keys.length > 0 && (
<span className="text-slate-400 dark:text-slate-600">
{" "}
+{" "}
</span>
)} )}
</Fragment>
))}
{Array.isArray(step.modifiers) && step.modifiers.length > 0 && Array.isArray(step.keys) && step.keys.length > 0 && ( {Array.isArray(step.keys) &&
<span className="text-slate-400 dark:text-slate-600"> + </span> step.keys.map((key, idx) => (
)} <Fragment key={`key-${idx}`}>
<span className="font-medium text-blue-600 dark:text-blue-400">
{Array.isArray(step.keys) && step.keys.map((key, idx) => ( {keyDisplayMap[key] || key}
<Fragment key={`key-${idx}`}> </span>
<span className="font-medium text-blue-600 dark:text-blue-400"> {idx < step.keys.length - 1 && (
{keyDisplayMap[key] || key} <span className="text-slate-400 dark:text-slate-600">
</span> {" "}
{idx < step.keys.length - 1 && ( +{" "}
<span className="text-slate-400 dark:text-slate-600"> + </span> </span>
)} )}
</Fragment> </Fragment>
))} ))}
</> </>
) : ( ) : (
<span className="font-medium text-slate-500 dark:text-slate-400">Delay only</span> <span className="font-medium text-slate-500 dark:text-slate-400">
)} Delay only
{step.delay !== DEFAULT_DELAY && ( </span>
<span className="ml-1 text-slate-400 dark:text-slate-500">({step.delay}ms)</span> )}
)} {step.delay !== DEFAULT_DELAY && (
<span className="ml-1 text-slate-400 dark:text-slate-500">
({step.delay}ms)
</span>
)}
</span>
</span> </span>
</span> );
); })}
})} </span>
</span> </p>
</p> </div>
</div>
<div className="flex items-center gap-1 ml-4"> <div className="ml-4 flex items-center gap-1">
<Button <Button
size="XS" size="XS"
className="text-red-500 dark:text-red-400" className="text-red-500 dark:text-red-400"
theme="light" theme="light"
LeadingIcon={LuTrash2} LeadingIcon={LuTrash2}
onClick={() => { onClick={() => {
setMacroToDelete(macro); setMacroToDelete(macro);
setShowDeleteConfirm(true); setShowDeleteConfirm(true);
}} }}
disabled={actionLoadingId === macro.id} disabled={actionLoadingId === macro.id}
aria-label={`Delete macro ${macro.name}`} aria-label={`Delete macro ${macro.name}`}
/> />
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
LeadingIcon={LuCopy} LeadingIcon={LuCopy}
onClick={() => handleDuplicateMacro(macro)} onClick={() => handleDuplicateMacro(macro)}
disabled={actionLoadingId === macro.id} disabled={actionLoadingId === macro.id}
aria-label={`Duplicate macro ${macro.name}`} aria-label={`Duplicate macro ${macro.name}`}
/> />
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
LeadingIcon={LuPenLine} LeadingIcon={LuPenLine}
text="Edit" text="Edit"
onClick={() => navigate(`${macro.id}/edit`)} onClick={() => navigate(`${macro.id}/edit`)}
disabled={actionLoadingId === macro.id} disabled={actionLoadingId === macro.id}
aria-label={`Edit macro ${macro.name}`} aria-label={`Edit macro ${macro.name}`}
/> />
</div>
</div> </div>
</div> </Card>
</Card> ))}
))}
<ConfirmDialog <ConfirmDialog
open={showDeleteConfirm} open={showDeleteConfirm}
onClose={() => { onClose={() => {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setMacroToDelete(null); setMacroToDelete(null);
}} }}
title="Delete Macro" title="Delete Macro"
description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`} description={`Are you sure you want to delete "${macroToDelete?.name}"? This action cannot be undone.`}
variant="danger" variant="danger"
confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"} confirmText={actionLoadingId === macroToDelete?.id ? "Deleting..." : "Delete"}
onConfirm={handleDeleteMacro} onConfirm={handleDeleteMacro}
isConfirming={actionLoadingId === macroToDelete?.id} isConfirming={actionLoadingId === macroToDelete?.id}
/> />
</div> </div>
), [macros, actionLoadingId, showDeleteConfirm, macroToDelete, handleDeleteMacro]); ),
[
macros,
showDeleteConfirm,
macroToDelete?.name,
macroToDelete?.id,
actionLoadingId,
handleDeleteMacro,
handleMoveMacro,
handleDuplicateMacro,
navigate,
],
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -259,7 +309,7 @@ export default function SettingsMacrosRoute() {
title="Keyboard Macros" title="Keyboard Macros"
description={`Combine keystrokes into a single action for faster workflows.`} description={`Combine keystrokes into a single action for faster workflows.`}
/> />
{ macros.length > 0 && ( {macros.length > 0 && (
<div className="flex items-center pl-2"> <div className="flex items-center pl-2">
<Button <Button
size="SM" size="SM"
@ -288,6 +338,7 @@ export default function SettingsMacrosRoute() {
<EmptyCard <EmptyCard
IconElm={LuCommand} IconElm={LuCommand}
headline="Create Your First Macro" headline="Create Your First Macro"
description="Combine keystrokes into a single action"
BtnElm={ BtnElm={
<Button <Button
size="SM" size="SM"
@ -299,7 +350,9 @@ export default function SettingsMacrosRoute() {
/> />
} }
/> />
) : MacroList} ) : (
MacroList
)}
</div> </div>
</div> </div>
); );

View File

@ -1,18 +1,29 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import {
import { SettingsPageHeader } from "../components/SettingsPageheader"; IPv4Mode,
IPv6Mode,
import { IPv4Mode, IPv6Mode, LLDPMode, mDNSMode, NetworkSettings, NetworkState, TimeSyncMode, useNetworkStateStore } from "@/hooks/stores"; LLDPMode,
mDNSMode,
NetworkSettings,
NetworkState,
TimeSyncMode,
useNetworkStateStore,
} from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import InputField from "@components/InputField"; import InputField from "@components/InputField";
import { SettingsItem } from "./devices.$id.settings"; import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsPageHeader } from "@/components/SettingsPageheader";
import Fieldset from "@/components/Fieldset";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import notifications from "@/notifications";
import dayjs from 'dayjs'; import { SettingsItem } from "./devices.$id.settings";
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
@ -25,13 +36,9 @@ const defaultNetworkSettings: NetworkSettings = {
lldp_tx_tlvs: [], lldp_tx_tlvs: [],
mdns_mode: "unknown", mdns_mode: "unknown",
time_sync_mode: "unknown", time_sync_mode: "unknown",
} };
export function LifeTimeLabel({ lifetime }: { lifetime: string }) { export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
if (lifetime == "") {
return <strong>N/A</strong>;
}
const [remaining, setRemaining] = useState<string | null>(null); const [remaining, setRemaining] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@ -43,46 +50,91 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [lifetime]); }, [lifetime]);
return <> if (lifetime == "") {
<strong>{dayjs(lifetime).format()}</strong> return <strong>N/A</strong>;
{remaining && <> }
{" "}<span className="text-xs text-slate-700 dark:text-slate-300">
({remaining}) return (
</span> <>
</>} <strong>{dayjs(lifetime).format("YYYY-MM-DD HH:mm")}</strong>
</> {remaining && (
<>
{" "}
<span className="text-xs text-slate-700 dark:text-slate-300">
({remaining})
</span>
</>
)}
</>
);
} }
export default function SettingsNetworkRoute() { export default function SettingsNetworkRoute() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const [networkState, setNetworkState] = useNetworkStateStore(state => [state, state.setNetworkState]); const [networkState, setNetworkState] = useNetworkStateStore(state => [
state,
state.setNetworkState,
]);
const [networkSettings, setNetworkSettings] =
useState<NetworkSettings>(defaultNetworkSettings);
// We use this to determine whether the settings have changed
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
const [networkSettings, setNetworkSettings] = useState<NetworkSettings>(defaultNetworkSettings);
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false); const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
const [customDomain, setCustomDomain] = useState<string>("");
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp");
useEffect(() => {
if (networkSettings.domain && networkSettingsLoaded) {
// Check if the domain is one of the predefined options
const predefinedOptions = ["dhcp", "local"];
if (predefinedOptions.includes(networkSettings.domain)) {
setSelectedDomainOption(networkSettings.domain);
} else {
setSelectedDomainOption("custom");
setCustomDomain(networkSettings.domain);
}
}
}, [networkSettings.domain, networkSettingsLoaded]);
const getNetworkSettings = useCallback(() => { const getNetworkSettings = useCallback(() => {
setNetworkSettingsLoaded(false); setNetworkSettingsLoaded(false);
send("getNetworkSettings", {}, resp => { send("getNetworkSettings", {}, resp => {
if ("error" in resp) return; if ("error" in resp) return;
console.log(resp.result); console.log(resp.result);
setNetworkSettings(resp.result as NetworkSettings); setNetworkSettings(resp.result as NetworkSettings);
if (!firstNetworkSettings.current) {
firstNetworkSettings.current = resp.result as NetworkSettings;
}
setNetworkSettingsLoaded(true); setNetworkSettingsLoaded(true);
}); });
}, [send]); }, [send]);
const setNetworkSettingsRemote = useCallback((settings: NetworkSettings) => { const setNetworkSettingsRemote = useCallback(
setNetworkSettingsLoaded(false); (settings: NetworkSettings) => {
send("setNetworkSettings", { settings }, resp => { setNetworkSettingsLoaded(false);
if ("error" in resp) { send("setNetworkSettings", { settings }, resp => {
notifications.error("Failed to save network settings: " + (resp.error.data ? resp.error.data : resp.error.message)); if ("error" in resp) {
notifications.error(
"Failed to save network settings: " +
(resp.error.data ? resp.error.data : resp.error.message),
);
setNetworkSettingsLoaded(true);
return;
}
// We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed
firstNetworkSettings.current = resp.result as NetworkSettings;
setNetworkSettings(resp.result as NetworkSettings);
setNetworkSettingsLoaded(true); setNetworkSettingsLoaded(true);
return; notifications.success("Network settings saved");
} });
setNetworkSettings(resp.result as NetworkSettings); },
setNetworkSettingsLoaded(true); [send],
notifications.success("Network settings saved"); );
});
}, [send]);
const getNetworkState = useCallback(() => { const getNetworkState = useCallback(() => {
send("getNetworkState", {}, resp => { send("getNetworkState", {}, resp => {
@ -90,7 +142,7 @@ export default function SettingsNetworkRoute() {
console.log(resp.result); console.log(resp.result);
setNetworkState(resp.result as NetworkState); setNetworkState(resp.result as NetworkState);
}); });
}, [send]); }, [send, setNetworkState]);
const handleRenewLease = useCallback(() => { const handleRenewLease = useCallback(() => {
send("renewDHCPLease", {}, resp => { send("renewDHCPLease", {}, resp => {
@ -131,278 +183,520 @@ export default function SettingsNetworkRoute() {
setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode }); setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode });
}; };
const filterUnknown = useCallback((options: { value: string; label: string; }[]) => { const handleHostnameChange = (value: string) => {
if (!networkSettingsLoaded) return options; setNetworkSettings({ ...networkSettings, hostname: value });
return options.filter(option => option.value !== "unknown"); };
}, [networkSettingsLoaded]);
const handleDomainChange = (value: string) => {
setNetworkSettings({ ...networkSettings, domain: value });
};
const handleDomainOptionChange = (value: string) => {
setSelectedDomainOption(value);
if (value !== "custom") {
handleDomainChange(value);
}
};
const handleCustomDomainChange = (value: string) => {
setCustomDomain(value);
handleDomainChange(value);
};
const filterUnknown = useCallback(
(options: { value: string; label: string }[]) => {
if (!networkSettingsLoaded) return options;
return options.filter(option => option.value !== "unknown");
},
[networkSettingsLoaded],
);
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
return ( return (
<div className="space-y-4"> <>
<SettingsPageHeader <Fieldset disabled={!networkSettingsLoaded} className="space-y-4">
title="Network" <SettingsPageHeader
description="Configure your network settings" title="Network"
/> description="Configure your network settings"
<div className="space-y-4"> />
<SettingsItem <div className="space-y-4">
title="MAC Address" <SettingsItem
description={<></>} title="MAC Address"
> description="Hardware identifier for the network interface"
<span className="select-auto font-mono text-xs text-slate-700 dark:text-slate-300"> >
{networkState?.mac_address} <InputField
</span> type="text"
</SettingsItem> size="SM"
</div> value={networkState?.mac_address}
<div className="space-y-4"> error={""}
<SettingsItem disabled={true}
title="Hostname" readOnly={true}
description={ className="dark:!text-opacity-60"
<> />
Hostname for the device </SettingsItem>
<br /> </div>
<span className="text-xs text-slate-700 dark:text-slate-300"> <div className="space-y-4">
Leave blank for default <SettingsItem
</span> title="Hostname"
</> description="Device identifier on the network. Blank for system default"
} >
> <div className="relative">
<InputField <div>
type="text" <InputField
placeholder="jetkvm" size="SM"
value={networkSettings.hostname} type="text"
error={""} placeholder="jetkvm"
onChange={e => { defaultValue={networkSettings.hostname}
setNetworkSettings({ ...networkSettings, hostname: e.target.value }); onChange={e => {
}} handleHostnameChange(e.target.value);
disabled={!networkSettingsLoaded} }}
/> />
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Domain"
description={
<>
Domain for the device
<br />
<span className="text-xs text-slate-700 dark:text-slate-300">
Leave blank to use DHCP provided domain, if there is no domain, use <span className="font-mono">local</span>
</span>
</>
}
>
<InputField
type="text"
placeholder="local"
value={networkSettings.domain}
error={""}
onChange={e => {
setNetworkSettings({ ...networkSettings, domain: e.target.value });
}}
disabled={!networkSettingsLoaded}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="IPv4 Mode"
description="Configure the IPv4 mode"
>
<SelectMenuBasic
size="SM"
value={networkSettings.ipv4_mode}
onChange={e => handleIpv4ModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
{ value: "dhcp", label: "DHCP" },
// { value: "static", label: "Static" },
])}
/>
</SettingsItem>
{networkState?.dhcp_lease && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<div className="space-y-3 w-full">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Current DHCP Lease
</h3>
<div>
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
{networkState?.dhcp_lease?.ip && <li>IP: <strong>{networkState?.dhcp_lease?.ip}</strong></li>}
{networkState?.dhcp_lease?.netmask && <li>Subnet: <strong>{networkState?.dhcp_lease?.netmask}</strong></li>}
{networkState?.dhcp_lease?.broadcast && <li>Broadcast: <strong>{networkState?.dhcp_lease?.broadcast}</strong></li>}
{networkState?.dhcp_lease?.ttl && <li>TTL: <strong>{networkState?.dhcp_lease?.ttl}</strong></li>}
{networkState?.dhcp_lease?.mtu && <li>MTU: <strong>{networkState?.dhcp_lease?.mtu}</strong></li>}
{networkState?.dhcp_lease?.hostname && <li>Hostname: <strong>{networkState?.dhcp_lease?.hostname}</strong></li>}
{networkState?.dhcp_lease?.domain && <li>Domain: <strong>{networkState?.dhcp_lease?.domain}</strong></li>}
{networkState?.dhcp_lease?.routers && <li>Gateway: <strong>{networkState?.dhcp_lease?.routers.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.dns && <li>DNS: <strong>{networkState?.dhcp_lease?.dns.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.ntp_servers && <li>NTP Servers: <strong>{networkState?.dhcp_lease?.ntp_servers.join(", ")}</strong></li>}
{networkState?.dhcp_lease?.server_id && <li>Server ID: <strong>{networkState?.dhcp_lease?.server_id}</strong></li>}
{networkState?.dhcp_lease?.bootp_next_server && <li>BootP Next Server: <strong>{networkState?.dhcp_lease?.bootp_next_server}</strong></li>}
{networkState?.dhcp_lease?.bootp_server_name && <li>BootP Server Name: <strong>{networkState?.dhcp_lease?.bootp_server_name}</strong></li>}
{networkState?.dhcp_lease?.bootp_file && <li>Boot File: <strong>{networkState?.dhcp_lease?.bootp_file}</strong></li>}
{networkState?.dhcp_lease?.lease_expiry && <li>
Lease Expiry: <LifeTimeLabel lifetime={`${networkState?.dhcp_lease?.lease_expiry}`} />
</li>}
{/* {JSON.stringify(networkState?.dhcp_lease)} */}
</ul>
</div>
</div>
<hr className="block w-full dark:border-slate-600" />
<div>
<Button
size="SM"
theme="danger"
text="Renew lease"
onClick={() => {
handleRenewLease();
}}
/>
</div>
</div> </div>
</div> </div>
</GridCard> </SettingsItem>
)} </div>
</div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <div className="space-y-4">
title="IPv6 Mode" <SettingsItem
description="Configure the IPv6 mode" title="Domain"
> description="Network domain suffix for the device"
<SelectMenuBasic >
<div className="space-y-2">
<SelectMenuBasic
size="SM"
value={selectedDomainOption}
onChange={e => handleDomainOptionChange(e.target.value)}
options={[
{ value: "dhcp", label: "DHCP provided" },
{ value: "local", label: ".local" },
{ value: "custom", label: "Custom" },
]}
/>
</div>
</SettingsItem>
{selectedDomainOption === "custom" && (
<div className="flex items-center justify-between gap-x-2">
<InputField
size="SM"
type="text"
placeholder="home"
value={customDomain}
onChange={e => setCustomDomain(e.target.value)}
/>
<Button
size="SM"
theme="primary"
text="Save Domain"
onClick={() => handleCustomDomainChange(customDomain)}
/>
</div>
)}
</div>
<div className="space-y-4">
<SettingsItem
title="mDNS"
description="Control mDNS (multicast DNS) operational mode"
>
<SelectMenuBasic
size="SM"
value={networkSettings.mdns_mode}
onChange={e => handleMdnsModeChange(e.target.value)}
options={filterUnknown([
{ value: "disabled", label: "Disabled" },
{ value: "auto", label: "Auto" },
{ value: "ipv4_only", label: "IPv4 only" },
{ value: "ipv6_only", label: "IPv6 only" },
])}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Time synchronization"
description="Configure time synchronization settings"
>
<SelectMenuBasic
size="SM"
value={networkSettings.time_sync_mode}
onChange={e => {
handleTimeSyncModeChange(e.target.value);
}}
options={filterUnknown([
{ value: "unknown", label: "..." },
// { value: "auto", label: "Auto" },
{ value: "ntp_only", label: "NTP only" },
{ value: "ntp_and_http", label: "NTP and HTTP" },
{ value: "http_only", label: "HTTP only" },
// { value: "custom", label: "Custom" },
])}
/>
</SettingsItem>
</div>
<Button
size="SM" size="SM"
value={networkSettings.ipv6_mode} theme="primary"
onChange={e => handleIpv6ModeChange(e.target.value)} disabled={firstNetworkSettings.current === networkSettings}
disabled={!networkSettingsLoaded} text="Save Settings"
options={filterUnknown([ onClick={() => setNetworkSettingsRemote(networkSettings)}
// { value: "disabled", label: "Disabled" },
{ value: "slaac", label: "SLAAC" },
// { value: "dhcpv6", label: "DHCPv6" },
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
// { value: "static", label: "Static" },
// { value: "link_local", label: "Link-local only" },
])}
/> />
</SettingsItem> </div>
{networkState?.ipv6_addresses && (
<GridCard> <div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="flex items-start gap-x-4 p-4">
<div className="space-y-3 w-full"> <div className="space-y-4">
<div className="space-y-2"> <SettingsItem title="IPv4 Mode" description="Configure the IPv4 mode">
<SelectMenuBasic
size="SM"
value={networkSettings.ipv4_mode}
onChange={e => handleIpv4ModeChange(e.target.value)}
options={filterUnknown([
{ value: "dhcp", label: "DHCP" },
// { value: "static", label: "Static" },
])}
/>
</SettingsItem>
{networkState?.dhcp_lease && (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease
</h3>
<div className="flex gap-x-6 gap-y-2">
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
IP Address
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ip}
</span>
</div>
)}
{networkState?.dhcp_lease?.netmask && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Subnet Mask
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.netmask}
</span>
</div>
)}
{networkState?.dhcp_lease?.dns && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => (
<div key={dns}>{dns}</div>
))}
</span>
</div>
)}
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
</span>
</div>
)}
{networkState?.dhcp_lease?.domain && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Domain
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.domain}
</span>
</div>
)}
{networkState?.dhcp_lease?.ntp_servers &&
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
NTP Servers
</div>
<div className="shrink text-right text-sm font-medium">
{networkState?.dhcp_lease?.ntp_servers.map(server => (
<div key={server}>{server}</div>
))}
</div>
</div>
)}
{networkState?.dhcp_lease?.hostname && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Hostname
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.hostname}
</span>
</div>
)}
</div>
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.routers &&
networkState?.dhcp_lease?.routers.length > 0 && (
<div className="flex justify-between pt-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.routers.map(router => (
<div key={router}>{router}</div>
))}
</span>
</div>
)}
{networkState?.dhcp_lease?.server_id && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DHCP Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.server_id}
</span>
</div>
)}
{networkState?.dhcp_lease?.lease_expiry && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Lease Expires
</span>
<span className="text-sm font-medium">
<LifeTimeLabel
lifetime={`${networkState?.dhcp_lease?.lease_expiry}`}
/>
</span>
</div>
)}
{networkState?.dhcp_lease?.mtu && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
MTU
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.mtu}
</span>
</div>
)}
{networkState?.dhcp_lease?.ttl && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
TTL
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ttl}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_next_server && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Next Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_next_server}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_server_name && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Server Name
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_server_name}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_file && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot File
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_file}
</span>
</div>
)}
</div>
</div>
<div>
<Button
size="SM"
theme="light"
className="text-red-500"
text="Renew DHCP Lease"
LeadingIcon={ArrowPathIcon}
onClick={() => setShowRenewLeaseConfirm(true)}
/>
</div>
</div>
</div>
</GridCard>
)}
</div>
<div className="space-y-4">
<SettingsItem title="IPv6 Mode" description="Configure the IPv6 mode">
<SelectMenuBasic
size="SM"
value={networkSettings.ipv6_mode}
onChange={e => handleIpv6ModeChange(e.target.value)}
options={filterUnknown([
// { value: "disabled", label: "Disabled" },
{ value: "slaac", label: "SLAAC" },
// { value: "dhcpv6", label: "DHCPv6" },
// { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" },
// { value: "static", label: "Static" },
// { value: "link_local", label: "Link-local only" },
])}
/>
</SettingsItem>
{networkState?.ipv6_addresses && (
<GridCard>
<div className="p-4">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information IPv6 Information
</h3> </h3>
<div className="space-y-2">
<div> <div className="grid grid-cols-2 gap-x-6 gap-y-2">
<h4 className="text-sm font-bold text-slate-900 dark:text-white"> {networkState?.dhcp_lease?.ip && (
IPv6 Link-local <div className="flex flex-col justify-between">
</h4> <span className="text-sm text-slate-600 dark:text-slate-400">
<p className="text-xs text-slate-700 dark:text-slate-300"> Link-local
{networkState?.ipv6_link_local} </span>
</p> <span className="text-sm font-medium">
</div> {networkState?.ipv6_link_local}
<div> </span>
<h4 className="text-sm font-bold text-slate-900 dark:text-white"> </div>
IPv6 Addresses )}
</h4> </div>
<ul className="list-none space-y-1 text-xs text-slate-700 dark:text-slate-300">
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.map(addr => ( <div className="space-y-3 pt-2">
<li key={addr.address}> {networkState?.ipv6_addresses &&
{addr.address} networkState?.ipv6_addresses.length > 0 && (
{addr.valid_lifetime && <> <div className="space-y-3">
<br /> <h4 className="text-sm font-semibold">IPv6 Addresses</h4>
- valid_lft: {" "} {networkState.ipv6_addresses.map(addr => (
<span className="text-xs text-slate-700 dark:text-slate-300"> <div
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} /> key={addr.address}
</span> className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-slate-100/40 p-4 pl-4 dark:border-blue-500 dark:bg-slate-900"
</>} >
{addr.preferred_lifetime && <> <div className="grid grid-cols-2 gap-x-8 gap-y-4">
<br /> <div className="col-span-2 flex flex-col justify-between">
- pref_lft: {" "} <span className="text-sm text-slate-600 dark:text-slate-400">
<span className="text-xs text-slate-700 dark:text-slate-300"> Address
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} /> </span>
</span> <span className="text-sm font-medium">
</>} {addr.address}
</li> </span>
))} </div>
</ul>
</div> {addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel
lifetime={`${addr.valid_lifetime}`}
/>
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel
lifetime={`${addr.preferred_lifetime}`}
/>
)}
</span>
</div>
)}
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </GridCard>
</GridCard> )}
)} </div>
</div> <div className="hidden space-y-4">
<div className="space-y-4 hidden"> <SettingsItem
<SettingsItem title="LLDP"
title="LLDP" description="Control which TLVs will be sent over Link Layer Discovery Protocol"
description="Control which TLVs will be sent over Link Layer Discovery Protocol" >
> <SelectMenuBasic
<SelectMenuBasic size="SM"
size="SM" value={networkSettings.lldp_mode}
value={networkSettings.lldp_mode} onChange={e => handleLldpModeChange(e.target.value)}
onChange={e => handleLldpModeChange(e.target.value)} options={filterUnknown([
disabled={!networkSettingsLoaded} { value: "disabled", label: "Disabled" },
options={filterUnknown([ { value: "basic", label: "Basic" },
{ value: "disabled", label: "Disabled" }, { value: "all", label: "All" },
{ value: "basic", label: "Basic" }, ])}
{ value: "all", label: "All" }, />
])} </SettingsItem>
/> </div>
</SettingsItem> </Fieldset>
</div> <ConfirmDialog
<div className="space-y-4"> open={showRenewLeaseConfirm}
<SettingsItem onClose={() => setShowRenewLeaseConfirm(false)}
title="mDNS" title="Renew DHCP Lease"
description="Control mDNS (multicast DNS) operational mode" description="This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process."
> variant="danger"
<SelectMenuBasic confirmText="Renew Lease"
size="SM" onConfirm={() => {
value={networkSettings.mdns_mode} handleRenewLease();
onChange={e => handleMdnsModeChange(e.target.value)} setShowRenewLeaseConfirm(false);
disabled={!networkSettingsLoaded} }}
options={filterUnknown([ />
{ value: "disabled", label: "Disabled" }, </>
{ value: "auto", label: "Auto" },
{ value: "ipv4_only", label: "IPv4 only" },
{ value: "ipv6_only", label: "IPv6 only" },
])}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Time synchronization"
description="Configure time synchronization settings"
>
<SelectMenuBasic
size="SM"
value={networkSettings.time_sync_mode}
onChange={e => handleTimeSyncModeChange(e.target.value)}
disabled={!networkSettingsLoaded}
options={filterUnknown([
{ value: "unknown", label: "..." },
// { value: "auto", label: "Auto" },
{ value: "ntp_only", label: "NTP only" },
{ value: "ntp_and_http", label: "NTP and HTTP" },
{ value: "http_only", label: "HTTP only" },
// { value: "custom", label: "Custom" },
])}
/>
</SettingsItem>
</div>
<div className="flex items-end gap-x-2">
<Button
onClick={() => {
setNetworkSettingsRemote(networkSettings);
}}
size="SM"
theme="light"
text="Save Settings"
/>
</div>
</div>
); );
} }

View File

@ -13,15 +13,16 @@ import {
LuNetwork, LuNetwork,
} from "react-icons/lu"; } from "react-icons/lu";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useResizeObserver } from "usehooks-ts";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { LinkButton } from "@/components/Button";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { LinkButton } from "../components/Button";
import { cx } from "../cva.config"; import { cx } from "../cva.config";
import { useUiStore } from "../hooks/stores";
import useKeyboard from "../hooks/useKeyboard";
import { useResizeObserver } from "usehooks-ts";
import LoadingSpinner from "../components/LoadingSpinner";
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ /* 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() { export default function SettingsRoute() {

View File

@ -56,7 +56,7 @@ export default function SetupRoute() {
return ( return (
<> <>
<GridBackground /> <GridBackground />
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<SimpleNavbar /> <SimpleNavbar />
<Container> <Container>
<div className="flex items-center justify-center w-full h-full isolate"> <div className="flex items-center justify-center w-full h-full isolate">

View File

@ -12,7 +12,7 @@ import {
useSearchParams, useSearchParams,
} from "react-router-dom"; } from "react-router-dom";
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import FocusTrap from "focus-trap-react"; import { FocusTrap } from "focus-trap-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import useWebSocket from "react-use-websocket"; import useWebSocket from "react-use-websocket";
@ -795,7 +795,7 @@ export default function KvmIdRoute() {
</div> </div>
</FocusTrap> </FocusTrap>
<div className="grid h-full select-none grid-rows-headerBody"> <div className="grid h-full grid-rows-(--grid-headerBody) select-none">
<DashboardNavbar <DashboardNavbar
primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]} primaryLinks={isOnDevice ? [] : [{ title: "Cloud Devices", to: "/devices" }]}
showConnectionStatus={true} showConnectionStatus={true}
@ -809,7 +809,7 @@ export default function KvmIdRoute() {
<WebRTCVideo /> <WebRTCVideo />
<div <div
style={{ animationDuration: "500ms" }} style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center p-4 opacity-0" className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
> >
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md"> <div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{!!ConnectionStatusElement && ConnectionStatusElement} {!!ConnectionStatusElement && ConnectionStatusElement}

View File

@ -8,7 +8,7 @@ export default function DevicesAlreadyAdopted() {
<> <>
<GridBackground /> <GridBackground />
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<SimpleNavbar /> <SimpleNavbar />
<Container> <Container>
<div className="flex items-center justify-center w-full h-full isolate"> <div className="flex items-center justify-center w-full h-full isolate">

View File

@ -1,14 +1,14 @@
import { useLoaderData, useRevalidator } from "react-router-dom"; import { useLoaderData, useRevalidator } from "react-router-dom";
import { LuMonitorSmartphone } from "react-icons/lu"; import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { useInterval } from "usehooks-ts";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import { LinkButton } from "@components/Button";
import KvmCard from "@components/KvmCard";
import { useInterval } from "usehooks-ts";
import { checkAuth } from "@/main";
import { User } from "@/hooks/stores";
import EmptyCard from "@components/EmptyCard"; import EmptyCard from "@components/EmptyCard";
import KvmCard from "@components/KvmCard";
import { LinkButton } from "@components/Button";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import { CLOUD_API } from "@/ui.config"; import { CLOUD_API } from "@/ui.config";
interface LoaderData { interface LoaderData {
@ -16,7 +16,7 @@ interface LoaderData {
user: User; user: User;
} }
export const loader = async () => { const loader = async () => {
const user = await checkAuth(); const user = await checkAuth();
try { try {
@ -40,7 +40,7 @@ export default function DevicesRoute() {
useInterval(revalidate.revalidate, 4000); useInterval(revalidate.revalidate, 4000);
return ( return (
<div className="relative h-full"> <div className="relative h-full">
<div className="grid h-full select-none grid-rows-headerBody"> <div className="grid h-full select-none grid-rows-(--grid-headerBody)">
<DashboardNavbar <DashboardNavbar
isLoggedIn={!!user} isLoggedIn={!!user}
primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]} primaryLinks={[{ title: "Cloud Devices", to: "/devices" }]}
@ -101,3 +101,5 @@ export default function DevicesRoute() {
</div> </div>
); );
} }
DevicesRoute.loader = loader;

View File

@ -56,7 +56,7 @@ export default function LoginLocalRoute() {
return ( return (
<> <>
<GridBackground /> <GridBackground />
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<SimpleNavbar /> <SimpleNavbar />
<Container> <Container>
<div className="isolate flex h-full w-full items-center justify-center"> <div className="isolate flex h-full w-full items-center justify-center">

View File

@ -14,7 +14,6 @@ import api from "../api";
import { DeviceStatus } from "./welcome-local"; import { DeviceStatus } from "./welcome-local";
const loader = async () => { const loader = async () => {
const res = await api const res = await api
.GET(`${DEVICE_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
@ -59,18 +58,24 @@ export default function WelcomeLocalModeRoute() {
<GridBackground /> <GridBackground />
<div className="grid min-h-screen"> <div className="grid min-h-screen">
<Container> <Container>
<div className="flex items-center justify-center w-full h-full isolate"> <div className="isolate flex h-full w-full items-center justify-center">
<div className="max-w-xl space-y-8"> <div className="max-w-xl space-y-8">
<div className="flex items-center justify-center opacity-0 animate-fadeIn"> <div className="animate-fadeIn flex items-center justify-center opacity-0">
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" /> <img
src={LogoWhiteIcon}
alt=""
className="-ml-4 hidden h-[32px] dark:block"
/>
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" /> <img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
</div> </div>
<div <div
className="space-y-2 text-center opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 text-center opacity-0"
style={{ animationDelay: "200ms" }} style={{ animationDelay: "200ms" }}
> >
<h1 className="text-4xl font-semibold text-black dark:text-white">Local Authentication Method</h1> <h1 className="text-4xl font-semibold text-black dark:text-white">
Local Authentication Method
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400"> <p className="font-medium text-slate-600 dark:text-slate-400">
Select how you{"'"}d like to secure your JetKVM device locally. Select how you{"'"}d like to secure your JetKVM device locally.
</p> </p>
@ -78,7 +83,7 @@ export default function WelcomeLocalModeRoute() {
<Form method="POST" className="space-y-8"> <Form method="POST" className="space-y-8">
<div <div
className="grid grid-cols-1 gap-6 opacity-0 animate-fadeIn sm:grid-cols-2" className="animate-fadeIn grid grid-cols-1 gap-6 opacity-0 sm:grid-cols-2"
style={{ animationDelay: "400ms" }} style={{ animationDelay: "400ms" }}
> >
{["password", "noPassword"].map(mode => ( {["password", "noPassword"].map(mode => (
@ -90,14 +95,14 @@ export default function WelcomeLocalModeRoute() {
})} })}
> >
<div <div
className="relative flex flex-col items-center p-6 cursor-pointer select-none" className="relative flex cursor-pointer flex-col items-center p-6 select-none"
onClick={() => setSelectedMode(mode as "password" | "noPassword")} onClick={() => setSelectedMode(mode as "password" | "noPassword")}
> >
<div className="space-y-0 text-center"> <div className="space-y-0 text-center">
<h3 className="text-base font-bold text-black dark:text-white"> <h3 className="text-base font-bold text-black dark:text-white">
{mode === "password" ? "Password protected" : "No Password"} {mode === "password" ? "Password protected" : "No Password"}
</h3> </h3>
<p className="mt-2 text-sm text-center text-gray-600 dark:text-gray-400"> <p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
{mode === "password" {mode === "password"
? "Secure your device with a password for added protection." ? "Secure your device with a password for added protection."
: "Quick access without password authentication."} : "Quick access without password authentication."}
@ -111,7 +116,7 @@ export default function WelcomeLocalModeRoute() {
onChange={() => { onChange={() => {
setSelectedMode(mode as "password" | "noPassword"); setSelectedMode(mode as "password" | "noPassword");
}} }}
className="absolute w-4 h-4 text-blue-600 right-2 top-2" className="absolute top-2 right-2 h-4 w-4 text-blue-600"
/> />
</div> </div>
</GridCard> </GridCard>
@ -120,7 +125,7 @@ export default function WelcomeLocalModeRoute() {
{actionData?.error && ( {actionData?.error && (
<p <p
className="text-sm text-center text-red-600 opacity-0 dark:text-red-400 animate-fadeIn" className="animate-fadeIn text-center text-sm text-red-600 opacity-0 dark:text-red-400"
style={{ animationDelay: "500ms" }} style={{ animationDelay: "500ms" }}
> >
{actionData.error} {actionData.error}
@ -128,7 +133,7 @@ export default function WelcomeLocalModeRoute() {
)} )}
<div <div
className="max-w-sm mx-auto opacity-0 animate-fadeIn" className="animate-fadeIn mx-auto max-w-sm opacity-0"
style={{ animationDelay: "500ms" }} style={{ animationDelay: "500ms" }}
> >
<Button <Button
@ -144,7 +149,7 @@ export default function WelcomeLocalModeRoute() {
</Form> </Form>
<p <p
className="max-w-md mx-auto text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400" className="animate-fadeIn mx-auto max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
style={{ animationDelay: "600ms" }} style={{ animationDelay: "600ms" }}
> >
You can always change your authentication method later in the settings. You can always change your authentication method later in the settings.

View File

@ -69,28 +69,34 @@ export default function WelcomeLocalPasswordRoute() {
<GridBackground /> <GridBackground />
<div className="grid min-h-screen"> <div className="grid min-h-screen">
<Container> <Container>
<div className="flex items-center justify-center w-full h-full isolate"> <div className="isolate flex h-full w-full items-center justify-center">
<div className="max-w-2xl space-y-8"> <div className="max-w-2xl space-y-8">
<div className="flex items-center justify-center opacity-0 animate-fadeIn"> <div className="animate-fadeIn flex items-center justify-center opacity-0">
<img src={LogoWhiteIcon} alt="" className="-ml-4 h-[32px] hidden dark:block" /> <img
src={LogoWhiteIcon}
alt=""
className="-ml-4 hidden h-[32px] dark:block"
/>
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" /> <img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
</div> </div>
<div <div
className="space-y-2 text-center opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 text-center opacity-0"
style={{ animationDelay: "200ms" }} style={{ animationDelay: "200ms" }}
> >
<h1 className="text-4xl font-semibold text-black dark:text-white">Set a Password</h1> <h1 className="text-4xl font-semibold text-black dark:text-white">
Set a Password
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400"> <p className="font-medium text-slate-600 dark:text-slate-400">
Create a strong password to secure your JetKVM device locally. Create a strong password to secure your JetKVM device locally.
</p> </p>
</div> </div>
<Fieldset className="space-y-12"> <Fieldset className="space-y-12">
<Form method="POST" className="max-w-sm mx-auto space-y-4"> <Form method="POST" className="mx-auto max-w-sm space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<div <div
className="opacity-0 animate-fadeIn" className="animate-fadeIn opacity-0"
style={{ animationDelay: "400ms" }} style={{ animationDelay: "400ms" }}
> >
<InputFieldWithLabel <InputFieldWithLabel
@ -106,21 +112,21 @@ export default function WelcomeLocalPasswordRoute() {
onClick={() => setShowPassword(false)} onClick={() => setShowPassword(false)}
className="pointer-events-auto" className="pointer-events-auto"
> >
<LuEye className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" /> <LuEye className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div> </div>
) : ( ) : (
<div <div
onClick={() => setShowPassword(true)} onClick={() => setShowPassword(true)}
className="pointer-events-auto" className="pointer-events-auto"
> >
<LuEyeOff className="w-4 h-4 cursor-pointer text-slate-500 dark:text-slate-400" /> <LuEyeOff className="h-4 w-4 cursor-pointer text-slate-500 dark:text-slate-400" />
</div> </div>
) )
} }
/> />
</div> </div>
<div <div
className="opacity-0 animate-fadeIn" className="animate-fadeIn opacity-0"
style={{ animationDelay: "400ms" }} style={{ animationDelay: "400ms" }}
> >
<InputFieldWithLabel <InputFieldWithLabel
@ -137,7 +143,7 @@ export default function WelcomeLocalPasswordRoute() {
{actionData?.error && <p className="text-sm text-red-600">{}</p>} {actionData?.error && <p className="text-sm text-red-600">{}</p>}
<div <div
className="opacity-0 animate-fadeIn" className="animate-fadeIn opacity-0"
style={{ animationDelay: "600ms" }} style={{ animationDelay: "600ms" }}
> >
<Button <Button
@ -153,7 +159,7 @@ export default function WelcomeLocalPasswordRoute() {
</Fieldset> </Fieldset>
<p <p
className="max-w-md text-xs text-center opacity-0 animate-fadeIn text-slate-500 dark:text-slate-400" className="animate-fadeIn max-w-md text-center text-xs text-slate-500 opacity-0 dark:text-slate-400"
style={{ animationDelay: "800ms" }} style={{ animationDelay: "800ms" }}
> >
This password will be used to secure your device data and protect against This password will be used to secure your device data and protect against

View File

@ -13,8 +13,6 @@ import { DEVICE_API } from "@/ui.config";
import api from "../api"; import api from "../api";
export interface DeviceStatus { export interface DeviceStatus {
isSetup: boolean; isSetup: boolean;
} }
@ -43,19 +41,24 @@ export default function WelcomeRoute() {
<div className="grid min-h-screen"> <div className="grid min-h-screen">
{imageLoaded && ( {imageLoaded && (
<Container> <Container>
<div className="flex items-center justify-center w-full h-full isolate"> <div className="isolate flex h-full w-full items-center justify-center">
<div className="max-w-3xl text-center"> <div className="max-w-3xl text-center">
<div className="space-y-8"> <div className="space-y-8">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-center opacity-0 animate-fadeIn animation-delay-1000"> <div className="animate-fadeIn animation-delay-1000 flex items-center justify-center opacity-0">
<img src={LogoWhiteIcon} alt="JetKVM Logo" className="h-[32px] hidden dark:block" /> <img
<img src={LogoBlueIcon} alt="JetKVM Logo" className="h-[32px] dark:hidden" /> src={LogoWhiteIcon}
alt="JetKVM Logo"
className="hidden h-[32px] dark:block"
/>
<img
src={LogoBlueIcon}
alt="JetKVM Logo"
className="h-[32px] dark:hidden"
/>
</div> </div>
<div <div className="animate-fadeIn animation-delay-1500 space-y-1 opacity-0">
className="space-y-1 opacity-0 animate-fadeIn"
style={{ animationDelay: "1500ms" }}
>
<h1 className="text-4xl font-semibold text-black dark:text-white"> <h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome to JetKVM Welcome to JetKVM
</h1> </h1>
@ -69,22 +72,19 @@ export default function WelcomeRoute() {
<img <img
src={DeviceImage} src={DeviceImage}
alt="JetKVM Device" alt="JetKVM Device"
className="animation-delay-0 max-w-md scale-[0.98] animate-fadeInScaleFloat opacity-0 transition-all duration-1000 ease-out" className="animation-delay-300 animate-fadeInScaleFloat max-w-md scale-[0.98] opacity-0 transition-all duration-1000 ease-out"
/> />
</div> </div>
</div> </div>
<div className="-mt-8 space-y-4"> <div className="-mt-8 space-y-4">
<p <p
style={{ animationDelay: "2000ms" }} style={{ animationDelay: "2000ms" }}
className="max-w-lg mx-auto text-lg opacity-0 animate-fadeIn text-slate-700 dark:text-slate-300" className="animate-fadeIn mx-auto max-w-lg text-lg text-slate-700 opacity-0 dark:text-slate-300"
> >
JetKVM combines powerful hardware with intuitive software to provide a JetKVM combines powerful hardware with intuitive software to provide a
seamless remote control experience. seamless remote control experience.
</p> </p>
<div <div className="animate-fadeIn animation-delay-2300 opacity-0">
style={{ animationDelay: "2300ms" }}
className="opacity-0 animate-fadeIn"
>
<LinkButton <LinkButton
size="LG" size="LG"
theme="light" theme="light"

View File

@ -5,98 +5,9 @@ import plugin from "tailwindcss/plugin";
/** @type {import("tailwindcss").Config} */ /** @type {import("tailwindcss").Config} */
export default { export default {
content: ["./src/**/*.{ts,tsx,svg}", "./index.html"],
darkMode: "selector", darkMode: "selector",
theme: {
extend: {
gridTemplateRows: {
layout: "auto 1fr auto",
headerBody: "auto 1fr",
bodyFooter: "1fr auto",
},
gridTemplateColumns: {
sidebar: "1fr minmax(360px, 25%)",
},
screens: {
xs: "480px",
"2xl": "1440px",
"3xl": "1920px",
"4xl": "2560px",
},
fontFamily: {
sans: ["Circular", ...defaultTheme.fontFamily.sans],
display: ["Circular", ...defaultTheme.fontFamily.sans],
mono: ["Source Code Pro Variable", ...defaultTheme.fontFamily.mono],
},
maxWidth: {
"8xl": "88rem",
"9xl": "96rem",
"10xl": "104rem",
"11xl": "112rem",
"12xl": "120rem",
},
animation: {
enter: "enter .2s ease-out",
leave: "leave .15s ease-in forwards",
fadeInScale: "fadeInScale 1s ease-out forwards",
fadeInScaleFloat:
"fadeInScaleFloat 1s ease-out forwards, float 3s ease-in-out infinite",
fadeIn: "fadeIn 1s ease-out forwards",
slideUpFade: "slideUpFade 1s ease-out forwards",
},
animationDelay: {
1000: "1000ms",
1500: "1500ms",
},
keyframes: {
enter: {
"0%": {
opacity: "0",
transform: "scale(.9)",
},
"100%": {
opacity: "1",
transform: "scale(1)",
},
},
leave: {
"0%": {
opacity: "1",
transform: "scale(1)",
},
"100%": {
opacity: "0",
transform: "scale(.9)",
},
},
fadeInScale: {
"0%": { opacity: "0", transform: "scale(0.98)" },
"100%": { opacity: "1", transform: "scale(1)" },
},
fadeInScaleFloat: {
"0%": { opacity: "0", transform: "scale(0.98) translateY(10px)" },
"100%": { opacity: "1", transform: "scale(1) translateY(0)" },
},
float: {
"0%, 100%": { transform: "translateY(0)" },
"50%": { transform: "translateY(-10px)" },
},
fadeIn: {
"0%": { opacity: "0", transform: "translateY(10px)" },
"70%": { opacity: "0.8", transform: "translateY(1px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
slideUpFade: {
"0%": { opacity: "0", transform: "translateY(20px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
},
},
plugins: [ plugins: [
require("@tailwindcss/forms"), require("@tailwindcss/forms"),
require("@tailwindcss/typography"),
require("@headlessui/tailwindcss"),
plugin(function ({ addVariant }) { plugin(function ({ addVariant }) {
addVariant("disabled-within", `&:has(input:is(:disabled),button:is(:disabled))`); addVariant("disabled-within", `&:has(input:is(:disabled),button:is(:disabled))`);
}), }),
@ -142,12 +53,5 @@ export default {
}, },
); );
}, },
function ({ addUtilities, theme }) {
const animationDelays = theme("animationDelay");
const utilities = Object.entries(animationDelays).map(([key, value]) => ({
[`.animation-delay-${key}`]: { animationDelay: value },
}));
addUtilities(utilities);
},
], ],
}; };

View File

@ -1,5 +1,6 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import basicSsl from "@vitejs/plugin-basic-ssl"; import basicSsl from "@vitejs/plugin-basic-ssl";
@ -16,7 +17,11 @@ export default defineConfig(({ mode, command }) => {
const { JETKVM_PROXY_URL, USE_SSL } = process.env; const { JETKVM_PROXY_URL, USE_SSL } = process.env;
const useSSL = USE_SSL === "true"; const useSSL = USE_SSL === "true";
const plugins = [tsconfigPaths(), react()]; const plugins = [
tailwindcss(),
tsconfigPaths(),
react()
];
if (useSSL) { if (useSSL) {
plugins.push(basicSsl()); plugins.push(basicSsl());
} }