create web

This commit is contained in:
wj-xiao
2024-08-09 19:45:45 +08:00
parent 1e3c131e0d
commit a21159aaff
112 changed files with 12314 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
*.log
pnpm-debug.log*
web/node_modules
web/dist
web/web
.DS_Store
*.local
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.idea
.vscode

9
web/.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

20
web/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

1
web/.prettierignore Normal file
View File

@@ -0,0 +1 @@
*.hbs

35
web/.prettierrc.yaml Normal file
View File

@@ -0,0 +1,35 @@
singleQuote: true
trailingComma: none
printWidth: 100
tabWidth: 2
bracketSpacing: true
importOrder:
- ^(react/(.*)$)|^(react$)
- ^(next/(.*)$)|^(next$)
- <THIRD_PARTY_MODULES>
- ''
- ^types$
- ^@/assets(.*)$
- ^@/api/(.*)$
- ^@/types$
- ^@/lib/(.*)$
- ^@/jotai/(.*)$
- ^@/hooks/(.*)$
- ^@/components/(.*)$
- ^@/pages/(.*)$
- ^@/styles/(.*)$
- ''
- '^[./]'
importOrderSeparation: false
importOrderSortSpecifiers: true
importOrderBuiltinModulesToTop: true
importOrderParserPlugins:
- typescript
- jsx
- tsx
- decorators-legacy
importOrderMergeDuplicateImports: true
importOrderCombineTypeAndValueImports: true
plugins:
- '@ianvs/prettier-plugin-sort-imports'
- prettier-plugin-tailwindcss

34
web/README.md Normal file
View File

@@ -0,0 +1,34 @@
# NanoKVM Frontend
## Structure
```shell
src
├── api // backend api
├── assets // static resouces
├── components // public components
├── i18n // i18n
├── jotai // Global jotai variables
├── lib // lib
├── pages // web pages
│ ├── auth // login and password
│ ├── desktop // remote desktop
│ └── terminal // web terminal
├── router.tsx // routers
└── types // types
```
## Deployment
Build:
```shell
cd web
pnpm install
pnpm build
```
Once compilation is complete, the `dist` folder will be generated. Rename the folder to `web` and upload it to the `/kvmapp/server/` directory in NanoKVM.
Then, restart the service by executing `/etc/init.d/S95nanokvm` in NanoKVM.
Additionally, browser may have old version cache. If you can't open the page, try a force refresh or clear the cache.

36
web/README_ZH.md Normal file
View File

@@ -0,0 +1,36 @@
# NanoKVM 前端页面
## 目录结构
```shell
src
├── api // 后端接口
├── assets // 资源文件
├── components // 公共组件
├── i18n // 多语言
├── jotai // 全局 jotai 变量
├── lib // lib
├── pages // 页面
│ ├── auth // 鉴权页面
│ ├── desktop // 远程桌面
│ └── terminal // 终端
├── router.tsx // 路由
└── types // 类型定义
```
## 部署
编译:
```shell
cd web
pnpm install
pnpm build
```
编译完成后会生成 `dist` 文件夹,将该文件夹重命名为 `web` 并上传到 NanoKVM 的 `/kvmapp/server/` 目录下。
然后在 NanoKVM 中执行 `/etc/init.d/S95nanokvm restart` 重启服务即可。
注意:更新 web 目录后,浏览器可能会有缓存。如果遇到打不开页面的情况,请强制刷新或清空缓存。

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/sipeed.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NanoKVM</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

62
web/package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.3.7",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.19.2",
"axios": "^1.7.2",
"clsx": "^2.1.1",
"crypto-js": "^4.2.0",
"i18next": "^23.11.5",
"jotai": "^2.9.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.408.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5",
"react-i18next": "^14.1.2",
"react-responsive": "^10.0.0",
"react-router-dom": "^6.23.1",
"react-simple-keyboard": "^3.7.124",
"semver": "^7.6.3",
"vaul": "^0.9.1",
"websocket": "^1.0.35"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
"@types/crypto-js": "^4.2.2",
"@types/js-cookie": "^3.0.6",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/semver": "^7.5.8",
"@types/websocket": "^1.0.10",
"@typescript-eslint/eslint-plugin": "^7.10.0",
"@typescript-eslint/parser": "^7.10.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.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.14",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2"
}
}

5130
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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

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

BIN
web/public/sipeed.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

17
web/src/api/auth.ts Normal file
View File

@@ -0,0 +1,17 @@
import { http } from '@/lib/http';
export function login(username: string, password: string) {
const data = {
username,
password
};
return http.post('/api/auth/login', data);
}
export function changePassword(username: string, password: string) {
const data = {
username,
password
};
return http.post('/api/auth/password', data);
}

25
web/src/api/firmware.ts Normal file
View File

@@ -0,0 +1,25 @@
import { http } from '@/lib/http.ts';
// get firmware version
export function getVersion() {
return http.get('/api/firmware/version');
}
// update firmware to latest version
export function update() {
return http.request({
method: 'post',
url: '/api/firmware/update',
timeout: 3 * 60 * 1000
});
}
// check if the lib exists
export function getLib() {
return http.get('/api/firmware/lib');
}
// update lib
export function updateLib() {
return http.post('/api/firmware/lib/update');
}

37
web/src/api/network.ts Normal file
View File

@@ -0,0 +1,37 @@
import { http } from '@/lib/http.ts';
// wake on lan
export function wol(mac: string) {
const data = {
mac
};
return http.post('/api/network/wol', data);
}
// get wake-on-lan macs history
export function getWolMacs() {
return http.get('/api/network/wol/mac');
}
export function deleteWolMac(mac: string) {
return http.request({
method: 'delete',
url: '/api/network/wol/mac',
data: { mac }
});
}
// check if tailscale exists
export function getTailscale() {
return http.get('/api/network/tailscale');
}
// install tailscale
export function installTailscale() {
return http.post('/api/network/tailscale/install');
}
// run tailscale
export function runTailscale() {
return http.post('/api/network/tailscale/run');
}

37
web/src/api/script.ts Normal file
View File

@@ -0,0 +1,37 @@
import { http } from '@/lib/http.ts';
export function uploadScript(formData: FormData) {
return http.request({
url: '/api/vm/script/upload',
method: 'post',
headers: {
'Content-Type': 'multipart/form-data'
},
data: formData
});
}
export function runScript(name: string, type: string) {
const data = {
name,
type
};
return http.request({
url: '/api/vm/script/run',
method: 'post',
data
});
}
export function getScripts() {
return http.get('/api/vm/script');
}
export function deleteScript(name: string) {
return http.request({
url: '/api/vm/script',
method: 'delete',
data: { name }
});
}

24
web/src/api/storage.ts Normal file
View File

@@ -0,0 +1,24 @@
import { http } from '@/lib/http.ts';
// get image list
export function getImages() {
return http.get('/api/storage/images');
}
// get mounted image
export function getMountedImage() {
return http.get('/api/storage/images/mounted');
}
// mount/unmount image
export function mountImage(file?: string) {
const data = {
file: file ? file : ''
};
return http.post('/api/storage/image/mount', data);
}
// reset hid
export function resetHid() {
return http.post('/api/storage/hid/reset');
}

16
web/src/api/stream.ts Normal file
View File

@@ -0,0 +1,16 @@
import { http } from '@/lib/http.ts';
// get whether the frame detect is enabled
export function getFrameDetect() {
return http.get('/api/stream/mjpeg/detect');
}
// enable/disable frame detect
export function updateFrameDetect() {
return http.post('/api/stream/mjpeg/detect');
}
// pause frame detect for a while (prevent a black screen when opening the page for the first time)
export function stopFrameDetect() {
return http.post('/api/stream/mjpeg/detect/stop');
}

View File

@@ -0,0 +1,15 @@
import { http } from '@/lib/http.ts';
// get virtual devices status
export function getVirtualDevice() {
return http.get('/api/vm/device/virtual');
}
// mount/unmount virtual device
export function updateVirtualDevice(device: string) {
const data = {
device
};
return http.post('/api/vm/device/virtual', data);
}

29
web/src/api/vm.ts Normal file
View File

@@ -0,0 +1,29 @@
import { http } from '@/lib/http.ts';
// get NanoKVM information
export function getInfo() {
return http.get('/api/vm/info');
}
// set gpio value
export function setGpio(type: string, duration: number) {
const data = {
type,
duration
};
return http.post('/api/vm/gpio', data);
}
// get led gpio enabled
export function getLedGpio() {
return http.get('/api/vm/gpio/led');
}
// update screen arguments
export function updateScreen(type: string, value: number) {
const data = {
type,
value
};
return http.post('/api/vm/screen', data);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#737373" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-monitor-x"><path d="m14.5 12.5-5-5"/><path d="m9.5 12.5 5-5"/><rect width="20" height="14" x="2" y="3" rx="2"/><path d="M12 17v4"/><path d="M8 21h8"/></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
padding: 0;
margin: 0;
background: #000;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #000000;
}
::-webkit-scrollbar-thumb {
background: #555;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #444;
}

View File

