Release 202412292127

This commit is contained in:
Adam Shiervani
2024-10-20 17:24:15 +02:00
commit 20780b65db
171 changed files with 24344 additions and 0 deletions

4
ui/.env.development Normal file
View File

@@ -0,0 +1,4 @@
VITE_SIGNAL_API=http://localhost:3000
VITE_CLOUD_APP=http://localhost:5173
VITE_CLOUD_API=http://localhost:3000

4
ui/.env.device Normal file
View File

@@ -0,0 +1,4 @@
VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com

4
ui/.env.production Normal file
View File

@@ -0,0 +1,4 @@
VITE_SIGNAL_API=https://api.jetkvm.com
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com

24
ui/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,24 @@
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",
],
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 }],
},
};

24
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

15
ui/.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"useTabs": false,
"arrowParens": "avoid",
"singleQuote": false,
"plugins": [
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx"
],
"printWidth": 90
}

57
ui/index.html Normal file
View File

@@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- These are the fonts used in the app -->
<link
rel="preload"
href="/fonts/fonts/CircularXXWeb-Medium.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/fonts/CircularXXWeb-Book.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/fonts/CircularXXWeb-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" />
<script>
// Initial theme setup
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches),
);
// Listen for system theme changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", ({ matches }) => {
if (!("theme" in localStorage)) {
// Only auto-switch if user hasn't manually set a theme
document.documentElement.classList.toggle("dark", matches);
}
});
</script>
</head>
<body
class="h-full w-full bg-[#f3f9ff] font-sans text-sm antialiased dark:bg-slate-900 md:text-base"
>
<div id="root" class="w-full h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6302
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
ui/package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "kvm-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "21.1.0"
},
"scripts": {
"dev": "vite dev --mode=development",
"build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:prod": "tsc && vite build --mode=production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@headlessui/react": "^2.1.10",
"@headlessui/tailwindcss": "^0.2.0",
"@heroicons/react": "^2.1.3",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"cva": "^1.0.0-beta.1",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.0.28",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"react": "^18.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.3.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112",
"recharts": "^2.12.6",
"tailwind-merge": "^2.2.2",
"usehooks-ts": "^3.1.0",
"validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.12",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0",
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}
}

6
ui/postcss.config.js Normal file
View File

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

BIN
ui/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

111
ui/public/fonts/fonts.css Normal file
View File

@@ -0,0 +1,111 @@
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Thin.woff2") format("woff2");
font-weight: 100;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ThinItalic.woff2") format("woff2");
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-LightItalic.woff2") format("woff2");
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Book.woff2") format("woff2");
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BookItalic.woff2") format("woff2");
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Medium.woff2") format("woff2");
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-MediumItalic.woff2") format("woff2");
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BoldItalic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-Black.woff2") format("woff2");
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-BlackItalic.woff2") format("woff2");
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ExtraBlack.woff2") format("woff2");
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: "Circular";
src: url("CircularXXWeb-ExtraBlackItalic.woff2") format("woff2");
font-weight: 900;
font-style: italic;
}

2
ui/public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

22
ui/src/api.ts Normal file
View File

@@ -0,0 +1,22 @@
function api(url: string, options: RequestInit): Promise<Response> {
const baseOptions: RequestInit = {
mode: "cors",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
...options,
};
return fetch(url, baseOptions);
}
export default Object.assign(api, {
GET: (url: string, options?: RequestInit) => api(url, { method: "GET", ...options }),
POST: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "POST", body: JSON.stringify(body), ...options }),
PUT: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "PUT", body: JSON.stringify(body), ...options }),
DELETE: (url: string, body?: object, options?: RequestInit) =>
api(url, { method: "DELETE", body: JSON.stringify(body), ...options }),
});

BIN
ui/src/assets/arch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
fill="black"/>
<path d="M4 13H20V18H4V13Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 511 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,15 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M2 7C2 5.89543 2.89543 5 4 5H20C21.1046 5 22 5.89543 22 7V19C22 20.1046 21.1046 21 20 21H4C2.89543 21 2 20.1046 2 19V7Z"
fill="transparent" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20 7H4V19H20V7ZM4 5C2.89543 5 2 5.89543 2 7V19C2 20.1046 2.89543 21 4 21H20C21.1046 21 22 20.1046 22 19V7C22 5.89543 21.1046 5 20 5H4Z"
fill="currentColor" />
<path d="M12 3H24V15H12V3Z" fill="transparent" />
<path
d="M14 6C14 5.44772 14.4477 5 15 5H21C21.5523 5 22 5.44772 22 6V12C22 12.5523 21.5523 13 21 13H15C14.4477 13 14 12.5523 14 12V6Z"
fill="transparent" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16 7V11H20V7H16ZM15 5C14.4477 5 14 5.44772 14 6V12C14 12.5523 14.4477 13 15 13H21C21.5523 13 22 12.5523 22 12V6C22 5.44772 21.5523 5 21 5H15Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 991 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
ui/src/assets/kali-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
ui/src/assets/logo-blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -0,0 +1,12 @@
<svg width="89" height="24" viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#1D4ED8"/>
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
<path d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z" fill="#1D4ED8"/>
<path d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z" fill="white"/>
<path d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z" fill="#1D4ED8"/>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
ui/src/assets/logo-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,30 @@
<svg viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" rx="6" fill="#1D4ED8" />
<path
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
fill="#1D4ED8" />
<path
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
fill="white" />
<path
d="M0 6C0 2.68629 2.68629 0 6 0H18C21.3137 0 24 2.68629 24 6V18C24 21.3137 21.3137 24 18 24H6C2.68629 24 0 21.3137 0 18V6Z"
fill="#1D4ED8" />
<path
d="M13.8854 12.0001C13.8854 13.0465 13.037 13.8949 11.9906 13.8949C10.9441 13.8949 10.0957 13.0465 10.0957 12.0001C10.0957 10.9536 10.9441 10.1052 11.9906 10.1052C13.037 10.1052 13.8854 10.9536 13.8854 12.0001Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.58968 9.36279C8.08026 9.54475 8.33045 10.09 8.14849 10.5805C7.9844 11.0229 7.89433 11.5023 7.89433 12.0048C7.89433 14.2684 9.73148 16.1051 11.9998 16.1051C14.268 16.1051 16.1052 14.2684 16.1052 12.0048C16.1052 11.5023 16.0151 11.0229 15.851 10.5805C15.6691 10.09 15.9192 9.54475 16.4098 9.36279C16.9004 9.18083 17.4456 9.43101 17.6276 9.92159C17.8687 10.5717 18 11.274 18 12.0048C18 15.3167 15.3127 17.9999 11.9998 17.9999C8.68682 17.9999 5.99951 15.3167 5.99951 12.0048C5.99951 11.274 6.13081 10.5717 6.37194 9.92159C6.5539 9.43101 7.09911 9.18083 7.58968 9.36279Z"
fill="white" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.9927 7.89481C11.3649 7.89481 10.7726 8.03489 10.243 8.28454C9.7697 8.50765 9.20516 8.30483 8.98206 7.83154C8.75895 7.35825 8.96177 6.79371 9.43506 6.57061C10.2121 6.20432 11.0799 6 11.9927 6C12.9056 6 13.7733 6.20432 14.5504 6.57061C15.0237 6.79371 15.2265 7.35825 15.0034 7.83154C14.7803 8.30483 14.2157 8.50765 13.7424 8.28454C13.2128 8.03489 12.6205 7.89481 11.9927 7.89481Z"
fill="white" />
<path
d="M28.32 13.84L30.192 13.456V14.512C30.192 15.184 30.3573 15.6747 30.688 15.984C31.0187 16.2827 31.4347 16.432 31.936 16.432C32.448 16.432 32.8533 16.272 33.152 15.952C33.4507 15.6213 33.6 15.168 33.6 14.592V6.656H35.52V14.544C35.52 15.0453 35.4347 15.52 35.264 15.968C35.0933 16.416 34.8533 16.8107 34.544 17.152C34.2347 17.4827 33.8613 17.7493 33.424 17.952C32.9867 18.144 32.496 18.24 31.952 18.24C31.3973 18.24 30.896 18.1547 30.448 17.984C30 17.8027 29.616 17.552 29.296 17.232C28.9867 16.912 28.7467 16.528 28.576 16.08C28.4053 15.632 28.32 15.1307 28.32 14.576V13.84ZM42.9919 13.248C42.9812 13.024 42.9332 12.8107 42.8479 12.608C42.7732 12.3947 42.6559 12.208 42.4959 12.048C42.3359 11.888 42.1385 11.76 41.9039 11.664C41.6692 11.568 41.3919 11.52 41.0719 11.52C40.7839 11.52 40.5225 11.5733 40.2879 11.68C40.0639 11.776 39.8719 11.9093 39.7119 12.08C39.5519 12.24 39.4239 12.4267 39.3279 12.64C39.2319 12.8427 39.1785 13.0453 39.1679 13.248H42.9919ZM44.7679 15.776C44.6612 16.1173 44.5065 16.4373 44.3039 16.736C44.1012 17.0347 43.8505 17.296 43.5519 17.52C43.2532 17.744 42.9119 17.92 42.5279 18.048C42.1439 18.176 41.7172 18.24 41.2479 18.24C40.7145 18.24 40.2079 18.1493 39.7279 17.968C39.2479 17.776 38.8265 17.504 38.4639 17.152C38.1012 16.7893 37.8079 16.352 37.5839 15.84C37.3705 15.3173 37.2639 14.7253 37.2639 14.064C37.2639 13.4453 37.3652 12.8853 37.5679 12.384C37.7812 11.8827 38.0639 11.456 38.4159 11.104C38.7679 10.7413 39.1732 10.464 39.6319 10.272C40.0905 10.0693 40.5652 9.968 41.0559 9.968C41.6532 9.968 42.1865 10.064 42.6559 10.256C43.1359 10.448 43.5359 10.72 43.8559 11.072C44.1865 11.424 44.4372 11.8507 44.6079 12.352C44.7785 12.8427 44.8639 13.3973 44.8639 14.016C44.8639 14.1653 44.8585 14.2987 44.8479 14.416C44.8372 14.5227 44.8265 14.5867 44.8159 14.608H39.1199C39.1305 14.9067 39.1945 15.1787 39.3119 15.424C39.4292 15.6693 39.5839 15.8827 39.7759 16.064C39.9679 16.2453 40.1865 16.3893 40.4319 16.496C40.6879 16.592 40.9599 16.64 41.2479 16.64C41.8132 16.64 42.2452 16.512 42.5439 16.256C42.8532 15.9893 43.0719 15.664 43.1999 15.28L44.7679 15.776ZM48.9895 10.208H50.6055V11.856H48.9895V15.472C48.9895 15.8133 49.0695 16.064 49.2295 16.224C49.3895 16.3733 49.6402 16.448 49.9815 16.448C50.1095 16.448 50.2375 16.4427 50.3655 16.432C50.4935 16.4107 50.5788 16.3947 50.6215 16.384V17.92C50.5682 17.9413 50.4508 17.9733 50.2695 18.016C50.0882 18.0693 49.8268 18.096 49.4855 18.096C48.7602 18.096 48.1895 17.8933 47.7735 17.488C47.3575 17.0827 47.1495 16.512 47.1495 15.776V11.856H45.7095V10.208H46.1095C46.5255 10.208 46.8295 10.0907 47.0215 9.856C47.2135 9.62133 47.3095 9.33333 47.3095 8.992V7.824H48.9895V10.208ZM56.0653 13.088L54.5613 14.736V18H52.6413V6.656H54.5613V12.096L59.4413 6.656H61.9693L57.3773 11.664L62.0173 18H59.6013L56.0653 13.088ZM70.6701 6.656H72.7021L68.4141 18H66.4621L62.2381 6.656H64.3181L67.4861 15.488L70.6701 6.656ZM84.7954 18V9.648L81.2594 18H79.5954L76.0914 9.68V18H74.2194V6.656H76.7794L80.4594 15.312L84.0914 6.656H86.6994V18H84.7954Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,11 @@
<svg viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3161_210)">
<path d="M11.5 1V7.60833H17.3333C17.3333 4.20833 14.7917 1.40833 11.5 1ZM4 12.6083C4 16.2917 6.98333 19.275 10.6667 19.275C14.35 19.275 17.3333 16.2917 17.3333 12.6083V9.275H4V12.6083ZM9.83333 1C6.54167 1.40833 4 4.20833 4 7.60833H9.83333V1Z"
fill="black"/>
</g>
<defs>
<clipPath id="clip0_3161_210">
<rect width="20" height="20" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 575 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,11 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3161_200)">
<path d="M7.49967 9.36667V6.25C7.49967 5.69747 7.71917 5.16756 8.10987 4.77686C8.50057 4.38616 9.03047 4.16667 9.58301 4.16667C10.1355 4.16667 10.6654 4.38616 11.0561 4.77686C11.4468 5.16756 11.6663 5.69747 11.6663 6.25V9.36667C12.6747 8.69167 13.333 7.55 13.333 6.25C13.333 4.175 11.658 2.5 9.58301 2.5C7.50801 2.5 5.83301 4.175 5.83301 6.25C5.83301 7.55 6.49134 8.69167 7.49967 9.36667ZM15.6997 13.225L11.9163 11.3417C11.7747 11.2833 11.6247 11.25 11.4663 11.25H10.833V6.25C10.833 5.55833 10.2747 5 9.58301 5C8.89134 5 8.33301 5.55833 8.33301 6.25V15.2C5.33301 14.5667 5.38301 14.575 5.27467 14.575C5.01634 14.575 4.78301 14.6833 4.61634 14.85L3.95801 15.5167L8.07467 19.6333C8.29967 19.8583 8.61634 20 8.95801 20H14.6163C15.2413 20 15.7247 19.5417 15.8163 18.9333L16.4413 14.5417C16.4497 14.4833 16.458 14.425 16.458 14.375C16.458 13.8583 16.1413 13.4083 15.6997 13.225Z"
fill="black"/>
</g>
<defs>
<clipPath id="clip0_3161_200">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 B

View File

@@ -0,0 +1,258 @@
import { Button } from "@components/Button";
import {
useHidStore,
useMountMediaStore,
useUiStore,
useSettingsStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
export default function Actionbar({
requestFullscreen,
}: {
requestFullscreen: () => Promise<void>;
}) {
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
const developerMode = useSettingsStore(state => state.developerMode);
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
const isOpen = useRef<boolean>(false);
const checkIfStateChanged = useCallback(
(open: boolean) => {
if (open !== isOpen.current) {
isOpen.current = open;
if (!open) {
setTimeout(() => {
setDisableFocusTrap(false);
console.log("Popover is closing. Returning focus trap to video");
}, 0);
}
}
},
[setDisableFocusTrap],
);
return (
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
<div
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
>
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
{developerMode && (
<Button
size="XS"
theme="light"
text="Web Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setEnableTerminal(!enableTerminal)}
/>
)}
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Paste text"
LeadingIcon={MdOutlineContentPasteGo}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<PasteModal />
</div>
);
}}
</PopoverPanel>
</Popover>
<div className="relative">
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Virtual Media"
LeadingIcon={({ className }) => {
return (
<>
<LuHardDrive className={className} />
<div
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
hidden: !remoteVirtualMediaState,
})}
/>
</>
);
}}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<MountPopopover />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div>
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Wake on Lan"
onClick={() => {
setDisableFocusTrap(true);
}}
LeadingIcon={({ className }) => (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
<path d="M6 8v1" />
<path d="M10 8v1" />
<path d="M14 8v1" />
<path d="M18 8v1" />
</svg>
)}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
style={{
transitionProperty: "opacity",
}}
className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="w-full max-w-xl mx-auto">
<WakeOnLanModal />
</div>
);
}}
</PopoverPanel>
</Popover>
</div>
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<div className="block lg:hidden">
<Button
size="XS"
theme="light"
text="Virtual Keyboard"
LeadingIcon={FaKeyboard}
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
<div className="hidden md:block">
<Button
size="XS"
theme="light"
text="Connection Stats"
LeadingIcon={({ className }) => (
<LuSignal
className={cx(className, "mb-0.5 text-green-500")}
strokeWidth={4}
/>
)}
onClick={() => {
toggleSidebarView("connection-stats");
}}
/>
</div>
<div className="hidden xs:block ">
<Button
size="XS"
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => toggleSidebarView("system")}
/>
</div>
<div className="items-center hidden gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button
size="XS"
theme="light"
text="Fullscreen"
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>
</div>
</div>
</div>
</Container>
);
}

View File

@@ -0,0 +1,99 @@
import { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons";
import SimpleNavbar from "@components/SimpleNavbar";
import Container from "@components/Container";
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter";
type AuthLayoutProps = {
title: string;
description: string;
action: string;
cta: string;
ctaHref: string;
showCounter?: boolean;
};
export default function AuthLayout({
title,
description,
action,
cta,
ctaHref,
showCounter,
}: AuthLayoutProps) {
const [sq] = useSearchParams();
const location = useLocation();
const returnTo = sq.get("returnTo") || location.state?.returnTo;
const deviceId = sq.get("deviceId") || location.state?.deviceId;
const navigation = useNavigation();
return (
<>
<GridBackground />
<div className="grid min-h-screen grid-rows-layout">
<SimpleNavbar
logoHref="/"
actionElement={
<div>
<LinkButton to={ctaHref} text={cta} theme="light" size="MD" />
</div>
}
/>
<Container>
<div className="flex items-center justify-center w-full h-full isolate">
<div className="max-w-2xl -mt-16 space-y-8">
{showCounter ? (
<div className="text-center">
<StepCounter currStepIdx={0} nSteps={2} />
</div>
) : null}
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">
{title}
</h1>
<p className="text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Fieldset className="space-y-12">
<div className="max-w-sm mx-auto space-y-4">
<form
action={`${import.meta.env.VITE_CLOUD_API}/oidc/google`}
method="POST"
>
{/*This could be the KVM ID*/}
{deviceId ? (
<input type="hidden" name="deviceId" value={deviceId} />
) : null}
{returnTo ? (
<input type="hidden" name="returnTo" value={returnTo} />
) : null}
<Button
size="LG"
theme="light"
fullWidth
text={`${action}`}
LeadingIcon={GoogleIcon}
textAlign="center"
type="submit"
loading={
(navigation.state === "submitting" ||
navigation.state === "loading") &&
navigation.formMethod?.toLowerCase() === "post" &&
navigation.formAction?.includes("auth/google")
}
/>
</form>
</div>
</Fieldset>
</div>
</div>
</Container>
</div>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { useRef, useState, useEffect } from "react";
import AnimateHeight, { Height } from "react-animate-height";
const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
const [height, setHeight] = useState<Height>("auto");
const contentDiv = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = contentDiv.current as HTMLDivElement;
const resizeObserver = new ResizeObserver(() => {
setHeight(element.clientHeight);
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, []);
return (
<AnimateHeight
{...props}
height={height}
duration={300}
contentClassName="auto-content pointer-events-none"
contentRef={contentDiv}
disableDisplayNone
>
{children}
</AnimateHeight>
);
};
export default AutoHeight;

View File

@@ -0,0 +1,245 @@
import React from "react";
import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
const sizes = {
XS: "h-[28px] px-2 text-xs",
SM: "h-[36px] px-3 text-[13px]",
MD: "h-[40px] px-3.5 text-sm",
LG: "h-[48px] px-4 text-base",
XL: "h-[56px] px-5 text-base",
};
const themes = {
primary: cx(
// Base styles
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow",
// Hover states
"group-hover:bg-blue-800",
// Active states
"group-active:bg-blue-900",
),
danger: cx(
// 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",
// Hover states
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
// Active states
"group-active:bg-red-800 dark:group-active:bg-red-800",
// Focus states
"group-focus:ring-red-700 dark:group-focus:ring-red-600",
),
light: cx(
// Base styles
"bg-white text-black border-slate-800/30 shadow dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
// Hover states
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
// Active states
"group-active:bg-blue-100/60 dark:group-active:bg-slate-600",
// Disabled states
"group-disabled:group-hover:bg-white dark:group-disabled:group-hover:bg-slate-800",
),
lightDanger: cx(
// Base styles
"bg-white text-black border-red-400/60 shadow-sm",
// Hover states
"group-hover:bg-red-50/80",
// Active states
"group-active:bg-red-100/60",
// Focus states
"group-focus:ring-red-700",
),
blank: cx(
// Base styles
"bg-white/0 text-black border-transparent dark:text-white",
// 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",
// Active states
"group-active:bg-slate-100/80",
),
};
const btnVariants = cva({
base: cx(
// Base styles
"border rounded select-none",
// Size classes
"justify-center items-center shrink-0",
// Transition classes
"outline-none transition-all duration-200",
// Text classes
"font-display text-center font-medium leading-tight",
// States
"group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
"group-disabled:opacity-50 group-disabled:pointer-events-none",
),
variants: {
size: sizes,
theme: themes,
},
});
const iconVariants = cva({
variants: {
size: {
XS: "h-3.5",
SM: "h-3.5",
MD: "h-5",
LG: "h-6",
XL: "h-6",
},
theme: {
primary: "text-white",
danger: "text-white ",
light: "text-black dark:text-white",
lightDanger: "text-black dark:text-white",
blank: "text-black dark:text-white",
},
},
});
type ButtonContentPropsType = {
text?: string | React.ReactNode;
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
fullWidth?: boolean;
className?: string;
textAlign?: "left" | "center" | "right";
size: keyof typeof sizes;
theme: keyof typeof themes;
loading?: boolean;
};
function ButtonContent(props: ButtonContentPropsType) {
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
props;
// Based on the size prop, we'll use the corresponding variant classnames
const iconClassName = iconVariants(props);
const btnClassName = btnVariants(props);
return (
<div className={cx(className, fullWidth ? "flex" : "inline-flex", btnClassName)}>
<div
className={cx(
"flex w-full min-w-0 items-center gap-x-1.5 text-center",
textAlign === "left" ? "!text-left" : "",
textAlign === "center" ? "!text-center" : "",
textAlign === "right" ? "!text-right" : "",
)}
>
{loading ? (
<div>
<LoadingSpinner className={cx(iconClassName, "animate-spin")} />
</div>
) : (
LeadingIcon && (
<LeadingIcon className={cx(iconClassName, "shrink-0 justify-start")} />
)
)}
{text && typeof text === "string" ? (
<span className="relative w-full truncate">{text}</span>
) : (
text
)}
{TrailingIcon && (
<TrailingIcon className={cx(iconClassName, "shrink-0 justify-end")} />
)}
</div>
</div>
);
}
type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
> &
React.ComponentProps<typeof ButtonContent> & {
fetcher?: FetcherWithComponents<unknown>;
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
const classes = cx(
"group outline-none",
props.fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "",
);
const navigation = useNavigation();
const loader = fetcher ? fetcher : navigation;
return (
<button
ref={ref}
formNoValidate={formNoValidate}
className={classes}
type={type}
disabled={disabled}
onClick={onClick}
name={props.name}
value={props.value}
>
<ButtonContent
{...props}
loading={
loading ??
(type === "submit" &&
(loader.state === "submitting" || loader.state === "loading") &&
loader.formMethod?.toLowerCase() === "post")
}
/>
</button>
);
},
);
Button.displayName = "Button";
type LinkPropsType = Pick<LinkProps, "to"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const classes = cx(
"group outline-none",
props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "",
props.className,
);
if (to.toString().startsWith("http")) {
return (
<ExtLink href={to.toString()} className={classes}>
<ButtonContent {...props} />
</ExtLink>
);
} else {
return (
<Link to={to} className={classes}>
<ButtonContent {...props} />
</Link>
);
}
};
type LabelPropsType = Pick<HTMLLabelElement, "htmlFor"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => {
const classes = cx(
"group outline-none block cursor-pointer",
props.disabled ? "pointer-events-none !opacity-70" : "",
props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "",
props.className,
);
return (
<div>
<label htmlFor={htmlFor} className={classes}>
<ButtonContent {...props} />
</label>
</div>
);
};

View File

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

View File

@@ -0,0 +1,19 @@
import React from "react";
type Props = {
headline: string;
description?: string | React.ReactNode;
Button?: React.ReactNode;
};
export const CardHeader = ({ headline, description, Button }: Props) => {
return (
<div className="flex items-center justify-between pb-0 gap-x-4">
<div className="space-y-1 grow">
<h3 className="text-lg font-bold leading-none text-black dark:text-white">{headline}</h3>
{description && <div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>}
</div>
{Button && <div>{Button}</div>}
</div>
);
};

View File

@@ -0,0 +1,77 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { cva, cx } from "@/cva.config";
const sizes = {
SM: "w-4 h-4",
MD: "w-5 h-5",
};
const checkboxVariants = cva({
base: cx(
"block rounded",
// Colors
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-blue-700 dark:text-blue-500 transition-colors",
// Hover
"hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
// Active
"active:bg-slate-200 dark:active:bg-slate-700",
// 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",
// Disabled
"disabled:pointer-events-none disabled:opacity-30",
),
variants: { size: sizes },
});
type CheckBoxProps = {
size?: keyof typeof sizes;
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
{ size = "MD", ...props },
ref,
) {
const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={classes} />;
});
Checkbox.displayName = "Checkbox";
type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
CheckBoxProps & {
fullWidth?: boolean;
disabled?: boolean;
};
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel(
{ label, id, description, as, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>,
) {
return (
<label
className={clsx(
"flex shrink-0 items-center justify-between gap-x-2",
fullWidth ? "flex" : "inline-flex",
readOnly ? "pointer-events-none opacity-50" : "",
)}
>
<Checkbox ref={ref as never} {...props} />
<div className="max-w-md">
<FieldLabel label={label} id={id} description={description} as="span" />
</div>
</label>
);
},
);
CheckboxWithLabel.displayName = "CheckboxWithLabel";
export default Checkbox;
export { CheckboxWithLabel, Checkbox };