@@ -0,0 +1,97 @@
.keyboardContainer {
display: flex;
background-color: #e5e5e5;
justify-content: center;
margin: 0 auto;
border-radius: 0 5px;
}
.simple-keyboard.hg-theme-default {
display: inline-block;
}
.simple-keyboard-main.simple-keyboard {
width: 640px;
min-width: 640px;
background: none;
}
.simple-keyboard-main.simple-keyboard .hg-button {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.simple-keyboard-main.simple-keyboard .hg-row:first-child {
margin-bottom: 10px;
}
.simple-keyboard-arrows.simple-keyboard {
align-self: flex-end;
background: none;
}
.simple-keyboard .hg-button.selectedButton {
background: rgba(5, 25, 70, 0.53);
color: white;
}
.simple-keyboard .hg-button.emptySpace {
pointer-events: none;
background: none;
border: none;
box-shadow: none;
}
.simple-keyboard-arrows .hg-row {
justify-content: center;
}
.simple-keyboard-arrows .hg-button {
width: 50px;
flex-grow: 0;
justify-content: center;
display: flex;
align-items: center;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.controlArrows {
display: flex;
align-items: center;
justify-content: space-between;
flex-flow: column;
}
.simple-keyboard-control.simple-keyboard {
background: none;
}
.simple-keyboard-control.simple-keyboard .hg-row:first-child {
margin-bottom: 10px;
}
.simple-keyboard-control .hg-button {
width: 50px;
flex-grow: 0;
justify-content: center;
display: flex;
align-items: center;
font-size: 14px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.hg-button.hg-functionBtn.hg-button-space {
width: 350px;
}
.simple-keyboard.hg-layout-default .hg-button.hg-highlight {
background: rgb(37 99 235);
border-bottom: 1px solid #2563eb;
box-shadow: 0 1px 3px 0 rgb(37 99 235 / 0.1), 0 1px 2px -1px rgb(37 99 235 / 0.1);
color: white;
}
.simple-keyboard.hg-layout-default .hg-button.hg-double{
text-align: center;
font-size: 14px;
line-height: 16px;
}

View File

@@ -0,0 +1,14 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { existToken } from '@/lib/cookie.ts';
export const ProtectedRoute = ({ children }: { children: ReactNode }) => {
const hasToken = existToken();
if (!hasToken) {
return <Navigate to={'/auth/login'} replace />;
}
return children;
};

View File

@@ -0,0 +1,20 @@
import { Helmet, HelmetData } from 'react-helmet-async';
type HeadProps = {
title?: string;
description?: string;
};
const helmetData = new HelmetData({});
export const Head = ({ title = '', description = '' }: HeadProps = {}) => {
return (
<Helmet
helmetData={helmetData}
title={title ? `${title} - NanoKVM` : undefined}
defaultTitle="NanoKVM"
>
<meta name="description" content={description} />
</Helmet>
);
};

View File

@@ -0,0 +1,17 @@
import { Button } from 'antd';
export const MainError = () => {
return (
<div
className="flex h-screen w-screen flex-col items-center justify-center space-y-5"
role="alert"
>
<h2 className="text-lg font-semibold text-red-500">
Sorry, there seems to be some problems :(
</h2>
<Button type="primary" danger onClick={() => window.location.assign(window.location.origin)}>
Refresh
</Button>
</div>
);
};

View File

@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom';
export const Root = () => {
return (
<div className="h-screen w-screen">
<Outlet />
</div>
);
};

144
web/src/i18n/en.ts Normal file
View File

@@ -0,0 +1,144 @@
export const en = {
translation: {
language: 'Language',
changePassword: 'Change Password',
logout: 'Logout',
keyboard: 'Keyboard',
images: 'Images',
loading: 'Loading',
empty: 'Nothing Found',
settings: 'Settings',
showMouse: 'Show mouse',
hideMouse: 'Hide mouse',
power: 'Power',
reset: 'Reset',
powerShort: 'Power (short click)',
powerLong: 'Power (long click)',
hddLed: 'HDD LED',
checkLibFailed: 'Failed to check runtime library, please try again',
updateLibFailed: 'Failed to update runtime library, please try again',
updatingLib: 'Updating runtime library. Please refresh the page after updating.',
checkForUpdate: 'Check for Update',
head: {
desktop: 'Remote Desktop',
login: 'Login',
changePassword: 'Change Password',
terminal: 'Terminal'
},
auth: {
login: 'Login',
placeholderUsername: 'Please enter username',
placeholderPassword: 'please enter password',
placeholderPassword2: 'please enter password again',
noEmptyUsername: 'username can not be empty',
noEmptyPassword: 'password cant not be empty',
noAccount: 'Failed to get user information, please refresh web page or reset password',
invalidUser: 'invalid username or password',
error: 'unexpected error',
changePassword: 'Change Password',
differentPassword: 'password do not match',
illegalUsername: 'username contains illegal characters',
illegalPassword: 'password contains illegal characters',
forgetPassword: 'Forget Password',
resetPassword: 'Reset Password',
reset1: 'If you have forgotten the password, please follow the steps to reset it:',
reset2: '1. Log into the NanoKVM device via SSH;',
reset3: '2. Delete the file in the device: ',
reset4: '3. Use the default account to login: ',
ok: 'Ok',
cancel: 'Cancel'
},
screen: {
resolution: 'Resolution',
fps: 'FPS',
customizeFps: 'Customize',
quality: 'Quality',
frameDetect: 'Frame Detect',
frameDetectTip:
"Calculate the difference between frames. Stop transmitting video stream when no changes are detected on the remote host's screen."
},
cursor: {
default: 'Default cursor',
pointer: 'Pointer cursor',
cell: 'Cell cursor',
text: 'Text cursor',
grab: 'Grab cursor',
hide: 'Hide cursor',
mode: 'Mouse mode',
absolute: 'Absolute mode',
relative: 'Relative mode',
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
resetHid: 'Reset HID'
},
script: {
title: 'Script',
upload: 'Upload',
run: 'Run',
runBackground: 'Run Background',
runFailed: 'Run failed',
attention: 'Attention',
delDesc: 'Are you sure to delete this file?',
confirm: 'Yes',
cancel: 'No',
delete: 'Delete',
close: 'Close'
},
terminal: {
title: 'Terminal',
nanokvm: 'NanoKVM Terminal',
serial: 'Serial Port Terminal',
serialPort: 'Serial Port',
serialPortPlaceholder: 'Please enter the serial port',
baudrate: 'Baud rate',
confirm: 'Ok'
},
wol: {
sending: 'Sending command...',
sent: 'Command sent',
input: 'Please enter the MAC',
ok: 'Ok'
},
about: {
title: 'About NanoKVM',
information: 'Information',
ip: 'IP',
mdns: 'mDNS',
firmware: 'Application Version',
image: 'Image Version',
deviceKey: 'Device Key',
queryFailed: 'Query failed',
community: 'Community'
},
update: {
title: 'Check for Update',
queryFailed: 'Get version failed',
updateFailed: 'Update failed. Please retry.',
isLatest: 'You already have the latest version.',
available: 'An update is available. Are you sure to update?',
updating: 'Update started. Please wait...',
confirm: 'Confirm',
cancel: 'Cancel'
},
virtualDevice: {
network: 'Virtual Network',
usb: 'Virtual USB'
},
tailscale: {
loading: 'Loading...',
notInstalled: 'Tailscale not found! Please install.',
install: 'Install',
installing: 'Installing...',
urlPeriod: 'This url is valid for 10 minutes',
installed: 'Tailscale already installed. Please login.',
login: 'Login',
failed: 'Install failed',
retry: 'Please refresh and try again. Or try to install manually',
download: 'Download the',
package: 'installation package',
unzip: 'and unzip it',
upTailscale: 'Upload tailscale to NanoKVM directory /usr/bin/',
upTailscaled: 'Upload tailscaled to NanoKVM directory /usr/sbin/',
refresh: 'Refresh current page'
}
}
};

23
web/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLanguage } from '@/lib/localstorage.ts';
import { en } from './en';
import { zh } from './zh';
const lng = getLanguage();
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources: { zh, en },
lng,
interpolation: {
escapeValue: false // react already safes from xss
}
})
.then();
export default i18n;

143
web/src/i18n/zh.ts Normal file
View File

@@ -0,0 +1,143 @@
export const zh = {
translation: {
currentVersion: '版本',
latestVersion: '最新版本:',
changePassword: '修改密码',
logout: '登出',
keyboard: '键盘',
images: '镜像',
loading: '加载中',
empty: '无镜像文件',
settings: '设置',
showMouse: '显示鼠标',
hideMouse: '隐藏鼠标',
power: '电源',
reset: '重启',
powerShort: '电源(短按)',
powerLong: '电源(长按)',
hddLed: '硬盘指示灯',
checkLibFailed: '检查运行库失败,请重试',
updateLibFailed: '更新运行库失败,请重试',
updatingLib: '正在更新运行库。更新完成后请刷新页面。',
head: {
desktop: '远程桌面',
login: '登录',
changePassword: '修改密码',
terminal: '终端'
},
auth: {
login: '登录',
placeholderUsername: '请输入用户名',
placeholderPassword: '请输入密码',
placeholderPassword2: '请再次输入密码',
noEmptyUsername: '用户名不能为空',
noEmptyPassword: '密码不能为空',
noAccount: '获取用户信息失败,请刷新重试或重置密码',
invalidUser: '用户名或密码错误',
error: '未知错误',
changePassword: '修改密码',
differentPassword: '两次密码不一致',
illegalUsername: '用户名中包含非法字符',
illegalPassword: '密码中包含非法字符',
forgetPassword: '忘记密码',
resetPassword: '重置密码',
reset1: '如果您忘记了登录密码,请按照以下步骤重置密码:',
reset2: '1. 通过 SSH 登录到您的 NanoKVM 设备',
reset3: '2. 删除设备中的文件:',
reset4: '3. 使用默认的账号登录: ',
ok: '确定',
cancel: '取消'
},
screen: {
resolution: '分辨率',
fps: '帧率',
customizeFps: '自定义',
quality: '图像质量',
frameDetect: '帧差检测',
frameDetectTip: '计算帧之间的差异,当检测到远程主机画面不变时,停止传输视频流'
},
cursor: {
default: '默认指针',
pointer: '悬浮指针',
cell: '单元指针',
text: '文本指针',
grab: '抓取指针',
hide: '隐藏指针',
mode: '鼠标模式',
absolute: '绝对模式',
relative: '相对模式',
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
resetHid: '重置 HID'
},
script: {
title: '脚本',
upload: '上传',
run: '运行',
runBackground: '后台运行',
runFailed: '运行失败',
attention: '注意',
delDesc: '确定要删除该文件吗?',
confirm: '确定',
cancel: '取消',
delete: '删除',
close: '关闭'
},
terminal: {
title: '终端',
nanokvm: 'NanoKVM 终端',
serial: '串口终端',
serialPort: '串口',
serialPortPlaceholder: '请输入串口',
baudrate: '波特率',
confirm: '确定'
},
wol: {
sending: '指令发送中...',
sent: '指令已发送',
input: '请输入MAC地址',
ok: '确定'
},
about: {
title: '关于 NanoKVM',
information: '信息',
ip: 'IP',
mdns: 'mDNS',
firmware: '应用版本',
image: '镜像版本',
deviceKey: '设备码',
queryFailed: '查询失败',
community: '社区'
},
update: {
title: '检查更新',
queryFailed: '获取版本号失败',
updateFailed: '更新失败,请重试',
isLatest: '已经是最新版本。',
available: '有新的可用版本,确定要更新吗?',
updating: '更新中,请稍候...',
confirm: '确定',
cancel: '取消'
},
virtualDevice: {
network: '虚拟网口',
usb: '虚拟U盘'
},
tailscale: {
loading: '加载中...',
notInstalled: '未检测到 Tailscale请先安装',
install: '安装',
installing: '安装中...',
urlPeriod: '该链接10分钟内有效',
installed: 'Tailscale 已安装,请执行登录操作',
login: '登录',
failed: '安装失败',
retry: '请刷新后重试,或尝试手动安装',
download: '下载',
package: '安装包',
unzip: '并解压',
upTailscale: '将 tailscale 文件上传到 /usr/bin/ 目录',
upTailscaled: '将 tailscaled 文件上传到 /usr/sbin/ 目录',
refresh: '刷新页面'
}
}
};

View File

@@ -0,0 +1,7 @@
import { atom } from 'jotai';
// is the keyboard enabled (Disable keyboard events when input is required)
export const isKeyboardEnableAtom = atom(true);
// is the virtual keyboard opened
export const isKeyboardOpenAtom = atom(false);

7
web/src/jotai/mouse.ts Normal file
View File

@@ -0,0 +1,7 @@
import { atom } from 'jotai';
// mouse cursor style
export const mouseStyleAtom = atom('cursor-default');
// mouse mode: absolute or relative
export const mouseModeAtom = atom('absolute');

View File

@@ -0,0 +1,6 @@
import { atom } from 'jotai';
import { Resolution } from '@/types';
// remote host screen resolution
export const resolutionAtom = atom<Resolution | null>(null);

View File

@@ -0,0 +1,4 @@
import { atom } from 'jotai';
// is settings popover opened
export const isSettingsOpenAtom = atom(false);

23
web/src/lib/cookie.ts Normal file
View File

@@ -0,0 +1,23 @@
import Cookies from 'js-cookie';
const COOKIE_TOKEN_KEY = 'nano-kvm-token';
export function existToken() {
const token = Cookies.get(COOKIE_TOKEN_KEY);
return !!token;
}
export function getToken() {
const token = Cookies.get(COOKIE_TOKEN_KEY);
if (!token) return null;
return token;
}
export function setToken(token: string) {
Cookies.set(COOKIE_TOKEN_KEY, token, { expires: 30 });
}
export function removeToken() {
Cookies.remove(COOKIE_TOKEN_KEY);
}

14
web/src/lib/encrypt.ts Normal file
View File

@@ -0,0 +1,14 @@
import CryptoJS from 'crypto-js';
const SECRET_KEY = 'nanokvm-sipeed-2024';
export function encrypt(data: string) {
const dataEncrypt = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
return encodeURIComponent(dataEncrypt);
}
export function decrypt(data: string) {
const bytes = CryptoJS.AES.decrypt(data, SECRET_KEY);
const dataDecrypt = bytes.toString(CryptoJS.enc.Utf8);
return decodeURIComponent(dataDecrypt);
}

74
web/src/lib/http.ts Normal file
View File

@@ -0,0 +1,74 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { removeToken } from '@/lib/cookie.ts';
type Response = {
code: number;
msg: string;
data: any;
};
class Http {
private instance: AxiosInstance;
constructor() {
this.instance = axios.create({
baseURL: this.getBaseURL(),
withCredentials: true,
timeout: 60 * 1000
});
this.setInterceptors();
}
private setInterceptors() {
this.instance.interceptors.request.use((config) => {
if (config.headers) {
config.headers.Accept = 'application/json';
}
return config;
});
this.instance.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
console.log(error);
const code = error.response?.status;
if (code === 401) {
removeToken();
window.location.reload();
}
return Promise.reject(error);
}
);
}
public getBaseURL() {
return `${window.location.protocol}//${window.location.host}`;
}
public get(url: string, params?: any): Promise<Response> {
return this.instance.request({
method: 'get',
url,
params
});
}
public post(url: string, data?: any): Promise<Response> {
return this.instance.request({
method: 'post',
url,
data
});
}
public request(config: AxiosRequestConfig): Promise<Response> {
return this.instance.request(config);
}
}
export const http = new Http();

117
web/src/lib/localstorage.ts Normal file
View File

@@ -0,0 +1,117 @@
import { Resolution } from '@/types';
const LANGUAGE_KEY = 'nano-kvm-language';
const RESOLUTION_KEY = 'nano-kvm-resolution';
const FPS_KEY = 'nano-kvm-fps';
const QUALITY_KEY = 'nano-kvm-quality';
const MOUSE_STYLE_KEY = 'nano-kvm-mouse-style';
const MOUSE_MODE_KEY = 'nano-kvm-mouse-mode';
const SKIP_UPDATE_KEY = 'nano-kvm-check-update';
type Item = {
value: string;
expiry: number;
};
// set the value with expiration time (unit: milliseconds)
function setWithExpiry(key: string, value: string, ttl: number) {
const now = new Date();
const item: Item = {
value: value,
expiry: now.getTime() + ttl
};
localStorage.setItem(key, JSON.stringify(item));
}
// get the value with expiration time
function getWithExpiry(key: string) {
const itemStr = localStorage.getItem(key);
if (!itemStr) return null;
const item: Item = JSON.parse(itemStr);
const now = new Date();
if (now.getTime() > item.expiry) {
localStorage.removeItem(key);
return null;
}
return item.value;
}
export function getLanguage() {
const lang = localStorage.getItem(LANGUAGE_KEY);
if (lang) return lang;
if (navigator.language.indexOf('zh') > -1) {
return 'zh';
}
return 'en';
}
export function setLanguage(language: string) {
localStorage.setItem(LANGUAGE_KEY, language);
}
export function getResolution(): Resolution | null {
const resolution = localStorage.getItem(RESOLUTION_KEY);
if (resolution) {
const obj = JSON.parse(window.atob(resolution));
return obj as Resolution;
}
return null;
}
export function setResolution(resolution: Resolution) {
localStorage.setItem(RESOLUTION_KEY, window.btoa(JSON.stringify(resolution)));
}
export function getFps() {
const fps = localStorage.getItem(FPS_KEY);
return fps ? Number(fps) : null;
}
export function setFps(fps: number) {
localStorage.setItem(FPS_KEY, String(fps));
}
export function getQuality() {
const quality = localStorage.getItem(QUALITY_KEY);
return quality ? Number(quality) : null;
}
export function setQuality(quality: number) {
localStorage.setItem(QUALITY_KEY, String(quality));
}
export function getMouseStyle() {
return localStorage.getItem(MOUSE_STYLE_KEY);
}
export function setMouseStyle(mouse: string) {
localStorage.setItem(MOUSE_STYLE_KEY, mouse);
}
export function getMouseMode() {
return localStorage.getItem(MOUSE_MODE_KEY);
}
export function setMouseMode(mouse: string) {
localStorage.setItem(MOUSE_MODE_KEY, mouse);
}
export function getSkipUpdate() {
const skip = getWithExpiry(SKIP_UPDATE_KEY);
if (skip) {
return Boolean(skip);
}
return false;
}
export function setSkipUpdate(skip: boolean) {
const expiry = 3 * 24 * 60 * 60 * 1000; // 3天
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry);
}

39
web/src/lib/websocket.ts Normal file
View File

@@ -0,0 +1,39 @@
import { w3cwebsocket as W3cWebSocket } from 'websocket';
class WsClient {
private instance: W3cWebSocket;
constructor() {
const url = this.getUrl();
this.instance = new W3cWebSocket(url);
}
private getUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/api/ws`;
}
public connect() {
this.close();
const url = this.getUrl();
this.instance = new W3cWebSocket(url);
}
public send(data: number[]) {
if (this.instance.readyState !== W3cWebSocket.OPEN) {
return;
}
const message = JSON.stringify(data);
this.instance.send(message);
}
public close() {
if (this.instance.readyState === W3cWebSocket.OPEN) {
this.instance.close();
}
}
}
export const client = new WsClient();

32
web/src/main.tsx Normal file
View File

@@ -0,0 +1,32 @@
import React, { Suspense } from 'react';
import { ConfigProvider, Spin, theme } from 'antd';
import ReactDOM from 'react-dom/client';
import { ErrorBoundary } from 'react-error-boundary';
import { HelmetProvider } from 'react-helmet-async';
import { RouterProvider } from 'react-router-dom';
import { MainError } from './components/main-error.tsx';
import { router } from './router';
import './i18n';
import './assets/styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Suspense
fallback={
<div className="flex h-screen w-screen items-center justify-center">
<Spin size="large" />
</div>
}
>
<ErrorBoundary FallbackComponent={MainError}>
<HelmetProvider>
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
<RouterProvider router={router} />
</ConfigProvider>
</HelmetProvider>
</ErrorBoundary>
</Suspense>
</React.StrictMode>
);

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { existToken, setToken } from '@/lib/cookie.ts';
import { encrypt } from '@/lib/encrypt.ts';
import { Head } from '@/components/head.tsx';
import { Tips } from './tips.tsx';
export const Login = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const [msg, setMsg] = useState('');
useEffect(() => {
if (existToken()) {
navigate('/', { replace: true });
}
}, []);
useEffect(() => {
if (msg) {
setTimeout(() => setMsg(''), 3000);
}
}, [msg]);
function login(values: any) {
const username = values.username;
const password = encrypt(values.password);
api
.login(username, password)
.then((rsp: any) => {
if (rsp.code !== 0) {
if (rsp.code === -3) {
setMsg(t('auth.noAccount'));
} else if (rsp.code === -4) {
setMsg(t('auth.invalidUser'));
} else {
setMsg(t('auth.error'));
}
return;
}
setMsg('');
setToken(rsp.data.token);
navigate('/', { replace: true });
window.location.reload();
})
.catch(() => {
setMsg(t('auth.error'));
});
}
return (
<>
<Head title={t('head.login')} />
<div className="flex h-screen w-screen flex-col items-center justify-center">
<h2 className="text-xl font-semibold text-neutral-100">{t('auth.login')}</h2>
<Form
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ remember: true }}
onFinish={login}
>
<Form.Item
name="username"
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
>
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder={t('auth.placeholderPassword')}
/>
</Form.Item>
<div className="text-red-500">{msg}</div>
<Form.Item>
<Button type="primary" htmlType="submit" className="w-full">
{t('auth.ok')}
</Button>
</Form.Item>
<div className="flex justify-end pb-4 text-sm">
<Tips />
</div>
</Form>
</div>
</>
);
};

View File

@@ -0,0 +1,57 @@
import { useState } from 'react';
import { Button, Modal, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
const { Text } = Typography;
export const Tips = () => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const showModal = () => {
setIsModalOpen(true);
};
const hideModal = () => {
setIsModalOpen(false);
};
return (
<>
<span
className="cursor-pointer text-neutral-500 underline underline-offset-4"
onClick={showModal}
>
{t('auth.forgetPassword')}
</span>
<Modal
title={t('auth.resetPassword')}
open={isModalOpen}
onCancel={hideModal}
closeIcon={null}
footer={null}
centered={true}
>
<div className="flex flex-col space-y-1 pt-1">
<div>{t('auth.reset1')}</div>
<div>{t('auth.reset2')}</div>
<div>
{t('auth.reset3')}
<Text code={true}>/etc/kvm/pwd</Text>
</div>
<div>
{t('auth.reset4')}
<Text code={true}>admin/admin</Text>
</div>
</div>
<div className="flex justify-center pb-3 pt-10">
<Button type="primary" className="w-24" onClick={hideModal}>
{t('auth.ok')}
</Button>
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { Button, Form, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import * as api from '@/api/auth.ts';
import { removeToken } from '@/lib/cookie.ts';
import { encrypt } from '@/lib/encrypt.ts';
import { Head } from '@/components/head.tsx';
export const Password = () => {
const { t } = useTranslation();
const [msg, setMsg] = useState('');
const navigate = useNavigate();
useEffect(() => {
if (msg) {
setTimeout(() => setMsg(''), 3000);
}
}, [msg]);
function changePassword(values: any) {
if (values.password !== values.password2) {
setMsg(t('auth.differentPassword'));
return;
}
if (!validateString(values.username)) {
setMsg(t('auth.illegalUsername'));
return;
}
if (!validateString(values.password)) {
setMsg('auth.illegalPassword');
return;
}
const username = values.username;
const password = encrypt(values.password);
api
.changePassword(username, password)
.then((rsp: any) => {
if (rsp.code !== 0) {
setMsg(t('auth.error'));
return;
}
removeToken();
navigate('/auth/login', { replace: true });
})
.catch(() => {
setMsg(t('auth.error'));
});
}
function validateString(str: string) {
const regex = /['"\\/]/;
return !regex.test(str);
}
return (
<>
<Head title={t('head.changePassword')} />
<div className="flex h-screen w-screen flex-col items-center justify-center">
<h2 className="text-xl font-semibold text-neutral-100">{t('auth.changePassword')}</h2>
<Form
style={{ minWidth: 300, maxWidth: 500 }}
initialValues={{ remember: true }}
onFinish={changePassword}
>
<Form.Item
name="username"
rules={[{ required: true, message: t('auth.noEmptyUsername'), min: 1 }]}
>
<Input prefix={<UserOutlined />} placeholder={t('auth.placeholderUsername')} />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder={t('auth.placeholderPassword')}
/>
</Form.Item>
<Form.Item
name="password2"
rules={[{ required: true, message: t('auth.noEmptyPassword'), min: 1 }]}
>
<Input
prefix={<LockOutlined />}
type="password"
placeholder={t('auth.placeholderPassword2')}
/>
</Form.Item>
<span className="text-red-500">{msg}</span>
<Form.Item>
<div className="flex w-full space-x-2">
<Button type="primary" htmlType="submit" className="w-1/2">
{t('auth.ok')}
</Button>
<Button className="w-1/2" onClick={() => navigate('/')}>
{t('auth.cancel')}
</Button>
</div>
</Form.Item>
</Form>
</div>
</>
);
};

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { message, Spin } from 'antd';
import { useAtom, useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from 'react-responsive';
import * as api from '@/api/firmware.ts';
import { getResolution } from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { resolutionAtom } from '@/jotai/resolution.ts';
import { Head } from '@/components/head.tsx';
import { Keyboard } from './keyboard';
import { VirtualKeyboard } from './keyboard/virtual-keyboard';
import { Menu } from './menu';
import { MenuPhone } from './menu-phone';
import { Mouse } from './mouse';
import { Screen } from './screen';
export const Desktop = () => {
const { t } = useTranslation();
const isBigScreen = useMediaQuery({ minWidth: 850 });
const [messageApi, contextHolder] = message.useMessage();
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
const [resolution, setResolution] = useAtom(resolutionAtom);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
getLib();
const cookieResolution = getResolution();
setResolution(cookieResolution ? cookieResolution : { width: 1280, height: 720 });
const timer = setInterval(() => {
client.send([0]);
}, 1000 * 60);
return () => {
clearInterval(timer);
client.close();
};
}, []);
function getLib() {
api.getLib().then((rsp) => {
if (rsp.code !== 0) {
showMessage(t('checkLibFailed'));
return;
}
if (rsp.data.exist) {
return;
}
downloadLib();
});
}
// 更新 lib
function downloadLib() {
setIsUpdating(true);
api
.updateLib()
.then((rsp) => {
if (rsp.code !== 0) {
showMessage(t('updateLibFailed'));
return;
}
setTimeout(() => {
window.location.reload();
}, 6000);
})
.finally(() => {
setIsUpdating(false);
});
}
function showMessage(content: string) {
messageApi.open({
type: 'warning',
content,
duration: 10,
style: {
marginTop: '8vh'
}
});
}
return (
<>
<Head title={t('head.desktop')} />
{contextHolder}
<Spin spinning={isUpdating} tip={t('updatingLib')} size="large" fullscreen />
{resolution && (
<>
{isBigScreen ? <Menu /> : <MenuPhone />}
<Screen />
<Mouse />
{isKeyboardEnable && <Keyboard />}
</>
)}
<VirtualKeyboard isBigScreen={isBigScreen} />
</>
);
};

View File

@@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react';
import { client } from '@/lib/websocket.ts';
import { KeyboardCodes } from './mappings.ts';
export const Keyboard = () => {
const metaRef = useRef(false);
// listen keyboard events
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
// press button
function handleKeyDown(event: any) {
disableEvent(event);
if (event.code === 'MetaLeft' || event.code === 'MetaRight') {
metaRef.current = true;
return;
}
metaRef.current = false;
const code = KeyboardCodes.get(event.code);
if (!code) {
console.log('unknown code: ', event.code);
return;
}
const ctrl = event.ctrlKey ? 1 : 0;
const shift = event.shiftKey ? 1 : 0;
const alt = event.altKey ? 1 : 0;
const meta = event.metaKey ? 1 : 0;
const data = [1, code, ctrl, shift, alt, meta];
client.send(data);
}
// release button
function handleKeyUp(event: any) {
disableEvent(event);
if (metaRef.current) {
client.send([1, KeyboardCodes.get('MetaLeft')!, 0, 0, 0, 1]);
metaRef.current = false;
}
const data = [1, 0, 0, 0, 0, 0];
client.send(data);
}
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
// disable the default keyboard events
function disableEvent(event: any) {
event.preventDefault();
event.stopPropagation();
}
return <></>;
};

View File

@@ -0,0 +1,119 @@
export const KeyboardCodes: Map<string, number> = new Map([
['KeyA', 4],
['KeyB', 5],
['KeyC', 6],
['KeyD', 7],
['KeyE', 8],
['KeyF', 9],
['KeyG', 10],
['KeyH', 11],
['KeyI', 12],
['KeyJ', 13],
['KeyK', 14],
['KeyL', 15],
['KeyM', 16],
['KeyN', 17],
['KeyO', 18],
['KeyP', 19],
['KeyQ', 20],
['KeyR', 21],
['KeyS', 22],
['KeyT', 23],
['KeyU', 24],
['KeyV', 25],
['KeyW', 26],
['KeyX', 27],
['KeyY', 28],
['KeyZ', 29],
['Digit1', 30],
['Digit2', 31],
['Digit3', 32],
['Digit4', 33],
['Digit5', 34],
['Digit6', 35],
['Digit7', 36],
['Digit8', 37],
['Digit9', 38],
['Digit0', 39],
['Enter', 40],
['Escape', 41],
['Backspace', 42],
['Tab', 43],
['Space', 44],
['Minus', 45],
['Equal', 46],
['BracketLeft', 47],
['BracketRight', 48],
['Backslash', 49],
['IntlBackslash', 49],
['Semicolon', 51],
['Quote', 52],
['Backquote', 53],
['KeyTilde', 53],
['Comma', 54],
['Period', 55],
['KeyDot', 55],
['Slash', 56],
['CapsLock', 57],
['F1', 58],
['F2', 59],
['F3', 60],
['F4', 61],
['F5', 62],
['F6', 63],
['F7', 64],
['F8', 65],
['F9', 66],
['F10', 67],
['F11', 68],
['F12', 69],
['F13', 70],
['PrintScreen', 70],
['ScrollLock', 71],
['Pause', 72],
['Insert', 73],
['Home', 74],
['PageUp', 75],
['Delete', 76],
['End', 77],
['PageDown', 78],
['ArrowRight', 79],
['ArrowLeft', 80],
['ArrowDown', 81],
['ArrowUp', 82],
['NumLock', 83],
['NumpadDivide', 84],
['NumpadMultiply', 85],
['NumpadSubtract', 86],
['NumpadAdd', 87],
['NumpadEnter', 88],
['Numpad1', 89],
['Numpad2', 90],
['Numpad3', 91],
['Numpad4', 92],
['Numpad5', 93],
['Numpad6', 94],
['Numpad7', 95],
['Numpad8', 96],
['Numpad9', 97],
['Numpad0', 98],
['NumpadDecimal', 99],
['KeyKpDot', 99],
['ControlLeft', 224],
['ShiftLeft', 225],
['AltLeft', 226],
['MetaLeft', 227],
['WinLeft', 227],
['ControlRight', 228],
['ShiftRight', 229],
['AltRight', 230],
['MetaRight', 231],
['WinRight', 231]
]);

View File

@@ -0,0 +1,185 @@
import { useRef, useState } from 'react';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { XIcon } from 'lucide-react';
import Keyboard, { KeyboardButtonTheme } from 'react-simple-keyboard';
import { Drawer } from 'vaul';
import 'react-simple-keyboard/build/css/index.css';
import '@/assets/styles/keyboard.css';
import { client } from '@/lib/websocket.ts';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
import { KeyboardCodes } from './mappings.ts';
import {
doubleKeys,
keyboardArrowsOptions,
keyboardControlPadOptions,
keyboardOptions,
modifierKeys,
specialKeyMap
} from './virtual-keys.ts';
type KeyboardProps = {
isBigScreen: boolean;
};
export const VirtualKeyboard = ({ isBigScreen }: KeyboardProps) => {
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
const [isCapsLock, setIsCapsLock] = useState(false);
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
const keyboardRef = useRef<any>(null);
function onKeyPress(key: string) {
if (modifierKeys.includes(key)) {
if (activeModifierKeys.includes(key)) {
sendModifierKeyDown();
sendModifierKeyUp();
} else {
setActiveModifierKeys([...activeModifierKeys, key]);
}
} else {
if (key === '{capslock}') {
setIsCapsLock(!isCapsLock);
}
sendKeydown(key);
}
}
function onKeyReleased(key: string) {
if (modifierKeys.includes(key)) {
return;
}
sendKeyup();
}
function sendKeydown(key: string) {
const specialKey = specialKeyMap.get(key);
const code = KeyboardCodes.get(specialKey ? specialKey : key);
if (!code) {
console.log('unknown code: ', key);
return;
}
const modifiers = sendModifierKeyDown();
client.send([1, code, ...modifiers]);
}
function sendKeyup() {
sendModifierKeyUp();
client.send([1, 0, 0, 0, 0, 0]);
}
function sendModifierKeyDown() {
let ctrl = 0;
let shift = 0;
let alt = 0;
let meta = 0;
activeModifierKeys.forEach((modifierKey) => {
const specialKey = specialKeyMap.get(modifierKey)!;
const code = KeyboardCodes.get(specialKey)!;
if ([224, 228].includes(code)) {
ctrl = 1;
} else if ([225, 229].includes(code)) {
shift = 1;
} else if ([226, 230].includes(code)) {
alt = 1;
} else if ([227, 231].includes(code)) {
meta = 1;
}
client.send([1, code, ctrl, shift, alt, meta]);
});
return [ctrl, shift, alt, meta];
}
function sendModifierKeyUp() {
if (activeModifierKeys.length === 0) return;
activeModifierKeys.forEach(() => {
client.send([1, 0, 0, 0, 0, 0]);
});
setActiveModifierKeys([]);
}
function getButtonTheme(): KeyboardButtonTheme[] {
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
const activeKeys = [...activeModifierKeys];
if (isCapsLock) {
activeKeys.push('{capslock}');
}
if (activeKeys.length > 0) {
theme.push({ class: 'hg-highlight', buttons: activeKeys.join(' ') });
}
return theme;
}
return (
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
<Drawer.Portal>
<Drawer.Content
className={clsx(
'fixed bottom-0 left-0 right-0 z-[999] mx-auto overflow-hidden rounded bg-white outline-none',
isBigScreen ? 'w-[820px]' : 'w-[650px]'
)}
>
{/* header */}
<div className="flex items-center justify-between px-3 py-1">
<div className="w-[100px] text-sm font-bold text-neutral-500">Keyboard</div>
<div className="h-1.5 w-12 flex-shrink-0 rounded-full bg-zinc-300" />
<div className="flex w-[100px] items-center justify-end">
<div
className="flex h-[20px] w-[20px] cursor-pointer items-center justify-center rounded text-neutral-600 hover:bg-neutral-300 hover:text-white"
onClick={() => setIsKeyboardOpen(false)}
>
<XIcon size={18} />
</div>
</div>
</div>
<div className="h-px flex-shrink-0 border-b bg-neutral-300" />
<div data-vaul-no-drag className="keyboardContainer w-full">
{/* main keyboard */}
<Keyboard
buttonTheme={getButtonTheme()}
keyboardRef={(r) => (keyboardRef.current = r)}
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
{...keyboardOptions}
/>
{/* control keyboard */}
{isBigScreen && (
<div className="controlArrows">
<Keyboard
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
{...keyboardControlPadOptions}
/>
<Keyboard
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
{...keyboardArrowsOptions}
/>
</div>
)}
</div>
</Drawer.Content>
<Drawer.Overlay />
</Drawer.Portal>
</Drawer.Root>
);
};

View File

@@ -0,0 +1,182 @@
// main keys
export const keyboardOptions = {
theme: 'simple-keyboard hg-theme-default hg-layout-default',
baseClass: 'simple-keyboard-main',
layout: {
default: [
'{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}'
]
},
display: {
'{escape}': 'Esc',
Backquote: '~<br/>`',
Digit1: '!<br/>1',
Digit2: '@<br/>2',
Digit3: '#<br/>3',
Digit4: '$<br/>4',
Digit5: '%<br/>5',
Digit6: '^<br/>6',
Digit7: '&<br/>7',
Digit8: '*<br/>8',
Digit9: '(<br/>9',
Digit0: ')<br/>0',
Minus: '_<br/>-',
Equal: '+<br/>=',
'{backspace}': 'Backspace',
'{tab}': 'Tab',
KeyQ: 'Q',
KeyW: 'W',
KeyE: 'E',
KeyR: 'R',
KeyT: 'T',
KeyY: 'Y',
KeyU: 'U',
KeyI: 'I',
KeyO: 'O',
KeyP: 'P',
BracketLeft: '{<br/>[',
BracketRight: '}<br/>]',
Backslash: '|<br>\\',
'{capslock}': 'Caps',
KeyA: 'A',
KeyS: 'S',
KeyD: 'D',
KeyF: 'F',
KeyG: 'G',
KeyH: 'H',
KeyJ: 'J',
KeyK: 'K',
KeyL: 'L',
Semicolon: ':<br/>;',
Quote: '"<br/>\'',
'{enter}': 'Enter',
'{shiftleft}': 'Shift',
KeyZ: 'Z',
KeyX: 'X',
KeyC: 'C',
KeyV: 'V',
KeyB: 'B',
KeyN: 'N',
KeyM: 'M',
Comma: '<<br/>,',
Period: '><br/>.',
Slash: '?<br/>/',
'{shiftright}': 'Shift',
'{controlleft}': 'Ctrl',
'{altleft}': 'Alt',
'{metaleft}': 'Cmd',
'{space}': 'Space',
'{metaright}': 'Cmd',
'{altright}': 'Alt'
}
};
// control keys
export const keyboardControlPadOptions = {
theme: 'simple-keyboard hg-theme-default hg-layout-default',
baseClass: 'simple-keyboard-control',
layout: {
default: [
'{prtscr} {scrolllock} {pause}',
'{insert} {home} {pageup}',
'{delete} {end} {pagedown}'
]
},
display: {
'{prtscr}': 'PrtScr',
'{scrolllock}': 'Lock',
'{pause}': 'Pause',
'{insert}': 'Ins',
'{home}': 'Home',
'{pageup}': 'PgUp',
'{delete}': 'Del',
'{end}': 'End',
'{pagedown}': 'PgDn'
}
};
// arrow keys
export const keyboardArrowsOptions = {
theme: 'simple-keyboard hg-theme-default hg-layout-default',
baseClass: 'simple-keyboard-arrows',
layout: {
default: ['{arrowup}', '{arrowleft} {arrowdown} {arrowright}']
}
};
// keys require special mapping
export const specialKeyMap = new Map([
['{escape}', 'Escape'],
['{backspace}', 'Backspace'],
['{tab}', 'Tab'],
['{capslock}', 'CapsLock'],
['{enter}', 'Enter'],
['{shiftleft}', 'ShiftLeft'],
['{shiftright}', 'ShiftRight'],
['{controlleft}', 'ControlLeft'],
['{altleft}', 'AltLeft'],
['{metaleft}', 'MetaLeft'],
['{space}', 'Space'],
['{metaright}', 'MetaRight'],
['{altright}', 'AltRight'],
['{prtscr}', 'PrintScreen'],
['{scrolllock}', 'ScrollLock'],
['{pause}', 'Pause'],
['{insert}', 'Insert'],
['{home}', 'Home'],
['{pageup}', 'PageUp'],
['{delete}', 'Delete'],
['{end}', 'End'],
['{pagedown}', 'PageDown'],
['{arrowright}', 'ArrowRight'],
['{arrowleft}', 'ArrowLeft'],
['{arrowdown}', 'ArrowDown'],
['{arrowup}', 'ArrowUp']
]);
// modifier keys
export const modifierKeys = [
'{shiftleft}',
'{controlleft}',
'{altleft}',
'{metaleft}',
'{shiftright}',
'{controlright}',
'{altright}',
'{metaright}'
];
// double line display buttons
export const doubleKeys = [
'Backquote',
'Digit1',
'Digit2',
'Digit3',
'Digit4',
'Digit5',
'Digit6',
'Digit7',
'Digit8',
'Digit9',
'Digit0',
'Minus',
'Equal',
'BracketLeft',
'BracketRight',
'Backslash',
'Semicolon',
'Quote',
'Comma',
'Period',
'Slash'
];

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
import clsx from 'clsx';
import { MenuIcon } from 'lucide-react';
import { Keyboard } from './keyboard';
import { Mouse } from './mouse';
import { Power } from './power';
import { Screen } from './screen';
import { Script } from './script';
import { Settings } from './settings';
import { Storage } from './storage';
import { Terminal } from './terminal';
import { Wol } from './wol';
export const MenuPhone = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<div className="fixed left-[10px] top-[10px] z-[1000]">
<div className="sticky top-[10px]">
{!isMenuOpen && (
<div
className="flex h-[32px] items-center justify-center rounded bg-neutral-800/80 px-2 text-white"
onClick={() => setIsMenuOpen((o) => !o)}
>
<MenuIcon size={18} />
</div>
)}
<div className={clsx('flex-col rounded bg-neutral-800/90', isMenuOpen ? 'flex' : 'hidden')}>
<div
className="flex h-[32px] rotate-90 items-center justify-center text-white"
onClick={() => setIsMenuOpen((o) => !o)}
>
<MenuIcon size={18} />
</div>
<Settings />
<Screen />
<Keyboard />
<Mouse />
<Storage />
<Script />
<Terminal />
<Wol />
<Power />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { useSetAtom } from 'jotai';
import { KeyboardIcon } from 'lucide-react';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
export const Keyboard = () => {
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
return (
<>
<div
className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700"
onClick={() => setIsKeyboardOpen((o) => !o)}
>
<KeyboardIcon size={18} />
</div>
</>
);
};