View File

@@ -0,0 +1,20 @@
import React, { ReactNode } from "react";
import { cx } from "@/cva.config";
function Container({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cx("mx-auto h-full w-full px-8 ", className)}>{children}</div>;
}
function Article({ children }: { children: React.ReactNode }) {
return (
<Container>
<div className="grid w-full grid-cols-12">
<div className="col-span-12 xl:col-span-11 xl:col-start-2">{children}</div>
</div>
</Container>
);
}
export default Object.assign(Container, {
Article,
});

View File

@@ -0,0 +1,32 @@
import Card from "@components/Card";
export type CustomTooltipProps = {
payload: { payload: { date: number; stat: number }; unit: string }[];
};
export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) {
const toolTipData = payload[0];
const { date, stat } = toolTipData.payload;
return (
<Card>
<div className="p-2 text-black dark:text-white">
<div className="font-semibold">
{new Date(date * 1000).toLocaleTimeString()}
</div>
<div className="space-y-1">
<div className="flex items-center gap-x-1">
<div className="h-[2px] w-2 bg-blue-700" />
<span >
{stat} {toolTipData?.unit}
</span>
</div>
</div>
</div>
</Card>
);
}
return null;
}

View File

@@ -0,0 +1,39 @@
import { GridCard } from "@/components/Card";
import React from "react";
import { cx } from "../cva.config";
type Props = {
IconElm?: React.FC<any>;
headline: string;
description?: string | React.ReactNode;
BtnElm?: React.ReactNode;
className?: string;
};
export default function EmptyCard({
IconElm,
headline,
description,
BtnElm,
className,
}: Props) {
return (
<GridCard>
<div
className={cx(
"flex min-h-[256px] w-full flex-col items-center justify-center gap-y-4 px-4 py-5 text-center",
className,
)}
>
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2">
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
</div>
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
{BtnElm}
</div>
</GridCard>
);
}

View File

@@ -0,0 +1,28 @@
import React from "react";
import { cx } from "@/cva.config";
export default function ExtLink({
className,
href,
id,
target,
children,
}: {
className?: string;
href: string;
id?: string;
target?: string;
children: React.ReactNode;
}) {
return (
<a
className={cx(className)}
target={target ?? "_blank"}
id={id}
rel="noopener noreferrer"
href={href}
>
{children}
</a>
);
}

View File

@@ -0,0 +1,51 @@
import React from "react";
import { cx } from "@/cva.config";
type Props = {
label: string | React.ReactNode;
id?: string;
as?: "label" | "span";
description?: string | React.ReactNode | null;
disabled?: boolean;
};
export default function FieldLabel({
label,
id,
as = "label",
description,
disabled,
}: Props) {
if (as === "label") {
return (
<label
htmlFor={id}
className={cx(
"flex select-none flex-col text-left font-display text-[13px] font-semibold leading-snug text-black dark:text-white",
disabled && "opacity-50",
)}
>
{label}
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
{description}
</span>
)}
</label>
);
} else if (as === "span") {
return (
<div className="flex flex-col select-none">
<span className="font-display text-[13px] font-medium leading-snug text-black">
{label}
</span>
{description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600">
{description}
</span>
)}
</div>
);
} else {
return <></>;
}
}

View File

@@ -0,0 +1,30 @@
import React from "react";
import clsx from "clsx";
import { FetcherWithComponents, useNavigation } from "react-router-dom";
export default function Fieldset({
children,
fetcher,
className,
disabled,
}: {
children: React.ReactNode;
fetcher?: FetcherWithComponents<any>;
className?: string;
disabled?: boolean;
}) {
const navigation = useNavigation();
const loader = fetcher ? fetcher : navigation;
return (
<fieldset
className={clsx(className)}
disabled={
disabled ??
((loader.state === "submitting" || loader.state === "loading") &&
loader.formMethod?.toLowerCase() === "post")
}
>
{children}
</fieldset>
);
}

View File

@@ -0,0 +1,41 @@
export default function GridBackground() {
return (
<div className="absolute w-screen h-screen overflow-hidden isolate opacity-60">
<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"
aria-hidden="true"
>
<defs>
<pattern
id="1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84"
width={200}
height={200}
x="50%"
y={-1}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" />
</pattern>
</defs>
<svg
x="50%"
y={-1}
className="overflow-visible fill-blue-100 dark:fill-blue-900/30"
>
<path
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
strokeWidth={0}
/>
</svg>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#1f932ae7-37de-4c0a-a8b0-a6e3b4d44b84)"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton, Transition } from "@headlessui/react";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
interface NavbarProps {
isLoggedIn: boolean;
primaryLinks?: { title: string; to: string }[];
userEmail?: string;
showConnectionStatus?: boolean;
picture?: string;
kvmName?: string;
}
export default function DashboardNavbar({
primaryLinks = [],
isLoggedIn,
showConnectionStatus,
userEmail,
picture,
kvmName,
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
: `${import.meta.env.VITE_CLOUD_API}/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) return;
setUser(null);
// The root route will redirect to appropiate login page, be it the local one or the cloud one
navigate("/");
}, [navigate, setUser]);
const usbState = useHidStore(state => state.usbState);
return (
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900">
<Container>
<div className="flex items-center justify-between h-14">
<div className="flex items-center shrink-0 gap-x-8">
<div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
</div>
<div className="flex gap-x-2">
{primaryLinks.map(({ title, to }, i) => {
return (
<LinkButton
key={i + title}
theme="blank"
size="SM"
text={title}
to={to}
LeadingIcon={LuMonitorSmartphone}
/>
);
})}
</div>
</div>
<div className="flex items-center justify-end w-full gap-x-2">
<div className="flex items-center space-x-4 shrink-0">
{showConnectionStatus && (
<div className="items-center hidden gap-x-2 md:flex">
<div className="w-[159px]">
<PeerConnectionStatusCard
state={peerConnectionState}
title={kvmName}
/>
</div>
<div className="hidden w-[159px] md:block">
<USBStateStatus
state={usbState}
peerConnectionState={peerConnectionState}
/>
</div>
</div>
)}
{isLoggedIn ? (
<>
<hr className="h-[20px] w-[1px] border-none bg-slate-800/20 dark:bg-slate-300/20" />
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton as={Fragment}>
<Button
theme="blank"
size="SM"
text={
<>
{picture ? <></> : userEmail}
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
</>
}
LeadingIcon={({ className }) => (
picture && (
<img
src={picture}
alt="Avatar"
className={cx(
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
/>
)
)}
/>
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-in-out duration-75"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in-out duration-75"
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white">
{userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div className="p-2">
<div className="text-xs font-display">
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
</div>
</div>
</Menu.Item>
</div>
)}
<div>
<Menu.Item>
<div onClick={onLogout}>
<button className="block w-full">
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
<div className="font-display">Log out</div>
</div>
</button>
</div>
</Menu.Item>
</div>
</div>
</Card>
</Menu.Items>
</Transition>
</Menu>
</>
) : null}
</div>
</div>
</div>
</Container>
</div>
);
}

328
ui/src/components/Icons.tsx Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,142 @@
import { cx } from "@/cva.config";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { useEffect } from "react";
import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() {
const activeKeys = useHidStore(state => state.activeKeys);
const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
);
const videoSize = useVideoStore(
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const settings = useSettingsStore();
useEffect(() => {
if (!rpcDataChannel) return;
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
rpcDataChannel.onerror = e =>
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]);
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const isNumLockActive = useHidStore(state => state.isNumLockActive);
const isScrollLockActive = useHidStore(state => state.isScrollLockActive);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const usbState = useHidStore(state => state.usbState);
const hdmiState = useVideoStore(state => state.hdmiState);
return (
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
<div className="flex flex-wrap items-stretch justify-between gap-1">
<div className="flex items-center">
<div className="flex flex-wrap items-center pl-2 gap-x-4">
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Resolution:</span>{" "}
<span className="text-xs">{videoSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex">
<span className="text-xs font-semibold">Video Size: </span>
<span className="text-xs">{videoClientSize}</span>
</div>
) : null}
{settings.debugMode ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
{mouseX},{mouseY}
</span>
</div>
) : null}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>
<span className="text-xs">{usbState}</span>
</div>
)}
{settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">HDMI State:</span>
<span className="text-xs">{hdmiState}</span>
</div>
)}
<div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs">
{[
...activeKeys.map(
x => Object.entries(keys).filter(y => y[1] === x)[0][0],
),
activeModifiers.map(
x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
),
].join(", ")}
</h2>
</div>
</div>
</div>
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
{isTurnServerInUse && (
<div className="shrink-0 p-1 px-1.5 text-xs text-black dark:text-white">
Relayed by Cloudflare
</div>
)}
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isCapsLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Caps Lock
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isNumLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Num Lock
</div>
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
isScrollLockActive
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
>
Scroll Lock
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "@/components/Card";
import { cva } from "@/cva.config";
const sizes = {
XS: "h-[26px] px-3 text-xs",
SM: "h-[36px] px-3 text-[14px]",
MD: "h-[40px] px-4 text-sm",
LG: "h-[48px] py-4 px-5 text-base",
};
const inputVariants = cva({
variants: { size: sizes },
});
type InputFieldProps = {
size?: keyof typeof sizes;
TrailingElm?: React.ReactNode;
LeadingElm?: React.ReactNode;
error?: string | null;
} & Omit<JSX.IntrinsicElements["input"], "size">;
type InputFieldWithLabelProps = InputFieldProps & {
label: React.ReactNode;
description?: string | null;
};
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
{ LeadingElm, TrailingElm, className, size = "MD", error, ...props },
ref,
) {
const sizeClasses = inputVariants({ size });
return (
<>
<Card
className={clsx(
// General styling
"relative flex w-full overflow-hidden",
"[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2",
// 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",
// 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",
)}
>
{LeadingElm && (
<div className={clsx("pointer-events-none border-r border-r-slate-300 dark:border-r-slate-600")}>
{LeadingElm}
</div>
)}
<input
ref={ref}
className={clsx(
sizeClasses,
TrailingElm ? "pr-2" : "",
className,
"block flex-1 border-0 bg-transparent leading-none placeholder:text-sm placeholder:text-slate-300 dark:placeholder:text-slate-500 focus:ring-0 text-black dark:text-white",
)}
{...props}
/>
{TrailingElm && (
<div className="flex items-center pr-3 pointer-events-none">{TrailingElm}</div>
)}
</Card>
{error && <FieldError error={error} />}
</>
);
});
InputField.displayName = "InputField";
const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProps>(
function InputFieldWithLabel(
{ label, description, id, ...props },
ref: Ref<HTMLInputElement>,
) {
return (
<div className="w-full space-y-1">
{(label || description) && (
<FieldLabel label={label} id={id} description={description} />
)}
<InputField ref={ref as any} id={id} {...props} />
</div>
);
},
);
InputFieldWithLabel.displayName = "InputFieldWithLabel";
export default InputField;
export { InputFieldWithLabel };
export function FieldError({ error }: { error: string | React.ReactNode }) {
return <div className="mt-[6px] text-[13px] leading-normal text-red-500">{error}</div>;
}

View File

@@ -0,0 +1,153 @@
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom";
import { LuEllipsisVertical } from "react-icons/lu";
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime();
// Get the amount of seconds between the given date and now
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
// Array reprsenting one minute, hour, day, week, month, etc in seconds
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
// Array equivalent to the above but in the string representation of the units
const units: Intl.RelativeTimeFormatUnit[] = [
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
];
// Grab the ideal cutoff unit
const unitIndex = cutoffs.findIndex(cutoff => cutoff > Math.abs(deltaSeconds));
// Get the divisor to divide from the seconds. E.g. if our unit is "day" our divisor
// is one day in seconds, so we can divide our seconds by this to get the # of days
const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;
// Intl.RelativeTimeFormat do its magic
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: "auto" });
return rtf.format(Math.floor(deltaSeconds / divisor), units[unitIndex]);
}
export default function KvmCard({
title,
id,
online,
lastSeen,
}: {
title: string;
id: string;
online: boolean;
lastSeen: Date | null;
}) {
return (
<Card>
<div className="px-5 py-5 space-y-3">
<div className="flex justify-between items-cente">
<div className="space-y-1.5">
<div className="text-lg font-bold leading-none text-black dark:text-white">
{title}
</div>
{online ? (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-green-600 bg-green-500" />
<div className="text-sm text-black dark:text-white">Online</div>
</div>
) : (
<div className="flex items-center gap-x-1.5">
<div className="h-2.5 w-2.5 rounded-full border border-slate-400/60 dark:border-slate-500 bg-slate-200 dark:bg-slate-600" />
<div className="text-sm text-black dark:text-white">
{lastSeen ? (
<>Last online {getRelativeTimeString(lastSeen)}</>
) : (
<>Never seen online</>
)}
</div>
</div>
)}
</div>
</div>
<div className="h-[1px] bg-slate-800/20 dark:bg-slate-300/20" />
<div className="flex justify-between">
<div>
{online ? (
<LinkButton
size="MD"
theme="light"
text="Connect to KVM"
LeadingIcon={MdConnectWithoutContact}
textAlign="center"
to={`/devices/${id}`}
/>
) : (
<Button
size="MD"
theme="light"
text="Troubleshoot Connection"
textAlign="center"
/>
)}
</div>
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton
as={Button}
theme="light"
TrailingIcon={LuEllipsisVertical}
size="MD"
></MenuButton>
</div>
<MenuItems
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"
>
<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">
<div className="divide-y divide-slate-800/20 dark:divide-slate-300/20">
<MenuItem>
<div>
<div className="block w-full">
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
<Link
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/rename`}
>
Rename
</Link>
</div>
</div>
</div>
</MenuItem>
<MenuItem>
<div>
<div className="block w-full">
<div className="flex items-center px-2 my-1 text-sm transition-colors rounded-md gap-x-2 hover:bg-slate-100 dark:hover:bg-slate-700">
<Link
className="block w-full py-1.5 text-black dark:text-white"
to={`./${id}/deregister`}
>
Deregister from cloud
</Link>
</div>
</div>
</div>
</MenuItem>
</div>
</Card>
</MenuItems>
</Menu>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import clsx from "clsx";
export default function LoadingSpinner({
className,
}: {
className: string | undefined;
}) {
return (
<svg
className={clsx(className, "flex-shrink-0 animate-spin p-[2px]")}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
// className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@@ -0,0 +1,356 @@
import { GridCard } from "@/components/Card";
import { useState } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { InputFieldWithLabel } from "./InputField";
import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores";
export default function LocalAuthPasswordDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") {
setError("Please enter a password");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
try {
const res = await api.POST("/auth/password-local", { password });
if (res.ok) {
setModalView("creationSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while setting the password");
}
} catch (error) {
setError("An error occurred while setting the password");
}
};
const handleUpdatePassword = async (
oldPassword: string,
newPassword: string,
confirmNewPassword: string,
) => {
if (newPassword !== confirmNewPassword) {
setError("Passwords do not match");
return;
}
if (oldPassword === "") {
setError("Please enter your old password");
return;
}
if (newPassword === "") {
setError("Please enter a new password");
return;
}
try {
const res = await api.PUT("/auth/password-local", {
oldPassword,
newPassword,
});
if (res.ok) {
setModalView("updateSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
}
} catch (error) {
setError("An error occurred while changing the password");
}
};
const handleDeletePassword = async (password: string) => {
if (password === "") {
setError("Please enter your current password");
return;
}
try {
const res = await api.DELETE("/auth/local-password", { password });
if (res.ok) {
setModalView("deleteSuccess");
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");
}
} catch (error) {
setError("An error occurred while disabling the password");
}
};
return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div className="p-10">
{modalView === "createPassword" && (
<CreatePasswordModal
onSetPassword={handleCreatePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "deletePassword" && (
<DeletePasswordModal
onDeletePassword={handleDeletePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "updatePassword" && (
<UpdatePasswordModal
onUpdatePassword={handleUpdatePassword}
onCancel={() => setOpen(false)}
error={error}
/>
)}
{modalView === "creationSuccess" && (
<SuccessModal
headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
onClose={() => setOpen(false)}
/>
)}
{modalView === "deleteSuccess" && (
<SuccessModal
headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
onClose={() => setOpen(false)}
/>
)}
{modalView === "updateSuccess" && (
<SuccessModal
headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
onClose={() => setOpen(false)}
/>
)}
</div>
</GridCard>
);
}
function CreatePasswordModal({
onSetPassword,
onCancel,
error,
}: {
onSetPassword: (password: string, confirmPassword: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access.
</p>
</div>
<InputFieldWithLabel
label="New Password"
type="password"
placeholder="Enter a strong password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
type="password"
placeholder="Re-enter your password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Secure Device"
onClick={() => onSetPassword(password, confirmPassword)}
/>
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function DeletePasswordModal({
onDeletePassword,
onCancel,
error,
}: {
onDeletePassword: (password: string) => void;
onCancel: () => void;
error: string | null;
}) {
const [password, setPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection.
</p>
</div>
<InputFieldWithLabel
label="Current Password"
type="password"
placeholder="Enter your current password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="danger"
text="Disable Protection"
onClick={() => onDeletePassword(password)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function UpdatePasswordModal({
onUpdatePassword,
onCancel,
error,
}: {
onUpdatePassword: (
oldPassword: string,
newPassword: string,
confirmNewPassword: string,
) => void;
onCancel: () => void;
error: string | null;
}) {
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device
protection.
</p>
</div>
<InputFieldWithLabel
label="Current Password"
type="password"
placeholder="Enter your current password"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
<InputFieldWithLabel
label="New Password"
type="password"
placeholder="Enter a new strong password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
<InputFieldWithLabel
label="Confirm New Password"
type="password"
placeholder="Re-enter your new password"
value={confirmNewPassword}
onChange={e => setConfirmNewPassword(e.target.value)}
/>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Update Password"
onClick={() => onUpdatePassword(oldPassword, newPassword, confirmNewPassword)}
/>
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
</div>
);
}
function SuccessModal({
headline,
description,
onClose,
}: {
headline: string;
description: string;
onClose: () => void;
}) {
return (
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config";
export default function Modal({
children,
className,
open,
onClose,
}: {
children: React.ReactNode;
className?: string;
open: boolean;
onClose: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className={cx(
"pointer-events-none relative w-full sm:my-8",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in",
className,
)}
>
<div className="inline-block w-full text-left pointer-events-auto">
<div className="flex justify-center" onClick={onClose}>
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() {
return (
<div className="h-full w-full">
<div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl">
<EmptyCard
IconElm={ExclamationTriangleIcon}
headline="Not found"
description="The page you were looking for does not exist."
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
export default function OtherSessionConnectedModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
}
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
<div className="p-10">
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div className="h-[24px]">
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Another Active Session Detected
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Only one active session is supported at a time. Would you like to take over
this session?
</p>
<div className="flex items-center justify-start space-x-4">
<Button
size="SM"
theme="primary"
text="Use Here"
onClick={() => setOpen(false)}
/>
</div>
</div>
</div>
</div>
</GridCard>
);
}

View File

@@ -0,0 +1,66 @@
import StatusCard from "@components/StatusCards";
const PeerConnectionStatusMap = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
error: "Connection error",
closing: "Closing",
failed: "Connection failed",
closed: "Closed",
new: "Connecting",
};
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
type StatusProps = {
[key in PeerConnections]: {
statusIndicatorClassName: string;
};
};
export default function PeerConnectionStatusCard({
state,
title,
}: {
state?: PeerConnections;
title?: string;
}) {
if (!state) return null;
const StatusCardProps: StatusProps = {
connected: {
statusIndicatorClassName: "bg-green-500 border-green-600",
},
connecting: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
disconnected: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
error: {
statusIndicatorClassName: "bg-red-500 border-red-600",
},
closing: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
failed: {
statusIndicatorClassName: "bg-red-500 border-red-600",
},
closed: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
["new"]: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
};
const props = StatusCardProps[state];
if (!props) return;
return (
<StatusCard
title={title || "JetKVM Device"}
status={PeerConnectionStatusMap[state]}
{...StatusCardProps[state]}
/>
);
}

View File

@@ -0,0 +1,16 @@
import { ReactNode } from "react";
export function SectionHeader({
title,
description,
}: {
title: string | ReactNode;
description: string | ReactNode;
}) {
return (
<div>
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "./Card";
import { cva } from "@/cva.config";
type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"],
"disabled" | "onChange" | "name" | "value"
> & {
defaultSelection?: string;
className?: string;
options: {
label: string;
value: string;
disabled?: boolean;
}[];
size?: keyof typeof sizes;
direction?: "vertical" | "horizontal";
error?: string;
fullWidth?: boolean;
} & React.ComponentProps<typeof FieldLabel>;
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
};
const selectMenuVariants = cva({
variants: { size: sizes },
});
export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuProps>(
function SelectMenuBasic(
{
fullWidth,
options,
className,
direction = "vertical",
label,
size = "MD",
name,
disabled,
value,
id,
onChange,
},
ref,
) {
const classes = selectMenuVariants({ size });
return (
<div
className={clsx(
direction === "vertical" ? "space-y-1" : "flex items-center gap-x-2",
fullWidth ? "w-full" : "w-auto",
className,
"text-sm",
)}
>
{label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
<select
ref={ref}
name={name}
disabled={disabled}
className={clsx(
classes,
// General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
// Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900",
// Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
// 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",
// Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)}
value={value}
id={id}
onChange={onChange}
>
{options.map(x => (
<option key={x.value} value={x.value} disabled={x.disabled}>
{x.label}
</option>
))}
</select>
</Card>
</div>
);
},
);

View File

@@ -0,0 +1,52 @@
import { Button } from "@components/Button";
import { cx } from "@/cva.config";
import { AvailableSidebarViews } from "@/hooks/stores";
export default function SidebarHeader({
title,
setSidebarView,
}: {
title: string;
setSidebarView: (view: AvailableSidebarViews | null) => void;
}) {
return (
<div className="flex items-center justify-between border-b border-b-slate-800/20 bg-white px-4 py-1.5 font-semibold text-black dark:bg-slate-900 dark:border-b-slate-300/20">
<div className="min-w-0" style={{ flex: 1 }}>
<h2 className="text-sm text-black truncate dark:text-white">{title}</h2>
</div>
<Button
size="XS"
theme="blank"
text="Hide"
LeadingIcon={({ className }) => (
<svg
className={cx(className, "rotate-180")}
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 2H4C2.89543 2 2 2.89543 2 4V18C2 19.1046 2.89543 20 4 20H18C19.1046 20 20 19.1046 20 18V4C20 2.89543 19.1046 2 18 2ZM4 0C1.79086 0 0 1.79086 0 4V18C0 20.2091 1.79086 22 4 22H18C20.2091 22 22 20.2091 22 18V4C22 1.79086 20.2091 0 18 0H4Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 21L6 1L8 1L8 21H6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.7803 7.21983C15.9208 7.36045 15.9997 7.55108 15.9997 7.74983C15.9997 7.94858 15.9208 8.1392 15.7803 8.27983L13.0603 10.9998L15.7803 13.7198C15.854 13.7885 15.9131 13.8713 15.9541 13.9633C15.9951 14.0553 16.0171 14.1546 16.0189 14.2553C16.0207 14.356 16.0022 14.456 15.9644 14.5494C15.9267 14.6428 15.8706 14.7276 15.7994 14.7989C15.7281 14.8701 15.6433 14.9262 15.5499 14.9639C15.4565 15.0017 15.3565 15.0202 15.2558 15.0184C15.1551 15.0166 15.0558 14.9946 14.9638 14.9536C14.8718 14.9126 14.789 14.8535 14.7203 14.7798L11.4703 11.5298C11.3299 11.3892 11.251 11.1986 11.251 10.9998C11.251 10.8011 11.3299 10.6105 11.4703 10.4698L14.7203 7.21983C14.8609 7.07938 15.0516 7.00049 15.2503 7.00049C15.4491 7.00049 15.6397 7.07938 15.7803 7.21983Z"
fill="currentColor"
/>
</svg>
)}
onClick={() => setSidebarView(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import Container from "@/components/Container";
import { Link } from "react-router-dom";
import React from "react";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
type Props = { logoHref?: string; actionElement?: React.ReactNode };
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
return (
<div>
<Container>
<div className="pb-4 my-4 border-b border-b-800/20 isolate dark:border-b-slate-300/20">
<div className="flex items-center justify-between">
<Link to={logoHref ?? "/"} className="hidden h-[26px] dark:inline-block">
<img src={LogoWhiteIcon} alt="" className="h-[26px] dark:block hidden" />
<img src={LogoBlueIcon} alt="" className="h-[26px] dark:hidden" />
</Link>
<div>{actionElement}</div>
</div>
</div>
</Container>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import {
Brush,
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({
data,
domain,
unit,
referenceValue,
}: {
data: { date: number; stat: number | null | undefined }[];
domain?: [string | number, string | number];
unit?: string;
referenceValue?: number;
}) {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 24, right: 8, left: 0, bottom: 0 }}>
<Brush startIndex={data.length - 60 * 2} height={1} className="hidden" />
<CartesianGrid
strokeDasharray={0}
vertical={false}
strokeLinecap="butt"
stroke="rgba(30, 41, 59, 0.1)"
/>
{referenceValue && (
<ReferenceLine
y={referenceValue}
strokeDasharray="3 3"
strokeWidth={1}
stroke="rgba(30, 41, 59, 0.3)"
/>
)}
<XAxis
dataKey="date"
tick={{
fontFamily: "Circular",
fontSize: "12px",
fill: "rgba(107, 114, 128, 1)",
}}
axisLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickLine={{ stroke: "rgba(30, 41, 59, 0.3)" }}
tickFormatter={date => {
return new Date(date * 1000).toLocaleString("en-US", {
hourCycle: "h23",
hour: "numeric",
minute: "2-digit",
});
}}
ticks={data
.filter(d => {
return d.date % 60 === 0;
})
.map(x => x.date)}
/>
<YAxis
dataKey="stat"
axisLine={false}
orientation="right"
tick={{
fontFamily: "Circular",
fontSize: "12px",
fill: "rgba(107, 114, 128, 1)",
}}
padding={{ top: 0, bottom: 0 }}
tickLine={false}
unit={unit}
domain={domain || ["auto", "auto"]}
/>
<Tooltip
cursor={false}
content={({ payload }) => {
return <CustomTooltip payload={payload as CustomTooltipProps["payload"]} />;
}}
/>
<Line
type="monotone"
isAnimationActive={false}
dataKey="stat"
stroke="rgb(29 78 216)"
strokeLinecap="round"
strokeWidth={2}
unit={unit}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,45 @@
import React from "react";
import { cx } from "@/cva.config";
interface Props {
title: string;
status: string;
icon?: React.FC<{ className: string | undefined }>;
iconClassName?: string;
statusIndicatorClassName?: string;
}
export default function StatusCard({
title,
status,
icon: Icon,
iconClassName,
statusIndicatorClassName,
}: Props) {
return (
<div className="flex items-center gap-x-3 rounded-md border bg-white dark:border-slate-600 dark:bg-slate-800 dark:text-white border-slate-800/20 px-2 py-1.5">
{Icon ? (
<span>
<Icon className={cx(iconClassName, "shrink-0")} />
</span>
) : null}
<div className="space-y-1">
<div className="text-xs font-semibold leading-none transition text-ellipsis">
{title}
</div>
<div className="text-xs leading-none">
<div className="flex items-center gap-x-1">
<div
className={cx(
"h-2 w-2 rounded-full border transition",
statusIndicatorClassName,
)}
/>
<span className={cx("transition")}>{status}</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { CheckIcon } from "@heroicons/react/16/solid";
import { cva, cx } from "@/cva.config";
import Card from "@/components/Card";
type Props = {
nSteps: number;
currStepIdx: number;
size?: keyof typeof sizes;
};
const sizes = {
SM: "text-xs leading-[12px]",
MD: "text-sm leading-[14px]",
};
const variants = cva({
variants: {
size: sizes,
},
});
export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props) {
const textStyle = variants({ size });
return (
<Card className="!inline-flex w-auto select-none items-center justify-center gap-x-2 rounded-lg p-1">
{[...Array(nSteps).keys()].map(i => {
if (i < currStepIdx) {
return (
<div
className={cx(
"flex items-center justify-center rounded-full border border-blue-800 bg-blue-700 text-slate-600 dark:border-blue-300",
textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6",
)}
key={`${i}-${currStepIdx}`}
>
<CheckIcon className="h-3.5 w-3.5 text-white" />
</div>
);
}
if (i === currStepIdx) {
return (
<div
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",
textStyle,
)}
key={`${i}-${currStepIdx}`}
>
Step {i + 1}
</div>
);
}
if (i > currStepIdx) {
return (
<Card
className={cx(
"flex items-center justify-center !rounded-full text-slate-600 dark:text-slate-400",
textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6",
)}
key={`${i}-${currStepIdx}`}
>
{i + 1}
</Card>
);
}
return null;
})}
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import "react-simple-keyboard/build/css/index.css";
import { useUiStore, useRTCStore } from "@/hooks/stores";
import { XTerm } from "./Xterm";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "../cva.config";
import { Transition } from "@headlessui/react";
function TerminalWrapper() {
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalChannel = useRTCStore(state => state.terminalChannel);
return (
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
<Transition show={enableTerminal} appear>
<div
className={cx([
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
"data-[enter]:translate-y-[500px]",
"data-[closed]:translate-y-[500px]",
])}
>
<div className="h-[500px] w-full bg-[#0f172a]">
<div className="flex items-center justify-center px-2 py-1 bg-white dark:bg-slate-800 border-y border-y-slate-800/30 dark:border-y-slate-300/20">
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Web Terminal
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setEnableTerminal(false)}
/>
</div>
</div>
<div className="h-[calc(100%-36px)] p-3">
<XTerm terminalChannel={terminalChannel} />
</div>
</div>
</div>
</Transition>
</div>
);
}
export default TerminalWrapper;

View File

@@ -0,0 +1,56 @@
import React from "react";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { FieldError } from "@/components/InputField";
import Card from "@/components/Card";
import { cx } from "@/cva.config";
type TextAreaProps = JSX.IntrinsicElements["textarea"] & {
error?: string | null;
};
const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
function TextArea(props, ref) {
return (
<Card
className={cx(
"relative w-full",
"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",
)}
>
<textarea
ref={ref}
{...props}
id="asd"
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",
props.className,
)}
/>
</Card>
);
},
);
type TextAreaWithLabelProps = {
label: string | React.ReactNode;
id?: string;
description?: string;
error?: string | null;
} & React.ComponentProps<typeof TextArea>;
export const TextAreaWithLabel = React.forwardRef<
HTMLTextAreaElement,
TextAreaWithLabelProps
>(function TextAreaWithLabel({ label, error, id, description, ...props }, ref) {
return (
<div className="space-y-1">
<FieldLabel label={label} id={id} description={description} />
<TextArea ref={ref} {...props} />
{error && <FieldError error={error} />}
</div>
);
});
export default TextArea;

View File

@@ -0,0 +1,97 @@
import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import React from "react";
import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = {
[key in USBStates]: {
icon: React.FC<{ className: string | undefined }>;
iconClassName: string;
statusIndicatorClassName: string;
};
};
const USBStateMap: {
[key in USBStates]: string;
} = {
configured: "Connected",
attached: "Connecting",
addressed: "Connecting",
"not attached": "Disconnected",
suspended: "Low power mode",
};
export default function USBStateStatus({
state,
peerConnectionState,
}: {
state: USBStates;
peerConnectionState?: RTCPeerConnectionState;
}) {
const StatusCardProps: StatusProps = {
configured: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 shrink-0",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
attached: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
addressed: {
icon: ({ className }) => <LoadingSpinner className={cx(className)} />,
iconClassName: "h-5 w-5 text-blue-500",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
"not attached": {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
suspended: {
icon: ({ className }) => (
<img className={cx(className)} src={KeyboardAndMouseConnectedIcon} alt="" />
),
iconClassName: "h-5 w-5 opacity-50 grayscale filter",
statusIndicatorClassName: "bg-green-500 border-green-600",
},
};
const props = StatusCardProps[state];
if (!props) {
console.log("Unsupport USB state: ", state);
return;
}
// If the peer connection is not connected, show the USB cable as disconnected
if (peerConnectionState !== "connected") {
const {
icon: Icon,
iconClassName,
statusIndicatorClassName,
} = StatusCardProps["not attached"];
return (
<StatusCard
title="USB"
status="Disconnected"
icon={Icon}
iconClassName={iconClassName}
statusIndicatorClassName={statusIndicatorClassName}
/>
);
}
return (
<StatusCard title="USB" status={USBStateMap[state]} {...StatusCardProps[state]} />
);
}

View File

@@ -0,0 +1,551 @@
import Card, { GridCard } from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { UpdateState, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "./LoadingSpinner";
export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string };
remote: { appVersion: string; systemVersion: string };
systemUpdateAvailable: boolean;
appUpdateAvailable: boolean;
}
export default function UpdateDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
// We need to keep track of the update state in the dialog even if the dialog is minimized
const { setModalView } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} onConfirmUpdate={onConfirmUpdate} />
</Modal>
);
}
export function Dialog({
setOpen,
onConfirmUpdate,
}: {
setOpen: (open: boolean) => void;
onConfirmUpdate: () => void;
}) {
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore();
const onFinishedLoading = useCallback(
async (versionInfo: SystemVersionInfo) => {
const hasUpdate =
versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable;
setVersionInfo(versionInfo);
if (hasUpdate) {
setModalView("updateAvailable");
} else {
setModalView("upToDate");
}
},
[setModalView],
);
// Reset modal view when dialog is opened
useEffect(() => {
setVersionInfo(null);
}, [setModalView]);
return (
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto">
<div className="p-10">
{modalView === "error" && (
<UpdateErrorState
errorMessage={otaState.error}
onClose={() => setOpen(false)}
onRetryUpdate={() => setModalView("loading")}
/>
)}
{modalView === "loading" && (
<LoadingState
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
)}
{modalView === "updateAvailable" && (
<UpdateAvailableState
onConfirmUpdate={onConfirmUpdate}
onClose={() => setOpen(false)}
versionInfo={versionInfo!}
/>
)}
{modalView === "updating" && (
<UpdatingDeviceState
otaState={otaState}
onMinimizeUpgradeDialog={() => {
setOpen(false);
}}
/>
)}
{modalView === "upToDate" && (
<SystemUpToDateState
checkUpdate={() => setModalView("loading")}
onClose={() => setOpen(false)}
/>
)}
{modalView === "updateCompleted" && (
<UpdateCompletedState onClose={() => setOpen(false)} />
)}
</div>
</GridCard>
);
}
function LoadingState({
onFinished,
onCancelCheck,
}: {
onFinished: (versionInfo: SystemVersionInfo) => void;
onCancelCheck: () => void;
}) {
const [progressWidth, setProgressWidth] = useState("0%");
const abortControllerRef = useRef<AbortController | null>(null);
const [send] = useJsonRpc();
const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, async resp => {
if ("error" in resp) {
notifications.error("Failed to check for updates");
reject(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
resolve(result);
}
});
});
}, [send]);
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setProgressWidth("0%");
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
const animationTimer = setTimeout(() => {
setProgressWidth("100%");
}, 500);
getVersionInfo()
.then(versionInfo => {
if (progressBarRef.current) {
progressBarRef.current?.classList.add("!duration-1000");
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
})
.then(versionInfo => {
if (!signal.aborted) {
onFinished(versionInfo as SystemVersionInfo);
}
})
.catch(error => {
if (!signal.aborted) {
console.error("LoadingState: Error fetching version info", error);
}
});
return () => {
clearTimeout(animationTimer);
abortControllerRef.current?.abort();
};
}, [getVersionInfo, onFinished]);
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="max-w-sm space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Checking for updates...
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
We{"'"}re ensuring your device has the latest features and improvements.
</p>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300">
<div
ref={progressBarRef}
style={{ width: progressWidth }}
className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out"
></div>
</div>
<div className="mt-4">
<Button
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
</div>
</div>
</div>
);
}
function UpdatingDeviceState({
otaState,
onMinimizeUpgradeDialog,
}: {
otaState: UpdateState["otaState"];
onMinimizeUpgradeDialog: () => void;
}) {
const formatProgress = (progress: number) => `${Math.round(progress)}%`;
const calculateOverallProgress = (type: "system" | "app") => {
const downloadProgress = Math.round((otaState[`${type}DownloadProgress`] || 0) * 100);
const updateProgress = Math.round((otaState[`${type}UpdateProgress`] || 0) * 100);
const verificationProgress = Math.round(
(otaState[`${type}VerificationProgress`] || 0) * 100,
);
if (!downloadProgress && !updateProgress && !verificationProgress) {
return 0;
}
console.log(
`For ${type}:\n` +
` Download Progress: ${downloadProgress}% (${otaState[`${type}DownloadProgress`]})\n` +
` Update Progress: ${updateProgress}% (${otaState[`${type}UpdateProgress`]})\n` +
` Verification Progress: ${verificationProgress}% (${otaState[`${type}VerificationProgress`]})`,
);
if (type === "app") {
// App: 65% download, 34% verification, 1% update(There is no "real" update for the app)
return Math.min(
downloadProgress * 0.55 + verificationProgress * 0.54 + updateProgress * 0.01,
100,
);
} else {
// System: 10% download, 90% update
return Math.min(
downloadProgress * 0.1 + verificationProgress * 0.1 + updateProgress * 0.8,
100,
);
}
};
const getUpdateStatus = (type: "system" | "app") => {
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
const verfiedAt = otaState[`${type}VerifiedAt`];
const updatedAt = otaState[`${type}UpdatedAt`];
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
} else if (!downloadFinishedAt) {
return `Downloading ${type} update...`;
} else if (!verfiedAt) {
return `Verifying ${type} update...`;
} else if (!updatedAt) {
return `Installing ${type} update...`;
} else {
return `Awaiting reboot`;
}
};
const isUpdateComplete = (type: "system" | "app") => {
return !!otaState[`${type}UpdatedAt`];
};
const areAllUpdatesComplete = () => {
if (otaState.systemUpdatePending && otaState.appUpdatePending) {
return isUpdateComplete("system") && isUpdateComplete("app");
}
return (
(otaState.systemUpdatePending && isUpdateComplete("system")) ||
(otaState.appUpdatePending && isUpdateComplete("app"))
);
};
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="w-full max-w-sm space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Updating your device
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Please don{"'"}t turn off your device. This process may take a few minutes.
</p>
</div>
<Card className="p-4 space-y-4">
{areAllUpdatesComplete() ? (
<div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white">
Rebooting to complete the update...
</span>
</div>
</div>
) : (
<>
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
<div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
</div>
)}
{otaState.systemUpdatePending && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">
Linux System Update
</p>
{calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
<div
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{
width: formatProgress(calculateOverallProgress("system")),
}}
></div>
</div>
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span>{getUpdateStatus("system")}</span>
{calculateOverallProgress("system") < 100 ? (
<span>{formatProgress(calculateOverallProgress("system"))}</span>
) : null}
</div>
</div>
)}
{otaState.appUpdatePending && (
<>
{otaState.systemUpdatePending && (
<hr className="dark:border-slate-600" />
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">
App Update
</p>
{calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" />
) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
<div
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{
width: formatProgress(calculateOverallProgress("app")),
}}
></div>
</div>
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span>{getUpdateStatus("app")}</span>
{calculateOverallProgress("system") < 100 ? (
<span>{formatProgress(calculateOverallProgress("app"))}</span>
) : null}
</div>
</div>
</>
)}
</>
)}
</Card>
<div className="flex justify-start mt-4 text-white gap-x-2">
<Button
size="XS"
theme="light"
text="Update in Background"
onClick={onMinimizeUpgradeDialog}
/>
</div>
</div>
</div>
);
}
function SystemUpToDateState({
checkUpdate,
onClose,
}: {
checkUpdate: () => void;
onClose: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
System is up to date
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
Your system is running the latest version. No updates are currently available.
</p>
<div className="flex mt-4 gap-x-2">
<Button
size="SM"
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
</div>
</div>
</div>
);
}
function UpdateAvailableState({
versionInfo,
onConfirmUpdate,
onClose,
}: {
versionInfo: SystemVersionInfo;
onConfirmUpdate: () => void;
onClose: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Update available
</p>
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
A new update is available to enhance system performance and improve
compatibility. We recommend updating to ensure everything runs smoothly.
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
{versionInfo?.systemUpdateAvailable ? (
<>
<span className="font-semibold">System:</span>{" "}
{versionInfo?.remote.systemVersion}
<br />
</>
) : null}
{versionInfo?.appUpdateAvailable ? (
<>
<span className="font-semibold">App:</span> {versionInfo?.remote.appVersion}
</>
) : null}
</p>
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} />
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
</div>
</div>
</div>
);
}
function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold dark:text-white">
Update Completed Successfully
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
Your device has been successfully updated to the latest version. Enjoy the new
features and improvements!
</p>
<div className="flex items-center justify-start">
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
</div>
</div>
</div>
);
}
function UpdateErrorState({
errorMessage,
onClose,
onRetryUpdate,
}: {
errorMessage: string | null;
onClose: () => void;
onRetryUpdate: () => void;
}) {
return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left">
<p className="text-base font-semibold dark:text-white">Update Error</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
An error occurred while updating your device. Please try again later.
</p>
{errorMessage && (
<p className="mb-4 text-sm font-medium text-red-600 dark:text-red-400">
Error details: {errorMessage}
</p>
)}
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { cx } from "@/cva.config";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores";
interface UpdateInProgressStatusCardProps {
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
return (
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
<GridCard cardClassName="!shadow-xl">
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
<div className="flex items-center gap-x-3">
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1">
<div className="text-sm font-semibold leading-none transition text-ellipsis">
Update in Progress
</div>
<div className="text-sm leading-none">
<div className="flex items-center gap-x-1">
<span className={cx("transition")}>
Please don{"'"}t turn off your device...
</span>
</div>
</div>
</div>
</div>
<Button
size="SM"
className="pointer-events-auto"
theme="light"
text="View Details"
onClick={() => {
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
/>
</div>
</GridCard>
</div>
);
}

View File

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

View File

@@ -0,0 +1,459 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react";
import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard";
import DetachIconRaw from "@/assets/detach-icon.svg";
import AttachIconRaw from "@/assets/attach-icon.svg";
export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
};
const AttachIcon = ({ className }: { className?: string }) => {
return <img src={AttachIconRaw} alt="Attach Icon" className={className} />;
};
function KeyboardWrapper() {
const [layoutName, setLayoutName] = useState("default");
const keyboardRef = useRef<HTMLDivElement>(null);
const showAttachedVirtualKeyboard = useUiStore(
state => state.isAttachedVirtualKeyboardVisible,
);
const setShowAttachedVirtualKeyboard = useUiStore(
state => state.setAttachedVirtualKeyboardVisibility,
);
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (e instanceof TouchEvent && e.touches.length > 1) return;
setIsDragging(true);
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const rect = keyboardRef.current.getBoundingClientRect();
setPosition({
x: clientX - rect.left,
y: clientY - rect.top,
});
}, []);
const onDrag = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!keyboardRef.current) return;
if (isDragging) {
const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX;
const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY;
const newX = clientX - position.x;
const newY = clientY - position.y;
const rect = keyboardRef.current.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setNewPosition({
x: Math.min(maxX, Math.max(0, newX)),
y: Math.min(maxY, Math.max(0, newY)),
});
}
},
[isDragging, position.x, position.y],
);
const endDrag = useCallback(() => {
setIsDragging(false);
}, []);
useEffect(() => {
const handle = keyboardRef.current;
if (handle) {
handle.addEventListener("touchstart", startDrag);
handle.addEventListener("mousedown", startDrag);
}
document.addEventListener("mouseup", endDrag);
document.addEventListener("touchend", endDrag);
document.addEventListener("mousemove", onDrag);
document.addEventListener("touchmove", onDrag);
return () => {
if (handle) {
handle.removeEventListener("touchstart", startDrag);
handle.removeEventListener("mousedown", startDrag);
}
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchend", endDrag);
document.removeEventListener("mousemove", onDrag);
document.removeEventListener("touchmove", onDrag);
};
}, [endDrag, onDrag, startDrag]);
const onKeyDown = useCallback(
(key: string) => {
const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight";
const isKeyCaps = key === "CapsLock";
const cleanKey = key.replace(/[()]/g, "");
const keyHasShiftModifier = key.includes("(");
// Handle toggle of layout for shift or caps lock
const toggleLayout = () => {
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
};
if (key === "CtrlAltDelete") {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (key === "AltMetaEscape") {
sendKeyboardEvent(
[keys["Escape"]],
[modifiers["MetaLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (isKeyShift || isKeyCaps) {
toggleLayout();
if (isCapsLockActive) {
setIsCapsLockActive(false);
sendKeyboardEvent([keys["CapsLock"]], []);
return;
}
}
// Handle caps lock state change
if (isKeyCaps) {
setIsCapsLockActive(!isCapsLockActive);
}
// Collect new active keys and modifiers
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
const newModifiers =
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
// Update current keys and modifiers
sendKeyboardEvent(newKeys, newModifiers);
// If shift was used as a modifier and caps lock is not active, revert to default layout
if (keyHasShiftModifier && !isCapsLockActive) {
setLayoutName("default");
}
setTimeout(resetKeyboardState, 100);
},
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
);
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
return (
<div
className="transition-all duration-500 ease-in-out"
style={{
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}}
>
<Transition
show={virtualKeyboard}
unmount={false}
enter="transition-all transform-gpu duration-500 ease-in-out"
enterFrom="opacity-0 translate-y-[100%]"
enterTo="opacity-100 translate-y-[0%]"
leave="transition-all duration-500 ease-in-out"
leaveFrom="opacity-100 translate-y-[0%]"
leaveTo="opacity-0 translate-y-[100%]"
>
<div>
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
>
<Card
className={cx("overflow-hidden", {
"rounded-none": showAttachedVirtualKeyboard,
})}
>
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20">
<div className="absolute flex items-center left-2 gap-x-2">
{showAttachedVirtualKeyboard ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
<div>
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard
baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default"
layout={{
default: ["Home Pageup", "Delete End Pagedown"],
}}
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
/>
</div>
</div>
</div>
</Card>
</div>
</div>
</Transition>
</div>
);
}
export default KeyboardWrapper;

View File

@@ -0,0 +1,461 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { useResizeObserver } from "@/hooks/useResizeObserver";
import { cx } from "@/cva.config";
import VirtualKeyboard from "@components/VirtualKeyboard";
import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
// Store hooks
const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition);
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
} = useVideoStore();
// RTC related states
const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
peerConnectionState || "",
);
// Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
useHidStore();
// Misc states and hooks
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
const [send] = useJsonRpc();
// Video-related
useResizeObserver({
ref: videoElm,
onResize: ({ width, height }) => {
// This is actually client size, not videoSize
if (width && height) {
if (!videoElm.current) return;
setVideoClientSize(width, height);
setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight);
}
},
});
const updateVideoSizeStore = useCallback(
(videoElm: HTMLVideoElement) => {
setVideoClientSize(videoElm.clientWidth, videoElm.clientHeight);
setVideoSize(videoElm.videoWidth, videoElm.videoHeight);
},
[setVideoClientSize, setVideoSize],
);
const onVideoPlaying = useCallback(() => {
setIsPlaying(true);
videoElm.current && updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]);
// On mount, get the video size
useEffect(
function updateVideoSizeOnMount() {
videoElm.current && updateVideoSizeStore(videoElm.current);
},
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
);
// Mouse-related
const sendMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar
setMousePosition(x, y);
},
[send, setMousePosition],
);
const mouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return;
const { buttons } = e;
// Clamp mouse position within the video boundaries
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
const x = Math.round((currMouseX / videoClientWidth) * 32767);
const y = Math.round((currMouseY / videoClientHeight) * 32767);
// Send mouse movement
sendMouseMovement(x, y, buttons);
},
[sendMouseMovement, videoClientHeight, videoClientWidth],
);
const mouseWheelHandler = useCallback(
(e: WheelEvent) => {
if (blockWheelEvent) return;
e.preventDefault();
// Define a scaling factor to adjust scrolling sensitivity
const scrollSensitivity = 0.8; // Adjust this value to change scroll speed
// Calculate the scroll value
const scroll = e.deltaY * scrollSensitivity;
// Clamp the scroll value to a reasonable range (e.g., -15 to 15)
const clampedScroll = Math.max(-4, Math.min(4, scroll));
// Round to the nearest integer
const roundedScroll = Math.round(clampedScroll);
// Invert the scroll value to match expected behavior
const invertedScroll = -roundedScroll;
console.log("wheelReport", { wheelY: invertedScroll });
send("wheelReport", { wheelY: invertedScroll });
setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), 50);
},
[blockWheelEvent, send],
);
const resetMousePosition = useCallback(() => {
sendMouseMovement(0, 0, 0);
}, [sendMouseMovement]);
// Keyboard-related
const handleModifierKeys = useCallback(
(e: KeyboardEvent, activeModifiers: number[]) => {
const { shiftKey, ctrlKey, altKey, metaKey } = e;
const filteredModifiers = activeModifiers.filter(Boolean);
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
return (
filteredModifiers
// Shift: Keep if Shift is pressed or if the key isn't a Shift key
// Example: If shiftKey is true, keep all modifiers
// If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight)
.filter(
modifier =>
shiftKey ||
(modifier !== modifiers["ShiftLeft"] &&
modifier !== modifiers["ShiftRight"]),
)
// Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key
// Example: If ctrlKey is true, keep all modifiers
// If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight)
.filter(
modifier =>
ctrlKey ||
(modifier !== modifiers["ControlLeft"] &&
modifier !== modifiers["ControlRight"]),
)
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
// Example: If altKey is true, keep all modifiers
// If altKey is false, filter out 0x04 (AltLeft) and 0x40 (AltRight)
.filter(
modifier =>
altKey ||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
)
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
.filter(
modifier =>
metaKey ||
(modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]),
)
);
},
[],
);
const keyDownHandler = useCallback(
async (e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
const key = e.key;
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
console.log(document.activeElement);
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote";
} else if (code == "Backquote" && ["§", "±"].includes(key)) {
code = "IntlBackslash";
}
// Add the key to the active keys
const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean);
// Add the modifier to the active modifiers
const newModifiers = handleModifierKeys(e, [
...prev.activeModifiers,
modifiers[code],
]);
// When pressing the meta key + another key, the key will never trigger a keyup
// event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
if (e.metaKey) {
setTimeout(() => {
const prev = useHidStore.getState();
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
}, 10);
}
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
);
const keyUpHandler = useCallback(
(e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock"));
// Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
// Filter out the modifier that was just released
const newModifiers = handleModifierKeys(
e,
prev.activeModifiers.filter(k => k !== modifiers[e.code]),
);
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
},
[
setIsNumLockActive,
setIsCapsLockActive,
setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
],
);
// Effect hooks
useEffect(
function setupKeyboardEvents() {
const abortController = new AbortController();
const signal = abortController.signal;
document.addEventListener("keydown", keyDownHandler, { signal });
document.addEventListener("keyup", keyUpHandler, { signal });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.clearKeys = () => sendKeyboardEvent([], []);
window.addEventListener("blur", resetKeyboardState, { signal });
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
return () => {
abortController.abort();
};
},
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
useEffect(
function setupVideoEventListeners() {
let videoElmRefValue = null;
if (!videoElm.current) return;
videoElmRefValue = videoElm.current;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => {
if (videoElmRefValue) abortController.abort();
};
},
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
if (!videoElm.current) return;
if (peerConnection?.iceConnectionState !== "connected") return;
setTimeout(() => {
if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
}
}, 0);
updateVideoSizeStore(videoElm.current);
},
[
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
],
);
// Focus trap management
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const sidebarView = useUiStore(state => state.sidebarView);
useEffect(() => {
setTimeout(function () {
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
// (document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
} else {
setDisableVideoFocusTrap(false);
}
}, 300);
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
return (
<div className="grid w-full h-full grid-rows-layout">
<div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}>
<Actionbar
requestFullscreen={async () =>
videoElm.current?.requestFullscreen({
navigationUI: "show",
})
}
/>
</fieldset>
</div>
<div className="h-full overflow-hidden">
<div className="relative h-full">
<div
className={cx(
"absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80",
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
"[background-position:0_0,10px_10px]",
"[background-size:20px_20px]",
)}
/>
<div className="flex flex-col h-full">
<div className="relative flex-grow overflow-hidden">
<div className="flex flex-col h-full">
<div className="grid flex-grow overflow-hidden grid-rows-bodyFooter">
<div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden">
<div className="relative flex items-center justify-center w-full h-full">
<video
ref={videoElm}
autoPlay={true}
controls={false}
onPlaying={onVideoPlaying}
onPlay={onVideoPlaying}
muted={true}
playsInline
disablePictureInPicture
controlsList="nofullscreen"
className={cx(
"outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError,
"animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow":
isPlaying,
},
)}
/>
<div
style={{ animationDuration: "500ms" }}
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingOverlay show={isLoading} />
<ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
</div>
</div>
</div>
</div>
<VirtualKeyboard />
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<InfoBar />
</div>
</div>
);
}