View File

@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import {
CheckIcon,
EyeOffIcon,
HandIcon,
MouseIcon,
MousePointerIcon,
PlusIcon,
RefreshCwIcon,
SquareMousePointerIcon,
TextCursorIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import * as ls from '@/lib/localstorage';
import { client } from '@/lib/websocket';
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
export const Mouse = () => {
const { t } = useTranslation();
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const mouseStyles = [
{ name: t('cursor.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
{ name: t('cursor.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
{ name: t('cursor.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
{ name: t('cursor.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
{ name: t('cursor.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
];
const mouseModes = [
{ name: t('cursor.absolute'), value: 'absolute' },
{ name: t('cursor.relative'), value: 'relative' }
];
useEffect(() => {
const style = ls.getMouseStyle();
if (style && style !== mouseStyle) {
setMouseStyle(style);
}
const mode = ls.getMouseMode();
if (mode && mode !== mouseMode) {
setMouseMode(mode);
}
}, []);
function updateStyle(style: string) {
setMouseStyle(style);
ls.setMouseStyle(style);
}
function updateMode(mode: string) {
setMouseMode(mode);
ls.setMouseMode(mode);
if (mode === 'relative') {
client.close();
setTimeout(() => {
client.connect();
}, 500);
}
}
function resetHid() {
setIsPopoverOpen(false);
client.close();
api.resetHid().finally(() => {
client.connect();
});
}
const content = (
<>
{mouseStyles.map((style) => (
<div
key={style.value}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600',
style.value === mouseStyle ? 'text-green-500' : 'text-white'
)}
onClick={() => updateStyle(style.value)}
>
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
<span>{style.name}</span>
</div>
))}
<Divider style={{ margin: '10px 0' }} />
<Popover
content={
<>
{mouseModes.map((mode) => (
<div
key={mode.value}
className="my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700"
onClick={() => updateMode(mode.value)}
>
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
{mode.value === mouseMode && <CheckIcon strokeWidth={3} size={16} />}
</div>
<span>{mode.name}</span>
</div>
))}
</>
}
placement="rightTop"
arrow={true}
trigger="hover"
>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<div className="flex h-[14px] w-[20px] items-end">
<SquareMousePointerIcon size={14} />
</div>
<span>{t('cursor.mode')}</span>
</div>
</Popover>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={resetHid}
>
<div className="flex h-[14px] w-[20px] items-end">
<RefreshCwIcon size={14} />
</div>
<span>{t('cursor.resetHid')}</span>
</div>
</>
);
return (
<Popover
content={content}
placement="rightBottom"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<MouseIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { Popover, Slider, Tooltip } from 'antd';
import clsx from 'clsx';
import {
CirclePowerIcon,
HardDriveIcon,
LoaderCircleIcon,
PowerIcon,
RotateCcwIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm';
export const Power = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isPowerOn, setIsPowerOn] = useState(false);
const [isHddOn, setIsHddOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [powerDuration, setPowerDuration] = useState(8);
useEffect(() => {
getLed();
const interval = setInterval(getLed, 5000);
return () => {
clearInterval(interval);
};
}, []);
function getLed() {
api.getLedGpio().then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsPowerOn(rsp.data.pwr);
setIsHddOn(rsp.data.hdd);
});
}
function click(button: 'reset' | 'power', duration?: number) {
if (isLoading) return;
setIsLoading(true);
setIsPopoverOpen(false);
const millisecond = Math.floor((duration ? duration : powerDuration) * 1000);
api.setGpio(button, millisecond).finally(() => {
setIsLoading(false);
});
}
function onChange(value: number) {
setPowerDuration(value);
}
const content = (
<div className="flex flex-col space-y-1">
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('reset', 0.8)}
>
<RotateCcwIcon size={16} />
<span>{t('reset')}</span>
</div>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('power', 0.8)}
>
<PowerIcon size={16} />
<span>{t('powerShort')}</span>
</div>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('power')}
>
<CirclePowerIcon size={16} />
<span>{t('powerLong')}</span>
<div className="flex h-full items-start text-xs text-neutral-500">{powerDuration}s</div>
</div>
<div className="px-3">
<Slider
defaultValue={8}
min={1}
max={30}
tooltip={{ placement: 'bottom' }}
onChange={onChange}
/>
</div>
</div>
);
return (
<>
<Tooltip placement="rightTop" title={t('hddLed')}>
<div
className={clsx(
'flex h-[30px] w-[18px] items-center justify-center px-2',
isHddOn ? 'text-green-600' : 'text-neutral-500'
)}
>
<HardDriveIcon size={18} />
</div>
</Tooltip>
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700/80">
<div
className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-500')}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : (
<PowerIcon size={18} />
)}
</div>
</div>
</Popover>
</>
);
};

View File

@@ -0,0 +1,115 @@
import { useRef, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { CheckIcon, ScanBarcodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setFps as setCookie } from '@/lib/localstorage';
const fpsList = [
{ key: 60, label: '60Hz' },
{ key: 30, label: '30Hz' },
{ key: 24, label: '24Hz' },
{ key: 15, label: '15Hz' },
{ key: 10, label: '10Hz' }
];
const defaultFps = [60, 30, 24, 15, 10];
type FpsProps = {
fps: number;
setFps: (fps: number) => void;
};
export const Fps = ({ fps, setFps }: FpsProps) => {
const { t } = useTranslation();
const [isCustomize, setIsCustomize] = useState(false);
const customizeRef = useRef(0);
function showCustomize() {
customizeRef.current = fps;
setIsCustomize(true);
}
function onChange(value: any) {
const num = Number(value);
if (num > 0 && num <= 60) {
customizeRef.current = num;
}
}
async function update(value: number) {
if (isCustomize && customizeRef.current === fps) {
setIsCustomize(false);
return;
}
const rsp = await updateScreen('fps', value);
if (rsp.code !== 0) {
return;
}
setFps(value);
setCookie(value);
isCustomize && setIsCustomize(false);
}
const content = (
<>
{fpsList.map((item) => (
<div
key={item.key}
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.key === fps && <CheckIcon size={14} />}
</div>
<span>{item.label}</span>
</div>
))}
<div
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
onClick={showCustomize}
>
{defaultFps.includes(fps) ? (
<>
<div className="flex h-[14px] w-[20px] items-end"></div>
<span>{t('screen.customizeFps')}</span>
</>
) : (
<>
<div className="flex h-[14px] w-[20px] items-end">
<CheckIcon size={14} />
</div>
<span>{`Customize(${fps}Hz)`}</span>
</>
)}
</div>
{isCustomize && (
<div className="flex items-center space-x-1 py-1">
<InputNumber<number> defaultValue={fps} min={1} max={60} onChange={onChange} />
<Button
size="small"
icon={<CheckOutlined />}
onClick={() => update(customizeRef.current)}
/>
<Button size="small" icon={<CloseOutlined />} onClick={() => setIsCustomize(false)} />
</div>
)}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<ScanBarcodeIcon size={18} />
<span className="select-none text-sm">{t('screen.fps')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { Tooltip } from 'antd';
import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getFrameDetect, updateFrameDetect } from '@/api/stream.ts';
export const FrameDetect = () => {
const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
getFrameDetect().then((rsp) => {
if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled);
}
});
}, []);
function update() {
if (isLoading) return;
setIsLoading(true);
updateFrameDetect()
.then((rsp) => {
if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled);
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<Tooltip placement="rightTop" title={t('screen.frameDetectTip')} color="#262626" arrow>
<div
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
onClick={update}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : isEnabled ? (
<Tally4Icon color="#22c55e" size={18} />
) : (
<Tally5Icon size={18} />
)}
<span className="select-none text-sm">{t('screen.frameDetect')}</span>
</div>
</Tooltip>
);
};

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
import { useAtomValue } from 'jotai';
import { MonitorIcon } from 'lucide-react';
import { updateScreen } from '@/api/vm';
import * as ls from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/resolution';
import { Fps } from './fps';
import { FrameDetect } from './frame-detect';
import { Quality } from './quality';
import { Resolution } from './resolution';
export const Screen = () => {
const resolution = useAtomValue(resolutionAtom);
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(80);
useEffect(() => {
updateScreen('resolution', resolution!.height);
const cookieFps = ls.getFps();
if (cookieFps) {
updateScreen('fps', cookieFps).then((rsp) => {
if (rsp.code === 0) {
setFps(cookieFps);
}
});
}
const cookieQuality = ls.getQuality();
if (cookieQuality) {
updateScreen('quality', cookieQuality).then((rsp) => {
if (rsp.code === 0) {
setQuality(cookieQuality);
}
});
}
}, []);
return (
<Popover
content={
<div className="flex flex-col space-y-1">
<Resolution />
<Fps fps={fps} setFps={setFps} />
<Quality quality={quality} setQuality={setQuality} />
<FrameDetect />
</div>
}
placement="rightBottom"
trigger="click"
>
<div className="flex h-[32px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700/80">
<MonitorIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,61 @@
import { Popover } from 'antd';
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setQuality as setCookie } from '@/lib/localstorage.ts';
const qualityList = [
{ key: 99, label: '100%' },
{ key: 90, label: '90%' },
{ key: 80, label: '80%' },
{ key: 70, label: '70%' },
{ key: 60, label: '60%' },
{ key: 51, label: '50%' }
];
type QualityProps = {
quality: number;
setQuality: (quality: number) => void;
};
export const Quality = ({ quality, setQuality }: QualityProps) => {
const { t } = useTranslation();
async function update(value: number) {
const rsp = await updateScreen('quality', value);
if (rsp.code !== 0) {
return;
}
setQuality(value);
setCookie(value);
}
const content = (
<>
{qualityList.map((item) => (
<div
key={item.key}
className="flex h-[30px] cursor-pointer select-none items-center space-x-1 rounded pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.key === quality && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.label}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<SquareActivityIcon size={18} />
<span className="select-none text-sm">{t('screen.quality')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,59 @@
import { Popover } from 'antd';
import { useAtom } from 'jotai';
import { CheckIcon, RatioIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { Resolution as TypeResolution } from '@/types';
import { setResolution as setCookie } from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/resolution.ts';
const resolutions: TypeResolution[] = [
{ width: 1920, height: 1080 },
{ width: 1280, height: 720 },
{ width: 800, height: 600 },
{ width: 640, height: 480 }
];
export const Resolution = () => {
const { t } = useTranslation();
const [resolution, setResolution] = useAtom(resolutionAtom);
async function update(item: TypeResolution) {
const rsp = await updateScreen('resolution', item.height);
if (rsp.code !== 0) {
return;
}
setResolution(item);
setCookie(item);
}
const content = (
<>
{resolutions.map((item) => (
<div
key={item.height}
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(item)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.height === resolution?.height && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.width}</span>
<span>x</span>
<span className="w-[36px]">{item.height}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<RatioIcon size={18} />
<span className="select-none text-sm">{t('screen.resolution')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,205 @@
import { ChangeEvent, useRef, useState } from 'react';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Divider, Popconfirm, Popover } from 'antd';
import clsx from 'clsx';
import { ChevronRightIcon, FileCodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/script.ts';
import { Run } from './run';
export const Script = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [scripts, setScripts] = useState<string[]>(['test.sh']);
const [currentScript, setCurrentScript] = useState('');
const [isRunning, setIsRunning] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const inputRef = useRef<any>(null);
function handleOpenChange(open: boolean) {
if (open) {
getScripts();
} else {
setCurrentScript('');
}
setIsPopoverOpen(open);
}
function selectFile() {
if (inputRef.current) {
inputRef.current.value = null;
}
inputRef.current?.click();
}
function uploadFile(e: ChangeEvent<HTMLInputElement>) {
if (!e.target?.files?.length) return;
const file = e.target.files[0];
if (isUploading) return;
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
api
.uploadScript(formData)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (!scripts.includes(rsp.data.file)) {
setScripts([...scripts, rsp.data.file]);
}
})
.finally(() => {
setIsUploading(false);
});
}
function runScript(type: string) {
if (!currentScript) return;
if (type === 'foreground') {
setIsRunning(true);
} else {
api.runScript(currentScript, type).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
});
}
setIsPopoverOpen(false);
}
function getScripts() {
api.getScripts().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.data?.files?.length > 0) {
setScripts(rsp.data.files);
}
});
}
function deleteScript() {
if (!currentScript) return;
api.deleteScript(currentScript).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setScripts(scripts.filter((script) => script !== currentScript));
});
}
function activate(script: string) {
setCurrentScript(script === currentScript ? '' : script);
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
</div>
<input
ref={inputRef}
type="file"
accept="*.sh,*.py"
className="hidden"
onChange={uploadFile}
/>
<Button
ghost
type="primary"
size="small"
icon={<UploadOutlined />}
loading={isUploading}
onClick={selectFile}
>
{t('script.upload')}
</Button>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{scripts.map((script) => (
<div
key={script}
className={clsx(
'my-1 cursor-pointer rounded',
script === currentScript ? 'bg-neutral-700/50' : 'hover:bg-neutral-700/50'
)}
>
<div
className="flex items-center justify-between space-x-5 px-2 py-1.5"
onClick={() => activate(script)}
>
<div className="max-w-[300px] select-none truncate">{script}</div>
<div className={clsx('h-[16px] w-[16px]', script === currentScript && 'rotate-90')}>
<ChevronRightIcon size={16} />
</div>
</div>
{script === currentScript && (
<div className="flex items-center justify-end space-x-2 p-3">
<Button type="primary" size="small" onClick={() => runScript('foreground')}>
{t('script.run')}
</Button>
<Button type="primary" size="small" onClick={() => runScript('background')}>
{t('script.runBackground')}
</Button>
<Popconfirm
title={t('script.attention')}
description={t('script.delDesc')}
onConfirm={deleteScript}
onCancel={() => {}}
okText={t('script.confirm')}
cancelText={t('script.cancel')}
placement="bottom"
>
<Button danger type="primary" size="small">
{t('script.delete')}
</Button>
</Popconfirm>
</div>
)}
</div>
))}
</div>
);
return (
<>
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<FileCodeIcon size={18} />
</div>
</Popover>
{isRunning && <Run script={currentScript} setIsRunning={setIsRunning} />}
</>
);
};

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Card, Modal, Spin } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/script';
type RunProps = {
script: string;
setIsRunning: (isRunning: boolean) => void;
};
export const Run = ({ script, setIsRunning }: RunProps) => {
const { t } = useTranslation();
const [state, setState] = useState('');
const [log, setLog] = useState('');
useEffect(() => {
setState('running');
api
.runScript(script, 'foreground')
.then((rsp) => {
if (rsp.code !== 0) {
setLog(rsp.msg);
setState('failed');
return;
}
setState('success');
setLog(rsp.data.log);
})
.catch(() => {
setLog(t('script.runFailed'));
setState('failed');
});
}, []);
return (
<Modal
title={script}
width={350}
closable={false}
footer={null}
style={{ top: 60 }}
open={true}
>
{state === 'running' ? (
<div className="flex h-[300px] items-center justify-center">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
) : (
<Card className="h-[500px] overflow-auto whitespace-pre-line font-mono">{log}</Card>
)}
<div className="mt-5 flex justify-center">
<Button type="primary" onClick={() => setIsRunning(false)}>
{t('script.close')}
</Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { useSetAtom } from 'jotai';
import { BookOpenIcon, GithubIcon, LoaderCircleIcon, MessageSquareIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getInfo } from '@/api/vm';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
type Info = {
ip: string;
mdns: string;
image: string;
firmware: string;
deviceKey: string;
};
export const About = () => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [info, setInfo] = useState<Info | undefined>();
const communities = [
{ name: 'wiki', icon: <BookOpenIcon size={24} />, url: 'https://wiki.sipeed.com/nanokvm' },
{ name: 'github', icon: <GithubIcon size={24} />, url: 'https://github.com/sipeed/NanoKVM' },
{
name: 'discussion',
icon: <MessageSquareIcon size={24} />,
url: 'https://maixhub.com/discussion/nanokvm'
}
];
useEffect(() => {
setLoading(true);
getInfo()
.then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
setInfo(undefined);
return;
}
setInfo(rsp.data);
})
.catch(() => {
setInfo(undefined);
})
.finally(() => {
setLoading(false);
});
}, []);
function showModal() {
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function openPage(url: string) {
window.open(url, '_blank');
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={showModal}
>
{t('about.title')}
</div>
<Modal
title={t('about.title')}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
width={380}
footer={null}
centered
>
<div className="my-5 h-[1px] bg-neutral-700/50" />
<div className="flex w-full flex-col space-y-2">
<span className="text-neutral-400">{t('about.information')}</span>
{loading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : info ? (
<div className="flex w-full flex-col space-y-1">
{Object.entries(info).map(([key, value]) => (
<div key={key} className="flex w-full items-center justify-between">
<span>{t(`about.${key}`)}</span>
<span>{value}</span>
</div>
))}
</div>
) : (
<span>{t('about.queryFailed')}</span>
)}
</div>
<div className="my-5 h-[1px] bg-neutral-700/50" />
<div className="text-neutral-400">{t('about.community')}</div>
<div className="my-3 flex space-x-5">
{communities.map((community) => (
<div
key={community.name}
className="flex h-20 w-20 cursor-pointer flex-col items-center justify-center space-y-1 rounded text-neutral-400 outline outline-1 outline-neutral-700 hover:bg-neutral-800"
onClick={() => openPage(community.url)}
>
{community.icon}
<span className="text-xs text-neutral-300">{community.name}</span>
</div>
))}
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export const ChangePassword = () => {
const navigate = useNavigate();
const { t } = useTranslation();
function changePassword() {
navigate('/auth/password');
}
return (
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={changePassword}
>
{t('changePassword')}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { useState } from 'react';
import { Badge, Divider, Popover } from 'antd';
import { useAtom } from 'jotai';
import { SettingsIcon } from 'lucide-react';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
import { About } from './about.tsx';
import { ChangePassword } from './change-password.tsx';
import { Language } from './language.tsx';
import { Logout } from './logout.tsx';
import { Tailscale } from './tailscale.tsx';
import { Update } from './update.tsx';
import { VirtualDevice } from './virtual-device.tsx';
export const Settings = () => {
const [isSettingsOpen, setIsSettingsOpen] = useAtom(isSettingsOpenAtom);
const [isBadgeVisible, setIsBadgeVisible] = useState(false);
const content = (
<>
<Language />
<About />
<Update setIsBadgeVisible={setIsBadgeVisible} />
<Divider style={{ margin: '10px 0' }} />
<VirtualDevice />
<Tailscale />
<Divider style={{ margin: '10px 0' }} />
<ChangePassword />
<Logout />
</>
);
return (
<>
<Popover
content={content}
placement="rightBottom"
trigger="click"
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700">
<Badge dot={isBadgeVisible} color="blue" offset={[1, 0]}>
<SettingsIcon size={18} />
</Badge>
</div>
</Popover>
</>
);
};

View File

@@ -0,0 +1,49 @@
import { Popover } from 'antd';
import clsx from 'clsx';
import { LanguagesIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { setLanguage } from '@/lib/localstorage.ts';
export const Language = () => {
const { t, i18n } = useTranslation();
function changeLanguage(language: string) {
if (i18n.language === language) return;
i18n.changeLanguage(language).then();
setLanguage(language);
}
const content = (
<>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'en' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('en')}
>
English
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'zh' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('zh')}
>
</div>
</>
);
return (
<Popover content={content} placement="right" trigger="hover" arrow>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 text-white hover:bg-neutral-600">
<LanguagesIcon size={16} />
<span>{t('language')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { removeToken } from '@/lib/cookie.ts';
export const Logout = () => {
const navigate = useNavigate();
const { t } = useTranslation();
function logout() {
removeToken();
navigate('/auth/login');
}
return (
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={logout}
>
{t('logout')}
</div>
);
};

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import { Button, Modal } from 'antd';
import { useSetAtom } from 'jotai';
import { LoaderCircleIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/network';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
type State = '' | 'installing' | 'installed' | 'failed';
export const Tailscale = () => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [state, setState] = useState<State>('');
const [loginUrl, setLoginUrl] = useState('');
function openModal() {
check();
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function check() {
if (['installing', 'installed'].includes(state)) return;
if (isLoading) return;
setIsLoading(true);
api
.getTailscale()
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.data.exist) {
setState('installed');
}
})
.finally(() => {
setIsLoading(false);
});
}
function install() {
if (state === 'installing') return;
setState('installing');
api
.installTailscale()
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
setState('failed');
return;
}
setState('installed');
})
.catch(() => {
setState('failed');
});
}
function run() {
if (state !== 'installed') return;
if (isStarting) return;
setIsStarting(true);
setLoginUrl('');
api
.runTailscale()
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.data.url) {
updateLoginUrl(rsp.data.url);
}
})
.finally(() => {
setIsStarting(false);
});
}
function updateLoginUrl(url: string) {
setLoginUrl(url);
window.open(url, '_blank');
setTimeout(() => setLoginUrl(''), 10 * 60 * 1000);
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={openModal}
>
Tailscale
</div>
<Modal
title="Tailscale"
open={isModalOpen}
width={350}
footer={null}
centered={true}
maskClosable={false}
onCancel={() => setIsModalOpen(false)}
>
<div className="flex h-[180px] items-center justify-center">
{isLoading ? (
<div className="flex items-center justify-center space-x-2 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('tailscale.loading')}</span>
</div>
) : (
<>
{state === '' && (
<div className="flex h-full flex-col items-center justify-center space-y-5">
<span>{t('tailscale.notInstalled')}</span>
<div className="flex justify-center">
<Button type="primary" onClick={install}>
{t('tailscale.install')}
</Button>
</div>
</div>
)}
{state === 'installing' && (
<div className="flex items-center justify-center space-x-2 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('tailscale.installing')}</span>
</div>
)}
{state === 'installed' && (
<div className="flex h-full flex-col items-center justify-center space-y-5">
{loginUrl ? (
<>
<a href={loginUrl} target="_blank">
{loginUrl}
</a>
<span className="text-sm text-neutral-400">{t('tailscale.urlPeriod')}</span>
</>
) : (
<>
<span>{t('tailscale.installed')}</span>
<div className="flex justify-center">
<Button type="primary" loading={isStarting} onClick={run}>
{t('tailscale.login')}
</Button>
</div>
</>
)}
</div>
)}
{state === 'failed' && (
<div className="flex flex-col space-y-10">
<div className="flex flex-col items-center justify-center font-bold text-neutral-400">
<span>{t('tailscale.failed')}</span>
<span>{t('tailscale.retry')}</span>
</div>
<ul className="list-decimal">
<li>
{t('tailscale.download')}
<a
className="px-1"
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
target="_blank"
>
{t('tailscale.package')}
</a>
{t('tailscale.unzip')}
</li>
<li>{t('tailscale.upTailscale')}</li>
<li>{t('tailscale.upTailscaled')}</li>
<li>{t('tailscale.refresh')}</li>
</ul>
</div>
)}
</>
)}
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { Badge, Button, Modal, Spin } from 'antd';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import semver from 'semver';
import * as api from '@/api/firmware.ts';
import { getSkipUpdate, setSkipUpdate } from '@/lib/localstorage.ts';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
type UpdateProps = {
setIsBadgeVisible: (isBadgeVisible: boolean) => void;
};
export const Update = ({ setIsBadgeVisible }: UpdateProps) => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [current, setCurrent] = useState('');
const [latest, setLatest] = useState('');
const [isLatest, setIsLatest] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
const [errMsg, setErrMsg] = useState('');
useEffect(() => {
const skip = getSkipUpdate();
if (!skip) {
getVersion();
}
}, []);
function showModal() {
getVersion();
setErrMsg('');
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function getVersion() {
if (isLoading) return;
setIsLoading(true);
api
.getVersion()
.then((rsp: any) => {
if (rsp.code !== 0) {
setErrMsg(t('update.queryFailed'));
return;
}
setCurrent(rsp.data.current);
setLatest(rsp.data.latest);
if (semver.gt(rsp.data.latest, rsp.data.current)) {
setIsLatest(false);
setIsBadgeVisible(true);
}
})
.catch(() => {
setErrMsg(t('update.queryFailed'));
})
.finally(() => {
setIsLoading(false);
});
}
function confirm() {
if (isLatest || !latest) {
setIsModalOpen(false);
return;
}
if (isUpdating) return;
setIsUpdating(true);
api
.update()
.then((rsp: any) => {
if (rsp.code !== 0) {
setErrMsg(t('update.updateFailed'));
setIsUpdating(false);
return;
}
setTimeout(() => {
window.location.reload();
}, 6000);
})
.catch(() => {
setErrMsg(t('update.updateFailed'));
setIsUpdating(false);
});
}
function cancel() {
setIsBadgeVisible(false);
setIsLatest(true);
setIsModalOpen(false);
setSkipUpdate(true);
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={showModal}
>
<Badge dot={!isLatest} color="blue" offset={[5, 5]}>
{t('update.title')}
</Badge>
</div>
<Modal
title={t('update.title')}
open={isModalOpen}
width={400}
footer={null}
closable={false}
centered
>
{isUpdating ? (
<div className="flex h-[300px] flex-col items-center justify-center space-y-5">
<Spin spinning={isUpdating} size="large" />
<span className="text-neutral-400">{t('update.updating')}</span>
</div>
) : (
<div className="flex flex-col items-center justify-center pb-5 pt-10">
{isLoading ? (
<Spin tip={t('update.loading')} />
) : (
<>
{errMsg !== '' ? (
<span>{errMsg}</span>
) : (
<>
{isLatest ? (
<div className="flex flex-col items-center space-y-1">
<span className="text-neutral-400">{current}</span>
<span>{t('update.isLatest')}</span>
</div>
) : (
<div className="flex flex-col items-center space-y-1">
<div className="flex space-x-2">
<span>{current}</span>
<span className="text-neutral-500">{'->'}</span>
<span>{latest}</span>
</div>
<span>{t('update.available')}</span>
</div>
)}
</>
)}
</>
)}
<div className="mt-10 flex items-center space-x-3">
<Button type="primary" onClick={confirm}>
{t('update.confirm')}
</Button>
{!isLatest && (
<Button type="default" onClick={cancel}>
{t('update.cancel')}
</Button>
)}
</div>
</div>
)}
</Modal>
</>
);
};

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { CheckIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/virtual-device';
export const VirtualDevice = () => {
const { t } = useTranslation();
const [isNetworkOn, setIsNetworkOn] = useState(false);
const [isUSBOn, setIsUSBOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
api.getVirtualDevice().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsNetworkOn(rsp.data.network);
setIsUSBOn(rsp.data.usb);
});
}, []);
function update(device: string) {
if (isLoading) return;
setIsLoading(true);
api
.updateVirtualDevice(device)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (device === 'network') {
setIsNetworkOn(rsp.data.on);
} else {
setIsUSBOn(rsp.data.on);
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
onClick={() => update('network')}
>
{isNetworkOn && <CheckIcon size={18} color="#3b82f6" />}
<span>{t('virtualDevice.network')}</span>
</div>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
onClick={() => update('usb')}
>
{isUSBOn && <CheckIcon size={18} color="#3b82f6" />}
<span>{t('virtualDevice.usb')}</span>
</div>
</>
);
};

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import {
DiscIcon,
FileBoxIcon,
LoaderCircleIcon,
PackageIcon,
PackageSearchIcon,
XIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import { client } from '@/lib/websocket.ts';
export const Storage = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState<string[]>([]);
const [mountingFile, setMountingFile] = useState('');
const [mountedFile, setMountedFile] = useState('');
useEffect(() => {
getFiles();
}, []);
function handleOpenChange(open: boolean) {
if (open) {
getFiles();
}
setIsPopoverOpen(open);
}
// 获取镜像列表
function getFiles() {
if (isLoading) return;
setIsLoading(true);
api
.getImages()
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
const images = rsp.data.files;
if (!images?.length) {
setFiles([]);
return;
}
setFiles(images);
// 获取已挂载的镜像
api.getMountedImage().then((rsp) => {
if (rsp.code === 0 && rsp.data?.file && images.includes(rsp.data.file)) {
setMountedFile(rsp.data.file);
}
});
})
.finally(() => {
setIsLoading(false);
});
}
// 挂载/取消挂载
function mountFile(file: string) {
if (mountingFile) return;
setMountingFile(file);
client.close();
const filename = mountedFile === file ? '' : file;
api
.mountImage(filename)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMountedFile(filename);
})
.finally(() => {
setMountingFile('');
client.connect();
});
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('images')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{isLoading ? (
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('loading')}</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('empty')}</span>
</div>
) : (
files.map((file) => (
<div
key={file}
className={clsx(
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
{ 'text-blue-500': mountedFile === file }
)}
onClick={() => mountFile(file)}
>
{mountedFile === file ? (
<>
<div className="h-[18px] w-[18px] group-hover:text-red-500">
<PackageIcon size={18} className="block group-hover:hidden" />
<XIcon size={18} className="hidden group-hover:block" />
</div>
</>
) : mountingFile === file ? (
<div className="h-[18px] w-[18px]">
<LoaderCircleIcon className="animate-spin" size={18} />
</div>
) : (
<div className="h-[18px] w-[18px]">
<FileBoxIcon size={18} />
</div>
)}
<span className="break-all">{file.replace(/^.*[\\/]/, '')}</span>
</div>
))
)}
</div>
);
return (
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div
className={clsx(
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
mountedFile ? 'text-blue-500' : 'text-neutral-300'
)}
>
<DiscIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Divider, Popover } from 'antd';
import { SquareTerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Nanokvm } from './nanokvm';
import { SerialPort } from './serial-port';
export const Terminal = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const content = (
<div className="min-w-[200px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('terminal.title')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
<Nanokvm setIsPopoverOpen={setIsPopoverOpen} />
<SerialPort setIsPopoverOpen={setIsPopoverOpen} />
</div>
);
return (
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<SquareTerminalIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,25 @@
import { TerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type NanokvmProps = {
setIsPopoverOpen: (open: boolean) => void;
};
export const Nanokvm = ({ setIsPopoverOpen }: NanokvmProps) => {
const { t } = useTranslation();
function openTerminal() {
setIsPopoverOpen(false);
window.open('/#terminal', '_blank');
}
return (
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
onClick={openTerminal}
>
<TerminalIcon size={16} />
<span>{t('terminal.nanokvm')}</span>
</div>
);
};

View File

@@ -0,0 +1,114 @@
import { ChangeEvent, useState } from 'react';
import { Button, Input, InputNumber, Modal, Radio, RadioChangeEvent } from 'antd';
import { useSetAtom } from 'jotai';
import { TerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
type SerialPortProps = {
setIsPopoverOpen: (open: boolean) => void;
};
export const SerialPort = ({ setIsPopoverOpen }: SerialPortProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [port, setPort] = useState('');
const [baudrate, setBaudrate] = useState(115200);
function openModal() {
setIsKeyboardEnable(false);
setIsModalOpen(true);
setIsPopoverOpen(false);
}
function closeModal() {
setIsKeyboardEnable(true);
setIsModalOpen(false);
}
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
setPort(e.target.value);
}
function handleRadioChange(e: RadioChangeEvent) {
setPort(e.target.value);
}
function onInputNumberChange(value: number | null) {
if (value === null) return;
setBaudrate(value);
}
function submit() {
if (!port || !baudrate) {
closeModal();
return;
}
setIsModalOpen(false);
window.open(`/#terminal?port=${port}&baud=${baudrate}`, '_blank');
}
return (
<>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
onClick={openModal}
>
<TerminalIcon size={16} />
<span>{t('terminal.serial')}</span>
</div>
<Modal
open={isModalOpen}
title={t('terminal.serial')}
width={350}
footer={null}
onCancel={closeModal}
>
<div className="mt-10 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">
{t('terminal.serialPort')}
</div>
<div className="w-1/2">
<Input
value={port}
placeholder={t('terminal.serialPortPlaceholder')}
onChange={handleInputChange}
/>
</div>
</div>
<div className="mt-3 pl-[100px]">
<Radio.Group size="large" onChange={handleRadioChange}>
<Radio value="/dev/ttyS1">
<code>/dev/ttyS1</code>
</Radio>
<Radio value="/dev/ttyS2">
<code>/dev/ttyS2</code>
</Radio>
</Radio.Group>
</div>
<div className="mt-7 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">{t('terminal.baudrate')}</div>
<InputNumber
controls={false}
min={1}
defaultValue={115200}
onChange={onInputNumberChange}
/>
</div>
<div className="mb-3 mt-12 flex justify-center">
<Button type="primary" onClick={submit}>
{t('terminal.confirm')}
</Button>
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,159 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Divider, Input, List, Popover } from 'antd';
import type { InputRef } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { NetworkIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { deleteWolMac, getWolMacs, wol } from '@/api/network.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const Wol = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [input, setInput] = useState('');
const [status, setStatus] = useState('');
const [log, setLog] = useState('');
const [macList, setMacList] = useState<string[]>([]);
const inputRef = useRef<InputRef>(null);
function handleOpenChange(open: boolean) {
if (open) {
getMacs();
setIsKeyboardEnable(false);
} else {
setInput('');
setStatus('');
setLog('');
setIsKeyboardEnable(true);
}
setIsPopoverOpen(open);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setInput(e.target.value);
}
function getMacs() {
getWolMacs().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMacList(rsp.data.macs.filter((mac: string) => mac !== ''));
});
}
function deleteMac(mac: string) {
deleteWolMac(mac).then((rsp) => {
if (rsp.code === 0) {
setMacList(macList.filter((item) => item !== mac));
}
});
}
function wake(mac?: string) {
if (status === 'loading') return;
const value = mac ? mac : input;
if (!value) return;
setStatus('loading');
setLog(t('wol.sending'));
wol(value)
.then((rsp) => {
if (rsp.code !== 0) {
setStatus('failed');
setLog(rsp.msg);
return;
}
setStatus('success');
setLog(t('wol.sent'));
})
.catch(() => {
setStatus('failed');
});
}
const content = (
<div className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">Wake-on-LAN</span>
</div>
<Divider style={{ margin: '10px 0 10px 0' }} />
<div className="pb-1 text-neutral-500">{t('wol.input')}</div>
<div className="flex items-center space-x-1">
<Input ref={inputRef} value={input} onChange={handleChange} />
<Button type="primary" onClick={() => wake()}>
{t('wol.ok')}
</Button>
</div>
<div className={clsx('py-2')}>
{status && (
<div
className={clsx(
'max-w-[300px] break-words text-sm',
status === 'failed' ? 'text-red-500' : 'text-green-500'
)}
>
{log}
</div>
)}
</div>
<List
itemLayout="horizontal"
locale={{ emptyText: ' ' }}
dataSource={macList}
renderItem={(mac) => (
<List.Item className="flex w-full items-center justify-between">
<div className="h-[24px] max-w-[200px]">{mac}</div>
<div className="flex items-center space-x-3">
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-green-500 hover:bg-neutral-700/80"
onClick={() => wake(mac)}
>
<SendIcon size={16} />
</div>
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-red-500 hover:bg-neutral-700"
onClick={() => deleteMac(mac)}
>
<Trash2Icon size={16} />
</div>
</div>
</List.Item>
)}
/>
</div>
);
return (
<Popover
content={content}
placement="rightTop"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<NetworkIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { MaximizeIcon, MinimizeIcon } from 'lucide-react';
export const Fullscreen = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
function onFullscreenChange() {
setIsFullscreen(!!document.fullscreenElement);
}
onFullscreenChange();
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, []);
function handleFullscreen() {
if (!document.fullscreenElement) {
const element = document.documentElement;
element.requestFullscreen().then();
} else {
document.exitFullscreen().then();
}
}
return (
<div
className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700"
onClick={handleFullscreen}
>
{isFullscreen ? <MinimizeIcon size={18} /> : <MaximizeIcon size={18} />}
</div>
);
};

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import { Divider } from 'antd';
import clsx from 'clsx';
import { MenuIcon, XIcon } from 'lucide-react';
import { Fullscreen } from './fullscreen';
import { Keyboard } from './keyboard';
import { Mouse } from './mouse';
import { Power } from './power';
import { Screen } from './screen';
import { Script } from './script';
import { Settings } from './settings';
import { Storage } from './storage';
import { Terminal } from './terminal';
import { Wol } from './wol';
export const Menu = () => {
const [isMenuOpen, setIsMenuOpen] = useState(true);
return (
<div className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
<div className="sticky top-[10px]">
<div
className={clsx(
'h-[40px] items-center justify-between rounded bg-neutral-800/90',
isMenuOpen ? 'flex' : 'hidden'
)}
>
<div className="flex h-[30px] select-none items-center px-3">
<img src="/sipeed.ico" width={18} height={18} alt="sipeed" />
</div>
<Screen />
<Keyboard />
<Mouse />
<Divider type="vertical" />
<Storage />
<Script />
<Terminal />
<Wol />
<Divider type="vertical" />
<Power />
<Divider type="vertical" />
<Settings />
<Fullscreen />
<div
className="mr-2 flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700"
onClick={() => setIsMenuOpen((o) => !o)}
>
<XIcon size={20} />
</div>
</div>
{!isMenuOpen && (
<div
className="flex h-[30px] w-[50px] items-center justify-center rounded bg-neutral-800/80 text-neutral-300 hover:bg-neutral-800 hover:text-white"
onClick={() => setIsMenuOpen((o) => !o)}
>
<MenuIcon />
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,19 @@
import { useSetAtom } from 'jotai';
import { KeyboardIcon } from 'lucide-react';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
export const Keyboard = () => {
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
return (
<>
<div
className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700"
onClick={() => setIsKeyboardOpen((o) => !o)}
>
<KeyboardIcon size={18} />
</div>
</>
);
};

View File

@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import {
CheckIcon,
EyeOffIcon,
HandIcon,
MouseIcon,
MousePointerIcon,
PlusIcon,
RefreshCwIcon,
SquareMousePointerIcon,
TextCursorIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import * as ls from '@/lib/localstorage';
import { client } from '@/lib/websocket';
import { mouseModeAtom, mouseStyleAtom } from '@/jotai/mouse';
export const Mouse = () => {
const { t } = useTranslation();
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const mouseStyles = [
{ name: t('cursor.default'), icon: <MousePointerIcon size={14} />, value: 'cursor-default' },
{ name: t('cursor.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
{ name: t('cursor.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
{ name: t('cursor.text'), icon: <TextCursorIcon size={14} />, value: 'cursor-text' },
{ name: t('cursor.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
];
const mouseModes = [
{ name: t('cursor.absolute'), value: 'absolute' },
{ name: t('cursor.relative'), value: 'relative' }
];
useEffect(() => {
const style = ls.getMouseStyle();
if (style && style !== mouseStyle) {
setMouseStyle(style);
}
const mode = ls.getMouseMode();
if (mode && mode !== mouseMode) {
setMouseMode(mode);
}
}, []);
function updateStyle(style: string) {
setMouseStyle(style);
ls.setMouseStyle(style);
}
function updateMode(mode: string) {
setMouseMode(mode);
ls.setMouseMode(mode);
if (mode === 'relative') {
client.close();
setTimeout(() => {
client.connect();
}, 500);
}
}
function resetHid() {
setIsPopoverOpen(false);
client.close();
api.resetHid().finally(() => {
client.connect();
});
}
const content = (
<>
{mouseStyles.map((style) => (
<div
key={style.value}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600',
style.value === mouseStyle ? 'text-green-500' : 'text-white'
)}
onClick={() => updateStyle(style.value)}
>
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
<span>{style.name}</span>
</div>
))}
<Divider style={{ margin: '10px 0' }} />
<Popover
content={
<>
{mouseModes.map((mode) => (
<div
key={mode.value}
className="my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700"
onClick={() => updateMode(mode.value)}
>
<div className="flex h-[16px] w-[16px] items-end text-blue-500">
{mode.value === mouseMode && <CheckIcon strokeWidth={3} size={16} />}
</div>
<span>{mode.name}</span>
</div>
))}
</>
}
placement="rightTop"
arrow={true}
trigger="hover"
>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<div className="flex h-[14px] w-[20px] items-end">
<SquareMousePointerIcon size={14} />
</div>
<span>{t('cursor.mode')}</span>
</div>
</Popover>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={resetHid}
>
<div className="flex h-[14px] w-[20px] items-end">
<RefreshCwIcon size={14} />
</div>
<span>{t('cursor.resetHid')}</span>
</div>
</>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<MouseIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { Popover, Slider, Tooltip } from 'antd';
import clsx from 'clsx';
import {
CirclePowerIcon,
HardDriveIcon,
LoaderCircleIcon,
PowerIcon,
RotateCcwIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/vm';
export const Power = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isPowerOn, setIsPowerOn] = useState(false);
const [isHddOn, setIsHddOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [powerDuration, setPowerDuration] = useState(8);
useEffect(() => {
getLed();
const interval = setInterval(getLed, 5000);
return () => {
clearInterval(interval);
};
}, []);
function getLed() {
api.getLedGpio().then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsPowerOn(rsp.data.pwr);
setIsHddOn(rsp.data.hdd);
});
}
function click(button: 'reset' | 'power', duration?: number) {
if (isLoading) return;
setIsLoading(true);
setIsPopoverOpen(false);
const millisecond = Math.floor((duration ? duration : powerDuration) * 1000);
api.setGpio(button, millisecond).finally(() => {
setIsLoading(false);
});
}
function onChange(value: number) {
setPowerDuration(value);
}
const content = (
<div className="flex flex-col space-y-1">
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('reset', 0.8)}
>
<RotateCcwIcon size={16} />
<span>{t('reset')}</span>
</div>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('power', 0.8)}
>
<PowerIcon size={16} />
<span>{t('powerShort')}</span>
</div>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-3 py-0.5 hover:bg-neutral-600"
onClick={() => click('power')}
>
<CirclePowerIcon size={16} />
<span>{t('powerLong')}</span>
<div className="flex h-full items-start text-xs text-neutral-500">{powerDuration}s</div>
</div>
<div className="px-3">
<Slider
defaultValue={8}
min={1}
max={30}
tooltip={{ placement: 'bottom' }}
onChange={onChange}
/>
</div>
</div>
);
return (
<>
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700/80">
<div
className={clsx('h-[18px] w-[18px]', isPowerOn ? 'text-green-600' : 'text-neutral-500')}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : (
<PowerIcon size={18} />
)}
</div>
</div>
</Popover>
<Tooltip placement="bottom" title={t('hddLed')}>
<div
className={clsx(
'flex h-[30px] w-[18px] items-center justify-center px-2',
isHddOn ? 'text-green-600' : 'text-neutral-500'
)}
>
<HardDriveIcon size={18} />
</div>
</Tooltip>
</>
);
};

View File

@@ -0,0 +1,117 @@
import { useRef, useState } from 'react';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, InputNumber, Popover } from 'antd';
import { CheckIcon, ScanBarcodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setFps as setCookie } from '@/lib/localstorage';
const fpsList = [
{ key: 60, label: '60Hz' },
{ key: 30, label: '30Hz' },
{ key: 24, label: '24Hz' },
{ key: 15, label: '15Hz' },
{ key: 10, label: '10Hz' }
];
const defaultFps = [60, 30, 24, 15, 10];
type FpsProps = {
fps: number;
setFps: (fps: number) => void;
};
export const Fps = ({ fps, setFps }: FpsProps) => {
const { t } = useTranslation();
const [isCustomize, setIsCustomize] = useState(false);
const customizeRef = useRef(0);
function showCustomize() {
customizeRef.current = fps;
setIsCustomize(true);
}
function onChange(value: any) {
const num = Number(value);
if (num > 0 && num <= 60) {
customizeRef.current = num;
}
}
async function update(value: number) {
if (isCustomize && customizeRef.current === fps) {
setIsCustomize(false);
return;
}
const rsp = await updateScreen('fps', value);
if (rsp.code !== 0) {
return;
}
setFps(value);
setCookie(value);
isCustomize && setIsCustomize(false);
}
const content = (
<>
{/* default fps list */}
{fpsList.map((item) => (
<div
key={item.key}
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.key === fps && <CheckIcon size={14} />}
</div>
<span>{item.label}</span>
</div>
))}
{/* customize fps */}
<div
className="flex w-[140px] cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 hover:bg-neutral-600"
onClick={showCustomize}
>
{defaultFps.includes(fps) ? (
<>
<div className="flex h-[14px] w-[20px] items-end"></div>
<span>{t('screen.customizeFps')}</span>
</>
) : (
<>
<div className="flex h-[14px] w-[20px] items-end">
<CheckIcon size={14} />
</div>
<span>{`Customize(${fps}Hz)`}</span>
</>
)}
</div>
{isCustomize && (
<div className="flex items-center space-x-1 py-1">
<InputNumber<number> defaultValue={fps} min={1} max={60} onChange={onChange} />
<Button
size="small"
icon={<CheckOutlined />}
onClick={() => update(customizeRef.current)}
/>
<Button size="small" icon={<CloseOutlined />} onClick={() => setIsCustomize(false)} />
</div>
)}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<ScanBarcodeIcon size={18} />
<span className="select-none text-sm">{t('screen.fps')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { Tooltip } from 'antd';
import { LoaderCircleIcon, Tally4Icon, Tally5Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getFrameDetect, updateFrameDetect } from '@/api/stream.ts';
export const FrameDetect = () => {
const { t } = useTranslation();
const [isEnabled, setIsEnabled] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
getFrameDetect().then((rsp) => {
if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled);
}
});
}, []);
function update() {
if (isLoading) return;
setIsLoading(true);
updateFrameDetect()
.then((rsp) => {
if (rsp.code === 0) {
setIsEnabled(rsp.data.enabled);
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<Tooltip placement="rightTop" title={t('screen.frameDetectTip')} color="#262626" arrow>
<div
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
onClick={update}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : isEnabled ? (
<Tally4Icon color="#22c55e" size={18} />
) : (
<Tally5Icon size={18} />
)}
<span className="select-none text-sm">{t('screen.frameDetect')}</span>
</div>
</Tooltip>
);
};

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
import { useAtomValue } from 'jotai';
import { MonitorIcon } from 'lucide-react';
import { updateScreen } from '@/api/vm';
import * as ls from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/resolution';
import { Fps } from './fps';
import { FrameDetect } from './frame-detect';
import { Quality } from './quality';
import { Resolution } from './resolution';
export const Screen = () => {
const resolution = useAtomValue(resolutionAtom);
const [fps, setFps] = useState(30);
const [quality, setQuality] = useState(80);
useEffect(() => {
updateScreen('resolution', resolution!.height);
const cookieFps = ls.getFps();
if (cookieFps) {
updateScreen('fps', cookieFps).then((rsp) => {
if (rsp.code === 0) {
setFps(cookieFps);
}
});
}
const cookieQuality = ls.getQuality();
if (cookieQuality) {
updateScreen('quality', cookieQuality).then((rsp) => {
if (rsp.code === 0) {
setQuality(cookieQuality);
}
});
}
}, []);
return (
<Popover
content={
<div className="flex flex-col space-y-1">
<Resolution />
<Fps fps={fps} setFps={setFps} />
<Quality quality={quality} setQuality={setQuality} />
<FrameDetect />
</div>
}
placement="bottomLeft"
trigger="click"
>
<div className="flex h-[32px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700/80">
<MonitorIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,61 @@
import { Popover } from 'antd';
import { CheckIcon, SquareActivityIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { setQuality as setCookie } from '@/lib/localstorage.ts';
const qualityList = [
{ key: 99, label: '100%' },
{ key: 90, label: '90%' },
{ key: 80, label: '80%' },
{ key: 70, label: '70%' },
{ key: 60, label: '60%' },
{ key: 51, label: '50%' }
];
type QualityProps = {
quality: number;
setQuality: (quality: number) => void;
};
export const Quality = ({ quality, setQuality }: QualityProps) => {
const { t } = useTranslation();
async function update(value: number) {
const rsp = await updateScreen('quality', value);
if (rsp.code !== 0) {
return;
}
setQuality(value);
setCookie(value);
}
const content = (
<>
{qualityList.map((item) => (
<div
key={item.key}
className="flex h-[30px] cursor-pointer select-none items-center space-x-1 rounded pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(item.key)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.key === quality && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.label}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<SquareActivityIcon size={18} />
<span className="select-none text-sm">{t('screen.quality')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,59 @@
import { Popover } from 'antd';
import { useAtom } from 'jotai';
import { CheckIcon, RatioIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { updateScreen } from '@/api/vm';
import { Resolution as TypeResolution } from '@/types';
import { setResolution as setCookie } from '@/lib/localstorage';
import { resolutionAtom } from '@/jotai/resolution.ts';
const resolutions: TypeResolution[] = [
{ width: 1920, height: 1080 },
{ width: 1280, height: 720 },
{ width: 800, height: 600 },
{ width: 640, height: 480 }
];
export const Resolution = () => {
const { t } = useTranslation();
const [resolution, setResolution] = useAtom(resolutionAtom);
async function update(item: TypeResolution) {
const rsp = await updateScreen('resolution', item.height);
if (rsp.code !== 0) {
return;
}
setResolution(item);
setCookie(item);
}
const content = (
<>
{resolutions.map((item) => (
<div
key={item.height}
className="flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-1 pr-5 hover:bg-neutral-600"
onClick={() => update(item)}
>
<div className="flex h-[14px] w-[20px] items-end">
{item.height === resolution?.height && <CheckIcon size={14} />}
</div>
<span className="flex w-[32px]">{item.width}</span>
<span>x</span>
<span className="w-[36px]">{item.height}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<RatioIcon size={18} />
<span className="select-none text-sm">{t('screen.resolution')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,205 @@
import { ChangeEvent, useRef, useState } from 'react';
import { UploadOutlined } from '@ant-design/icons';
import { Button, Divider, Popconfirm, Popover } from 'antd';
import clsx from 'clsx';
import { ChevronRightIcon, FileCodeIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/script.ts';
import { Run } from './run';
export const Script = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [scripts, setScripts] = useState<string[]>([]);
const [currentScript, setCurrentScript] = useState('');
const [isRunning, setIsRunning] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const inputRef = useRef<any>(null);
function handleOpenChange(open: boolean) {
if (open) {
getScripts();
} else {
setCurrentScript('');
}
setIsPopoverOpen(open);
}
function selectFile() {
if (inputRef.current) {
inputRef.current.value = null;
}
inputRef.current?.click();
}
function uploadFile(e: ChangeEvent<HTMLInputElement>) {
if (!e.target?.files?.length) return;
const file = e.target.files[0];
if (isUploading) return;
setIsUploading(true);
const formData = new FormData();
formData.append('file', file);
api
.uploadScript(formData)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (!scripts.includes(rsp.data.file)) {
setScripts([...scripts, rsp.data.file]);
}
})
.finally(() => {
setIsUploading(false);
});
}
function runScript(type: string) {
if (!currentScript) return;
if (type === 'foreground') {
setIsRunning(true);
} else {
api.runScript(currentScript, type).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
});
}
setIsPopoverOpen(false);
}
function getScripts() {
api.getScripts().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.data?.files?.length > 0) {
setScripts(rsp.data.files);
}
});
}
function deleteScript() {
if (!currentScript) return;
api.deleteScript(currentScript).then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setScripts(scripts.filter((script) => script !== currentScript));
});
}
function activate(script: string) {
setCurrentScript(script === currentScript ? '' : script);
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('script.title')}</span>
</div>
<input
ref={inputRef}
type="file"
accept=".sh,.py"
className="hidden"
onChange={uploadFile}
/>
<Button
ghost
type="primary"
size="small"
icon={<UploadOutlined />}
loading={isUploading}
onClick={selectFile}
>
{t('script.upload')}
</Button>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{scripts.map((script) => (
<div
key={script}
className={clsx(
'my-1 cursor-pointer rounded',
script === currentScript ? 'bg-neutral-700/50' : 'hover:bg-neutral-700/50'
)}
>
<div
className="flex items-center justify-between space-x-5 px-2 py-1.5"
onClick={() => activate(script)}
>
<div className="max-w-[300px] select-none truncate">{script}</div>
<div className={clsx('h-[16px] w-[16px]', script === currentScript && 'rotate-90')}>
<ChevronRightIcon size={16} />
</div>
</div>
{script === currentScript && (
<div className="flex items-center justify-end space-x-2 p-3">
<Button type="primary" size="small" onClick={() => runScript('foreground')}>
{t('script.run')}
</Button>
<Button type="primary" size="small" onClick={() => runScript('background')}>
{t('script.runBackground')}
</Button>
<Popconfirm
title={t('script.attention')}
description={t('script.delDesc')}
onConfirm={deleteScript}
onCancel={() => {}}
okText={t('script.confirm')}
cancelText={t('script.cancel')}
placement="bottom"
>
<Button danger type="primary" size="small">
{t('script.delete')}
</Button>
</Popconfirm>
</div>
)}
</div>
))}
</div>
);
return (
<>
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<FileCodeIcon size={18} />
</div>
</Popover>
{isRunning && <Run script={currentScript} setIsRunning={setIsRunning} />}
</>
);
};

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Card, Modal, Spin } from 'antd';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/script';
type RunProps = {
script: string;
setIsRunning: (isRunning: boolean) => void;
};
export const Run = ({ script, setIsRunning }: RunProps) => {
const { t } = useTranslation();
const [state, setState] = useState('');
const [log, setLog] = useState('');
useEffect(() => {
setState('running');
api
.runScript(script, 'foreground')
.then((rsp) => {
if (rsp.code !== 0) {
setLog(rsp.msg);
setState('failed');
return;
}
setState('success');
setLog(rsp.data.log);
})
.catch(() => {
setLog(t('script.runFailed'));
setState('failed');
});
}, []);
return (
<Modal
title={script}
width={600}
closable={false}
footer={null}
style={{ top: 60 }}
open={true}
>
{state === 'running' ? (
<div className="flex h-[300px] items-center justify-center">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
) : (
<Card className="h-[600px] overflow-auto whitespace-pre-line font-mono">{log}</Card>
)}
<div className="mt-5 flex justify-center">
<Button type="primary" onClick={() => setIsRunning(false)}>
{t('script.close')}
</Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { useSetAtom } from 'jotai';
import { BookOpenIcon, GithubIcon, LoaderCircleIcon, MessageSquareIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { getInfo } from '@/api/vm';
import { isSettingsOpenAtom } from '@/jotai/settings';
type Info = {
ip: string;
mdns: string;
image: string;
firmware: string;
deviceKey: string;
};
export const About = () => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [info, setInfo] = useState<Info | undefined>();
const communities = [
{ name: 'wiki', icon: <BookOpenIcon size={24} />, url: 'https://wiki.sipeed.com/nanokvm' },
{ name: 'github', icon: <GithubIcon size={24} />, url: 'https://github.com/sipeed/NanoKVM' },
{
name: 'discussion',
icon: <MessageSquareIcon size={24} />,
url: 'https://maixhub.com/discussion/nanokvm'
}
];
useEffect(() => {
setLoading(true);
getInfo()
.then((rsp: any) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
setInfo(undefined);
return;
}
setInfo(rsp.data);
})
.catch(() => {
setInfo(undefined);
})
.finally(() => {
setLoading(false);
});
}, []);
function showModal() {
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function openPage(url: string) {
window.open(url, '_blank');
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={showModal}
>
{t('about.title')}
</div>
<Modal
title={t('about.title')}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
width={450}
footer={null}
centered
>
<div className="my-5 h-[1px] bg-neutral-700/50" />
<div className="flex w-full flex-col space-y-2">
<span className="text-neutral-400">{t('about.information')}</span>
{loading ? (
<LoaderCircleIcon className="animate-spin" size={18} />
) : info ? (
<div className="flex w-full flex-col space-y-1">
{Object.entries(info).map(([key, value]) => (
<div key={key} className="flex w-full items-center justify-between">
<span>{t(`about.${key}`)}</span>
<span>{value}</span>
</div>
))}
</div>
) : (
<span>{t('about.queryFailed')}</span>
)}
</div>
<div className="my-5 h-[1px] bg-neutral-700/50" />
<div className="text-neutral-400">{t('about.community')}</div>
<div className="my-3 flex space-x-5">
{communities.map((community) => (
<div
key={community.name}
className="flex h-20 w-20 cursor-pointer flex-col items-center justify-center space-y-1 rounded text-neutral-400 outline outline-1 outline-neutral-700 hover:bg-neutral-800"
onClick={() => openPage(community.url)}
>
{community.icon}
<span className="text-xs text-neutral-300">{community.name}</span>
</div>
))}
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export const ChangePassword = () => {
const navigate = useNavigate();
const { t } = useTranslation();
function changePassword() {
navigate('/auth/password');
}
return (
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={changePassword}
>
{t('changePassword')}
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { useState } from 'react';
import { Badge, Divider, Popover } from 'antd';
import { useAtom } from 'jotai';
import { SettingsIcon } from 'lucide-react';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
import { About } from './about.tsx';
import { ChangePassword } from './change-password.tsx';
import { Language } from './language.tsx';
import { Logout } from './logout.tsx';
import { Tailscale } from './tailscale.tsx';
import { Update } from './update.tsx';
import { VirtualDevice } from './virtual-device.tsx';
export const Settings = () => {
const [isSettingsOpen, setIsSettingsOpen] = useAtom(isSettingsOpenAtom);
const [isBadgeVisible, setIsBadgeVisible] = useState(false);
const content = (
<>
<Language />
<About />
<Update setIsBadgeVisible={setIsBadgeVisible} />
<Divider style={{ margin: '10px 0' }} />
<VirtualDevice />
<Tailscale />
<Divider style={{ margin: '10px 0' }} />
<ChangePassword />
<Logout />
</>
);
return (
<>
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center space-x-1 rounded px-2 text-white hover:bg-neutral-700">
<Badge dot={isBadgeVisible} color="blue" offset={[1, 0]}>
<SettingsIcon size={18} />
</Badge>
</div>
</Popover>
</>
);
};

View File

@@ -0,0 +1,49 @@
import { Popover } from 'antd';
import clsx from 'clsx';
import { LanguagesIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { setLanguage } from '@/lib/localstorage.ts';
export const Language = () => {
const { t, i18n } = useTranslation();
function changeLanguage(language: string) {
if (i18n.language === language) return;
i18n.changeLanguage(language).then();
setLanguage(language);
}
const content = (
<>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'en' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('en')}
>
English
</div>
<div
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === 'zh' ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage('zh')}
>
</div>
</>
);
return (
<Popover content={content} placement="right" trigger="hover" arrow>
<div className="flex cursor-pointer select-none items-center space-x-2 rounded px-3 py-1.5 text-white hover:bg-neutral-600">
<LanguagesIcon size={16} />
<span>{t('language')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { removeToken } from '@/lib/cookie.ts';
export const Logout = () => {
const navigate = useNavigate();
const { t } = useTranslation();
function logout() {
removeToken();
navigate('/auth/login');
}
return (
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={logout}
>
{t('logout')}
</div>
);
};

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import { Button, Modal } from 'antd';
import { useSetAtom } from 'jotai';
import { LoaderCircleIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/network';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
type State = '' | 'installing' | 'installed' | 'failed';
export const Tailscale = () => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [state, setState] = useState<State>('');
const [loginUrl, setLoginUrl] = useState('');
function openModal() {
check();
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function check() {
if (['installing', 'installed'].includes(state)) return;
if (isLoading) return;
setIsLoading(true);
api
.getTailscale()
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.data.exist) {
setState('installed');
}
})
.finally(() => {
setIsLoading(false);
});
}
function install() {
if (state === 'installing') return;
setState('installing');
api
.installTailscale()
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
setState('failed');
return;
}
setState('installed');
})
.catch(() => {
setState('failed');
});
}
function run() {
if (state !== 'installed') return;
if (isStarting) return;
setIsStarting(true);
setLoginUrl('');
api
.runTailscale()
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (rsp.data.url) {
updateLoginUrl(rsp.data.url);
}
})
.finally(() => {
setIsStarting(false);
});
}
function updateLoginUrl(url: string) {
setLoginUrl(url);
window.open(url, '_blank');
setTimeout(() => setLoginUrl(''), 10 * 60 * 1000);
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={openModal}
>
Tailscale
</div>
<Modal
title="Tailscale"
open={isModalOpen}
width={450}
footer={null}
centered={true}
maskClosable={false}
onCancel={() => setIsModalOpen(false)}
>
<div className="flex h-[200px] items-center justify-center">
{isLoading ? (
<div className="flex items-center justify-center space-x-2 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('tailscale.loading')}</span>
</div>
) : (
<>
{state === '' && (
<div className="flex h-full flex-col items-center justify-center space-y-5">
<span>{t('tailscale.notInstalled')}</span>
<div className="flex justify-center">
<Button type="primary" onClick={install}>
{t('tailscale.install')}
</Button>
</div>
</div>
)}
{state === 'installing' && (
<div className="flex items-center justify-center space-x-2 text-neutral-500">
<LoaderCircleIcon className="animate-spin" size={18} />
<span>{t('tailscale.installing')}</span>
</div>
)}
{state === 'installed' && (
<div className="flex h-full flex-col items-center justify-center space-y-5">
{loginUrl ? (
<>
<a href={loginUrl} target="_blank">
{loginUrl}
</a>
<span className="text-sm text-neutral-400">{t('tailscale.urlPeriod')}</span>
</>
) : (
<>
<span>{t('tailscale.installed')}</span>
<div className="flex justify-center">
<Button type="primary" loading={isStarting} onClick={run}>
{t('tailscale.login')}
</Button>
</div>
</>
)}
</div>
)}
{state === 'failed' && (
<div className="flex flex-col space-y-10">
<div className="flex flex-col items-center justify-center font-bold text-neutral-400">
<span>{t('tailscale.failed')}</span>
<span>{t('tailscale.retry')}</span>
</div>
<ul className="list-decimal">
<li>
{t('tailscale.download')}
<a
className="px-1"
href="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
target="_blank"
>
{t('tailscale.package')}
</a>
{t('tailscale.unzip')}
</li>
<li>{t('tailscale.upTailscale')}</li>
<li>{t('tailscale.upTailscaled')}</li>
<li>{t('tailscale.refresh')}</li>
</ul>
</div>
)}
</>
)}
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { Badge, Button, Modal, Spin } from 'antd';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import semver from 'semver';
import * as api from '@/api/firmware.ts';
import { getSkipUpdate, setSkipUpdate } from '@/lib/localstorage.ts';
import { isSettingsOpenAtom } from '@/jotai/settings.ts';
type UpdateProps = {
setIsBadgeVisible: (isBadgeVisible: boolean) => void;
};
export const Update = ({ setIsBadgeVisible }: UpdateProps) => {
const { t } = useTranslation();
const setIsSettingsOpen = useSetAtom(isSettingsOpenAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [current, setCurrent] = useState('');
const [latest, setLatest] = useState('');
const [isLatest, setIsLatest] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
const [errMsg, setErrMsg] = useState('');
useEffect(() => {
const skip = getSkipUpdate();
if (!skip) {
getVersion();
}
}, []);
function showModal() {
getVersion();
setErrMsg('');
setIsModalOpen(true);
setIsSettingsOpen(false);
}
function getVersion() {
if (isLoading) return;
setIsLoading(true);
api
.getVersion()
.then((rsp: any) => {
if (rsp.code !== 0) {
setErrMsg(t('update.queryFailed'));
return;
}
setCurrent(rsp.data.current);
setLatest(rsp.data.latest);
if (semver.gt(rsp.data.latest, rsp.data.current)) {
setIsLatest(false);
setIsBadgeVisible(true);
}
})
.catch(() => {
setErrMsg(t('update.queryFailed'));
})
.finally(() => {
setIsLoading(false);
});
}
function confirm() {
if (isLatest || !latest) {
setIsModalOpen(false);
return;
}
if (isUpdating) return;
setIsUpdating(true);
api
.update()
.then((rsp: any) => {
if (rsp.code !== 0) {
setErrMsg(t('update.updateFailed'));
setIsUpdating(false);
return;
}
setTimeout(() => {
window.location.reload();
}, 6000);
})
.catch(() => {
setErrMsg(t('update.updateFailed'));
setIsUpdating(false);
});
}
function cancel() {
setIsBadgeVisible(false);
setIsLatest(true);
setIsModalOpen(false);
setSkipUpdate(true);
}
return (
<>
<div
className="flex cursor-pointer select-none items-center rounded px-3 py-1.5 hover:bg-neutral-600"
onClick={showModal}
>
<Badge dot={!isLatest} color="blue" offset={[5, 5]}>
{t('update.title')}
</Badge>
</div>
<Modal
title={t('update.title')}
open={isModalOpen}
width={400}
footer={null}
closable={false}
centered
>
{isUpdating ? (
<div className="flex h-[300px] flex-col items-center justify-center space-y-5">
<Spin spinning={isUpdating} size="large" />
<span className="text-neutral-400">{t('update.updating')}</span>
</div>
) : (
<div className="flex flex-col items-center justify-center pb-5 pt-10">
{isLoading ? (
<Spin tip={t('update.loading')} />
) : (
<>
{errMsg !== '' ? (
<span>{errMsg}</span>
) : (
<>
{isLatest ? (
<div className="flex flex-col items-center space-y-1">
<span className="text-neutral-400">{current}</span>
<span>{t('update.isLatest')}</span>
</div>
) : (
<div className="flex flex-col items-center space-y-1">
<div className="flex space-x-2">
<span>{current}</span>
<span className="text-neutral-500">{'->'}</span>
<span>{latest}</span>
</div>
<span>{t('update.available')}</span>
</div>
)}
</>
)}
</>
)}
<div className="mt-10 flex items-center space-x-3">
<Button type="primary" onClick={confirm}>
{t('update.confirm')}
</Button>
{!isLatest && (
<Button type="default" onClick={cancel}>
{t('update.cancel')}
</Button>
)}
</div>
</div>
)}
</Modal>
</>
);
};

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { CheckIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/virtual-device';
export const VirtualDevice = () => {
const { t } = useTranslation();
const [isNetworkOn, setIsNetworkOn] = useState(false);
const [isUSBOn, setIsUSBOn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
api.getVirtualDevice().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setIsNetworkOn(rsp.data.network);
setIsUSBOn(rsp.data.usb);
});
}, []);
function update(device: string) {
if (isLoading) return;
setIsLoading(true);
api
.updateVirtualDevice(device)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
if (device === 'network') {
setIsNetworkOn(rsp.data.on);
} else {
setIsUSBOn(rsp.data.on);
}
})
.finally(() => {
setIsLoading(false);
});
}
return (
<>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
onClick={() => update('network')}
>
{isNetworkOn && <CheckIcon size={18} color="#3b82f6" />}
<span>{t('virtualDevice.network')}</span>
</div>
<div
className="flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 text-white hover:bg-neutral-600"
onClick={() => update('usb')}
>
{isUSBOn && <CheckIcon size={18} color="#3b82f6" />}
<span>{t('virtualDevice.usb')}</span>
</div>
</>
);
};

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { Divider, Popover } from 'antd';
import clsx from 'clsx';
import {
DiscIcon,
FileBoxIcon,
LoaderCircleIcon,
PackageIcon,
PackageSearchIcon,
XIcon
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as api from '@/api/storage';
import { client } from '@/lib/websocket.ts';
export const Storage = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState<string[]>([]);
const [mountingFile, setMountingFile] = useState('');
const [mountedFile, setMountedFile] = useState('');
useEffect(() => {
getFiles();
}, []);
function handleOpenChange(open: boolean) {
if (open) {
getFiles();
}
setIsPopoverOpen(open);
}
// 获取镜像列表
function getFiles() {
if (isLoading) return;
setIsLoading(true);
api
.getImages()
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
const images = rsp.data.files;
if (!images?.length) {
setFiles([]);
return;
}
setFiles(images);
// 获取已挂载的镜像
api.getMountedImage().then((rsp) => {
if (rsp.code === 0 && rsp.data?.file && images.includes(rsp.data.file)) {
setMountedFile(rsp.data.file);
}
});
})
.finally(() => {
setIsLoading(false);
});
}
// 挂载/取消挂载
function mountFile(file: string) {
if (mountingFile) return;
setMountingFile(file);
client.close();
const filename = mountedFile === file ? '' : file;
api
.mountImage(filename)
.then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMountedFile(filename);
})
.finally(() => {
setMountingFile('');
client.connect();
});
}
const content = (
<div className="min-w-[250px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('images')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
{isLoading ? (
<div className="flex items-center space-x-2 py-2 pl-2 pr-4 text-neutral-400">
<LoaderCircleIcon className="animate-spin" size={18} />
<span className="text-sm">{t('loading')}</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center space-x-2 pl-2 pr-4 text-neutral-500">
<PackageSearchIcon size={18} />
<span className="text-sm">{t('empty')}</span>
</div>
) : (
files.map((file) => (
<div
key={file}
className={clsx(
'group my-1 flex max-w-[300px] cursor-pointer select-none items-center space-x-2 rounded py-2 pl-2 pr-4 hover:bg-neutral-700/80',
{ 'text-blue-500': mountedFile === file }
)}
onClick={() => mountFile(file)}
>
{mountedFile === file ? (
<>
<div className="h-[18px] w-[18px] group-hover:text-red-500">
<PackageIcon size={18} className="block group-hover:hidden" />
<XIcon size={18} className="hidden group-hover:block" />
</div>
</>
) : mountingFile === file ? (
<div className="h-[18px] w-[18px]">
<LoaderCircleIcon className="animate-spin" size={18} />
</div>
) : (
<div className="h-[18px] w-[18px]">
<FileBoxIcon size={18} />
</div>
)}
<span className="break-all">{file.replace(/^.*[\\/]/, '')}</span>
</div>
))
)}
</div>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div
className={clsx(
'flex h-[30px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700',
mountedFile ? 'text-blue-500' : 'text-neutral-300'
)}
>
<DiscIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Divider, Popover } from 'antd';
import { SquareTerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Nanokvm } from './nanokvm';
import { SerialPort } from './serial-port';
export const Terminal = () => {
const { t } = useTranslation();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const content = (
<div className="min-w-[200px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">{t('terminal.title')}</span>
</div>
<Divider style={{ margin: '10px 0 15px 0' }} />
<Nanokvm setIsPopoverOpen={setIsPopoverOpen} />
<SerialPort setIsPopoverOpen={setIsPopoverOpen} />
</div>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<SquareTerminalIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,25 @@
import { TerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
type NanokvmProps = {
setIsPopoverOpen: (open: boolean) => void;
};
export const Nanokvm = ({ setIsPopoverOpen }: NanokvmProps) => {
const { t } = useTranslation();
function openTerminal() {
setIsPopoverOpen(false);
window.open('/#terminal', '_blank');
}
return (
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
onClick={openTerminal}
>
<TerminalIcon size={16} />
<span>{t('terminal.nanokvm')}</span>
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { ChangeEvent, useState } from 'react';
import { Button, Input, InputNumber, Modal, Radio, RadioChangeEvent } from 'antd';
import { useSetAtom } from 'jotai';
import { TerminalIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
type SerialPortProps = {
setIsPopoverOpen: (open: boolean) => void;
};
export const SerialPort = ({ setIsPopoverOpen }: SerialPortProps) => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isModalOpen, setIsModalOpen] = useState(false);
const [port, setPort] = useState('');
const [baudrate, setBaudrate] = useState(115200);
function openModal() {
setIsKeyboardEnable(false);
setIsModalOpen(true);
setIsPopoverOpen(false);
}
function closeModal() {
setIsKeyboardEnable(true);
setIsModalOpen(false);
}
function handleInputChange(e: ChangeEvent<HTMLInputElement>) {
setPort(e.target.value);
}
function handleRadioChange(e: RadioChangeEvent) {
setPort(e.target.value);
}
function onInputNumberChange(value: number | null) {
if (value === null) return;
setBaudrate(value);
}
function submit() {
if (!port || !baudrate) {
closeModal();
return;
}
setIsModalOpen(false);
window.open(`/#terminal?port=${port}&baud=${baudrate}`, '_blank');
}
return (
<>
<div
className="flex h-[28px] cursor-pointer select-none items-center space-x-2 rounded px-2 py-1 hover:bg-neutral-700/80"
onClick={openModal}
>
<TerminalIcon size={16} />
<span>{t('terminal.serial')}</span>
</div>
<Modal open={isModalOpen} title={t('terminal.serial')} footer={null} onCancel={closeModal}>
<div className="mt-10 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">
{t('terminal.serialPort')}
</div>
<div className="w-1/2">
<Input
value={port}
placeholder={t('terminal.serialPortPlaceholder')}
onChange={handleInputChange}
/>
</div>
</div>
<div className="mt-3 pl-[100px]">
<Radio.Group size="large" onChange={handleRadioChange}>
<Radio value="/dev/ttyS1">
<code>/dev/ttyS1</code>
</Radio>
<Radio value="/dev/ttyS2">
<code>/dev/ttyS2</code>
</Radio>
</Radio.Group>
</div>
<div className="mt-7 flex items-center space-x-[20px]">
<div className="flex w-[80px] justify-end text-neutral-400">{t('terminal.baudrate')}</div>
<InputNumber
controls={false}
min={1}
defaultValue={115200}
onChange={onInputNumberChange}
/>
</div>
<div className="mb-3 mt-12 flex justify-center">
<Button type="primary" onClick={submit}>
{t('terminal.confirm')}
</Button>
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,159 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Button, Divider, Input, List, Popover } from 'antd';
import type { InputRef } from 'antd';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { NetworkIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { deleteWolMac, getWolMacs, wol } from '@/api/network.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
export const Wol = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [input, setInput] = useState('');
const [status, setStatus] = useState('');
const [log, setLog] = useState('');
const [macList, setMacList] = useState<string[]>([]);
const inputRef = useRef<InputRef>(null);
function handleOpenChange(open: boolean) {
if (open) {
getMacs();
setIsKeyboardEnable(false);
} else {
setInput('');
setStatus('');
setLog('');
setIsKeyboardEnable(true);
}
setIsPopoverOpen(open);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setInput(e.target.value);
}
function getMacs() {
getWolMacs().then((rsp) => {
if (rsp.code !== 0) {
console.log(rsp.msg);
return;
}
setMacList(rsp.data.macs.filter((mac: string) => mac !== ''));
});
}
function deleteMac(mac: string) {
deleteWolMac(mac).then((rsp) => {
if (rsp.code === 0) {
setMacList(macList.filter((item) => item !== mac));
}
});
}
function wake(mac?: string) {
if (status === 'loading') return;
const value = mac ? mac : input;
if (!value) return;
setStatus('loading');
setLog(t('wol.sending'));
wol(value)
.then((rsp) => {
if (rsp.code !== 0) {
setStatus('failed');
setLog(rsp.msg);
return;
}
setStatus('success');
setLog(t('wol.sent'));
})
.catch(() => {
setStatus('failed');
});
}
const content = (
<div className="min-w-[300px]">
<div className="flex items-center justify-between px-1">
<span className="text-base font-bold text-neutral-300">Wake-on-LAN</span>
</div>
<Divider style={{ margin: '10px 0 10px 0' }} />
<div className="pb-1 text-neutral-500">{t('wol.input')}</div>
<div className="flex items-center space-x-1">
<Input ref={inputRef} value={input} onChange={handleChange} />
<Button type="primary" onClick={() => wake()}>
{t('wol.ok')}
</Button>
</div>
<div className={clsx('py-2')}>
{status && (
<div
className={clsx(
'max-w-[300px] break-words text-sm',
status === 'failed' ? 'text-red-500' : 'text-green-500'
)}
>
{log}
</div>
)}
</div>
<List
itemLayout="horizontal"
locale={{ emptyText: ' ' }}
dataSource={macList}
renderItem={(mac) => (
<List.Item className="flex w-full items-center justify-between">
<div className="h-[24px] max-w-[200px]">{mac}</div>
<div className="flex items-center space-x-3">
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-green-500 hover:bg-neutral-700/80"
onClick={() => wake(mac)}
>
<SendIcon size={16} />
</div>
<div
className="flex h-[24px] w-[24px] cursor-pointer items-center justify-center rounded text-red-500 hover:bg-neutral-700"
onClick={() => deleteMac(mac)}
>
<Trash2Icon size={16} />
</div>
</div>
</List.Item>
)}
/>
</div>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
open={isPopoverOpen}
onOpenChange={handleOpenChange}
>
<div className="flex h-[30px] cursor-pointer items-center justify-center rounded px-2 text-neutral-300 hover:bg-neutral-700">
<NetworkIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,97 @@
import { useEffect } from 'react';
import { useAtomValue } from 'jotai';
import { client } from '@/lib/websocket.ts';
import { resolutionAtom } from '@/jotai/resolution.ts';
import { MouseButton, MouseEvent } from './constants';
export const Absolute = () => {
const resolution = useAtomValue(resolutionAtom);
// listen mouse events
useEffect(() => {
const canvas = document.getElementById('screen');
if (!canvas) return;
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('wheel', handleWheel);
canvas.addEventListener('click', disableEvent);
canvas.addEventListener('contextmenu', disableEvent);
// press button
function handleMouseDown(event: any) {
disableEvent(event);
let button: MouseButton;
switch (event.button) {
case 0:
button = MouseButton.Left;
break;
case 1:
button = MouseButton.Wheel;
break;
case 2:
button = MouseButton.Right;
break;
default:
console.log(`unknown button ${event.button}`);
return;
}
const data = [2, MouseEvent.Down, button, 0, 0];
client.send(data);
}
// release button
function handleMouseUp(event: any) {
disableEvent(event);
const data = [2, MouseEvent.Up, MouseButton.None, 0, 0];
client.send(data);
}
// mouse move
function handleMouseMove(event: any) {
disableEvent(event);
const rect = canvas!.getBoundingClientRect();
const x = (event.clientX - rect.left) / resolution!.width;
const y = (event.clientY - rect.top) / resolution!.height;
const hexX = x < 0 ? 0x0001 : Math.floor(0x7fff * x) + 0x0001;
const hexY = y < 0 ? 0x0001 : Math.floor(0x7fff * y) + 0x0001;
const data = [2, MouseEvent.MoveAbsolute, MouseButton.None, hexX, hexY];
client.send(data);
}
// mouse scroll
function handleWheel(event: any) {
disableEvent(event);
const delta = Math.floor(event.deltaY);
if (delta === 0) return;
const data = [2, MouseEvent.Scroll, 0, 0, delta];
client.send(data);
}
return () => {
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('click', disableEvent);
canvas.removeEventListener('contextmenu', disableEvent);
};
}, [resolution]);
// disable default events
function disableEvent(event: any) {
event.preventDefault();
event.stopPropagation();
}
return <></>;
};

View File

@@ -0,0 +1,14 @@
export enum MouseEvent {
Up = 0,
Down = 1,
MoveAbsolute = 2,
MoveRelative = 3,
Scroll = 4
}
export enum MouseButton {
None = 0,
Left = 1,
Right = 2,
Wheel = 3
}

View File

@@ -0,0 +1,12 @@
import { useAtomValue } from 'jotai';
import { mouseModeAtom } from '@/jotai/mouse.ts';
import { Absolute } from './absolute.tsx';
import { Relative } from './relative.tsx';
export const Mouse = () => {
const mouseMode = useAtomValue(mouseModeAtom);
return <>{mouseMode === 'relative' ? <Relative /> : <Absolute />}</>;
};

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