201
ui/src/components/Xterm.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { useEffect, useLayoutEffect, useRef } from "react";
import { Terminal } from "xterm";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import "xterm/css/xterm.css";
import { useRTCStore, useUiStore } from "../hooks/stores";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Add this debounce function at the top of the file
function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: number | null = null;
return (...args: any[]) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
// Add these configurations:
convertEol: true,
linuxMode: false, // Disable Linux mode which might affect line endings
} as const;
interface XTermProps {
terminalChannel: RTCDataChannel | null;
}
export function XTerm({ terminalChannel }: XTermProps) {
const xtermRef = useRef<Terminal | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalElmRef = useRef<HTMLDivElement | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const peerConnection = useRTCStore(state => state.peerConnection);
useEffect(() => {
setDisableKeyboardFocusTrap(true);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [setDisableKeyboardFocusTrap]);
const initializeTerminalAddons = (term: Terminal) => {
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new ClipboardAddon());
term.loadAddon(new Unicode11Addon());
term.loadAddon(new WebLinksAddon());
term.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
term.loadAddon(webGl2Addon);
}
return fitAddon;
};
const setupTerminalChannel = (
term: Terminal,
channel: RTCDataChannel,
abortController: AbortController,
) => {
channel.onopen = () => {
// Handle terminal input
term.onData(data => {
if (channel.readyState === "open") {
channel.send(data);
}
});
// Handle terminal output
channel.addEventListener(
"message",
(event: MessageEvent) => {
term.write(new Uint8Array(event.data));
},
{ signal: abortController.signal },
);
// Send initial terminal size
if (channel.readyState === "open") {
channel.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
}
};
};
useLayoutEffect(() => {
if (!terminalElmRef.current) return;
// Ensure the container has dimensions before initializing
if (!terminalElmRef.current.offsetHeight || !terminalElmRef.current.offsetWidth) {
return;
}
const term = new Terminal(TERMINAL_CONFIG);
const fitAddon = initializeTerminalAddons(term);
const abortController = new AbortController();
// Setup escape key handler
term.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setEnableTerminal(false);
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
let elm: HTMLDivElement | null = terminalElmRef.current;
// Initialize terminal
setTimeout(() => {
if (elm) {
console.log("opening terminal");
term.open(elm);
fitAddon.fit();
}
}, 800);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
// Setup resize handling
const debouncedResizeHandler = debounce(() => fitAddon.fit(), 100);
const resizeObserver = new ResizeObserver(debouncedResizeHandler);
resizeObserver.observe(terminalElmRef.current);
// Focus terminal after a short delay
setTimeout(() => {
term.focus();
terminalElmRef.current?.focus();
}, 500);
// Setup terminal channel if available
const channel = peerConnection?.createDataChannel("terminal");
if (channel) {
setupTerminalChannel(term, channel, abortController);
}
// Cleanup
return () => {
resizeObserver.disconnect();
abortController.abort();
term.dispose();
elm = null;
xtermRef.current = null;
fitAddonRef.current = null;
};
}, [peerConnection, setDisableKeyboardFocusTrap, setEnableTerminal, terminalChannel]);
return (
<div className="w-full h-full" ref={containerRef}>
<div
className="w-full h-full terminal-container"
ref={terminalElmRef}
style={{ display: "flex", minHeight: "100%" }}
></div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { Button } from "@components/Button";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import Card, { GridCard } from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SectionHeader } from "@components/SectionHeader";
import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink,
LuPlus,
LuRadioReceiver,
} from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications";
import MountMediaModal from "../MountMediaDialog";
import { useClose } from "@headlessui/react";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc();
const {
remoteVirtualMediaState,
isMountMediaDialogOpen,
setModalView,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
} = useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
const secondLastItem =
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
if (!secondLastItem || !lastItem) return 0;
const lastTime = lastItem[0];
const secondLastTime = secondLastItem[0];
const timeDelta = lastTime - secondLastTime;
const lastBytesSent = lastItem[1].bytesSent;
const secondLastBytesSent = secondLastItem[1].bytesSent;
const bytesDelta = lastBytesSent - secondLastBytesSent;
return bytesDelta / timeDelta;
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, response => {
if ("error" in response) {
notifications.error(
`Failed to get virtual media state: ${response.error.message}`,
);
} else {
setRemoteVirtualMediaState(response.result as unknown as RemoteVirtualMediaState);
}
});
}, [send, setRemoteVirtualMediaState]);
const handleUnmount = () => {
send("unmountImage", {}, response => {
if ("error" in response) {
notifications.error(`Failed to unmount image: ${response.error.message}`);
} else {
syncRemoteVirtualMediaState();
}
});
};
const renderGridCardContent = () => {
if (!remoteVirtualMediaState) {
return (
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No mounted media
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a file to get started
</p>
</div>
</div>
);
}
const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) {
case "WebRTC":
return (
<>
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full text-sm text-black truncate dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="flex flex-col items-center my-2 gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} />
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
: "N/A"}
</span>
</div>
</div>
</div>
</div>
</>
);
case "HTTP":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3>
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
case "Storage":
return (
<div className="">
<div className="inline-block mb-0">
<Card>
<div className="p-1">
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" />
</div>
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p>
</div>
);
default:
return null;
}
};
const close = useClose();
useEffect(() => {
syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 ">
<div className="space-y-4">
<SectionHeader
title="Virtual Media"
description="Mount an image to boot from or install an operating system."
/>
{remoteVirtualMediaState?.source === "WebRTC" ? (
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex items-center w-full text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
</Card>
) : null}
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex flex-col items-center justify-center h-full text-center">
{renderGridCardContent()}
</div>
</div>
</Card>
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex items-center justify-between text-xs select-none">
<div className="text-white select-none dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
</div>
</div>
<MountMediaModal
open={isMountMediaDialogOpen}
setOpen={setIsMountMediaDialogOpen}
/>
</div>
{!remoteVirtualMediaState && (
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Add New Media"
onClick={() => {
setModalView("mode");
setIsMountMediaDialogOpen(true);
}}
LeadingIcon={LuPlus}
/>
</div>
)}
</div>
</GridCard>
);
});
MountPopopover.displayName = "MountSidebarRoute";
export default MountPopopover;

View File

@@ -0,0 +1,164 @@
import { Button } from "@components/Button";
import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications";
import { useCallback, useEffect, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react";
import { chars, keys, modifiers } from "@/keyboardMappings";
const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier };
};
export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const [send] = useJsonRpc();
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose();
const onCancelPasteMode = useCallback(() => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
setInvalidChars([]);
}, [setDisableVideoFocusTrap, setPasteMode]);
const onConfirmPaste = useCallback(async () => {
setPasteMode(false);
setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
const text = TextAreaRef.current.value;
try {
for (const char of text) {
const { key, shift } = chars[char] ?? {};
if (!key) continue;
await new Promise<void>((resolve, reject) => {
send(
"keyboardReport",
hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0),
params => {
if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => {
if ("error" in params) return reject(params.error);
resolve();
});
},
);
});
}
} catch (error) {
notifications.error("Failed to paste text");
}
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
useEffect(() => {
if (TextAreaRef.current) {
TextAreaRef.current.focus();
}
}, []);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4">
<div className="space-y-4">
<SectionHeader
title="Paste text"
description="Paste text from your client to the remote host"
/>
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div>
<div className="w-full" onKeyUp={e => e.stopPropagation()}>
<TextAreaWithLabel
ref={TextAreaRef}
label="Paste from host"
rows={4}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onConfirmPaste();
} else if (e.key === "Escape") {
e.preventDefault();
onCancelPasteMode();
}
}}
onChange={e => {
const value = e.target.value;
const invalidChars = [
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)]
.map(x => x.segment)
.filter(char => !chars[char]),
),
];
setInvalidChars(invalidChars);
}}
/>
{invalidChars.length > 0 && (
<div className="flex items-center mt-2 gap-x-2">
<ExclamationCircleIcon className="w-4 h-4 text-red-500 dark:text-red-400" />
<span className="text-xs text-red-500 dark:text-red-400">
The following characters won&apos;t be pasted:{" "}
{invalidChars.join(", ")}
</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div
className="flex items-center justify-end opacity-0 animate-fadeIn gap-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Cancel"
onClick={() => {
onCancelPasteMode();
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Confirm Paste"
onClick={onConfirmPaste}
LeadingIcon={LuCornerDownLeft}
/>
</div>
</div>
</GridCard>
);
}

View File

@@ -0,0 +1,104 @@
import { InputFieldWithLabel } from "@components/InputField";
import { useState, useRef } from "react";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
import { LuArrowLeft } from "react-icons/lu";
interface AddDeviceFormProps {
onAddDevice: (name: string, macAddress: string) => void;
setShowAddForm: (show: boolean) => void;
errorMessage: string | null;
setErrorMessage: (errorMessage: string | null) => void;
}
export default function AddDeviceForm({
setShowAddForm,
onAddDevice,
errorMessage,
setErrorMessage,
}: AddDeviceFormProps) {
const [isDeviceNameValid, setIsDeviceNameValid] = useState<boolean>(false);
const [isMacAddressValid, setIsMacAddressValid] = useState<boolean>(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const macInputRef = useRef<HTMLInputElement>(null);
return (
<div className="space-y-4">
<div
className="space-y-4 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.5s",
animationFillMode: "forwards",
}}
>
<InputFieldWithLabel
ref={nameInputRef}
placeholder="Plex Media Server"
label="Device Name"
required
onChange={e => {
setIsDeviceNameValid(e.target.validity.valid);
setErrorMessage(null);
}}
maxLength={30}
/>
<InputFieldWithLabel
ref={macInputRef}
placeholder="00:b0:d0:63:c2:26"
label="MAC Address"
onKeyUp={e => e.stopPropagation()}
required
pattern="^([0-9a-fA-F][0-9a-fA-F]:){5}([0-9a-fA-F][0-9a-fA-F])$"
error={errorMessage}
onChange={e => {
setIsMacAddressValid(e.target.validity.valid);
setErrorMessage(null);
}}
minLength={17}
maxLength={17}
onKeyDown={e => {
if (isMacAddressValid || isDeviceNameValid) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
} else if (e.key === "Escape") {
e.preventDefault();
setShowAddForm(false);
}
}
}}
/>
</div>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="light"
text="Back"
LeadingIcon={LuArrowLeft}
onClick={() => setShowAddForm(false)}
/>
<Button
size="SM"
theme="primary"
text="Save Device"
disabled={!isDeviceNameValid || !isMacAddressValid}
onClick={() => {
const deviceName = nameInputRef.current?.value || "";
const macAddress = macInputRef.current?.value || "";
onAddDevice(deviceName, macAddress);
}}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { Button } from "@components/Button";
import Card from "@components/Card";
import { FieldError } from "@components/InputField";
import { LuPlus, LuSend, LuTrash2 } from "react-icons/lu";
export interface StoredDevice {
name: string;
macAddress: string;
}
interface DeviceListProps {
storedDevices: StoredDevice[];
errorMessage: string | null;
onSendMagicPacket: (macAddress: string) => void;
onDeleteDevice: (index: number) => void;
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}
export default function DeviceList({
storedDevices,
errorMessage,
onSendMagicPacket,
onDeleteDevice,
onCancelWakeOnLanModal,
setShowAddForm,
}: DeviceListProps) {
return (
<div className="space-y-4">
<Card className="opacity-0 animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{storedDevices.map((device, index) => (
<div key={index} className="flex items-center justify-between p-3 gap-x-2">
<div className="space-y-0.5">
<p className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-100">{device?.name}</p>
<p className="text-sm text-slate-600 dark:text-slate-400">
{device.macAddress?.toLowerCase()}
</p>
</div>
{errorMessage && <FieldError error={errorMessage} />}
<div className="flex items-center space-x-2">
<Button
size="XS"
theme="light"
text="Wake"
LeadingIcon={LuSend}
onClick={() => onSendMagicPacket(device.macAddress)}
/>
<Button
size="XS"
theme="danger"
LeadingIcon={LuTrash2}
onClick={() => onDeleteDevice(index)}
aria-label="Delete device"
/>
</div>
</div>
))}
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={onCancelWakeOnLanModal}
/>
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Card from "@components/Card";
import { PlusCircleIcon } from "@heroicons/react/16/solid";
import { LuPlus } from "react-icons/lu";
import { Button } from "../../Button";
export default function EmptyStateCard({
onCancelWakeOnLanModal,
setShowAddForm,
}: {
onCancelWakeOnLanModal: () => void;
setShowAddForm: (show: boolean) => void;
}) {
return (
<div className="space-y-4 select-none">
<Card className="opacity-0 animate-fadeIn">
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" />
</div>
</Card>
</div>
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No devices added
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
Add a device to start using Wake-on-LAN
</p>
</div>
</div>
</div>
</Card>
<div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button size="SM" theme="blank" text="Close" onClick={onCancelWakeOnLanModal} />
<Button
size="SM"
theme="primary"
text="Add New Device"
onClick={() => setShowAddForm(true)}
LeadingIcon={LuPlus}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import EmptyStateCard from "./EmptyStateCard";
import DeviceList, { StoredDevice } from "./DeviceList";
import AddDeviceForm from "./AddDeviceForm";
export default function WakeOnLanModal() {
const [storedDevices, setStoredDevices] = useState<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc();
const close = useClose();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState<string | null>(null);
const onCancelWakeOnLanModal = useCallback(() => {
close();
setDisableFocusTrap(false);
}, [close, setDisableFocusTrap]);
const onSendMagicPacket = useCallback(
(macAddress: string) => {
setErrorMessage(null);
if (rpcDataChannel?.readyState !== "open") return;
send("sendWOLMagicPacket", { macAddress }, resp => {
if ("error" in resp) {
const isInvalid = resp.error.data?.includes("invalid MAC address");
if (isInvalid) {
setErrorMessage("Invalid MAC address");
} else {
setErrorMessage("Failed to send Magic Packet");
}
} else {
notifications.success("Magic Packet sent successfully");
setDisableFocusTrap(false);
close();
}
});
},
[close, rpcDataChannel?.readyState, send, setDisableFocusTrap],
);
const syncStoredDevices = useCallback(() => {
send("getWakeOnLanDevices", {}, resp => {
if ("result" in resp) {
setStoredDevices(resp.result as StoredDevice[]);
} else {
console.error("Failed to load Wake-on-LAN devices:", resp.error);
}
});
}, [send, setStoredDevices]);
// Load stored devices from the backend
useEffect(() => {
syncStoredDevices();
}, [syncStoredDevices]);
const onDeleteDevice = useCallback(
(index: number) => {
const updatedDevices = storedDevices.filter((_, i) => i !== index);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to update Wake-on-LAN devices:", resp.error);
} else {
syncStoredDevices();
}
});
},
[storedDevices, send, syncStoredDevices],
);
const onAddDevice = useCallback(
(name: string, macAddress: string) => {
if (!name || !macAddress) return;
const updatedDevices = [...storedDevices, { name, macAddress }];
console.log("updatedDevices", updatedDevices);
send("setWakeOnLanDevices", { params: { devices: updatedDevices } }, resp => {
if ("error" in resp) {
console.error("Failed to add Wake-on-LAN device:", resp.error);
setAddDeviceErrorMessage("Failed to add device");
} else {
setShowAddForm(false);
syncStoredDevices();
}
});
},
[send, storedDevices, syncStoredDevices],
);
return (
<GridCard>
<div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody">
<div className="space-y-4">
<SectionHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."
/>
{showAddForm ? (
<AddDeviceForm
setShowAddForm={setShowAddForm}
errorMessage={addDeviceErrorMessage}
setErrorMessage={setAddDeviceErrorMessage}
onAddDevice={onAddDevice}
/>
) : storedDevices.length === 0 ? (
<EmptyStateCard
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
) : (
<DeviceList
storedDevices={storedDevices}
errorMessage={errorMessage}
onSendMagicPacket={onSendMagicPacket}
onDeleteDevice={onDeleteDevice}
onCancelWakeOnLanModal={onCancelWakeOnLanModal}
setShowAddForm={setShowAddForm}
/>
)}
</div>
</div>
</div>
</GridCard>
);
}

Some files were not shown because too many files have changed in this diff Show More